Blog>

Óscar Pastor
16 Jun 2022

String Interning en Java: Cómo reducir la memoria que ocupan los Strings

Tiempo de lectura 8 minutos
  • intern
  • java
  • strings

Sumario Utilización del String Pool de java (String interning) para optimizar el uso de memoria cuando se están utilizando una gran cantidad de cadenas.

Si alguna vez te han encargado un desarrollo en el que se hace uso de una gran cantidad de cadenas de caracteres, seguro que has tenido la preocupación de que llegado un momento, el servidor donde tengas alojada la aplicación de un error OutOfMemory. Para solucionarlo, usaremos el String Interning.

Por suerte, este tipo de situaciones no son habituales, sin embargo, hay algunos escenarios en los que puedes encontrártelas:

La mala noticia, es que la memoria es un recurso limitado, si hacemos un uso muy intensivo de ella acabaremos por tener un OutOfMemory. La buena es que hay maneras de mitigar estos problemas, optimizando el uso de la memoria y reduciendo la probabilidad de este temido error.

Identity Comparison vs Equality Comparison

Antes de comenzar con el String Interning, es importante recordar un par de conceptos de Java sobre las comparaciones. Las variables en Java contienen referencias a los objetos que representan. (Olvidemos los tipos básicos), podemos usar el operador == que nos va a indicar si dos variables referencian el mismo objeto o podemos usar el método equals que nos va a decir si dos variables referencian objetos iguales.

public static void main(String[] args){
  String firstString  = new String ("hello world");
  String secondString = new String ("hello world");  	  	  
  System.out.println("Identity comparison : " + (firstString == secondString));     
  System.out.println("Equality comparison : " + (firstString.equals(secondString))); 
}

Identity comparison : false
Equality comparison : true

Las variables referencian dos cadenas iguales (pero no a la misma cadena). Veamos ahora otro ejemplo:

public static void main(String[] args){
  String firstString  = "hello world";
  String secondString = "hello world";	  	  	  
  System.out.println("Identity comparison : " + ( firstString == secondString)); 
  System.out.println("Equality comparison : " + ( firstString.equals(secondString))); 
}

Identity comparison : true
Equality comparison : true

El resultado de este segundo ejemplo quizás sorprenda a más de un lector. las variables no es que referencien a dos cadenas iguales, es que referencian a la misma cadena ¿por qué?

String interning: String pool

La razón de que en el segundo ejemplo las variables referencian a la misma cadena es que la JVM interniza automáticamente las cadenas literales mediante el String pool. El String pool es un repositorio de cadenas interno, al cual, si le pasamos una cadena, nos devuelve la representación canónica de la misma.

El proceso es muy sencillo: cuando el String pool recibe una referencia a una cadena, comprueba si ya contiene una cadena igual (llamada representación canónica); en caso afirmativo, devuelve la referencia a dicha representación y en caso negativo, genera la representación canónica y la devuelve igualmente. El resultado es que el String pool ante cadenas iguales devuelve referencias a la misma cadena (también igual)

¿Qué sucede con las cadenas que no son literales? Las cadenas que no son literales, son aquellas que se crean mediante new bien directamente o bien desde métodos de manipulación de cadenas (como replace, substring, etc.)?

Estas cadenas no pasan por el String pool y tendremos variables referenciando a varias cadenas que son iguales, pero no la misma. No obstante, tenemos la posibilidad de acceder al String pool igual que hace la JVM para las cadenas literales. El método de instancia String.intern() envía a la propia cadena al String pool y devuelve la referencia a la cadena canónica.

En este punto conviene hacer dos anotaciones:

Veamos todo esto con un ejemplo

public static void main(String[] args){
	  
  String firstString  = new String ("hello world");
  String secondString = new String ("hello world");
	  
  System.out.println("Ref firstString  : " + System.identityHashCode(firstString));
  System.out.println("Ref secondString : " + System.identityHashCode(secondString));
	  	  	  	  	  
  System.out.println("----");
	  
  firstString = firstString.intern();
  secondString = secondString.intern();
	  	  	  	  	  
  System.out.println("Ref firstString  : " + System.identityHashCode(firstString));
  System.out.println("Ref secondString : " + System.identityHashCode(secondString));
	  
  System.out.println("----");
	  
  System.out.println("firstString value  : " + firstString);
  System.out.println("secondString value : " + secondString);
	  
  System.out.println("----");
	  	  	  	  
  System.out.println("Identity comparison : " + ( firstString == secondString ) ); 
	   
}

Ref firstString  : 123961122
Ref secondString : 942731712
----
Ref firstString  : 971848845
Ref secondString : 971848845
----
firstString value  : hello world
secondString value : hello world
----
Identity comparison : true

Como se puede ver, nada más crear ambas cadenas, las variables firstString y secondString referencian objetos que son iguales, pero no el mismo. Después de asignarles a ambas el resultado de la internización observamos que ambas referencian a la misma cadena (la representación canónica). Para que esto tenga algún sentido, debemos desreferenciar las cadenas originales para que el recolector de basura libere el espacio que ocupan. Por ello asignamos el resultado de intern() a la propia variable (firstString = firstString.intern()).

