Esta es una traducción del artículo de Alexey Shipilev "Hágalo usted mismo (OpenJDK) recolector de basura" , publicado con el consentimiento del autor. Informe cualquier error tipográfico y otros errores en PM; lo solucionaremos.
El proceso de crear algo en tiempo de ejecución es un ejercicio divertido. ¡Al menos la creación de la primera versión! Crear un subsistema de tiempo de ejecución confiable, de alto rendimiento y a prueba de fallas, cuyo comportamiento se puede observar y depurar convenientemente, es una tarea muy, muy difícil.
Hacer un simple recolector de basura es engañosamente simple, y ahora quiero hacer esto en este artículo. Roman Kennke en FOSDEM 2019 hizo una charla y una demostración titulada "Escribir un GC en 20 minutos", utilizando una versión anterior de este parche. A pesar del hecho de que el código implementado allí demuestra mucho y es ampliamente comentado, existe la necesidad de una buena descripción de alto nivel de lo que está sucediendo: así es como apareció este artículo.
Una comprensión básica del trabajo de los recolectores de basura será de gran ayuda para comprender lo que está escrito aquí. El artículo utilizará detalles e ideas en una implementación específica de HotSpot, pero aquí no habrá un curso introductorio sobre diseño de GC. Tome el Manual de GC y lea los primeros capítulos sobre los conceptos básicos de GC, y aún más rápido comenzará el artículo de Wikipedia .

