Máquina virtual Java - Guía rápida

La JVM es una especificación y puede tener diferentes implementaciones, siempre que se adhieran a las especificaciones. Las especificaciones se pueden encontrar en el siguiente enlace:https://docs.oracle.com

Oracle tiene su propia implementación de JVM (llamada HotSpot JVM), IBM tiene la suya propia (la J9 JVM, por ejemplo).

Las operaciones definidas dentro de la especificación se dan a continuación (fuente - Oracle JVM Specs, consulte el enlace anterior) -

  • El formato de archivo de 'clase'
  • Tipos de datos
  • Tipos y valores primitivos
  • Tipos y valores de referencia
  • Áreas de datos en tiempo de ejecución
  • Frames
  • Representacion de objetos
  • Aritmética de coma flotante
  • Métodos especiales
  • Exceptions
  • Resumen del conjunto de instrucciones
  • Bibliotecas de clases
  • Diseño público, implementación privada

La JVM es una máquina virtual, una computadora abstracta que tiene su propio ISA, su propia memoria, pila, montón, etc. Se ejecuta en el sistema operativo host y le asigna sus demandas de recursos.

La arquitectura de HotSpot JVM 3 se muestra a continuación:

El motor de ejecución se compone del recolector de basura y el compilador JIT. La JVM viene en dos sabores:client and server. Ambos comparten el mismo código de tiempo de ejecución, pero difieren en lo que se utiliza JIT. Aprenderemos más sobre esto más adelante. El usuario puede controlar qué sabor a utilizar, especificando los indicadores JVM -client o -server . La JVM del servidor se ha diseñado para aplicaciones Java de larga ejecución en servidores.

La JVM viene en versiones 32b y 64b. El usuario puede especificar qué versión usar usando -d32 o -d64 en los argumentos de la VM. La versión 32b solo podía abordar hasta 4G de memoria. Con aplicaciones críticas que mantienen grandes conjuntos de datos en la memoria, la versión 64b satisface esa necesidad.

La JVM gestiona el proceso de carga, vinculación e inicialización de clases e interfaces de forma dinámica. Durante el proceso de carga, elJVM finds the binary representation of a class and creates it.

Durante el proceso de vinculación, el loaded classes are combined into the run-time state of the JVM so that they can be executed during the initialization phase. La JVM básicamente utiliza la tabla de símbolos almacenada en el grupo de constantes de tiempo de ejecución para el proceso de vinculación. La inicialización consiste en realidadexecuting the linked classes.

Tipos de cargadores

los BootStrapcargador de clases está en la parte superior de la jerarquía del cargador de clases. Carga las clases estándar de JDK en el directorio lib de JRE .

los Extension El cargador de clases está en el medio de la jerarquía del cargador de clases y es el hijo inmediato del cargador de clases de arranque y carga las clases en el directorio lib \ ext de JRE.

los Applicationcargador de clases está en la parte inferior de la jerarquía del cargador de clases y es el hijo inmediato del cargador de clases de la aplicación. Carga los frascos y clases especificadas por elCLASSPATH ENV variable.

Enlace

El proceso de vinculación consta de los siguientes tres pasos:

Verification- Esto lo hace el verificador de Bytecode para garantizar que los archivos .class generados (el Bytecode) sean válidos. De lo contrario, se produce un error y el proceso de vinculación se detiene.

Preparation - La memoria se asigna a todas las variables estáticas de una clase y se inicializan con los valores predeterminados.

Resolution- Todas las referencias de memoria simbólicas se sustituyen por las referencias originales. Para lograr esto, se utiliza la tabla de símbolos en la memoria constante de tiempo de ejecución del área de método de la clase.

Inicialización

Esta es la fase final del proceso de carga de clases. A las variables estáticas se les asignan valores originales y se ejecutan bloques estáticos.

La especificación JVM define ciertas áreas de datos en tiempo de ejecución que se necesitan durante la ejecución del programa. Algunos de ellos se crean mientras se inicia la JVM. Otros son locales de los hilos y se crean solo cuando se crea un hilo (y se destruyen cuando se destruye el hilo). Estos se enumeran a continuación:

Registro de PC (Contador de programas)

Es local para cada hilo y contiene la dirección de la instrucción JVM que el hilo está ejecutando actualmente.

Apilar

Es local para cada hilo y almacena parámetros, variables locales y direcciones de retorno durante las llamadas a métodos. Se puede producir un error de StackOverflow si un subproceso exige más espacio de pila del permitido. Si la pila se puede expandir dinámicamente, aún puede generar OutOfMemoryError.