El resultado es que tenemos dos variables, referenciando una única cadena y, por tanto, haciendo un menor uso de memoria (compartiendo memoria).

¿Tanto rollo para tener dos variables apuntando al mismo objeto? Es mucho más sencillo:

String first = new String(“Hello World”);
String second = first;

Esto es cierto, querido lector, pero ten en cuenta que no siempre las cadenas las creamos nosotros mismos y no siempre tenemos casos tan sencillos como este.

Caso Práctico de String Interning

Veamos un caso práctico para entender mejor el String Interning.

Digamos que tenemos un servicio de atención al cliente. En una tabla de nuestra base de datos tenemos varios miles de llamadas recibidas en la última semana y debemos tener en memoria una lista con dichas llamadas (es posible que si trabajamos con JSF + CDI nos encontremos incluso con que el servidor tiene varias copias de esa lista en memoria). Los datos que manejamos son:

Nos preocupa lo que pueda ocupar todo eso en memoria. Como dije al principio, somos conscientes de que los milagros no existen y no podremos meter esos miles de registros en unos pocos Bytes, pero intentaremos minimizar el impacto.

Empecemos por la clase que representa cada llamada (clase Contact). Básicamente un pojo.

public class Contact {
  private String calling;
  private String called;
  private String area;
  private String center;
  -----
  Getters & Setters			
}

A continuación, el método que recibe un ResultSet (con esos miles de llamadas) a partir del cual vamos a construir la lista de contactos.

public List<Contact> getContacts(ResultSet rs) throws Exception{
	   
  List<Contact> allContacts = new ArrayList<>();
	   
  while (rs.next()) {
     Contact c = new Contact();
     c.setCalling( rs.getString("calling") );
     c.setArea(    rs.getString("Area").intern() );
     c.setCalled(  rs.getString("called").intern() );
     c.setCenter(  rs.getString("center").intern() );
     allContacts.add(c);
  }
	   
  return allContacts;
	   
}

Fijémonos en la clase Contact. Puede darnos la sensación de que las instancias de esta clase serán objetos pesados, pero en realidad lo que son pesadas son las cadenas de texto. La instancia de Contact es realmente bastante ligera ya que tan solo almacena 4 referencias.

Las cadenas, por el contrario, si son objetos pesados. Sin embargo, hemos optado por internizarlos, de modo que la mayor parte de las referencias en las instancias de Contact van a ser a las mismas cadenas (compartimos memoria).

En concreto tenemos 52 provincias diferentes, 5 números called diferentes y 4 centros de trabajo. Por muchas instancas de Contact que tengamos tan solo vamos a tener 61 cadenas en memoria entre provincias, teléfonos llamados y centros de trabajo.

Sin embargo, los números llamantes (calling) no los internizamos ¿por qué?: Los números de abonado de los clientes son muchos y probablemente se repetirán poco, tengamos en cuenta que las representaciones canónicas ocupan memoria como cualquier otra cadena y, si no están referenciados desde varias variables no nos aporta un ahorro real de memoria. Por otro lado las llamadas al String pool (esto es, la invocación al método intern()) introduce un cierto overhead  y, además, si hacemos crecer mucho el String pool su rendimiento será cada vez peor.

Si estamos usando java 7 o anterior, la situación en este sentido empeora aún más, porque el área de memoria del String pool está fuera del heap y, por lo tanto, está fuera del ámbito de actuación del recolector de basura. Dicho de otra manera, cada cadena que introduzcamos en el pool String ocupará memoria, incluso si está desreferenciada, hasta que paremos y arranquemos la JVM, que en el caso de tratarse de un servidor puede ser al cabo de meses o años.

Por suerte, de java 8 en adelante el String pool se ubica en el heap y actúa sobe él el recolector de basura. No obstante, el String pool debemos mantenerlo en un tamaño reducido, pocas cadenas pero con muchas referencias, evitando hacer un uso indiscriminado del mismo.

He visto la luz, a partir de ahora usaré la internización y ahorraré un montón de memoria

Quieto parado. El uso de la internización es una práctica de último recurso, que no debe convertirse en la tónica general a la hora de programar.

Por tanto, como conclusión, el recurso del String Interning, es algo que tienes a tu disposición para dar solución a los problemas de memoria antes descritos y, por supuesto, está ahí para ser empleado cuando lo necesitas. Pero debes extremar las precauciones, plantearte si lo estás utilizando correctamente y tan solo hacer uso del mismo cuando realmente sea necesario.

Autor

Óscar Pastor
Óscar Pastor

Desarrollador en dev&del

Capitán en Hello, World!

Máxima especialización en tecnologías Java y J2EE.

Tiene un mixto que se llama Alejandro, canta como los ángeles. Un mixto es un cruce entre canario y jilguero. ¡Ah! Por cierto, el que canta bien es Alejandro.

¿Estás interesado?

Déjanos tus datos y contactaremos contigo lo antes posible