In C, malloc()
alloca una regione di memoria nell'heap e restituisce un puntatore ad essa. Questo è tutto ciò che ottieni. La memoria non è inizializzata e non hai alcuna garanzia che siano tutti zeri o qualsiasi altra cosa.
In Java, chiamare new
fa un'allocazione basata su heap proprio come malloc()
, ma hai anche un sacco di comodità aggiuntiva (o spese generali, se preferisci). Ad esempio, non è necessario specificare esplicitamente il numero di byte da allocare. Il compilatore lo capisce in base al tipo di oggetto che stai tentando di allocare. Inoltre, vengono chiamati costruttori di oggetti (a cui è possibile passare argomenti se si desidera controllare il modo in cui si verifica l'inizializzazione). Quando new
ritorna, hai la garanzia di avere un oggetto inizializzato.
Ma sì, alla fine della chiamata sia il risultato di malloc()
e new
sono semplicemente dei puntatori ad alcuni blocchi di dati basati su heap.
La seconda parte della domanda pone domande sulle differenze tra una pila e una pila. Risposte molto più complete possono essere trovate seguendo un corso (o leggendo un libro sulla progettazione di compilatori). Sarebbe utile anche un corso sui sistemi operativi. Ci sono anche numerose domande e risposte su SO riguardo alle pile e ai cumuli.
Detto questo, fornirò una panoramica generale che spero non sia troppo dettagliata e abbia l'obiettivo di spiegare le differenze a un livello abbastanza alto.
Fondamentalmente, il motivo principale per avere due sistemi di gestione della memoria, vale a dire un heap e uno stack, è per l' efficienza . Un motivo secondario è che ognuno è migliore in alcuni tipi di problemi rispetto all'altro.
Le pile sono per me un po 'più facili da capire come un concetto, quindi inizio con le pile. Consideriamo questa funzione in C ...
int add(int lhs, int rhs) {
int result = lhs + rhs;
return result;
}
Quanto sopra sembra abbastanza semplice. Definiamo una funzione denominata add()
e passiamo negli addendi sinistro e destro. La funzione li aggiunge e restituisce un risultato. Si prega di ignorare tutti i casi limite come gli overflow che potrebbero verificarsi, a questo punto non è pertinente alla discussione.
Lo add()
scopo della funzione sembra piuttosto semplice, ma cosa possiamo dire del suo ciclo di vita? Soprattutto le sue esigenze di utilizzo della memoria?
Ancora più importante, il compilatore sa a priori (cioè al momento della compilazione) quanto sono grandi i tipi di dati e quanti saranno usati. Gli argomenti lhs
e rhs
sono sizeof(int)
, 4 byte ciascuno. Anche la variabile result
è sizeof(int)
. Il compilatore può dire che la add()
funzione utilizza 4 bytes * 3 ints
o un totale di 12 byte di memoria.
Quando add()
viene chiamata la funzione, un registro hardware chiamato puntatore dello stack avrà un indirizzo che punta in cima allo stack. Per allocare la memoria che la add()
funzione deve eseguire, tutto il codice di immissione della funzione deve essere emettere una singola istruzione di linguaggio assembly per ridurre il valore del registro del puntatore dello stack di 12. In tal modo, crea memoria per tre ints
, uno ciascuno per lhs
, rhs
e result
. Ottenere lo spazio di memoria necessario eseguendo una singola istruzione è una vittoria enorme in termini di velocità perché le singole istruzioni tendono ad essere eseguite in un tick di clock (1 miliardesimo di secondo una CPU da 1 GHz).
Inoltre, dal punto di vista del compilatore, può creare una mappa per le variabili che assomiglia moltissimo come indicizzare un array:
lhs: ((int *)stack_pointer_register)[0]
rhs: ((int *)stack_pointer_register)[1]
result: ((int *)stack_pointer_register)[2]
Ancora una volta, tutto questo è molto veloce.
Quando la add()
funzione esce, deve ripulire. Lo fa sottraendo 12 byte dal registro del puntatore dello stack. È simile a una chiamata free()
ma utilizza solo un'istruzione CPU e richiede solo un segno di spunta. È molto, molto veloce.
Ora considera un'allocazione basata su heap. Questo entra in gioco quando non sappiamo a priori di quanta memoria avremo bisogno (cioè ne impareremo solo in fase di esecuzione).
Considera questa funzione:
int addRandom(int count) {
int numberOfBytesToAllocate = sizeof(int) * count;
int *array = malloc(numberOfBytesToAllocate);
int result = 0;
if array != NULL {
for (i = 0; i < count; ++i) {
array[i] = (int) random();
result += array[i];
}
free(array);
}
return result;
}
Si noti che la addRandom()
funzione non sa al momento della compilazione quale sarà il valore count
dell'argomento. Per questo motivo, non ha senso cercare di definire array
come faremmo se lo mettessimo in pila, in questo modo:
int array[count];
Se count
è enorme, il nostro stack potrebbe diventare troppo grande e sovrascrivere altri segmenti di programma. Quando si verifica questo overflow dello stack, il programma si arresta in modo anomalo (o peggio).
Quindi, nei casi in cui non sappiamo quanta memoria avremo bisogno fino al runtime, usiamo malloc()
. Quindi possiamo semplicemente chiedere il numero di byte di cui abbiamo bisogno quando ne abbiamo bisogno e malloc()
andremo a controllare se può vendere quel numero di byte. Se è possibile, ottimo, lo recuperiamo, in caso contrario, otteniamo un puntatore NULL che ci dice che la chiamata è malloc()
fallita. In particolare, tuttavia, il programma non si arresta in modo anomalo! Ovviamente tu come programmatore puoi decidere che il tuo programma non può essere eseguito se l'allocazione delle risorse fallisce, ma la terminazione avviata dal programmatore è diversa da un incidente spurio.
Quindi ora dobbiamo tornare a guardare l'efficienza. L'allocatore dello stack è super veloce: un'istruzione da allocare, un'istruzione da deallocare, ed è fatta dal compilatore, ma ricorda che lo stack è pensato per cose come variabili locali di dimensioni note, quindi tende ad essere abbastanza piccolo.
L'allocatore di heap invece è più lento di diversi ordini di grandezza. Deve fare una ricerca nelle tabelle per vedere se ha abbastanza memoria libera per poter vendere la quantità di memoria che l'utente desidera. Deve aggiornare quelle tabelle dopo aver venduto la memoria per assicurarsi che nessun altro possa utilizzare quel blocco (questa contabilità potrebbe richiedere all'allocatore di riservare memoria per sé in aggiunta a ciò che prevede di vendere). L'allocatore deve utilizzare strategie di blocco per assicurarsi che distribuisca la memoria in modo thread-safe. E quando la memoria è finalmentefree()
d, che si verifica in momenti diversi e in genere in un ordine non prevedibile, l'allocatore deve trovare blocchi contigui e ricucirli insieme per riparare la frammentazione dell'heap. Se sembra che ci vorrà più di una singola istruzione CPU per ottenere tutto ciò, hai ragione! È molto complicato e richiede un po 'di tempo.
Ma i cumuli sono grandi. Molto più grande delle pile. Possiamo ottenere molta memoria da loro e sono fantastici quando non sappiamo al momento della compilazione di quanta memoria avremo bisogno. Quindi compromettiamo la velocità per un sistema di memoria gestito che ci rifiuta educatamente invece di schiantarci quando proviamo ad allocare qualcosa di troppo grande.
Spero che ciò aiuti a rispondere ad alcune delle tue domande. Per favore fatemi sapere se desiderate chiarimenti su uno dei punti precedenti.