La recolección de basura [ 1] en la programación es una forma de gestión automática de la memoria . Un proceso especial , llamado recolector de elementos no utilizados , libera memoria periódicamente eliminando objetos que se han vuelto innecesarios .
La recolección de basura automática mejora la seguridad del acceso a la memoria .
La recolección de basura fue aplicada por primera vez por John McCarthy en 1959 en un entorno de programación en el lenguaje de programación funcional que desarrolló, Lisp . Posteriormente, fue utilizado en otros sistemas y lenguajes de programación, principalmente en los funcionales y lógicos . La necesidad de recolección de basura en este tipo de lenguajes se debe al hecho de que la estructura de dichos lenguajes hace que sea extremadamente inconveniente realizar un seguimiento de la vida útil de los objetos en la memoria y administrarlos manualmente. Las listas ampliamente utilizadas en estos lenguajes y las estructuras de datos complejas basadas en ellas se crean, agregan, amplían y copian constantemente durante la operación de los programas, y es difícil determinar correctamente el momento de la eliminación de un objeto.
Los lenguajes de procedimientos y objetos industriales no utilizaron la recolección de basura durante mucho tiempo. Se dio preferencia a la gestión manual de la memoria, por ser más eficiente y predecible. Pero desde la segunda mitad de la década de 1980, la tecnología de recolección de basura se ha utilizado tanto en lenguajes de programación de directivas ( imperativo ) como de objetos, y desde la segunda mitad de la década de 1990, un número creciente de lenguajes creados y entornos enfocados en la programación de aplicaciones incluyen un mecanismo de recolección de basura ya sea como el único o como uno de los mecanismos de administración de memoria dinámica disponibles. Actualmente se utiliza en Oberon , Java , Python , Ruby , C# , D , F# , Go y otros lenguajes.
La forma tradicional en que los lenguajes directivos gestionan la memoria es manual. Su esencia es la siguiente:
En cualquier lenguaje que permita la creación de objetos en memoria dinámica, existen dos problemas potenciales: referencias colgantes y pérdidas de memoria .
Un puntero colgante es una referencia a un objeto que ya se ha eliminado de la memoria. Después de eliminar un objeto, todas las referencias guardadas en el programa se vuelven "colgantes". La memoria previamente ocupada por un objeto puede pasar al sistema operativo y volverse inaccesible, o usarse para asignar un nuevo objeto en el mismo programa. En el primer caso, un intento de acceder a un enlace "colgante" activará el mecanismo de protección de la memoria y bloqueará el programa, y en el segundo caso, tendrá consecuencias impredecibles.
La aparición de referencias colgantes suele ser el resultado de una estimación incorrecta de la vida útil de un objeto: el programador llama al comando para eliminar el objeto antes de que cese su uso.
Al crear un objeto en la memoria dinámica, el programador no puede eliminarlo una vez que se completa el uso. Si a una variable que hace referencia a un objeto se le asigna un nuevo valor y no hay otras referencias al objeto, se vuelve inaccesible mediante programación, pero sigue ocupando memoria porque no se llamó al comando de eliminación. Esta situación se denomina pérdida de memoria .
Si los objetos, cuyas referencias se pierden, se crean constantemente en el programa, entonces una fuga de memoria se manifiesta en un aumento gradual en la cantidad de memoria utilizada; si el programa se ejecuta durante mucho tiempo, la cantidad de memoria que utiliza crece constantemente y, después de un tiempo, el sistema se ralentiza notablemente (debido a la necesidad de usar el intercambio para cualquier asignación de memoria ), o el programa agota el espacio de direcciones disponible y termina con un error.
Si la memoria de la computadora fuera infinita , sería posible simplemente dejar objetos innecesarios en la memoria. Gestión automática de memoria con recolección de basura: emulación de una computadora tan infinita en una memoria finita [2] . Muchas de las limitaciones de los recolectores de basura (no hay garantía de que un finalizador se ejecute; solo administra la memoria, no otros recursos) se derivan de esta metáfora.
En un sistema de recolección de elementos no utilizados, es responsabilidad del entorno de ejecución del programa desasignar la memoria. El programador solo crea objetos dinámicos y los usa, puede que no le importe borrar objetos, ya que el entorno lo hace por él. Para hacer esto, se incluye un módulo de software especial llamado "recolector de basura" en el entorno de tiempo de ejecución. Este módulo se ejecuta periódicamente, determina cuáles de los objetos creados en la memoria dinámica ya no se utilizan y libera la memoria que ocupan.
La frecuencia de ejecución del recolector de basura está determinada por las características del sistema. El recopilador puede ejecutarse en segundo plano, comenzando cuando el programa está inactivo (por ejemplo, cuando el programa está inactivo, esperando la entrada del usuario). El recolector de basura se ejecuta incondicionalmente, deteniendo la ejecución del programa ( Stop- the -world ) cuando no se puede realizar la siguiente operación de asignación de memoria debido a que se ha agotado toda la memoria disponible. Una vez que se libera la memoria, se reanuda la operación de asignación de memoria interrumpida y el programa continúa ejecutándose. Si resulta que no se puede liberar la memoria, el tiempo de ejecución finaliza el programa con un mensaje de error "Memoria insuficiente".
Sería óptimo eliminar de la memoria los objetos a los que no se accederá en el curso de la operación posterior del programa. Sin embargo, la identificación de tales objetos es imposible, ya que se reduce a un problema de detención algorítmicamente insoluble (para esto, basta con suponer que algún objeto X se usará si y solo si el programa P se completa con éxito ). Por lo tanto, los recolectores de basura usan estimaciones conservadoras para asegurarse de que un objeto no se use en el futuro.
Por lo general, el criterio de que un objeto todavía está en uso es la presencia de referencias a él: si no hay más referencias a este objeto en el sistema, entonces, obviamente, ya no puede ser utilizado por el programa y, por lo tanto, puede ser eliminado Este criterio es utilizado por la mayoría de los recolectores de basura modernos y también se denomina accesibilidad de objetos . No es teóricamente el mejor, ya que según él, los objetos alcanzables también incluyen aquellos objetos que nunca se usarán, pero a los que todavía hay referencias, pero garantiza la protección contra la aparición de referencias "colgantes" y puede implementarse de manera bastante eficiente. .
De manera informal, se puede dar la siguiente definición recursiva de un objeto alcanzable:
Algoritmo de bandera
Un algoritmo simple para determinar los objetos alcanzables, el algoritmo Mark and Sweep, es el siguiente:
Si dos o más objetos se refieren entre sí, pero no se hace referencia a ninguno de estos objetos desde el exterior, el grupo completo se considera inalcanzable. Este algoritmo le permite garantizar la eliminación de grupos de objetos cuyo uso ha cesado, pero en los que existen vínculos entre sí. Estos grupos a menudo se denominan "islas de aislamiento".
Algoritmo de conteo de referenciasOtra variante del algoritmo de accesibilidad es el habitual recuento de referencias . Su uso ralentiza las operaciones de asignación de referencias, pero la definición de objetos accesibles es trivial: todos estos son objetos cuyo valor de recuento de referencias es superior a cero. Sin aclaraciones adicionales, este algoritmo, a diferencia del anterior, no elimina cadenas cerradas cíclicamente de objetos obsoletos que tienen enlaces entre sí.
Una vez que se define un conjunto de objetos inalcanzables, el recolector de basura puede desasignar la memoria ocupada por ellos y dejar el resto como está. También es posible mover todo o parte de los objetos restantes a otras áreas de memoria después de liberar memoria, actualizando todas las referencias a ellos junto con esto. Estas dos implementaciones se conocen como no reubicables y reubicables , respectivamente .
Ambas estrategias tienen ventajas y desventajas.
Velocidad de asignación y desasignación de memoria Un recolector de basura que no se reubica libera memoria más rápido (porque solo marca los bloques de memoria apropiados como libres), pero dedica más tiempo a asignarla (porque la memoria se fragmenta y la asignación tiene que encontrar la cantidad correcta de bloques de tamaño apropiado en la memoria ). El recolector de movimiento tarda relativamente más en recolectar basura (se necesita más tiempo para desfragmentar la memoria y cambiar todas las referencias a los objetos que se están moviendo), pero el movimiento permite un algoritmo de asignación de memoria extremadamente simple y rápido ( O(1) ). Durante la desfragmentación, los objetos se mueven para dividir toda la memoria en dos grandes áreas: ocupada y libre, y se guarda un puntero a su borde. Para asignar nueva memoria, basta con mover este límite, devolviendo una pieza desde el principio de la memoria libre. Velocidad de acceso a objetos en memoria dinámica Los objetos cuyos campos se comparten pueden ser colocados uno cerca del otro en la memoria por el colector de movimientos. Entonces es más probable que estén en la memoria caché del procesador al mismo tiempo, lo que reducirá la cantidad de accesos a la memoria RAM relativamente lenta . Compatibilidad con código extranjero La reubicación del recolector de elementos no utilizados genera problemas cuando se usa código que no está administrado por la administración automática de memoria (dicho código se denomina extraño en la terminología tradicional o no administrado en la terminología de Microsoft ) . Un puntero a la memoria asignada en un sistema con un recopilador que no se reubica puede simplemente pasarse a un código externo para su uso, manteniendo al menos una referencia regular al objeto para que el recopilador no lo elimine. El colector en movimiento cambia la posición de los objetos en la memoria, cambiando sincrónicamente todas las referencias a ellos, pero no puede cambiar las referencias en el código externo, como resultado, las referencias pasadas al código externo después de mover el objeto se volverán incorrectas. Para trabajar con código externo, se utilizan varias técnicas especiales, por ejemplo, la fijación es un bloqueo explícito de un objeto que prohíbe su movimiento durante la recolección de basura.Como muestra la práctica, los objetos creados recientemente se vuelven inalcanzables con más frecuencia que los objetos que existen desde hace mucho tiempo. De acuerdo con este patrón, muchos recolectores de basura modernos subdividen todos los objetos en varias generaciones , una serie de objetos con una vida útil cercana. Tan pronto como se agota la memoria asignada a una de las generaciones, en esta generación y en todas las generaciones “más jóvenes”, se realiza una búsqueda de objetos inalcanzables. Todos ellos se eliminan y los restantes se transfieren a la generación "más antigua".
El uso de generaciones reduce el tiempo del ciclo de recolección de basura al reducir la cantidad de objetos que se escanean durante la recolección, pero este método requiere que el tiempo de ejecución realice un seguimiento de las referencias entre diferentes generaciones.
Para que un programa utilice la recolección de elementos no utilizados, se deben cumplir una serie de condiciones relacionadas con el lenguaje, el entorno de ejecución y la tarea en sí.
La necesidad de un tiempo de ejecución con un recolector de basura Naturalmente, la recolección de basura requiere un entorno dinámico que admita la ejecución del programa y la presencia de un recolector de basura en este entorno. Para los idiomas interpretados o los idiomas compilados en el código de bytes de la máquina virtual, el recolector de elementos no utilizados puede incluirse en el código del intérprete de idioma o código de bytes, pero para los idiomas compilados en el código objeto, el recolector de elementos no utilizados se ve obligado a formar parte del sistema. biblioteca, que se vincula (estática o dinámicamente) con el código del programa al crear un archivo ejecutable, aumentando el tamaño del programa y su tiempo de carga. Soporte de lenguaje de programación El recolector de basura solo puede funcionar correctamente cuando puede rastrear con precisión todas las referencias a todos los objetos creados. Obviamente, si el lenguaje permite la conversión de referencias (punteros) a otros tipos de datos (enteros, matrices de bytes, etc.), como C / C++ , se vuelve imposible rastrear el uso de dichas referencias convertidas y la recolección de basura no tiene sentido. - no protege de enlaces "colgantes" y pérdidas de memoria. Por lo tanto, los lenguajes orientados a la recolección de basura generalmente restringen significativamente la libertad de usar punteros, abordar la aritmética, las conversiones de tipos de punteros a otros tipos de datos. Algunos de ellos no tienen ningún tipo de datos de "puntero", otros sí, pero no permiten conversiones ni cambios de tipo. Admisibilidad técnica de los retrasos a corto plazo en el trabajo de los programas La recolección de basura se realiza periódicamente, generalmente en horarios desconocidos. Si suspender el programa por un tiempo comparable al tiempo de recolección de basura puede conducir a errores críticos , obviamente es imposible usar la recolección de basura en tal situación. Tener alguna reserva de memoria libre Cuanta más memoria esté disponible para el tiempo de ejecución, menos se ejecutará el recolector de elementos no utilizados y más eficiente será. Ejecutar un recolector de elementos no utilizados en un sistema donde la cantidad de memoria disponible para el recolector de elementos no utilizados se acerca a la demanda máxima del programa puede ser ineficiente y un desperdicio. Cuanto menos excedente de memoria, más a menudo se ejecuta el recopilador y más tiempo lleva ejecutarlo. La caída en el rendimiento del programa en este modo puede ser demasiado significativa.Al contrario de lo que suele decirse, la presencia de recolección de basura no libera al programador de todos los problemas de gestión de memoria.
Liberar otros recursos ocupados por el objeto. Además de la memoria dinámica, un objeto puede poseer otros recursos, a veces más valiosos que la memoria. Si un objeto abre un archivo al crearlo, debe cerrarlo al finalizar el uso; si se conecta a un DBMS, debe desconectarse. En los sistemas con gestión de memoria manual, esto se hace inmediatamente antes de que el objeto se elimine de la memoria, con mayor frecuencia en los destructores de los objetos correspondientes. En los sistemas con recolección de basura, suele ser posible ejecutar algún código justo antes de borrar un objeto, los llamados finalizers , pero no son adecuados para liberar recursos, ya que no se sabe de antemano el momento del borrado, y puede convertirse que el recurso se libera mucho más tarde de que el objeto deja de ser utilizado. En tales casos, el programador todavía tiene que rastrear el uso del objeto manualmente y realizar operaciones manualmente para liberar los recursos ocupados por el objeto. En C# , existe una interfaz específica para este propósito , IDisposableen Java- .AutoCloseable Pérdida de memoria En sistemas con recolección de basura, también pueden ocurrir pérdidas de memoria, aunque tienen una naturaleza ligeramente diferente. Una referencia a un objeto no utilizado se puede almacenar en otro objeto que se está utilizando y se convierte en una especie de "ancla" que retiene el objeto innecesario en la memoria. Por ejemplo, el objeto creado se agrega a la colección utilizada para operaciones auxiliares, luego deja de usarse, pero no se elimina de la colección. La colección contiene la referencia, el objeto permanece accesible y no se recolecta basura. El resultado es la misma pérdida de memoria. Para eliminar tales problemas, el tiempo de ejecución puede admitir una característica especial: las llamadas referencias débiles . Las referencias débiles no retienen el objeto y se convierten nulltan pronto como el objeto desaparece, por lo que el código debe estar preparado para el hecho de que un día la referencia no apuntará a ninguna parte. Pérdida de eficiencia en operaciones con asignación y desasignación de memoria frecuente Algunas acciones que son bastante inofensivas en sistemas con administración de memoria manual pueden generar una sobrecarga desproporcionadamente grande en sistemas con recolección de elementos no utilizados. Un ejemplo clásico de tal problema se muestra a continuación. Cadena fuera = "" ; // Se supone que las cadenas contienen una gran cantidad de cadenas cortas, // de las cuales debe recopilar una cadena grande en la variable de salida. for ( String str : strings ) { out += str ; // Este código creará // una nueva variable de cadena en cada iteración y le asignará memoria. } Este código Java parece como si la variable out, creada una vez, se "añadiera" con una nueva línea cada vez en el ciclo. De hecho, las cadenas en Java son inmutables, por lo que en este código, en cada paso del ciclo, ocurrirá lo siguiente:En comparación con la gestión manual de la memoria, la recolección de elementos no utilizados es más segura porque evita las fugas de memoria y los enlaces colgantes que provocan la eliminación prematura de objetos. También simplifica el proceso de programación en sí .
Se cree que la recolección de basura reduce significativamente la sobrecarga de administración de memoria en comparación con los lenguajes que no la implementan. Según un estudio [3] , los programadores de C dedican entre el 30 % y el 40 % de su tiempo total de desarrollo (excluyendo la depuración) solo a la gestión de la memoria. Sin embargo, existen estudios con conclusiones opuestas, por ejemplo, en [4] se afirma que la diferencia real en la velocidad de desarrollo de software en C++, donde no existe recolección automática de basura, y en Java, donde se implementa , es pequeño.
La presencia de un recolector de basura en un desarrollador sin experiencia puede crear la falsa creencia de que no necesita prestar atención a la gestión de la memoria en absoluto. Si bien el recolector de basura reduce los problemas de mala administración de la memoria, no los elimina por completo, y los que persisten no se muestran como errores obvios, como un error de protección general , sino como memoria desperdiciada cuando se ejecuta un programa. Un ejemplo típico: si el programador ha perdido de vista el hecho de que queda al menos un puntero no anulable en el objeto en el ámbito global, dicho objeto nunca se eliminará; encontrar una pseudofuga de este tipo puede ser muy difícil.
A menudo, es fundamental no solo asegurarse de que el recurso se libere, sino también asegurarse de que se libere antes de que se llame a algún otro procedimiento, por ejemplo, archivos abiertos, entradas en secciones críticas. Los intentos de otorgar el control de estos recursos al recolector de basura (a través de finalizadores ) serán ineficientes o incluso incorrectos, por lo que deberá administrarlos manualmente. Recientemente, incluso en lenguajes con un recolector de basura, se ha introducido una sintaxis que garantiza la ejecución de "código de limpieza" (por ejemplo, un método especial de "destructor") cuando una variable que hace referencia a un objeto queda fuera del alcance.
En muchos casos, los sistemas con recolección de basura son menos eficientes, tanto en términos de velocidad como de uso de memoria (lo cual es inevitable, ya que el recolector de basura en sí mismo consume recursos y necesita un exceso de memoria libre para funcionar correctamente). Además, en sistemas con recolección de basura, es más difícil implementar algoritmos de bajo nivel que requieran acceso directo a la memoria RAM de la computadora, ya que el uso libre de punteros es imposible y el acceso directo a la memoria requiere interfaces especiales escritas en lenguajes de bajo nivel. . Por otro lado, los sistemas modernos de recolección de basura utilizan algoritmos de administración de memoria muy eficientes con una sobrecarga mínima. También es imposible no tener en cuenta el hecho de que ahora la memoria RAM es relativamente barata y está disponible. Bajo tales condiciones, las situaciones en las que son los costos de recolección de basura los que se vuelven críticos para la eficiencia del programa son extremadamente raras.
La ventaja significativa de la recolección de basura es cuando los objetos creados dinámicamente viven durante mucho tiempo, se duplican muchas veces y las referencias a ellos se pasan entre diferentes partes del programa. En tales condiciones, es bastante difícil determinar el lugar donde el objeto ha dejado de usarse y puede eliminarse. Dado que esta es precisamente la situación con el uso generalizado de estructuras de datos que cambian dinámicamente (listas, árboles, gráficos), la recolección de basura es necesaria en lenguajes funcionales y lógicos que usan ampliamente tales estructuras, como Haskell , Lisp o Prolog . El uso de la recolección de basura en los lenguajes imperativos tradicionales (basados en un paradigma estructural, quizás complementado por las facilidades de los objetos) está determinado por el equilibrio deseado entre la simplicidad y la velocidad del desarrollo del programa y la eficiencia de su ejecución.
El soporte en algunos lenguajes imperativos para llamar automáticamente al destructor cuando un objeto sale del alcance sintáctico ( C++ [5] , Ada , Delphi ) le permite colocar el código de liberación de memoria en el destructor y asegurarse de que se llamará de todos modos . Esto le permite concentrar lugares peligrosos dentro de la implementación de la clase y no requiere recursos adicionales, aunque impone requisitos más altos en las calificaciones del programador. Al mismo tiempo, es posible liberar de forma segura otros recursos ocupados por el objeto en el destructor.
Una alternativa a la recolección de basura es la tecnología de uso de " referencias inteligentes ", cuando una referencia a un objeto dinámico en sí mismo realiza un seguimiento del número de usuarios y elimina automáticamente el objeto cuando este número se convierte en cero. Un problema bien conocido con las "referencias inteligentes" es que en condiciones en las que el programa crea constantemente muchos objetos pequeños de corta duración en la memoria (por ejemplo, al procesar estructuras de listas), pierden rendimiento frente a la recolección de elementos no utilizados.
Desde la década de 1960, existe la gestión de memoria basada en regiones , una tecnología en la que la memoria se divide en fragmentos relativamente grandes llamados regiones , y ya dentro de las regiones, la memoria se asigna a objetos individuales. Con el control manual, las regiones son creadas y eliminadas por el propio programador, con el control automático, se utilizan varios tipos de estimaciones conservadoras para determinar cuándo dejan de usarse todos los objetos asignados dentro de la región, después de lo cual el sistema de administración de memoria elimina toda la región. Por ejemplo, se crea una región en la que se asigna memoria para todos los objetos creados dentro de un determinado ámbito, no pasados fuera, y esta región se destruye con un comando cuando la ejecución del programa sale de este ámbito. La transición en la gestión de la memoria (ya sea manual o automática) de objetos individuales a unidades más grandes en muchos casos nos permite simplificar la contabilidad de la vida útil de los objetos y, al mismo tiempo, reducir los costos generales. Existen implementaciones (de diversos grados de automatización) de gestión de memoria regional para muchos lenguajes de programación, incluidos ML , Prolog , C , Cyclone .
El lenguaje de programación Rust ofrece el concepto de "propiedad" basado en el estricto control del compilador sobre la vida útil y el alcance de los objetos. La idea es que cuando se crea un objeto, la variable a la que se le asigna una referencia se convierte en el "propietario" de ese objeto, y el alcance de la variable de propietario limita la vida útil del objeto. Al salir del alcance del propietario, el objeto se elimina automáticamente. Al asignar una referencia de objeto a otra variable, se puede "tomar prestado", pero el préstamo siempre es temporal y debe completarse durante la vida del propietario del objeto. La "propiedad" se puede transferir a otra variable (por ejemplo, se puede crear un objeto dentro de una función y devolverlo como resultado), pero el propietario original pierde el acceso al objeto. En conjunto, las reglas están diseñadas para garantizar que un objeto no se pueda modificar sin control a través de referencias extrañas. El compilador realiza un seguimiento estático de la vida útil de los objetos: cualquier operación que incluso pueda conducir potencialmente a guardar una referencia a un objeto después de que su propietario quede fuera del alcance conduce a un error de compilación, lo que elimina la aparición de "referencias colgantes" y pérdidas de memoria. Este enfoque complica la técnica de programación (respectivamente, dificultando el aprendizaje del lenguaje), pero elimina la necesidad tanto de la asignación manual como de la desasignación de memoria, y el uso de la recolección de elementos no utilizados.
La recolección de basura como un atributo indispensable del entorno de ejecución del programa se utiliza en lenguajes basados en el paradigma declarativo , como LISP , ML , Prolog , Haskell . Su necesidad en este caso se debe a la propia naturaleza de estos lenguajes, que no contienen herramientas para gestionar manualmente el tiempo de vida de los objetos y no tienen la posibilidad de integración natural de dichas herramientas. La estructura básica de datos complejos en dichos lenguajes suele ser una lista dinámica de enlaces simples que consta de celdas de lista asignadas dinámicamente. Las listas se crean, copian, duplican, combinan y dividen constantemente, lo que hace que sea casi imposible administrar manualmente la vida útil de cada celda de la lista asignada.
En lenguajes imperativos, la recolección de basura es una opción, junto con el manual y algunas técnicas de administración de memoria alternativas Aquí se considera como un medio para simplificar la programación y prevenir errores . Uno de los primeros lenguajes imperativos compilados con recolección de basura fue Oberon , que demostró la aplicabilidad y la eficiencia bastante alta de este mecanismo para este tipo de lenguaje, pero el lenguaje Java trajo gran popularidad y popularidad a este enfoque . Posteriormente, el enfoque de Java se repitió en el entorno .NET y en casi todos los lenguajes que trabajan en él, comenzando con C# y Visual Basic .NET . Al mismo tiempo, aparecieron muchos lenguajes interpretados (JavaScript, Python, Ruby, Lua), donde se incluyó la recolección de basura por razones de accesibilidad del lenguaje para los no programadores y simplificación de la codificación. El aumento de la potencia del hardware, que se produjo simultáneamente con la mejora de los propios recolectores, llevó al hecho de que la sobrecarga adicional para la recolección de basura dejó de ser significativa. La mayoría de los lenguajes imperativos modernos recolectados en basura no tienen ninguna forma de eliminar objetos explícitamente de forma manual (como el operador de eliminación). En los sistemas que usan un intérprete o compilan a código de bytes, el recolector de elementos no utilizados es parte del tiempo de ejecución; en los mismos lenguajes que compilan a código objeto de procesador, se implementa como una biblioteca de sistema requerida.
También hay una pequeña cantidad de idiomas ( nim , Modula-3 , D ) que admiten la gestión de memoria tanto manual como automática, para lo cual la aplicación utiliza dos montones separados.