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 Runtime
mé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 usarloRuntime.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 dadoRuntime.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 Sizeof
una 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
Sizeof
Los 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 heap1
y heap2
para instanciar cualquier cosa de interés.
También observe cómo Sizeof
imprime el tamaño del objeto: el cierre transitivo de los datos requeridos por todas count
las 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.Object
obtengo:
'before' heap: 510696, 'after' heap: 1310696 heap delta: 800000, {class java.lang.Object} size = 8 bytes
Entonces, un plano Object
toma 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 ints
en Integer
instancias 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 int
valor puede caber en solo 4 bytes adicionales. Usar un Integer
me 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
Long
deberí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 Long
tiene 8 bytes de Object
sobrecarga, más 8 bytes más para el valor largo real. Por el contrario, Integer
tení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 int
matrices:
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 char
matrices:
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 Object
sobrecarga de 8 bytes, una matriz primitiva agrega otros 8 bytes (de los cuales al menos 4 bytes admiten el length
campo). Y el uso int[1]
parece no ofrecer ninguna ventaja de memoria sobre una Integer
instancia, 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 String
s with content, I need a helper method to create String
s 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 String
s 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, String
los mensajes de correo electrónico pueden consumir incluso más memoria de lo que sugieren sus longitudes: String
los mensajes de correo electrónico generados a partir de StringBuffer
s (ya sea explícitamente o mediante el operador de concatenación '+') probablemente tengan char
matrices con longitudes mayores que las String
longitudes informadas porque los StringBuffer
s suelen comenzar con una capacidad de 16 , luego duplíquelo en append()
operaciones. Entonces, por ejemplo, createString(1) + ' '
termina con una char
matriz de tamaño 16, no 2.
qué hacemos?
"Todo esto está muy bien, pero no tenemos más remedio que utilizar String
sy otros tipos proporcionados por Java, ¿verdad?" Te escucho preguntar. Vamos a averiguar.