Montón

Se comparte entre todos los hilos y contiene objetos, metadatos de clases, matrices, etc., que se crean durante el tiempo de ejecución. Se crea cuando se inicia la JVM y se destruye cuando se cierra la JVM. Puede controlar la cantidad de montón que su JVM demanda del sistema operativo usando ciertos indicadores (más sobre esto más adelante). Se debe tener cuidado de no exigir demasiado o menos memoria, ya que tiene importantes implicaciones en el rendimiento. Además, el GC gestiona este espacio y elimina continuamente los objetos muertos para liberar el espacio.

Área de método

Esta área de tiempo de ejecución es común a todos los subprocesos y se crea cuando se inicia la JVM. Almacena estructuras por clase como el grupo constante (más sobre esto más adelante), el código para constructores y métodos, datos de métodos, etc. El JLS no especifica si esta área necesita ser recolectada de basura y, por lo tanto, implementaciones JVM puede optar por ignorar GC. Además, esto puede expandirse o no según las necesidades de la aplicación. La JLS no exige nada con respecto a esto.

Pool constante de tiempo de ejecución

La JVM mantiene una estructura de datos por clase / por tipo que actúa como la tabla de símbolos (una de sus muchas funciones) mientras vincula las clases cargadas.

Pilas de métodos nativos

Cuando un hilo invoca un método nativo, ingresa a un nuevo mundo en el que las estructuras y restricciones de seguridad de la máquina virtual Java ya no obstaculizan su libertad. Es probable que un método nativo pueda acceder a las áreas de datos en tiempo de ejecución de la máquina virtual (depende de la interfaz del método nativo), pero también puede hacer cualquier otra cosa que desee.

Recolección de basura

La JVM gestiona todo el ciclo de vida de los objetos en Java. Una vez que se crea un objeto, el desarrollador ya no tiene que preocuparse por él. En caso de que el objeto se muera (es decir, ya no se hace referencia a él), el GC lo expulsa del montón utilizando uno de los muchos algoritmos: GC serie, CMS, G1, etc.

Durante el proceso de GC, los objetos se mueven en la memoria. Por lo tanto, esos objetos no se pueden utilizar durante el proceso. Toda la aplicación debe detenerse mientras dure el proceso. Estas pausas se denominan pausas de "detener el mundo" y son una enorme sobrecarga. Los algoritmos de GC apuntan principalmente a reducir este tiempo. Discutiremos esto con gran detalle en los siguientes capítulos.

Gracias al GC, las pérdidas de memoria son muy raras en Java, pero pueden ocurrir. Veremos en los capítulos posteriores cómo crear una fuga de memoria en Java.

En este capítulo, aprenderemos sobre el compilador JIT y la diferencia entre lenguajes compilados e interpretados.

Idiomas compilados frente a interpretados

Los lenguajes como C, C ++ y FORTRAN son lenguajes compilados. Su código se entrega como código binario dirigido a la máquina subyacente. Esto significa que el código de alto nivel se compila en código binario a la vez mediante un compilador estático escrito específicamente para la arquitectura subyacente. El binario que se produce no se ejecutará en ninguna otra arquitectura.

Por otro lado, los lenguajes interpretados como Python y Perl pueden ejecutarse en cualquier máquina, siempre que tengan un intérprete válido. Repasa línea por línea el código de alto nivel, convirtiéndolo en código binario.

El código interpretado suele ser más lento que el código compilado. Por ejemplo, considere un bucle. Un interpretado convertirá el código correspondiente para cada iteración del ciclo. Por otro lado, un código compilado hará que la traducción sea solo una. Además, dado que los intérpretes ven solo una línea a la vez, no pueden realizar ningún código significativo, como cambiar el orden de ejecución de declaraciones como compiladores.

Veremos un ejemplo de dicha optimización a continuación:

Adding two numbers stored in memory. Dado que acceder a la memoria puede consumir varios ciclos de CPU, un buen compilador emitirá instrucciones para recuperar los datos de la memoria y ejecutar la adición solo cuando los datos estén disponibles. No esperará y, mientras tanto, ejecutará otras instrucciones. Por otro lado, tal optimización no sería posible durante la interpretación, ya que el intérprete no es consciente del código completo en un momento dado.

Pero luego, los lenguajes interpretados pueden ejecutarse en cualquier máquina que tenga un intérprete válido de ese idioma.

¿Java está compilado o interpretado?

