Cómo no tirar basura en Java

Existe una idea errónea popular de que si no le gusta la recolección de basura, no necesita escribir en Java, sino en C / C ++. Durante los últimos tres años he estado escribiendo código Java de baja latencia para el comercio de divisas, y tuve que evitar crear objetos innecesarios en todos los sentidos. Como resultado, formulé algunas reglas simples para mí, cómo reducir las asignaciones en Java, si no a cero, luego a un mínimo razonable, sin recurrir a la administración manual de memoria. Quizás también sea útil para alguien de la comunidad.


¿Por qué evitar la basura?


Sobre qué es GC y cómo configurarlos, se dijo y escribió mucho. Pero en última instancia, no importa cómo configure el GC, codifique que la basura funcionará de manera subóptima. Siempre hay una compensación entre el rendimiento y la latencia. Se hace imposible mejorar uno sin empeorar al otro. Como regla general, la sobrecarga del GC se mide mediante el estudio de los registros: puede comprender a partir de ellos en qué momentos hubo pausas y cuánto tiempo tomaron. Sin embargo, los registros del GC no contienen toda la información sobre esta sobrecarga. El objeto creado por el subproceso se coloca automáticamente en la memoria caché L1 del núcleo del procesador en el que se ejecuta el subproceso. Esto lleva a la exclusión de otros datos potencialmente útiles. Con una gran cantidad de asignaciones, los datos útiles también se pueden extraer del caché L3. La próxima vez que el subproceso acceda a estos datos, se producirá la falta de caché, lo que provocará demoras en la ejecución del programa. Además, dado que el caché L3 es común para todos los núcleos dentro del mismo procesador, un flujo de basura empujará datos y otros subprocesos / aplicaciones desde el caché L3, y ya encontrarán errores de caché costosos adicionales, incluso si están escritos en C y no crees basura. Sin configuraciones, sin recolectores de basura (ni C4 ni ZGC) ayudarán a hacer frente a este problema. La única forma de mejorar la situación en su conjunto es no crear objetos innecesarios innecesariamente. Java, a diferencia de C ++, no tiene un rico arsenal de mecanismos para trabajar con memoria, pero, sin embargo, hay varias formas de minimizar las asignaciones. Serán discutidos.


Digresión lírica

Por supuesto, no necesita escribir todo el código libre de basura. Lo que pasa con el lenguaje Java es que puede simplificar enormemente su vida eliminando solo las principales fuentes de basura. Tampoco puede lidiar con la recuperación segura de la memoria al escribir algoritmos sin bloqueo. Si un determinado código se ejecuta solo una vez al inicio de la aplicación, puede asignar tanto como desee, y no es gran cosa. Bueno, por supuesto, la principal herramienta de trabajo para deshacerse del exceso de basura es el generador de perfiles de asignación.


Usando tipos primitivos


Lo más simple que se puede hacer en muchos casos es usar tipos primitivos en lugar de tipos de objetos. La JVM tiene una serie de optimizaciones para minimizar la sobrecarga de los tipos de objetos, como el almacenamiento en caché de pequeños valores de tipos enteros y la inclusión de clases simples. Pero no siempre vale la pena confiar en estas optimizaciones, ya que es posible que no funcionen: el valor entero puede no almacenarse en caché y puede que no se formen líneas. Además, cuando trabajamos con un número entero condicional, nos vemos obligados a seguir el enlace, lo que potencialmente conduce a una pérdida de caché. Además, todos los objetos tienen encabezados que ocupan espacio adicional en el caché, desplazando otros datos desde allí. Vamos a tomarlo: un int primitivo toma 4 bytes. Object Integer ocupa 16 bytes + el tamaño del enlace a este entero es de 4 bytes como mínimo (en el caso de Ups comprimido). En total, resulta que Integer ocupa cinco (!) Veces más espacio que int . Por lo tanto, es mejor usar tipos primitivos usted mismo. Daré algunos ejemplos.


Ejemplo 1. Cálculos convencionales.


Digamos que tenemos una función regular que solo cuenta algo.


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

Es probable que dicho código se vuelva en línea (tanto el método como las clases) y no dará lugar a asignaciones innecesarias, pero no puede estar seguro de esto. Incluso si esto sucede, habrá un problema con el hecho de que una NullPointerException podría volar desde aquí. De una forma u otra, la JVM tendrá que insertar cheques null debajo del capó, o comprender de alguna manera por el contexto que null no puede ser un argumento. De todos modos, es mejor simplemente escribir el mismo código en primitivas.


 int getValue(int a, int b, int c) { return (a + b) / c; } 

Ejemplo 2. Lambdas


A veces los objetos se crean sin nuestro conocimiento. Por ejemplo, si pasamos tipos primitivos a donde se esperan tipos de objetos. Esto sucede a menudo cuando se usan expresiones lambda.
Imagina que tenemos este código:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

