En C, malloc()
alloue une région de mémoire dans le tas et lui renvoie un pointeur. C'est tout ce que vous obtenez. La mémoire n'est pas initialisée et vous n'avez aucune garantie que ce sont tous des zéros ou autre chose.
En Java, appeler new
fait une allocation basée sur le tas comme malloc()
, mais vous bénéficiez également d'une tonne de commodité supplémentaire (ou de frais généraux, si vous préférez). Par exemple, vous n'avez pas besoin de spécifier explicitement le nombre d'octets à allouer. Le compilateur le calcule pour vous en fonction du type d'objet que vous essayez d'allouer. De plus, les constructeurs d'objets sont appelés (auxquels vous pouvez transmettre des arguments si vous souhaitez contrôler la façon dont l'initialisation se produit). Quandnew
retour, vous êtes assuré d'avoir un objet qui est initialisé.
Mais oui, à la fin de l'appel, le résultat de malloc()
et new
sont simplement des pointeurs vers un morceau de données basées sur le tas.
La deuxième partie de votre question porte sur les différences entre une pile et un tas. Des réponses beaucoup plus complètes peuvent être trouvées en suivant un cours (ou en lisant un livre sur) la conception du compilateur. Un cours sur les systèmes d'exploitation serait également utile. Il y a aussi de nombreuses questions et réponses sur SO sur les piles et les tas.
Cela dit, je vais donner un aperçu général, je l'espère, n'est pas trop verbeux et vise à expliquer les différences à un niveau assez élevé.
Fondamentalement, la principale raison d'avoir deux systèmes de gestion de mémoire, à savoir un tas et une pile, est pour l' efficacité . Une raison secondaire est que chacun est meilleur à certains types de problèmes que l'autre.
Les piles sont un peu plus faciles à comprendre en tant que concept, donc je commence par les piles. Considérons cette fonction en C ...
int add(int lhs, int rhs) {
int result = lhs + rhs;
return result;
}
Ce qui précède semble assez simple. Nous définissons une fonction nomméeadd()
et passons dans les addends gauche et droit. La fonction les ajoute et renvoie un résultat. Veuillez ignorer tous les cas marginaux tels que les débordements qui pourraient se produire, à ce stade, cela ne concerne pas la discussion.
le add()
but de la fonction semble assez simple, mais que pouvons-nous dire de son cycle de vie? Surtout ses besoins d'utilisation de la mémoire?
Plus important encore, le compilateur sait a priori (c'est-à-dire au moment de la compilation) quelle est la taille des types de données et combien seront utilisés. Les arguments lhs
et rhs
sont de sizeof(int)
4 octets chacun. La variable l' result
est également sizeof(int)
. Le compilateur peut dire que la add()
fonction utilise 4 bytes * 3 ints
ou un total de 12 octets de mémoire.
Lorsque la add()
fonction est appelée, un registre matériel appelé pointeur de pile contient une adresse qui pointe vers le haut de la pile. Afin d'allouer la mémoire que la add()
fonction doit exécuter, tout ce que le code d'entrée de fonction doit faire est d'émettre une seule instruction de langage d'assemblage pour décrémenter la valeur du registre du pointeur de pile de 12. Ce faisant, il crée un stockage sur la pile pour trois ints
, une pour chaque lhs
, rhs
etresult
. Obtenir l'espace mémoire dont vous avez besoin en exécutant une seule instruction est un gain énorme en termes de vitesse car les instructions simples ont tendance à s'exécuter en une seule fois (1 milliardième de seconde un CPU à 1 GHz).
De plus, du point de vue du compilateur, il peut créer une carte des variables qui ressemble énormément à l'indexation d'un tableau:
lhs: ((int *)stack_pointer_register)[0]
rhs: ((int *)stack_pointer_register)[1]
result: ((int *)stack_pointer_register)[2]
Encore une fois, tout cela est très rapide.
Lorsque la add()
fonction se termine, elle doit être nettoyée. Pour ce faire, il soustrait 12 octets du registre de pointeur de pile. C'est similaire à un appel à free()
mais il n'utilise qu'une seule instruction CPU et il ne prend qu'un tick. C'est très, très rapide.
Considérons maintenant une allocation basée sur le tas. Cela entre en jeu lorsque nous ne savons pas a priori de combien de mémoire nous aurons besoin (c'est-à-dire que nous ne l'apprendrons qu'au moment de l'exécution).
Considérez cette fonction:
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;
}
Notez que la addRandom()
fonction ne sait pas au moment de la compilation quelle sera la valeur de l' count
argument. Pour cette raison, cela n'a pas de sens d'essayer de définir array
comme nous le ferions si nous le mettions sur la pile, comme ceci:
int array[count];
Si count
c'est énorme, cela pourrait entraîner une trop grande taille de notre pile et écraser d'autres segments de programme. Lorsque cette pile déborde se produit, votre programme plante (ou pire).
Donc, dans les cas où nous ne savons pas de combien de mémoire nous aurons besoin jusqu'à l'exécution, nous utilisons malloc()
. Ensuite, nous pouvons simplement demander le nombre d'octets dont nous avons besoin quand nous en avons besoin, et malloc()
vérifierons s'il peut vendre autant d'octets. Si c'est le cas, très bien, nous le récupérons, sinon, nous obtenons un pointeur NULL qui nous indique que l'appel a malloc()
échoué. Cependant, le programme ne plante pas! Bien sûr, en tant que programmeur, vous pouvez décider que votre programme n'est pas autorisé à s'exécuter si l'allocation des ressources échoue, mais la terminaison déclenchée par le programmeur est différente d'un crash parasite.
Il nous faut donc maintenant revenir sur l'efficacité. L'allocateur de pile est super rapide - une instruction à allouer, une instruction à désallouer, et c'est fait par le compilateur, mais rappelez-vous que la pile est destinée à des choses comme des variables locales d'une taille connue, donc elle a tendance à être assez petite.
L'allocateur de tas, d'autre part, est plus lent de plusieurs ordres de grandeur. Il doit effectuer une recherche dans les tableaux pour voir s'il dispose de suffisamment de mémoire libre pour pouvoir vendre la quantité de mémoire que l'utilisateur souhaite. Il doit mettre à jour ces tables après avoir distribué la mémoire pour s'assurer que personne d'autre ne peut utiliser ce bloc (cette comptabilité peut nécessiter que l'allocateur se réserve de la mémoire pour lui-même en plus de ce qu'il prévoit de vendre). L'allocateur doit utiliser des stratégies de verrouillage pour s'assurer qu'il distribue la mémoire de manière thread-safe. Et quand la mémoire est enfinfree()
d, qui se produit à des moments différents et dans aucun ordre prévisible, l'allocateur doit trouver des blocs contigus et les recoudre pour réparer la fragmentation du tas. Si vous avez l'impression que cela va prendre plus d'une instruction CPU pour accomplir tout cela, vous avez raison! C'est très compliqué et cela prend du temps.
Mais les tas sont gros. Beaucoup plus grand que les piles. Nous pouvons obtenir beaucoup de mémoire d'eux et ils sont parfaits lorsque nous ne savons pas au moment de la compilation de la quantité de mémoire dont nous aurons besoin. Nous échangeons donc la vitesse pour un système de mémoire géré qui nous décline poliment au lieu de planter lorsque nous essayons d'allouer quelque chose de trop grand.
J'espère que cela aidera à répondre à certaines de vos questions. Veuillez me faire savoir si vous souhaitez des éclaircissements sur l'un des points ci-dessus.