Java intentó encontrar un término medio. Dado que la JVM se encuentra entre el compilador javac y el hardware subyacente, el compilador javac (o cualquier otro compilador) compila el código Java en Bytecode, que se entiende por una JVM específica de la plataforma. La JVM luego compila el Bytecode en binario usando la compilación JIT (Just-in-time), a medida que se ejecuta el código.

Puntos calientes

En un programa típico, solo hay una pequeña sección de código que se ejecuta con frecuencia y, a menudo, es este código el que afecta significativamente el rendimiento de toda la aplicación. Estas secciones de código se denominanHotSpots.

Si alguna sección de código se ejecuta solo una vez, compilarla sería una pérdida de esfuerzo y sería más rápido interpretar el Bytecode en su lugar. Pero si la sección es una sección activa y se ejecuta varias veces, la JVM la compilaría en su lugar. Por ejemplo, si un método se llama varias veces, los ciclos adicionales que se necesitarían para compilar el código se compensarían con el binario más rápido que se genera.

Además, cuanto más ejecute la JVM un método o un bucle en particular, más información recopilará para realizar diversas optimizaciones de modo que se genere un binario más rápido.

Consideremos el siguiente código:

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Si se interpreta este código, el intérprete deduciría para cada iteración que las clases de obj1. Esto se debe a que cada clase en Java tiene un método .equals (), que se extiende desde la clase Object y se puede anular. Entonces, incluso si obj1 es una cadena para cada iteración, la deducción aún se realizará.

Por otro lado, lo que realmente sucedería es que la JVM notaría que para cada iteración, obj1 es de la clase String y, por lo tanto, generaría código correspondiente al método .equals () de la clase String directamente. Por lo tanto, no se requerirán búsquedas y el código compilado se ejecutará más rápido.

Este tipo de comportamiento solo es posible cuando la JVM sabe cómo se comporta el código. Por tanto, espera antes de compilar determinadas secciones del código.

A continuación se muestra otro ejemplo:

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Un intérprete, para cada ciclo, obtiene el valor de 'suma' de la memoria, le agrega 'I' y lo almacena de nuevo en la memoria. El acceso a la memoria es una operación cara y normalmente requiere varios ciclos de CPU. Dado que este código se ejecuta varias veces, es un HotSpot. El JIT compilará este código y realizará la siguiente optimización.

Una copia local de 'suma' se almacenaría en un registro, específico para un hilo en particular. Todas las operaciones se realizarían con el valor en el registro y cuando se complete el ciclo, el valor se volvería a escribir en la memoria.

¿Qué sucede si otros subprocesos también acceden a la variable? Dado que algún otro hilo está realizando actualizaciones en una copia local de la variable, verían un valor obsoleto. La sincronización de subprocesos es necesaria en tales casos. Una primitiva de sincronización muy básica sería declarar 'suma' como volátil. Ahora, antes de acceder a una variable, un hilo vaciaría sus registros locales y obtendría el valor de la memoria. Después de acceder a él, el valor se escribe inmediatamente en la memoria.

A continuación se muestran algunas optimizaciones generales que realizan los compiladores JIT:

  • Método de inserción
  • Eliminación de código muerto
  • Heurística para optimizar los sitios de llamadas
  • Plegado constante

JVM admite cinco niveles de compilación:

  • Interpreter
  • C1 con optimización completa (sin creación de perfiles)
  • C1 con contadores de invocación y back-edge (perfiles ligeros)
  • C1 con perfilado completo
  • C2 (utiliza datos de creación de perfiles de los pasos anteriores)

Use -Xint si desea deshabilitar todos los compiladores JIT y usar solo el intérprete.

Cliente vs servidor JIT

Utilice -client y -server para activar los modos respectivos.

El compilador del cliente (C1) comienza a compilar el código antes que el compilador del servidor (C2). Entonces, para cuando C2 haya comenzado la compilación, C1 ya habrá compilado secciones de código.

Pero mientras espera, C2 perfila el código para saber más sobre él que el C1. Por lo tanto, el tiempo que espera si se compensa con las optimizaciones se puede utilizar para generar un binario mucho más rápido. Desde la perspectiva de un usuario, la compensación es entre el tiempo de inicio del programa y el tiempo que tarda en ejecutarse. Si el tiempo de inicio es lo más importante, entonces se debe usar C1. Si se espera que la aplicación se ejecute durante mucho tiempo (típico de las aplicaciones implementadas en servidores), es mejor usar C2 ya que genera un código mucho más rápido que compensa en gran medida cualquier tiempo de inicio adicional.