A pesar de que la variable x es una primitiva, se creará un objeto de tipo Integer, que se pasará a la calculadora. Para evitar esto, use IntConsumer lugar de Consumer<Integer> :


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Tal código ya no conducirá a la creación de un objeto adicional. Java.util.function tiene un conjunto completo de interfaces estándar adaptadas para usar tipos primitivos: DoubleSupplier , LongFunction , etc. Bueno, si falta algo, siempre puede agregar la interfaz deseada con primitivas. Por ejemplo, en lugar de BiConsumer<Integer, Double> puede usar una interfaz casera.


 interface IntDoubleConsumer { void accept(int x, double y); } 

Ejemplo 3. Colecciones


Usar un tipo primitivo puede ser difícil porque una variable de este tipo está en una colección. Supongamos que tenemos algo de List<Integer> y queremos saber qué números hay en él y calcular cuántas veces se repite cada uno de los números. Para esto, usamos HashMap<Integer, Integer> . El código se ve así:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

Este código es malo de varias maneras a la vez. En primer lugar, utiliza una estructura de datos intermedia, que probablemente podría hacerse sin ella. Bueno, bueno, por simplicidad, asumimos que esta lista será necesaria más tarde, es decir. no puedes eliminarlo por completo. En segundo lugar, el objeto Integer usa en ambos lugares en lugar de int primitivo. En tercer lugar, hay muchas asignaciones en el método de compute . Cuarto, se asigna un iterador. Es probable que esta asignación se convierta en línea, pero no obstante. ¿Cómo convertir este código en código libre de basura? Solo necesita usar la colección en las primitivas de alguna biblioteca de terceros. Hay varias bibliotecas que contienen tales colecciones. El siguiente código utiliza la biblioteca agrona .


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

Los objetos que se crean aquí son dos colecciones y dos int[] , que se encuentran dentro de estas colecciones. Ambas colecciones se pueden reutilizar llamando al método clear() en ellas. Al usar colecciones en primitivas, no complicamos nuestro código (e incluso lo simplificamos al eliminar el método de cálculo con una lambda compleja dentro de él) y recibimos los siguientes bonos adicionales en comparación con el uso de colecciones estándar:


  1. Ausencia casi completa de asignaciones. Si las colecciones se reutilizan, entonces no habrá asignaciones en absoluto.
  2. Ahorro significativo de memoria ( IntArrayList ocupa aproximadamente cinco veces menos espacio que ArrayList<Integer> . Como ya se mencionó, nos preocupa el uso económico de las memorias caché del procesador, no de la RAM.
  3. Acceso en serie a la memoria. Mucho se ha escrito sobre el tema de por qué esto es importante, por lo que no me detendré allí. Aquí hay un par de artículos: Martin Thompson y Ulrich Drepper .

Otro pequeño comentario sobre colecciones. Puede resultar que la colección contenga valores de diferentes tipos y, por lo tanto, no es posible reemplazarla con una colección con primitivas. En mi opinión, esto es una señal de un mal diseño de la estructura de datos o del algoritmo en su conjunto. Lo más probable en este caso, la asignación de objetos adicionales no es el problema principal.


Objetos mutables


Pero, ¿qué pasa si no se puede prescindir de las primitivas? Por ejemplo, en el caso de que el método que necesitamos deba devolver varios valores. La respuesta es simple: use objetos mutables.


Pequeña digresión

Algunos idiomas enfatizan el uso de objetos inmutables, por ejemplo en Scala. El argumento principal a su favor es que escribir código multiproceso se simplifica enormemente. Sin embargo, también hay gastos generales asociados con la asignación excesiva de basura. Si queremos evitarlos, entonces no debemos crear objetos inmutables de corta duración.


¿Cómo se ve en la práctica? Supongamos que necesitamos calcular el cociente y el resto de la división. Y para esto usamos el siguiente código.


 class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; } 

¿Cómo se puede deshacerse de la asignación en este caso? Así es, pase IntPair como argumento y escriba el resultado allí. En este caso, necesita escribir un javadoc detallado, y aún mejor, usar algún tipo de convención para nombres de variables, donde se escribe el resultado. Por ejemplo, pueden comenzar con el prefijo. El código libre de basura en este caso se verá así:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

Quiero señalar que el método de divide no debería guardar un enlace para emparejarlo en ninguna parte o pasarlo a métodos que puedan hacer esto, de lo contrario, podríamos tener grandes problemas. Como podemos ver, los objetos mutables son más difíciles de usar que los tipos primitivos, por lo que si puede usar primitivos, entonces es mejor hacerlo. De hecho, en nuestro ejemplo, transferimos el problema de asignación desde dentro del método de división al exterior. En todos los lugares donde llamamos a este método, necesitaremos un poco de IntPair , que pasaremos para divide . A menudo es suficiente para almacenar este ficticio en el campo final del objeto, desde donde llamamos al método de divide . Permíteme darte un ejemplo descabellado: supongamos que nuestro programa solo trata de recibir una secuencia de números a través de la red, los divide y envía el resultado al mismo socket.


 class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } } 