Contenido
1. En qué consiste GC
Ahora que se han escrito muchos GC diferentes, es bastante sencillo crear uno propio: muchos elementos ya escritos pueden (re) usarse para cambiar algunas de las preocupaciones sobre los detalles de implementación a código probado y probado.
1.1. Epsilon gc
OpenJDK 11 presenta un nuevo JEP 318: "Epsilon: un recolector de basura sin operación (experimental)" . Su tarea es proporcionar una implementación mínima para el caso cuando liberar memoria no es necesario o incluso está prohibido. JEP discute con más detalle por qué podría ser útil.
Desde el punto de vista de la implementación, "recolector de basura" es un mal nombre, sería más correcto usar el término "administrador de memoria automática" , que es responsable de asignar y liberar memoria. Epsilon GC implementa solo "asignación", y no trata con "lanzamiento" en absoluto. Por lo tanto, puede tomar Epsilon GC y comenzar a implementar los algoritmos de "lanzamiento" desde cero.
1.1.1. Asignación de memoria
La parte más desarrollada del Epsilon GC es responsable de asignar memoria . Atiende solicitudes externas para asignar memoria de tamaño arbitrario y crear Buffer de asignación local de subprocesos (TLAB) del tamaño deseado. La implementación en sí está tratando de no extender demasiado la TLAB, ya que no habrá memoria libre y nadie devolverá los bytes perdidos.
1.1.2. Barreras
Algunos recolectores de basura requieren interacción con la aplicación para mantener invariantes GC, lo que obliga al tiempo de ejecución y a la aplicación a crear las llamadas barreras al intentar acceder al montón. Esto es cierto para todos los coleccionistas de subprocesos múltiples, así como para muchos coleccionistas con generaciones y deteniendo al mundo.
Epsilon no requiere barreras, pero el tiempo de ejecución y el compilador aún quieren saber que las barreras no hacen nada. Manejarlo cada vez en todas partes puede ser agotador. Afortunadamente, comenzando con OpenJDK 11, hay un nuevo JEP-304: "Interfaz de recolección de basura" , que hace que sea mucho más fácil insertar barreras. En particular, la barrera establecida en Epsilon está vacía , y todo el trabajo trivial (guardar, cargar, CAS, arraycopy) se puede delegar a implementaciones de barreras triviales de una superclase existente. Si está creando un GC que tampoco necesita barreras, simplemente puede reutilizar el código de Epsilon.
1.1.3. Conexión de monitoreo
La última parte tediosa de la implementación del GC son los enganches a un montón de mecanismos de monitoreo dentro de la JVM: MX-bins, comandos de diagnóstico, etc. deberían funcionar. Epsilon ya ha hecho todo esto por ti.
1.2. Rantime y GC
1.2.1 Elementos de la raíz
El recolector de basura, en el caso general, necesita saber qué exactamente en el tiempo de ejecución de Java tiene referencias de montón. Estos elementos raíz, llamados raíces GC , pueden ser ranuras en pilas de flujo y variables locales (incluidas las que se encuentran en el código compilado JIT), clases nativas y cargadores de clases, referencias en JNI, etc. Los intentos de identificar estos elementos pueden ser muy complejos y tediosos. Pero en Hotspot, todos se rastrean utilizando los subsistemas VM apropiados, por lo que simplemente puede aprender cómo funcionan las implementaciones de GC existentes con ellos. Más adelante en el texto lo veremos.
1.2.2. Rastreo de objetos
El recolector de basura debe omitir los enlaces salientes en los objetos Java. Esta operación se encuentra en todas partes, por lo que las partes comunes del tiempo de ejecución brindan soluciones preparadas; no necesita escribir nada usted mismo. A continuación, habrá una sección con una implementación específica, y allí puede encontrar, por ejemplo, llamadas obj→oop_iterate
.
1.2.3 Desplazamientos
El recolector de basura en movimiento necesita anotar las nuevas direcciones de los objetos movidos en alguna parte. Hay varios lugares donde puede escribir estos datos de reenvío .
- Puede reutilizar la "palabra marcadora" en el objeto mismo (Serie, Paralelo, etc.). Después de que el mundo se detiene, se controlan todos los accesos al objeto, y se garantiza que ningún hilo de Java pueda ver los datos temporales que decidimos ingresar en la palabra del marcador. Puede reutilizarlo para almacenar datos de reenvío.
- Puede mantener una tabla de movimiento nativa separada ( ZGC , C4 y otras). Esto aísla completamente el GC del tiempo de ejecución y el resto de la aplicación, ya que solo el GC sabe acerca de la existencia de dicha tabla. Los ensambladores competitivos generalmente usan tal esquema: no quieren sufrir con un montón de problemas innecesarios.
- Puede agregar otra palabra al objeto ( Shenandoah y otros). Esta combinación de los dos enfoques anteriores no solo permite que el tiempo de ejecución y la aplicación trabajen con los encabezados existentes sin problemas, sino que también guarda los datos de reenvío.
1.2.4. Datos de marcador
El recolector de basura necesita escribir datos de marcado en alguna parte. Y de nuevo, hay varias formas de guardarlos:
- Puede reutilizar la palabra del marcador en el objeto mismo (Serie, Paralelo, etc.). Nuevamente, en el modo de parada mundial, puede usar los bits en la palabra del marcador para codificar el hecho de una etiqueta. Además, si necesita recorrer todos los objetos vivos, vamos a lo largo del montón, objeto tras objeto, esto es posible debido al hecho de que el montón es analizable .
- Puede mantener una estructura separada para almacenar datos de marcado (G1, Shenandoah, etc.). Esto generalmente se hace usando un mapa de bits separado , que asigna cada N bytes del montón a 1 bit de la tarjeta. Por lo general, los objetos Java están alineados por 8 bytes , por lo que la tarjeta asigna cada 64 bits desde el montón a 1 bit de la tarjeta, ocupando 1/64 del tamaño del montón en la memoria nativa. Estos gastos generales dan buenos resultados cuando se escanea el montón para detectar la presencia de objetos vivos, especialmente los dispersos: omitir el mapa a menudo es mucho más rápido que evitar que el montón se desarme objeto por objeto.
- Codifique las etiquetas en enlaces (ZGC, C4 y otros). Esto requiere coordinación con la aplicación, luego debe cortar todas estas etiquetas de los enlaces o realizar otros trucos para mantener la corrección. En otras palabras, necesitamos barreras o algún trabajo adicional del GC.
2. Plan general
Lo más probable es que el más fácil de implementar sobre Epsilon sea el Mark-Compact, en el estilo LISP2. La idea básica de este GC se describe tanto en Wikipedia como en el Manual de GC (capítulo 3.2). Un bosquejo del algoritmo estará en la sección con la implementación a continuación, pero le recomiendo leer un poco de Wikipedia o el Manual de GC para comprender lo que vamos a hacer.
El algoritmo en cuestión es el GC cambiante : los objetos en movimiento se mueven en una matriz hasta el comienzo del montón. Tiene sus pros y sus contras:
- Mantiene el orden de las asignaciones de memoria. Esto es muy bueno para controlar el diseño en la memoria, si es importante para ti (¡controla a los monstruos, es tu momento!). La desventaja es que no obtendrá la ubicación de enlace automático de esta manera.
- Su complejidad es O (N) del número de objetos. Sin embargo, la linealidad tiene un precio: se requiere que GC omita un montón de 4 veces para cada ciclo de construcción.
- ¡No requiere memoria libre en el montón! No es necesario reservar memoria en el montón para evacuar objetos vivos, por lo que incluso puede trabajar con un montón que se desborda en un 99. (9)%. Si tomamos otras ideas de recolectores simples, por ejemplo, un carroñero con un semi-espacio (carroñero semi-espacial), tendremos que reescribir ligeramente la presentación del montón y reservar un poco de espacio para la evacuación, pero esto está más allá del alcance de este ejercicio.
- Si trabaja un poco en el tema, puede lograr cero memoria y consumo de tiempo durante los períodos en que el GC está inactivo. Comienza en una memoria en un estado arbitrario y se detiene, compactándolo significativamente. Esto encaja muy bien con el funcionamiento de Epsilon: solo sigue resaltando justo después del último objeto. Esto también es una desventaja: unos pocos objetos muertos al comienzo del montón conducen a una gran cantidad de movimientos.
- Simplemente no requiere nuevas barreras, puede reutilizar
EpsilonBarrierSet
tal como está.
Para simplificar, la implementación de GC utilizará una parada completa del mundo (stop-the-world, STW), no tendrá generaciones o subprocesos múltiples. Para este caso, tiene sentido usar un mapa de bits para almacenar marcas y reutilizar la palabra del marcador para almacenar datos de movimiento.
3. Implementación del núcleo del GC
Leer y comprender toda la implementación puede ser demasiado complicado para una persona ignorante. En esta sección, lo entenderemos paso a paso.
3.1. Prologo
El recolector de basura generalmente necesita hacer un par de cosas para prepararse para la recolección. Lea los comentarios, deben hablar por sí mismos:
{ GCTraceTime(Info, gc) time("Step 0: Prologue", NULL);
Como usamos un mapa de bits para rastrear la accesibilidad de los objetos, necesitamos borrarlo antes de usarlo. O en nuestro caso, dado que nuestro objetivo es nunca pedir recursos antes de comenzar el ciclo GC, tendremos que enviar el mapa de bits a la memoria de antemano. Esto proporciona varias ventajas interesantes, al menos en Linux, donde la mayoría del mapa de bits apuntará a la página cero, especialmente para los montones dispersos.
Los subprocesos deberían liberar sus TLAB y solicitar a GC nuevos después de completar la compilación.
No confunda TLAB y java.lang.ThreadLocal
. Desde el punto de vista del GC, ThreadLocal son objetos ordinarios, y el GC no los compilará a menos que se requiera específicamente lo contrario en el código Java.
Algunas partes del tiempo de ejecución, especialmente aquellas que tienen enlaces al montón de Java, se romperán durante la recolección de basura, por lo que debe advertirles específicamente que el GC comenzará a funcionar pronto. Esto permitirá que los subsistemas respectivos preparen y salven parte de su estado antes de que el GC haga su movimiento.
3.2. Marcado
Marcar para detener el modo mundial se vuelve bastante simple cuando casi todo se ha hecho por nosotros. El etiquetado es bastante estándar, y muy probablemente, en muchas implementaciones, GC es el primer paso.
{ GCTraceTime(Info, gc) time("Step 1: Mark", NULL);
Esto funciona exactamente igual que para cualquier otro gráfico: comienza el recorrido con el conjunto inicial de vértices alcanzables, recorre los bordes salientes y registra todos los vértices visitados. El recorrido continúa hasta que terminan todos los picos no visitados. En GC, los "vértices" son objetos y los "bordes" son enlaces entre ellos.
Técnicamente, podríamos pasar recursivamente por el gráfico de objetos, pero esta es una mala idea para gráficos arbitrarios que pueden tener diámetros muy grandes. ¡Imagine una lista vinculada de mil millones de picos! Por lo tanto, para limitar la profundidad de la recursión, utilizamos una pila de marcado que registra los objetos detectados.
El conjunto inicial de objetos accesibles son las raíces GC. Ahora no te detengas en qué son las process_roots
, más sobre eso más adelante. Por ahora, digamos que omite todos los enlaces accesibles desde el lado de la VM.
Un mapa de bits con marcas sirve tanto como herramienta para grabar el frente de onda de marcado (muchos objetos ya visitados) como, al final, como depósito del resultado deseado, un conjunto de todos los objetos accesibles. El trabajo real tiene lugar en EpsilonScanOopClosure
, se aplica a todos los objetos interesantes y se repite en todos los enlaces del objeto seleccionado.
¡Mire, Java sabía cómo cerrar (cerrar) antes de ponerse de moda!
class EpsilonScanOopClosure : public BasicOopIterateClosure { private: EpsilonMarkStack* const _stack; MarkBitMap* const _bitmap; template <class T> void do_oop_work(T* p) {
Después de completar este paso, _bitmap
contiene bits que indican la ubicación de los objetos vivos. Gracias a esto, es posible evitar todos los objetos vivos, por ejemplo:
3.3. Calcular nuevas direcciones
Este también es un paso bastante simple, e implementa exactamente lo que dice el algoritmo.

Lo único que llama la atención es que decidimos almacenar nuevas direcciones en la palabra de marcado de objetos Java, y esta palabra ya puede estar ocupada por algo importante, por ejemplo, información sobre bloqueos. Afortunadamente, tales palabras de marcado no triviales son bastante raras, y simplemente podemos almacenarlas por separado, si es necesario: para esto PreservedMarks
utiliza PreservedMarks
.
El trabajo algorítmico real lo realiza EpsilonCalcNewLocationObjectClosure
:
class EpsilonCalcNewLocationObjectClosure : public ObjectClosure { private: HeapWord* _compact_point; PreservedMarks* const _preserved_marks; public: EpsilonCalcNewLocationObjectClosure(HeapWord* start, PreservedMarks* pm) : _compact_point(start), _preserved_marks(pm) {} void do_object(oop obj) {
forward_to
es la parte más importante porque almacena la "dirección de movimiento" en la palabra del marcador del objeto. Esto será necesario en los próximos pasos.
3.4. Fijar punteros
Ahora debe volver a revisar el montón y volver a escribir todos los enlaces con sus nuevas direcciones de acuerdo con el siguiente algoritmo:

{ GCTraceTime(Info, gc) time("Step 3: Adjust pointers", NULL);
Hay dos tipos de referencias a objetos desplazados: salientes ya sea de objetos en el montón mismo o de raíces GC. Necesita actualizar ambas clases de enlaces. Algunas etiquetas guardadas también almacenan referencias a objetos, por lo que debe solicitarles que se actualicen. PreservedMarks
sabe cómo hacer esto porque espera "reenviar datos" en el mismo lugar donde los guardamos, en la palabra de marcado del objeto.
Los cierres se dividen en dos tipos: algunos toman objetos y omiten su contenido, otros actualizan estas direcciones. Aquí puede hacer una pequeña optimización del rendimiento: si el objeto no se mueve, puede guardar un par de registros en un montón:
class EpsilonAdjustPointersOopClosure : public BasicOopIterateClosure { private: template <class T> void do_oop_work(T* p) {
Después de completar este paso, esencialmente rompimos el montón: los enlaces apuntan a las direcciones "incorrectas" en las que los objetos aún no se encuentran. ¡Vamos a arreglarlo!
3.5. Movemos objetos
Tiempo para mover objetos a nuevas direcciones, de acuerdo con el algoritmo:

Vuelva a rodear los montones y aplique el cierre EpsilonMoveObjectsObjectClosure
a todos los objetos vivos:
{ GCTraceTime(Info, gc) time("Step 4: Move objects", NULL);
Inmediatamente después de eso, puede arrastrar el montón del montón de puntos de compactación, lo que permite asignar memoria directamente desde este lugar, inmediatamente después de que finalice el ciclo de recolección de basura.
Tenga en cuenta que en el ensamblaje de desplazamiento podemos sobrescribir el contenido de los objetos existentes, pero dado que el escaneo va en la misma dirección, los objetos sobrescritos ya se copian en el lugar correcto.
Las ubicaciones antiguas y nuevas de la misma instalación pueden cruzarse. Por ejemplo, si cambia un objeto de 100 bytes por 8 bytes. El procedimiento de copia debería funcionar por sí mismo, y el contenido de intersección debería copiarse correctamente, preste atención a Copy::aligned_*conjoint*_words
.
El cierre en sí simplemente moverá los objetos movidos a las nuevas direcciones:
class EpsilonMoveObjectsObjectClosure : public ObjectClosure { public: void do_object(oop obj) {
3.6. Epílogo
La recolección de basura ha finalizado, el montón vuelve a ser casi consistente, quedan los últimos toques finales:
{ GCTraceTime(Info, gc) time("Step 5: Epilogue", NULL);
Informamos al resto del tiempo de ejecución que deben comenzar los procedimientos posteriores al ensamblaje. Restauramos las palabras de marcador especiales que guardamos anteriormente. Beso de despedida a nuestra tarjeta de marcador: ya no es necesario.
Y, si realmente lo desea, puede reducir la memoria para las asignaciones a un nuevo tamaño, ¡devolviendo así la memoria al sistema operativo!
4. Conecte el GC a la VM
4.1. Transversal de la raíz
¿Recuerda que debe omitir enlaces especiales y accesibles desde VM? Puede solicitar a cada subsistema VM especial que omita los enlaces ocultos de otros objetos Java. Una lista exhaustiva de tales elementos raíz en el Hotspot actual se parece a esto:
void EpsilonHeap::do_roots(OopClosure* cl) {
, . GC .
4.2.
GC , VM . Hotspot VM_Operation
, GC VM- :
, GC — , .
4.3.
, GC , , GC , . , allocate_work
, GC :
HeapWord* EpsilonHeap::allocate_or_collect_work(size_t size) { HeapWord* res = allocate_work(size); if (res == NULL && EpsilonSlidingGC) { vmentry_collect(GCCause::_allocation_failure); res = allocate_work(size); } return res; }
!
5.
OpenJDK.
$ hg clone https://hg.openjdk.java.net/jdk/jdk/ jdk-jdk $ cd jdk-jdk $ curl https://shipilev.net/jvm/diy-gc/webrev/jdk-jdk-epsilon.changeset | patch -p1
OpenJDK :
$ ./configure --with-debug-level=fastdebug $ make images
:
$ build/linux-x86_64-server-fastdebug/images/jdk/bin/java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+EpsilonSlidingGC -version openjdk version "13-internal" 2019-09-17 OpenJDK Runtime Environment (build 13-internal+0-adhoc.shade.jdk-jdk-epsilon) OpenJDK 64-Bit Server VM (build 13-internal+0-adhoc.shade.jdk-jdk-epsilon, mixed mode, sharing
6.
, GC ? :
- . . Hotspot , JVM fastdebug , GC.
- . , . , ( ) , .
- Pruebas , , , . - , .
, , :
$ CONF=linux-x86_64-server-fastdebug make images run-test TEST=gc/epsilon/ Building targets 'images run-test' in configuration 'linux-x86_64-server-fastdebug' Test selection 'gc/epsilon/', will run: * jtreg:test/hotspot/jtreg/gc/epsilon Running test 'jtreg:test/hotspot/jtreg/gc/epsilon' Passed: gc/epsilon/TestAlwaysPretouch.java Passed: gc/epsilon/TestAlignment.java Passed: gc/epsilon/TestElasticTLAB.java Passed: gc/epsilon/TestEpsilonEnabled.java Passed: gc/epsilon/TestHelloWorld.java Passed: gc/epsilon/TestLogTrace.java Passed: gc/epsilon/TestDieDefault.java Passed: gc/epsilon/TestDieWithOnError.java Passed: gc/epsilon/TestMemoryPools.java Passed: gc/epsilon/TestMaxTLAB.java Passed: gc/epsilon/TestPrintHeapSteps.java Passed: gc/epsilon/TestArraycopyCheckcast.java Passed: gc/epsilon/TestClasses.java Passed: gc/epsilon/TestUpdateCountersSteps.java Passed: gc/epsilon/TestDieWithHeapDump.java Passed: gc/epsilon/TestByteArrays.java Passed: gc/epsilon/TestManyThreads.java Passed: gc/epsilon/TestRefArrays.java Passed: gc/epsilon/TestObjects.java Passed: gc/epsilon/TestElasticTLABDecay.java Passed: gc/epsilon/TestSlidingGC.java Test results: passed: 21 TEST SUCCESS
? fastdebug . ? - .
7.
- spring-petclinic , Apache Bench GC! , , GC .
: -Xlog:gc -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+EpsilonSlidingGC
:
:
Heap: 20480M reserved, 20480M (100.00%) committed, 19497M (95.20%) used GC(2) Step 0: Prologue 2.085ms GC(2) Step 1: Mark 51.005ms GC(2) Step 2: Calculate new locations 71.207ms GC(2) Step 3: Adjust pointers 49.671ms GC(2) Step 4: Move objects 22.839ms GC(2) Step 5: Epilogue 1.008ms GC(2) GC Stats: 70561 (8.63%) reachable from roots, 746676 (91.37%) reachable from heap, 91055 (11.14%) moved, 2237 (0.27%) markwords preserved GC(2) Heap: 20480M reserved, 20480M (100.00%) committed, 37056K (0.18%) used GC(2) Lisp2-style Mark-Compact (Allocation Failure) 20479M->36M(20480M) 197.940ms
200 ? GC! , . , , : ( — , ). - ( ).
, GC . , -Xlog:gc -XX:+UseSerialGC
— , , :
GC(46) Pause Young (Allocation Failure) 575M->39M(1943M) 2.603ms GC(47) Pause Young (Allocation Failure) 575M->39M(1943M) 2.606ms GC(48) Pause Young (Allocation Failure) 575M->39M(1943M) 2.747ms GC(49) Pause Young (Allocation Failure) 575M->39M(1943M) 2.578ms
, 2 . , , GC . -Xlog:gc -XX:+UseSerialGC
, , :
GC(3) Pause Full (Allocation Failure) 16385M->34M(18432M) 1969.694ms GC(4) Pause Full (Allocation Failure) 16385M->34M(18432M) 2261.405ms GC(5) Pause Full (Allocation Failure) 16385M->34M(18432M) 2327.577ms GC(6) Pause Full (Allocation Failure) 16385M->34M(18432M) 2328.976ms
, . .
8. ?
. , GC OpenJDK — , , .
:
. , // . . , , « » , , .
GC, java.lang.ref.Reference.referent
— Java-, , , - . FinalReference
, .
ReferenceProcessor
/ / .
VM. VM, , , . . , , , - , .
. — , GC, . , , , .
mark-compact GC Full GC fallbacks Shenandoah ( OpenJDK 8) G1 ( OpenJDK 10, JEP 307: «Parallel Full GC for G1» ).
. , «» , , , - . . , .
. , , , . , «» — «» «» , .
- GC Handbook .
9.
? GC — , , , GC.
, - GC . , , GC (, Serial GC Parallel GC), .
Minuto de publicidad. , 5-6 2019, JPoint — Java-. — OpenJDK, GraalVM, Kotlin . .