Para programas como IDE (NetBeans, Eclipse) y otros programas de GUI, el tiempo de inicio es crítico. NetBeans puede tardar un minuto o más en iniciarse. Se compilan cientos de clases cuando se inician programas como NetBeans. En tales casos, el compilador C1 es la mejor opción.

Tenga en cuenta que hay dos versiones de C1: 32b and 64b. C2 viene solo en64b.

Compilación escalonada

En versiones anteriores de Java, el usuario podría haber seleccionado una de las siguientes opciones:

  • Intérprete (-Xint)
  • C1 (-cliente)
  • C2 (-servidor)

Llegó en Java 7. Utiliza el compilador C1 para iniciarse y, a medida que el código se calienta, cambia al C2. Se puede activar con las siguientes opciones de JVM: -XX: + TieredCompilation. El valor predeterminado esset to false in Java 7, and to true in Java 8.

De los cinco niveles de compilación, la compilación por niveles utiliza 1 -> 4 -> 5.

En una máquina 32b, solo se puede instalar la versión 32b de la JVM. En una máquina 64b, el usuario puede elegir entre la versión 32b y la 64b. Pero hay ciertos matices en esto que pueden afectar el rendimiento de nuestras aplicaciones Java.

Si la aplicación Java usa menos de 4G de memoria, deberíamos usar la JVM 32b incluso en máquinas 64b. Esto se debe a que las referencias de memoria en este caso solo serían 32b y manipularlas sería menos costoso que manipular direcciones 64b. En este caso, la JVM de 64b funcionaría peor incluso si usamos OOPS (punteros a objetos ordinarios). Al usar OOPS, la JVM puede usar direcciones 32b en la JVM 64b. Sin embargo, manipularlos sería más lento que las referencias reales de 32b, ya que las referencias nativas subyacentes seguirían siendo 64b.

Si nuestra aplicación va a consumir más de 4G de memoria, tendremos que utilizar la versión 64b ya que las referencias 32b no pueden abordar más de 4G de memoria. Podemos tener ambas versiones instaladas en la misma máquina y podemos cambiar entre ellas usando la variable PATH.

En este capítulo, aprenderemos sobre las optimizaciones JIT.

Método de inserción

En esta técnica de optimización, el compilador decide reemplazar sus llamadas de función con el cuerpo de la función. A continuación se muestra un ejemplo de lo mismo:

int sum3;

static int add(int a, int b) {
   return a + b;
}

public static void main(String…args) {
   sum3 = add(5,7) + add(4,2);
}

//after method inlining
public static void main(String…args) {
   sum3 = 5+ 7 + 4 + 2;
}

Con esta técnica, el compilador evita que la máquina tenga que realizar llamadas de función (requiere empujar y hacer estallar parámetros en la pila). Por lo tanto, el código generado se ejecuta más rápido.

La inserción de métodos solo se puede realizar para funciones no virtuales (funciones que no se anulan). Considere lo que sucedería si el método 'agregar' fuera anulado en una subclase y el tipo de objeto que contiene el método no se conoce hasta el tiempo de ejecución. En este caso, el compilador no sabría qué método incluir. Pero si el método se marcó como 'final', entonces el compilador sabría fácilmente que puede estar en línea porque no puede ser anulado por ninguna subclase. Tenga en cuenta que no se garantiza en absoluto que un método final esté siempre integrado.

Eliminación de código inaccesible y muerto

El código inalcanzable es código al que no pueden acceder los posibles flujos de ejecución. Consideraremos el siguiente ejemplo:

void foo() {
   if (a) return;
   else return;
   foobar(a,b); //unreachable code, compile time error
}

El código muerto también es un código inalcanzable, pero el compilador arroja un error en este caso. En cambio, solo recibimos una advertencia. Cada bloque de código, como constructores, funciones, try, catch, if, while, etc., tiene sus propias reglas para el código inalcanzable definido en JLS (Java Language Specification).

Plegado constante

Para comprender el concepto de plegado constante, consulte el siguiente ejemplo.

final int num = 5;
int b = num * 6; //compile-time constant, num never changes
//compiler would assign b a value of 30.

El ciclo de vida de un objeto Java lo gestiona la JVM. Una vez que el programador crea un objeto, no debemos preocuparnos por el resto de su ciclo de vida. La JVM encontrará automáticamente aquellos objetos que ya no estén en uso y recuperará su memoria del montón.

