Una pregunta habitual de los que comienzan con Java (e incluso en entrevistas de trabajo para puestos que usan este lenguaje) tiene que ver con las comparaciones entre cadenas de texto. Si vienes de otros lenguajes puedes estar acostumbrado a comparar cadenas con el operador igualdad ==
o, simplemente, te puede parecer la forma más evidente y obvia de hacerlo.
Sin embargo, consideremos un código como este:
String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false
Aunque las cadenas son idénticas y obtienes dos objetos String
idénticos, la comparación devuelve false
🤔
En este otro ejemplo similar:
String s1 = "campusMVP";
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false
devuelve false
también.
Sin embargo esto:
String s1 = "campusMVP";
String s2 = "campusMVP";
System.out.println(s1 == s2); //Devuelve true
devuelve true
.
Es más, un caso más común sería el de comparar el resultado de una función que devuelve una cadena, con otra cadena cualquiera. Algo así:
String s1 = "OK";
if (s1 == FuncionQueDevuelveCadena()) {
System.out.println("¡Coinciden!");
}
else {
System.out.println("No coinciden"));
}
Bien, lo curioso del fragmento anterior es que, dependiendo de lo que haga la función y aunque la cadena devuelta sea exactamente la misma en ambos casos, el resultado puede ser true
o false
.
¿A qué es debido esto?
El funcionamiento de las cadenas en Java - Repositorio o Pool
de cadenas
Las cadenas en Java son una especie aparte. Tienen muchos detalles que es preciso conocer para entenderlas (por ejemplo, que son inmutables, o Final
en Java, o que internamente se pueden codificar en memoria de varias formas). Conocerlas bien es un paso indispensable para dominar el lenguaje.
Un concepto sencillo una vez que lo entiendes, pero que no todo el mundo tiene claro es la forma de instanciarlas dependiendo de cómo se declaren.
Básicamente tenemos dos maneras de declarar una cadena: con un literal o instanciando una clase:
String literal = "campusMVP";
String clase = new String("campusMVP");
En ambos casos lo que obtenemos es lo mismo: una clase String
con una cadena dentro (que no es más que una matriz de caracteres en memoria). Sin embargo, hay una gran diferencia entre hacerlo de una forma o la otra.
Cuando se declara una cadena de manera literal por primera vez, la JVM la coloca en un espacio especial denominado repositorio de cadenas (string pool en inglés). Este repositorio contiene una copia única de las diferentes cadenas declaradas como literales en el código. Aquí la palabra importante es "única". Como las cadenas son inmutables, si declaras más de una vez la misma cadena no tiene sentido tenerla varias veces en memoria, así que la siguiente vez que la declares lo que hace la JVM es ir al repositorio de cadenas y localizarla, devolviendo una referencia al mismo objeto String
que en la primera declaración. OJO: es el mismo objeto, no una copia. Por eso al escribir esto:
String s1 = "campusMVP";
String s2 = "campusMVP";
String s3 = new String(s1);
System.out.println(s1 == s2); //true
System.out.println(s1 == "campusMVP"); //true
En todos los casos la comparación se realiza con éxito. Lo que ocurre es que en todos los casos se usa el mismo objeto String
exactamente. En la línea 1, en la primera declaración, se almacena en el repositorio de cadenas. En la línea 2, la segunda declaración del literal, la JVM va al repositorio y mira si ya existe la misma cadena de antes. Como en este caso sí que existe, lo que hace es devolver una referencia a la misma cadena que en la línea 1. ¡Por eso son iguales! Porque no solo representan los mismos caracteres, sino que son de hecho la misma clase (y apuntan a la misma matriz en memoria). En la línea 4, cuando se hace la comparación en s1
y el mismo literal, vuelve a pasar lo mismo: la JVM localiza el literal en el repositorio de cadenas y devuelve la misma referencia, por eso es true
de nuevo.
Se puede ver visualmente mejor en esta figura, que trata de representar la situación:
Esto funciona incluso con la combinación de varios literales. La JVM es lo suficientemente inteligente como para hacer que estas dos cadenas sean la misma:
String s1 = "campusMVP";
String s2 = "campus" + "MVP";
System.out.println(s1 == s2); //true
A pesar de que podría parecer que hay 3 cadenas en el repositorio de cadenas, y que devolvería dos objetos diferentes.
Declaración de cadenas como clases
Sin embargo, cuando declaramos una cadena usando una clase (con new String()
), la operación es diferente. Lo que se hace es asignar en memoria el espacio necesario para la cadena y devolver la clase que permite manejarla. No se pasa por el repositorio de cadenas para nada.
Por ello este fragmento:
String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false
devuelve false
en la comparación. Se trata de dos cadenas diferentes, aunque internamente representen los mismos caracteres:
Nota: lo sé, es absurdo declarar cadenas de esta manera envolviendo un literal con una clase String
. Y de hecho, raramente lo verás hecho por ahí. Pero es que hay infinidad de maneras de obtener clases String
en una variable: desde transformar un literal hasta sacarlas de una base de datos. Solo pongo el ejemplo de esta manera para que se entienda lo que quiero explicar, no para indicar que esta es una manera recomendada de declarar las cadenas.
¿Qué ocurre si tenemos dos cadenas literales y queremos compararlas con ==
sin considerar mayúsculas y minúsculas?
Podríamos hacer esto:
String s1 = "campusMVP";
String s2 = "campusMVP";
System.out.println(s1.toLowerCase() == s2.toLowerCase()); //false!
En este caso la comparación devuelve false
, ya que lo que obtenemos son dos cadenas idénticas pero representadas por dos objetos diferentes. El operador ==
compara referencias a objetos y en este caso, aunque los datos que contienen son los mismos, no se trata del mismo objeto. Incluso siendo s1
y s2
el mismo objeto String,
la operación toLowerCase()
lo que hace es generar una nueva cadena a partir de la misma cadena original, pero se trata de dos objetos String
diferentes en memoria. Es como el caso anterior. Al no ser literales, no se obtienen desde el repositorio de cadenas aunque ya existan allí.
Por eso es tan peligroso comparar cadenas con ==
. Aunque las cadenas sean idénticas, como lo que se comparan son referencias a objetos String
y no el valor subyacente, solo devuelven true
para cadenas iguales cuando se refieren al mismo literal.
Conclusión: no utilices nunca el operador ==
para comparar cadenas si quieres hacerlo bien en Java.
Y entonces ¿cómo comparo cadenas con seguridad?
Pues con el método equals()
que tienen todos los objetos. De hecho, es heredado de Object
, la clase base "raíz" de todas las existentes. Este método lo que hace es comparar el estado interno de dos objetos para ver si son iguales, mientras que ==
lo que hace es comparar las referencias.
En el caso de las cadenas, lo que hace es comparar que las dos matrices de caracteres que contienen los objetos String
que comparamos sean iguales, que es realmente lo que nos interesa por regla general.
Así, para comparar dos cadenas cualquiera con seguridad debes usar este método:
String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1.equals(s2)); //Devuelve true
System.out.println(s1.equals("campusMVP")); //Devuelve true
System.out.println("campusMVP".equals(s1)); //Devuelve true
Pero es que da igual cómo obtengamos la cadena final: siempre devuelve true
si las cadenas que representan los dos objetos son iguales, por ejemplo:
String s1 = new String("campusMVP");
String s2 = new String("CAMPUSMVP");
System.out.println(s1.equals("campus" + "MVP")); //Devuelve true
System.out.println(s1.toLowerCase().equals(
s2.toLowerCase()
)); //Devuelve true
En este caso sí que podemos comparar las dos cadenas en minúsculas para ver si son iguales.
Conclusión 2: las comparaciones de cadenas en Java hazlas siempre con equals()
.
"Internalizando" objetos de tipo cadena
Esto es ya casi una curiosidad más que otra cosa. Pero es que, aunque no es algo que se use muy habitualmente, existe la posibilidad de hacer que los objetos de tipo String
que tengamos se "internalicen" de modo que pasen a formar parte del pool de cadenas de texto, como si los hubiésemos declarado como literales.
Esto se consigue gracias al método intern()
que tienen las cadenas. Lo que hace este método es lo mismo que ocurre cuando se declara una cadena literal: va al repositorio de cadenas y mira si ya existe la misma cadena ahí (en caso negativo la crea), y devuelve como resultado una referencia a la cadena preexistente, en lugar de crear una nueva.
Si llamamos a intern()
, así, por ejemplo esto:
String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1.intern() == s2.intern()); //Devuelve true
Devolverá true
ya que intern()
devuelve como resultado de la llamada la misma referencia a la misma cadena del pool de cadenas.
Es decir, intern()
lo que hace es desligar el objeto de los datos originales y asignarle el mismo dato que hay en el repositorio de cadenas. Visualmente sería algo así (fíjate en la cadena s3
):
¿Para qué vale esto de intern()
? Bueno, como decía, realmente pocas veces lo vas a utilizar, pero esto podría ayudarte a ahorrar mucha memoria en los casos en los que vas a generar muchas cadenas y un gran número de las cuales van a ser iguales.
Por ejemplo, imagínate que vas a obtener desde una base de datos muchos miles de registros de pedidos de clientes, y que tienes un número bastante limitado de clientes y de productos, lo que pasa que hacen muchos pedidos de lo mismo. "Internalizando" las cadenas evitarías tener los mismos nombres de cliente y de pedidos duplicados miles de veces en memoria, dejando tan solo unas pocas copias de los mismos dentro del repositorio de cadenas y ahorrando potencialmente mucha memoria, sobre todo si los nombres son largos.
Como nota técnica adicional sobre esto decir que, hasta Java 7, ya hace muchos años (se lanzó en 2011), el espacio disponible para almacenar las cadenas en el pool era limitado y además no entraba dentro de la recolección de basura. Eso significaba que si abusabas mucho del "interning" podrías acabar con una excepción de tipo OutOfMemory
, lo cual podría provocar un desastre en tu aplicación. Es por esto que en muchos artículos que encontrarás por ahí sobre este tema, y que están anticuados, te recomiendan no usar intern()
. La realidad es que, a partir de Java 7, y por lo tanto en la práctica totalidad de las aplicaciones Java que vas a encontrar por ahí, el repositorio de cadenas se guarda ya en el "montón" (o heap) (es decir, en memoria dinámica), por lo que no hay problemas de límites y además puede ser reclamada por el recolector de basura. Además en Java 13 (de septiembre de 2019) se permite también la devolución al sistema operativo de memoria dinámica no utilizada, con lo que incluso está más optimizado.
En resumen
Las cadenas de texto son "bichos raros" en casi todos los lenguajes, pero en Java especialmente, al poder actuar a la vez como literales y como objetos, pero al mismo tiempo presentan diferencias a la hora de utilizarlas y en especial de compararlas.
Por ello las comparaciones de cadenas deben evitar el uso del operador ==
(que compara referencias de objetos y no sus datos) y emplear en cambio el método equals()
. Además es importante conocer el funcionamiento interno de las cadenas, las implicaciones que tiene y cómo podemos optimizar el uso de memoria cuando manejamos cadenas de gran tamaño (o muchas) con valores idénticos.
Te de dejado un pequeño patio de juegos para que puedas comprobar el funcionamiento de todo lo explicado y probar a realizar cambios y ver cómo funcionan. Puedes editar y ejecutar directamente a continuación o abrirlo en una ventana aparte para trabajar con más comodidad si estás en un ordenador de escritorio:
¡Espero que te resulte útil!