En C, malloc()
asigna una región de memoria en el montón y le devuelve un puntero. Eso es todo lo que obtienes. La memoria no está inicializada y no tiene garantía de que sea todo ceros o cualquier otra cosa.
En Java, las llamadas new
hacen una asignación basada en el montón como malloc()
, pero también obtienes un montón de conveniencia adicional (o gastos generales, si lo prefieres). Por ejemplo, no tiene que especificar explícitamente el número de bytes que se asignarán. El compilador lo resuelve según el tipo de objeto que está tratando de asignar. Además, se llaman constructores de objetos (a los que puede pasar argumentos si desea controlar cómo se produce la inicialización). Cuando new
regrese, tiene la garantía de tener un objeto inicializado.
Pero sí, al final de la llamada, tanto el resultado malloc()
como new
simplemente son punteros a una porción de datos basados en el montón.
La segunda parte de su pregunta se refiere a las diferencias entre una pila y un montón. Se pueden encontrar respuestas mucho más completas tomando un curso sobre (o leyendo un libro sobre) el diseño del compilador. Un curso sobre sistemas operativos también sería útil. También hay numerosas preguntas y respuestas en SO sobre las pilas y los montones.
Dicho esto, daré una descripción general que espero que no sea demasiado detallada y tenga como objetivo explicar las diferencias a un nivel bastante alto.
Fundamentalmente, la razón principal para tener dos sistemas de administración de memoria, es decir, un montón y una pila, es la eficiencia . Una razón secundaria es que cada uno es mejor en ciertos tipos de problemas que el otro.
Para mí, las pilas son algo más fáciles de entender como concepto, así que empiezo con las pilas. Consideremos esta función en C ...
int add(int lhs, int rhs) {
int result = lhs + rhs;
return result;
}
Lo anterior parece bastante sencillo. Definimos una función llamada add()
y pasamos en los sumandos izquierdo y derecho. La función los agrega y devuelve un resultado. Ignore todas las cosas del caso de borde, como desbordamientos que puedan ocurrir, en este punto no es pertinente para la discusión.
El add()
propósito de la función parece bastante sencillo, pero ¿qué podemos decir sobre su ciclo de vida? ¿Especialmente sus necesidades de utilización de memoria?
Lo más importante es que el compilador sabe a priori (es decir, en tiempo de compilación) qué tan grandes son los tipos de datos y cuántos se utilizarán. Los argumentos lhs
y rhs
son de sizeof(int)
4 bytes cada uno. La variable result
es también sizeof(int)
. El compilador puede decir que la add()
función usa 4 bytes * 3 ints
o un total de 12 bytes de memoria.
Cuando add()
se llama a la función, un registro de hardware llamado puntero de la pila tendrá una dirección que apunte a la parte superior de la pila. Para asignar la memoria add()
que necesita ejecutar la función, todo el código de entrada de función debe emitir una sola instrucción de lenguaje ensamblador para disminuir el valor del registro del puntero de la pila en 12. Al hacerlo, crea almacenamiento en la pila para tres ints
, uno para cada uno lhs
, rhs
y result
. Obtener el espacio de memoria que necesita ejecutando una sola instrucción es una ganancia enorme en términos de velocidad porque las instrucciones individuales tienden a ejecutarse en un tic de reloj (mil millonésimas de segundo una CPU de 1 GHz).
Además, desde la vista del compilador, puede crear un mapa de las variables que se parece mucho a la indexación de una matriz:
lhs: ((int *)stack_pointer_register)[0]
rhs: ((int *)stack_pointer_register)[1]
result: ((int *)stack_pointer_register)[2]
Nuevamente, todo esto es muy rápido.
Cuando la add()
función sale, tiene que limpiarse. Lo hace restando 12 bytes del registro del puntero de la pila. Es similar a una llamada a, free()
pero solo usa una instrucción de CPU y solo toma un tic. Es muy, muy rápido.
Ahora considere una asignación basada en el montón. Esto entra en juego cuando no sabemos a priori cuánta memoria necesitaremos (es decir, solo lo aprenderemos en tiempo de ejecución).
Considere esta función:
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;
}
Observe que la addRandom()
función no sabe en tiempo de compilación cuál count
será el valor del argumento. Debido a esto, no tiene sentido tratar de definir array
como lo haríamos si lo pusiéramos en la pila, así:
int array[count];
Si count
es enorme, podría hacer que nuestra pila crezca demasiado y sobrescriba otros segmentos del programa. Cuando ocurre este desbordamiento de pila, su programa se bloquea (o peor).
Entonces, en casos donde no sabemos cuánta memoria necesitaremos hasta el tiempo de ejecución, usamos malloc()
. Luego, podemos pedir la cantidad de bytes que necesitamos cuando la necesitemos, e malloc()
iremos a verificar si puede vender esa cantidad de bytes. Si puede, genial, lo recuperamos, si no, obtenemos un puntero NULL que nos dice que la llamada malloc()
falló. Notablemente, sin embargo, ¡el programa no se bloquea! Por supuesto, usted como programador puede decidir que su programa no puede ejecutarse si falla la asignación de recursos, pero la terminación iniciada por el programador es diferente a un bloqueo espurio.
Así que ahora tenemos que volver para analizar la eficiencia. El asignador de pila es súper rápido: una instrucción para asignar, una instrucción para desasignar, y la realiza el compilador, pero recuerde que la pila está destinada a cosas como variables locales de un tamaño conocido, por lo que tiende a ser bastante pequeña.
El asignador de almacenamiento dinámico, por otro lado, es varios órdenes de magnitud más lento. Tiene que hacer una búsqueda en tablas para ver si tiene suficiente memoria libre para poder vender la cantidad de memoria que desea el usuario. Tiene que actualizar esas tablas después de vender la memoria para asegurarse de que nadie más pueda usar ese bloque (esta contabilidad puede requerir que el asignador reserve memoria para sí mismo además de lo que planea vender). El asignador tiene que emplear estrategias de bloqueo para asegurarse de que distribuye la memoria de manera segura. Y cuando la memoria finalmente esfree()
d, que ocurre en diferentes momentos y generalmente sin un orden predecible, el asignador tiene que encontrar bloques contiguos y volver a unirlos para reparar la fragmentación del montón. Si eso parece que se necesitará más de una instrucción de CPU para lograr todo eso, ¡tienes razón! Es muy complicado y lleva un tiempo.
Pero los montones son grandes. Mucho más grande que las pilas. Podemos obtener mucha memoria de ellos y son geniales cuando no sabemos en tiempo de compilación cuánta memoria necesitaremos. Por lo tanto, cambiamos la velocidad por un sistema de memoria administrado que nos rechaza cortésmente en lugar de fallar cuando intentamos asignar algo demasiado grande.
Espero que eso ayude a responder algunas de sus preguntas. Avíseme si desea una aclaración sobre cualquiera de los anteriores.