La recolección de basura es una operación importante que hace JVM y ajustarla a nuestras necesidades puede dar un impulso masivo al rendimiento de nuestra aplicación. Existe una variedad de algoritmos de recolección de basura que son proporcionados por las JVM modernas. Necesitamos ser conscientes de las necesidades de nuestra aplicación para decidir qué algoritmo utilizar.

No puede desasignar un objeto mediante programación en Java, como puede hacer en lenguajes que no son de GC, como C y C ++. Por lo tanto, no puede tener referencias colgantes en Java. Sin embargo, puede tener referencias nulas (referencias que se refieren a un área de memoria donde la JVM nunca almacenará objetos). Siempre que se utiliza una referencia nula, la JVM lanza una NullPointerException.

Tenga en cuenta que, si bien es raro encontrar pérdidas de memoria en los programas Java gracias al GC, ocurren. Crearemos una fuga de memoria al final de este capítulo.

Los siguientes GC se utilizan en JVM modernas

  • Colector en serie
  • Recopilador de rendimiento
  • Recopilador de CMS
  • Colector G1

Cada uno de los algoritmos anteriores hace la misma tarea: encontrar objetos que ya no están en uso y recuperar la memoria que ocupan en el montón. Uno de los enfoques ingenuos para esto sería contar el número de referencias que tiene cada objeto y liberarlo tan pronto como el número de referencias se vuelva 0 (esto también se conoce como recuento de referencias). ¿Por qué es esto ingenuo? Considere una lista enlazada circular. Cada uno de sus nodos tendrá una referencia a él, pero no se hace referencia al objeto completo desde ningún lugar y, idealmente, debería liberarse.

La JVM no solo libera la memoria, sino que también fusiona pequeños chucks de memoria en otros más grandes. Esto se hace para prevenir la fragmentación de la memoria.

En una nota simple, un algoritmo GC típico realiza las siguientes actividades:

  • Encontrar objetos no utilizados
  • Liberando la memoria que ocupan en el montón
  • Fusionando los fragmentos

El GC debe detener los subprocesos de la aplicación mientras se está ejecutando. Esto se debe a que mueve los objetos cuando se ejecuta y, por lo tanto, esos objetos no se pueden utilizar. Estas paradas se denominan 'pausas de parada-el-mundo y minimizar la frecuencia y la duración de estas pausas es lo que buscamos al sintonizar nuestro GC.

Fusión de memoria

A continuación se muestra una demostración simple de la fusión de la memoria.

La parte sombreada son objetos que deben liberarse. Incluso después de que se reclame todo el espacio, solo podemos asignar un objeto de tamaño máximo = 75Kb. Esto es incluso después de que tengamos 200 Kb de espacio libre como se muestra a continuación.

La mayoría de las JVM dividen el montón en tres generaciones: the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation). ¿Cuáles son las razones detrás de tal pensamiento?

Los estudios empíricos han demostrado que la mayoría de los objetos que se crean tienen una vida útil muy corta:

Fuente

https://www.oracle.com

Como puede ver, a medida que se asignan más y más objetos con el tiempo, la cantidad de bytes que sobreviven se reduce (en general). Los objetos Java tienen una alta tasa de mortalidad.

Veremos un ejemplo sencillo. La clase String en Java es inmutable. Esto significa que cada vez que necesite cambiar el contenido de un objeto String, debe crear un nuevo objeto por completo. Supongamos que realiza cambios en la cadena 1000 veces en un bucle como se muestra en el siguiente código:

String str = “G11 GC”;

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

En cada bucle, creamos un nuevo objeto de cadena, y la cadena creada durante la iteración anterior se vuelve inútil (es decir, no está referenciada por ninguna referencia). La vida útil de ese objeto fue solo una iteración: el GC los recopilará en poco tiempo. Estos objetos de corta duración se mantienen en el área de la generación joven del montón. El proceso de recolección de objetos de la generación joven se llama recolección de basura menor y siempre causa una pausa de "detener el mundo".

A medida que la generación joven se llena, el GC realiza una recolección de basura menor. Los objetos muertos se descartan y los objetos vivos se trasladan a la generación anterior. Los subprocesos de la aplicación se detienen durante este proceso.

Aquí, podemos ver las ventajas que ofrece un diseño de tal generación. La generación joven es solo una pequeña parte del montón y se llena rápidamente. Pero el procesamiento lleva mucho menos tiempo que el que se tarda en procesar todo el montón. Entonces, las pausas de 'stop-theworld' en este caso son mucho más breves, aunque más frecuentes. Siempre debemos apuntar a pausas más breves sobre otras más largas, aunque puedan ser más frecuentes. Discutiremos esto en detalle en secciones posteriores de este tutorial.

