Consejo 130 de Java: ¿Conoce el tamaño de sus datos?

Recientemente, ayudé a diseñar una aplicación de servidor Java que se parecía a una base de datos en memoria. Es decir, sesgamos el diseño hacia el almacenamiento en caché de toneladas de datos en la memoria para proporcionar un rendimiento de consulta súper rápido.

Una vez que hicimos funcionar el prototipo, naturalmente decidimos perfilar la huella de la memoria de datos después de haberla analizado y cargado desde el disco. Sin embargo, los insatisfactorios resultados iniciales me llevaron a buscar explicaciones.

Nota: Puede descargar el código fuente de este artículo desde Recursos.

La herramienta

Dado que Java oculta deliberadamente muchos aspectos de la gestión de la memoria, descubrir cuánta memoria consumen sus objetos requiere algo de trabajo. Puede utilizar el Runtime.freeMemory()método para medir las diferencias de tamaño del montón antes y después de que se hayan asignado varios objetos. Varios artículos, como "Question of the Week No. 107" de Ramchander Varadarajan (Sun Microsystems, septiembre de 2000) y "Memory Matters" de Tony Sintes ( JavaWorld, diciembre de 2001), detallan esa idea. Desafortunadamente, la solución del artículo anterior falla porque la implementación emplea un Runtimemétodo incorrecto , mientras que la solución del último artículo tiene sus propias imperfecciones:

  • Una sola llamada a Runtime.freeMemory()resulta insuficiente porque una JVM puede decidir aumentar su tamaño de pila actual en cualquier momento (especialmente cuando ejecuta la recolección de basura). A menos que el tamaño total del montón ya esté en el tamaño máximo de -Xmx, deberíamos usarlo Runtime.totalMemory()-Runtime.freeMemory()como tamaño del montón utilizado.
  • La ejecución de una sola Runtime.gc()llamada puede no resultar lo suficientemente agresiva para solicitar la recolección de basura. Podríamos, por ejemplo, solicitar que los finalizadores de objetos también se ejecuten. Y dado Runtime.gc()que no está documentado el bloqueo hasta que se completa la recopilación, es una buena idea esperar hasta que se estabilice el tamaño del montón percibido.
  • Si la clase perfilada crea datos estáticos como parte de su inicialización de clase por clase (incluidos los inicializadores de clase estática y de campo), la memoria de pila utilizada para la primera instancia de clase puede incluir esos datos. Deberíamos ignorar el espacio de pila consumido por la primera instancia de clase.

Teniendo en cuenta esos problemas, presento Sizeofuna herramienta con la que fisgoneo en varias clases de aplicaciones y núcleos de Java:

public class Sizeof {public static void main (String [] args) throws Exception {// Calentar todas las clases / métodos que usaremos runGC (); memoria usada (); // Array para mantener fuertes referencias a los objetos asignados final int count = 100000; Objeto [] objetos = nuevo Objeto [recuento]; long heap1 = 0; // Asignar count + 1 objetos, descartar el primero para (int i = -1; i = 0) objects [i] = object; else {objeto = nulo; // Descartar el objeto de calentamiento runGC (); heap1 = usedMemory (); // Toma una instantánea del montón anterior}} runGC (); long heap2 = usedMemory (); // Toma una instantánea del montón después: final int size = Math.round (((float) (heap2 - heap1)) / count); System.out.println ("'antes de' heap:" + heap1 + ", 'después de' heap:" + heap2); System.out.println ("heap delta:" + (heap2 - heap1) + ", {" + objetos [0].getClass () + "} tamaño =" + tamaño + "bytes"); for (int i = 0; i <count; ++ i) objects [i] = null; objetos = nulo; } private static void runGC () throws Exception {// Ayuda a llamar a Runtime.gc () // usando varias llamadas a métodos: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lanza Exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; for (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesi <cuenta; ++ i) objetos [i] = nulo; objetos = nulo; } private static void runGC () throws Exception {// Ayuda a llamar a Runtime.gc () // usando varias llamadas a métodos: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lanza Exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; for (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesi <cuenta; ++ i) objetos [i] = nulo; objetos = nulo; } private static void runGC () throws Exception {// Ayuda a llamar a Runtime.gc () // usando varias llamadas a métodos: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lanza Exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; for (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesgc () // usando varias llamadas a métodos: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lanza Exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; for (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesgc () // usando varias llamadas a métodos: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lanza Exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; for (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesThread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clasesThread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} private static long usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } tiempo de ejecución final estático privado s_runtime = Runtime.getRuntime (); } // Fin de clases

SizeofLos métodos clave son runGC()y usedMemory(). Utilizo un runGC()método de envoltura para llamar _runGC()varias veces porque parece hacer que el método sea más agresivo. (No estoy seguro de por qué, pero es posible que la creación y destrucción de un marco de pila de llamadas de método provoque un cambio en el conjunto raíz de accesibilidad y haga que el recolector de basura trabaje más duro. Además, consumir una gran fracción del espacio del montón para crear suficiente trabajo que el recolector de basura se active también ayuda. En general, es difícil asegurarse de que todo se recopile. Los detalles exactos dependen de la JVM y del algoritmo de recolección de basura).

Note cuidadosamente los lugares donde invoco runGC(). Puede editar el código entre las declaraciones heap1y heap2para instanciar cualquier cosa de interés.

También observe cómo Sizeofimprime el tamaño del objeto: el cierre transitivo de los datos requeridos por todas countlas instancias de clase, dividido por count. Para la mayoría de las clases, el resultado será la memoria consumida por una sola instancia de clase, incluidos todos sus campos de propiedad. Ese valor de huella de memoria difiere de los datos proporcionados por muchos perfiladores comerciales que informan huellas de memoria poco profundas (por ejemplo, si un objeto tiene un int[]campo, su consumo de memoria aparecerá por separado).

Los resultados

Apliquemos esta sencilla herramienta a algunas clases y luego veamos si los resultados coinciden con nuestras expectativas.

Nota: Los siguientes resultados se basan en el JDK 1.3.1 de Sun para Windows. Debido a lo que está y no está garantizado por el lenguaje Java y las especificaciones de JVM, no puede aplicar estos resultados específicos a otras plataformas u otras implementaciones de Java.

java.lang.Object

Bueno, la raíz de todos los objetos tenía que ser mi primer caso. Porque java.lang.Objectobtengo:

'before' heap: 510696, 'after' heap: 1310696 heap delta: 800000, {class java.lang.Object} size = 8 bytes 

Entonces, un plano Objecttoma 8 bytes; Por supuesto, nadie debe esperar que el tamaño sea 0, ya que cada instancia debe llevar alrededor de los campos que las operaciones de base de apoyo como equals(), hashCode(), wait()/notify(), y así sucesivamente.

java.lang.Integer

Mis colegas y yo con frecuencia incluimos archivos nativos intsen Integerinstancias para que podamos almacenarlos en colecciones de Java. ¿Cuánto nos cuesta en memoria?

'before' heap: 510696, 'after' heap: 2110696 heap delta: 1600000, {class java.lang.Integer} size = 16 bytes 

El resultado de 16 bytes es un poco peor de lo que esperaba porque un intvalor puede caber en solo 4 bytes adicionales. Usar un Integerme cuesta una sobrecarga de memoria del 300 por ciento en comparación con cuando puedo almacenar el valor como un tipo primitivo.

java.lang.Long

Longdebería ocupar más memoria que Integer, pero no:

'before' heap: 510696, 'after' heap: 2110696 heap delta: 1600000, {class java.lang.Long} size = 16 bytes 

Claramente, el tamaño real del objeto en el montón está sujeto a la alineación de memoria de bajo nivel realizada por una implementación de JVM particular para un tipo de CPU en particular. Parece que a Longtiene 8 bytes de Objectsobrecarga, más 8 bytes más para el valor largo real. Por el contrario, Integertenía un agujero de 4 bytes sin usar, probablemente porque la JVM que uso fuerza la alineación del objeto en un límite de palabra de 8 bytes.

Matrices

Jugar con matrices de tipos primitivos resulta instructivo, en parte para descubrir cualquier sobrecarga oculta y en parte para justificar otro truco popular: envolver valores primitivos en una matriz de tamaño 1 para usarlos como objetos. Al modificar Sizeof.main()para tener un bucle que incrementa la longitud de la matriz creada en cada iteración, obtengo para las intmatrices:

longitud: 0, {clase [I} tamaño = 16 bytes longitud: 1, {clase [I} tamaño = 16 bytes longitud: 2, {clase [I} tamaño = 24 bytes longitud: 3, {clase [I} tamaño = 24 bytes de longitud: 4, {clase [I} tamaño = 32 bytes de longitud: 5, {clase [I} tamaño = 32 bytes de longitud: 6, {clase [I} tamaño = 40 bytes de longitud: 7, {clase [I} tamaño = 40 bytes longitud: 8, {clase [I} tamaño = 48 bytes longitud: 9, {clase [I} tamaño = 48 bytes longitud: 10, {clase [I} tamaño = 56 bytes 

y para charmatrices:

longitud: 0, {clase [C} tamaño = 16 bytes longitud: 1, {clase [C} tamaño = 16 bytes longitud: 2, {clase [C} tamaño = 16 bytes longitud: 3, {clase [C} tamaño = 24 bytes de longitud: 4, {clase [C} tamaño = 24 bytes de longitud: 5, {clase [C} tamaño = 24 bytes de longitud: 6, {clase [C} tamaño = 24 bytes de longitud: 7, {clase [C} tamaño = 32 bytes longitud: 8, {clase [C} tamaño = 32 bytes longitud: 9, {clase [C} tamaño = 32 bytes longitud: 10, {clase [C} tamaño = 32 bytes 

Arriba, la evidencia de alineación de 8 bytes vuelve a aparecer. Además, además de la inevitable Objectsobrecarga de 8 bytes, una matriz primitiva agrega otros 8 bytes (de los cuales al menos 4 bytes admiten el lengthcampo). Y el uso int[1]parece no ofrecer ninguna ventaja de memoria sobre una Integerinstancia, excepto tal vez como una versión mutable de los mismos datos.

Matrices multidimensionales

Multidimensional arrays offer another surprise. Developers commonly employ constructs like int[dim1][dim2] in numerical and scientific computing. In an int[dim1][dim2] array instance, every nested int[dim2] array is an Object in its own right. Each adds the usual 16-byte array overhead. When I don't need a triangular or ragged array, that represents pure overhead. The impact grows when array dimensions greatly differ. For example, a int[128][2] instance takes 3,600 bytes. Compared to the 1,040 bytes an int[256] instance uses (which has the same capacity), 3,600 bytes represent a 246 percent overhead. In the extreme case of byte[256][1], the overhead factor is almost 19! Compare that to the C/C++ situation in which the same syntax does not add any storage overhead.

java.lang.String

Let's try an empty String, first constructed as new String():

'before' heap: 510696, 'after' heap: 4510696 heap delta: 4000000, {class java.lang.String} size = 40 bytes 

The result proves quite depressing. An empty String takes 40 bytes—enough memory to fit 20 Java characters.

Before I try Strings with content, I need a helper method to create Strings guaranteed not to get interned. Merely using literals as in:

 object = "string with 20 chars"; 

will not work because all such object handles will end up pointing to the same String instance. The language specification dictates such behavior (see also the java.lang.String.intern() method). Therefore, to continue our memory snooping, try:

 public static String createString (final int length) { char [] result = new char [length]; for (int i = 0; i < length; ++ i) result [i] = (char) i; return new String (result); } 

After arming myself with this String creator method, I get the following results:

length: 0, {class java.lang.String} size = 40 bytes length: 1, {class java.lang.String} size = 40 bytes length: 2, {class java.lang.String} size = 40 bytes length: 3, {class java.lang.String} size = 48 bytes length: 4, {class java.lang.String} size = 48 bytes length: 5, {class java.lang.String} size = 48 bytes length: 6, {class java.lang.String} size = 48 bytes length: 7, {class java.lang.String} size = 56 bytes length: 8, {class java.lang.String} size = 56 bytes length: 9, {class java.lang.String} size = 56 bytes length: 10, {class java.lang.String} size = 56 bytes 

The results clearly show that a String's memory growth tracks its internal char array's growth. However, the String class adds another 24 bytes of overhead. For a nonempty String of size 10 characters or less, the added overhead cost relative to useful payload (2 bytes for each char plus 4 bytes for the length), ranges from 100 to 400 percent.

Of course, the penalty depends on your application's data distribution. Somehow I suspected that 10 characters represents the typical String length for a variety of applications. To get a concrete data point, I instrumented the SwingSet2 demo (by modifying the String class implementation directly) that came with JDK 1.3.x to track the lengths of the Strings it creates. After a few minutes playing with the demo, a data dump showed that about 180,000 Strings were instantiated. Sorting them into size buckets confirmed my expectations:

[0-10]: 96481 [10-20]: 27279 [20-30]: 31949 [30-40]: 7917 [40-50]: 7344 [50-60]: 3545 [60-70]: 1581 [70-80]: 1247 [80-90]: 874 ... 

That's right, more than 50 percent of all String lengths fell into the 0-10 bucket, the very hot spot of String class inefficiency!

En realidad, Stringlos mensajes de correo electrónico pueden consumir incluso más memoria de lo que sugieren sus longitudes: Stringlos mensajes de correo electrónico generados a partir de StringBuffers (ya sea explícitamente o mediante el operador de concatenación '+') probablemente tengan charmatrices con longitudes mayores que las Stringlongitudes informadas porque los StringBuffers suelen comenzar con una capacidad de 16 , luego duplíquelo en append()operaciones. Entonces, por ejemplo, createString(1) + ' 'termina con una charmatriz de tamaño 16, no 2.

qué hacemos?

"Todo esto está muy bien, pero no tenemos más remedio que utilizar Stringsy otros tipos proporcionados por Java, ¿verdad?" Te escucho preguntar. Vamos a averiguar.

Clases de envoltura