Por brevedad, no escribí código "extra" para el manejo de errores, la finalización correcta del programa, etc. La idea principal de este fragmento de código es que el objeto IntPair que IntPair se crea una vez y se almacena en el campo final .


Agrupaciones de objetos


Cuando usamos objetos mutables, primero debemos tomar un objeto vacío de algún lugar, luego escribir los datos que necesitamos en él, usarlo en algún lugar y luego devolver el objeto "en su lugar". En el ejemplo anterior, el objeto siempre estaba "en su lugar", es decir en el campo final Desafortunadamente, esto no siempre es posible hacerlo de una manera simple. Por ejemplo, es posible que no sepamos de antemano exactamente cuántos objetos necesitamos. En este caso, los grupos de objetos vienen en nuestra ayuda. Cuando necesitamos un objeto vacío, lo obtenemos del grupo de objetos, y cuando deja de ser necesario, lo devolvemos allí. Si no hay ningún objeto libre en el grupo, el grupo crea un nuevo objeto. De hecho, se trata de una gestión de memoria manual con todas las consecuencias resultantes. Es aconsejable no recurrir a este método si es posible utilizar los métodos anteriores. ¿Qué podría salir mal?


  • Podemos olvidar devolver el objeto al grupo y luego se creará basura ("pérdida de memoria"). Este es un problema pequeño: el rendimiento disminuirá ligeramente, pero el GC funcionará y el programa continuará funcionando.
  • Podemos devolver el objeto al grupo, pero guarde el enlace en alguna parte. Luego, alguien más obtendrá el objeto del grupo, y en este punto de nuestro programa ya habrá dos enlaces al mismo objeto. Este es un problema clásico de uso libre después. Es difícil debutar porque A diferencia de C ++, el programa no se bloqueará y continuará funcionando incorrectamente .

Para reducir la probabilidad de cometer los errores anteriores, puede usar la construcción estándar de prueba con recursos. Puede verse así:


 public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } } 

El método de división podría verse así:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

Y el método listenSocket así:


 void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } } 

En el IDE, generalmente puede configurar el resaltado de todos los casos cuando los objetos AutoCloseable se usan fuera del bloque de prueba con recursos. Pero esta no es una opción absoluta, porque resaltar en el IDE solo se puede desactivar. Por lo tanto, hay otra forma de garantizar el retorno del objeto al grupo: la inversión de control. Daré un ejemplo:


 class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } } 

En este caso, básicamente no podemos acceder al objeto de la clase IntPair exterior. Desafortunadamente, este método tampoco siempre funciona. Por ejemplo, no funcionará si un hilo obtiene objetos del grupo y lo pone en una cola, y otro hilo los saca de la cola y regresa al grupo.


Obviamente, si no almacenamos objetos genéricos en el grupo, pero algunos objetos de la biblioteca que no implementan AutoCloseable , la opción de prueba con recursos tampoco funcionará.


Un problema adicional aquí es multihilo. La implementación del conjunto de objetos debe ser muy rápida, lo cual es bastante difícil de lograr. Un grupo lento puede hacer más daño al rendimiento que bien. A su vez, la asignación de nuevos objetos en TLAB es muy rápida, mucho más rápida que malloc en C. Escribir un grupo de objetos rápido es un tema separado que no quisiera desarrollar ahora. Solo puedo decir que no he visto ninguna buena implementación "lista para usar".


En lugar de una conclusión


En resumen, reutilizar objetos con piscinas de objetos son hemorroides graves. Afortunadamente, casi siempre puedes prescindir de él. Mi experiencia personal es que el uso excesivo de grupos de objetos señala problemas con la arquitectura de la aplicación. Como regla, una instancia del objeto almacenado en caché en el campo final es suficiente para nosotros. Pero incluso esto es excesivo si es posible usar tipos primitivos.


Actualización:


Sí, recordé otra manera para aquellos que no tienen miedo a los cambios bit a bit: empaquetar varios tipos primitivos pequeños en uno grande. Supongamos que necesitamos devolver dos int . En este caso particular, no puede usar el objeto IntPair , pero devuelve uno long , los primeros 4 bytes en los que corresponderán al primer int 'y, y los segundos 4 bytes al segundo. El código podría verse así:


 long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } } 

Tales métodos, por supuesto, deben probarse a fondo, porque es bastante fácil escribirlos. Pero entonces solo úsalo.

Source: https://habr.com/ru/post/es436024/


All Articles