La generación joven se divide en dos espacios: eden and survivor space. Los objetos que han sobrevivido durante la recolección de eden se trasladan al espacio de supervivientes, y los que sobreviven al espacio de supervivientes se trasladan a la generación anterior. La generación joven se compacta mientras se recolecta.

A medida que los objetos se trasladan a la generación anterior, eventualmente se llena y debe recolectarse y compactarse. Diferentes algoritmos adoptan diferentes enfoques para esto. Algunos de ellos detienen los subprocesos de la aplicación (lo que lleva a una larga pausa de 'detener el mundo' ya que la generación anterior es bastante grande en comparación con la generación joven), mientras que algunos de ellos lo hacen simultáneamente mientras los subprocesos de la aplicación siguen ejecutándose. Este proceso se llama GC completo. Dos de estos coleccionistas sonCMS and G1.

Analicemos ahora estos algoritmos en detalle.

GC en serie

es el GC predeterminado en las máquinas de clase cliente (máquinas de un solo procesador o JVM 32b, Windows). Normalmente, los GC tienen muchos subprocesos múltiples, pero el GC en serie no lo es. Tiene un solo subproceso para procesar el montón y detendrá los subprocesos de la aplicación siempre que esté haciendo un GC menor o un GC mayor. Podemos ordenar a la JVM que use este GC especificando la bandera:-XX:+UseSerialGC. Si queremos que utilice algún algoritmo diferente, especifique el nombre del algoritmo. Tenga en cuenta que la generación anterior se compacta por completo durante una GC importante.

Rendimiento GC

Este GC está predeterminado en máquinas JVM 64b y máquinas con varias CPU. A diferencia del GC en serie, utiliza varios subprocesos para procesar a las generaciones jóvenes y viejas. Debido a esto, el GC también se llamaparallel collector. Podemos ordenar a nuestra JVM que use este recopilador usando la bandera:-XX:+UseParallelOldGC o -XX:+UseParallelGC(para JDK 8 en adelante). Los subprocesos de la aplicación se detienen mientras se realiza una recolección de basura mayor o menor. Al igual que el colector en serie, compacta completamente a la generación joven durante una gran GC.

El GC de rendimiento recopila el YG y el OG. Cuando el edén se ha llenado, el recolector expulsa objetos vivos de él hacia el OG o uno de los espacios de supervivientes (SS0 y SS1 en el diagrama de abajo). Los objetos muertos se descartan para liberar el espacio que ocupaban.

Antes de GC de YG

Después de GC de YG

Durante un GC completo, el colector de rendimiento vacía todo el YG, SS0 y SS1. Después de la operación, el OG contiene solo objetos vivos. Debemos tener en cuenta que los dos recopiladores anteriores detienen los subprocesos de la aplicación mientras procesan el montón. Esto significa largas pausas en el "mundo secundario" durante una CG importante. Los siguientes dos algoritmos apuntan a eliminarlos, a costa de más recursos de hardware:

Recopilador de CMS

Significa "barrido de marca concurrente". Su función es que utiliza algunos subprocesos en segundo plano para escanear periódicamente la generación anterior y deshacerse de los objetos muertos. Pero durante una GC menor, los subprocesos de la aplicación se detienen. Sin embargo, las pausas son bastante pequeñas. Esto convierte al CMS en un recopilador de pausa baja.

Este recopilador necesita tiempo de CPU adicional para escanear el montón mientras ejecuta los subprocesos de la aplicación. Además, los subprocesos de fondo simplemente recogen el montón y no realizan ninguna compactación. Pueden hacer que el montón se fragmente. A medida que esto continúa, después de cierto tiempo, el CMS detendrá todos los subprocesos de la aplicación y compactará el montón utilizando un solo subproceso. Utilice los siguientes argumentos de JVM para decirle a la JVM que utilice el recopilador de CMS:

“XX:+UseConcMarkSweepGC -XX:+UseParNewGC” como argumentos de JVM para indicarle que utilice el recopilador de CMS.

Antes de GC

Después de GC

Tenga en cuenta que la recopilación se realiza al mismo tiempo.

G1 GC

Este algoritmo funciona dividiendo el montón en varias regiones. Al igual que el recopilador de CMS, detiene los subprocesos de la aplicación mientras realiza una GC menor y utiliza subprocesos en segundo plano para procesar la generación anterior mientras mantiene los subprocesos de la aplicación en funcionamiento. Dado que dividió a la generación anterior en regiones, las sigue compactando mientras mueve objetos de una región a otra. Por tanto, la fragmentación es mínima. Puedes usar la bandera:XX:+UseG1GCpara decirle a su JVM que use este algoritmo. Al igual que CMS, también necesita más tiempo de CPU para procesar el montón y ejecutar los subprocesos de la aplicación al mismo tiempo.

Este algoritmo ha sido diseñado para procesar montones más grandes (> 4G), que se dividen en varias regiones diferentes. Algunas de esas regiones comprenden a la generación joven y el resto a la vieja. El YG se borra de forma tradicional: todos los subprocesos de la aplicación se detienen y todos los objetos que todavía están vivos para la generación anterior o el espacio de supervivientes.

Tenga en cuenta que todos los algoritmos de GC dividieron el montón en YG y OG, y usan un STWP para borrar el YG. Este proceso suele ser muy rápido.

En el último capítulo, aprendimos sobre varios CG generacionales. En este capítulo, discutiremos cómo ajustar el GC.

Tamano de la pila

El tamaño de la pila es un factor importante en el rendimiento de nuestras aplicaciones Java. Si es demasiado pequeño, se llenará con frecuencia y, como resultado, el GC tendrá que recolectarlo con frecuencia. Por otro lado, si solo aumentamos el tamaño del montón, aunque es necesario recopilarlo con menos frecuencia, la duración de las pausas aumentaría.

Además, aumentar el tamaño del montón tiene una grave penalización en el sistema operativo subyacente. Al usar la paginación, el sistema operativo hace que nuestros programas de aplicación vean mucha más memoria de la que realmente está disponible. El sistema operativo gestiona esto utilizando algo de espacio de intercambio en el disco, copiando partes inactivas de los programas en él. Cuando se necesitan esas partes, el sistema operativo las vuelve a copiar del disco a la memoria.

Supongamos que una máquina tiene 8G de memoria y la JVM ve 16G de memoria virtual, la JVM no sabría que, de hecho, solo hay 8G disponibles en el sistema. Solo solicitará 16G al sistema operativo, y una vez que obtenga esa memoria, continuará usándola. El sistema operativo tendrá que intercambiar una gran cantidad de datos dentro y fuera, y esto es una gran penalización en el rendimiento del sistema.

Y luego vienen las pausas que ocurrirían durante la GC completa de dicha memoria virtual. Dado que el GC actuará en todo el montón para la recolección y compactación, tendrá que esperar mucho para que la memoria virtual se intercambie fuera del disco. En el caso de un recopilador concurrente, los subprocesos en segundo plano tendrán que esperar mucho para que los datos se copien desde el espacio de intercambio a la memoria.

Entonces, aquí surge la pregunta de cómo deberíamos decidir el tamaño óptimo del montón. La primera regla es nunca solicitar al sistema operativo más memoria de la que realmente está presente. Esto evitaría totalmente el problema del intercambio frecuente. Si la máquina tiene varias JVM instaladas y en ejecución, entonces la solicitud total de memoria de todas ellas combinadas es menor que la RAM real presente en el sistema.

Puede controlar el tamaño de la solicitud de memoria por parte de la JVM utilizando dos indicadores:

  • -XmsN - Controla la memoria inicial solicitada.

  • -XmxN - Controla la memoria máxima que se puede solicitar.

Los valores predeterminados de estos dos indicadores dependen del sistema operativo subyacente. Por ejemplo, para 64b JVM que se ejecutan en MacOS, -XmsN = 64M y -XmxN = mínimo de 1G o 1/4 de la memoria física total.

Tenga en cuenta que la JVM se puede ajustar entre los dos valores automáticamente. Por ejemplo, si nota que está sucediendo demasiada GC, seguirá aumentando el tamaño de la memoria siempre que sea inferior a -XmxN y se cumplan los objetivos de rendimiento deseados.

Si sabe exactamente cuánta memoria necesita su aplicación, puede configurar -XmsN = -XmxN. En este caso, la JVM no necesita calcular un valor "óptimo" del montón y, por lo tanto, el proceso de GC se vuelve un poco más eficiente.

Tamaños de generación

Puede decidir cuánto del montón desea asignar al YG y cuánto desea asignar al OG. Ambos valores afectan el rendimiento de nuestras aplicaciones de la siguiente manera.

Si el tamaño del YG es muy grande, entonces se recolectará con menos frecuencia. Esto resultaría en una menor cantidad de objetos promovidos al OG. Por otro lado, si aumenta demasiado el tamaño de OG, recolectarlo y compactarlo tomaría demasiado tiempo y esto conduciría a largas pausas de STW. Por lo tanto, el usuario debe encontrar un equilibrio entre estos dos valores.

A continuación se muestran las banderas que puede utilizar para establecer estos valores:

  • -XX:NewRatio=N: Relación de YG a OG (valor predeterminado = 2)

  • -XX:NewSize=N: Tamaño inicial de YG

  • -XX:MaxNewSize=N: Tamaño máximo de YG

  • -XmnN: Establezca NewSize y MaxNewSize en el mismo valor usando esta bandera

El tamaño inicial del YG está determinado por el valor de NewRatio mediante la fórmula dada:

(total heap size) / (newRatio + 1)

Dado que el valor inicial de newRatio es 2, la fórmula anterior da el valor inicial de YG como 1/3 del tamaño total del montón. Siempre puede anular este valor especificando explícitamente el tamaño del YG usando el indicador NewSize. Esta bandera no tiene ningún valor predeterminado, y si no se establece explícitamente, el tamaño del YG se seguirá calculando utilizando la fórmula anterior.

Permagen y Metaspace

El permagen y el metaspace son áreas de montón donde la JVM mantiene los metadatos de las clases. El espacio se llama 'permagen' en Java 7, y en Java 8, se llama 'metaespacio'. Esta información es utilizada por el compilador y el tiempo de ejecución.

Puedes controlar el tamaño del permagen usando las siguientes banderas: -XX: PermSize=N y -XX:MaxPermSize=N. El tamaño de Metaspace se puede controlar usando:-XX:Metaspace- Size=N y -XX:MaxMetaspaceSize=N.

Hay algunas diferencias en la forma en que se gestionan el permagen y el metaespacio cuando no se establecen los valores de las banderas. De forma predeterminada, ambos tienen un tamaño inicial predeterminado. Pero mientras que el metaespacio puede ocupar tanto del montón como sea necesario, el permagen no puede ocupar más que los valores iniciales predeterminados. Por ejemplo, la JVM 64b tiene 82M de espacio de almacenamiento dinámico como tamaño máximo de permagen.

Tenga en cuenta que dado que el metaespacio puede ocupar cantidades ilimitadas de memoria a menos que se especifique lo contrario, puede haber un error de memoria insuficiente. Se lleva a cabo una GC completa cada vez que se cambia el tamaño de estas regiones. Por lo tanto, durante el inicio, si hay muchas clases que se están cargando, el metaespacio puede seguir cambiando de tamaño dando como resultado un GC completo cada vez. Por lo tanto, las aplicaciones grandes necesitan mucho tiempo para iniciarse en caso de que el tamaño del metaespacio inicial sea demasiado bajo. Es una buena idea aumentar el tamaño inicial ya que reduce el tiempo de inicio.

Aunque el permagen y el metaspace contienen los metadatos de la clase, no es permanente y el GC recupera el espacio, como en el caso de los objetos. Esto suele ocurrir en el caso de aplicaciones de servidor. Siempre que realice una nueva implementación en el servidor, los metadatos antiguos deben limpiarse, ya que los nuevos cargadores de clases ahora necesitarán espacio. Este espacio es liberado por el GC.

Discutiremos sobre el concepto de pérdida de memoria en Java en este capítulo.

El siguiente código crea una pérdida de memoria en Java:

void queryDB() {
   try{
      Connection conn = ConnectionFactory.getConnection();
      PreparedStatement ps = conn.preparedStatement("query"); // executes a
      SQL
      ResultSet rs = ps.executeQuery();
      while(rs.hasNext()) {
         //process the record
      }
   } catch(SQLException sqlEx) {
      //print stack trace
   }
}

En el código anterior, cuando el método sale, no hemos cerrado el objeto de conexión. Por lo tanto, la conexión física permanece abierta antes de que se active el GC y vea el objeto de conexión como inalcanzable. Ahora, llamará al método final en el objeto de conexión, sin embargo, es posible que no se implemente. Por tanto, el objeto no se recogerá como basura en este ciclo.

Lo mismo ocurrirá en el siguiente hasta que el servidor remoto vea que la conexión ha estado abierta durante mucho tiempo y la finalice a la fuerza. Por lo tanto, un objeto sin referencia permanece en la memoria durante mucho tiempo, lo que crea una fuga.