Java cuenta con múltiples tipos de datos para trabajar con cadenas de caracteres. El más conocido de ellos es String
, pero también tenemos a nuestra disposición otros como StringBuilder
y StringBuffer
. Para elegir el más adecuado en cada caso hemos de conocer las diferencias entre ellos. Es un conocimiento que nos será útil para mejorar el rendimiento de nuestras aplicaciones y que, además, podría ayudarnos a responder cuestiones en una entrevista de trabajo en la que se soliciten conocimientos sobre Java.
Lo primero que habríamos de preguntarnos es la razón por la que existen múltiples tipos de datos en Java para operar sobre una misma categoría de información: las cadenas de caracteres.
Como se apuntaba en el artículo Cadenas compactas en Java 9 que publicábamos hace unas semanas, una fracción importante de toda la información con la que trabajan las aplicaciones son cadenas de caracteres. Por ello es importante que el trabajo con las mismas sea lo más eficiente posible. Es la razón por la que en la clase String
el atributo encargado de almacenar los caracteres, tal y como se aprecia en la siguiente imagen correspondiente a la cabecera del módulo String.java
, se declare como final
. Esto implica que su contenido, una vez asignado, ya no podrá modificarse. Por esto se dice que el tipo String
de Java es inmutable.
No hemos de olvidar que Java es un lenguaje muy usado para el desarrollo de aplicaciones de servidor, en las que es habitual la creación de múltiples hilos (threads) de ejecución a fin de paralelizar el trabajo y aprovechar las actuales configuraciones multi-procesador y multi-núcleo. En este contexto, un tipo inmutable aporta indudables ventajas. Las operaciones sobre String
no precisan de mecanismos de sincronización para el acceso simultáneo desde múltiples hilos, lo cual permite trabajar con ellas con un buen rendimiento. La sincronización implica, en general, la serialización de las operaciones, afectando de manera importante al rendimiento con que es posible llevarlas a cabo.
Todas las ventajas suelen tener asociada una contrapartida y, en este caso concreto, la desventaja surge al manipular el contenido de una cadena. Dado que el tipo String
es inmutable (no podemos modificar su contenido), cualquier operación de modificación sobre una variable de este tipo, como puede ser concatenar una cadena a otra o usar métodos como toUpperCase()
, replace()
o similares, implica la creación de un nuevo objeto String
como resultado. Por ejemplo:
String saludo = "Hola"; // Creamos la cadena con un contenido
saludo = saludo + " mundo"; // y le agregamos después una subcadena
Cuando en la segunda línea extendemos el contenido original de la variable saludo
, lo que ocurre es que se libera el objeto String
original, el creado en la primera sentencia, y se crea otro nuevo para alojar el resultado de la operación. Por otra parte, las literales de cadena de caracteres, como "Hola"
y " mundo"
en el anterior ejemplo, se almacenan en un espacio específico conocido como constant string pool. Esto facilita la reutilización de estas literales, reduciendo la cantidad de memoria total en caso de que aparezcan múltiples veces en el código del programa. Además se emplea una estrategia de asignación de memoria estática, en lugar de dinámica, mejorando así la velocidad de ejecución.
Obviamente, el hecho de se liberen y creen nuevos objetos String
cada vez que se cambia su contenido influye también en el rendimiento de los programas. El recolector de basura de Java tendrá más trabajo. No obstante, la decisión de hacer inmutable el tipo String
parte de análisis realizados sobre aplicaciones en los que se observa que en una gran proporción de los casos su contenido no es modificado, por lo que los beneficios obtenidos son, en general, superiores a los inconvenientes.
Cadenas de caracteres mutables: los tipos StringBuilder y StringBuffer
Dado que en una aplicación puede surgir la necesidad de alterar de manera frecuente el contenido de una cadena de caracteres, Java nos ofrece tipos de datos específicos para operar sobre ellas. Las cadenas de caracteres mutables, representadas por los tipos StringBuilder
y StringBuffer
, se comportan como cualquier otro tipo de dato por referencia en Java: se asigna memoria dinámicamente según es necesario. No se hace uso del constant string pool y el contenido puede modificarse sin necesidad de liberar y crear nuevos objetos. Por ejemplo:
StringBuilder saludo = new StringBuilder("Hola");
saludo.append(" mundo");
En este caso, al agregar la segunda cadena a la primera, sencillamente se actualiza el contenido inicial de la variable saludo
, de tipo StringBuilder
, en lugar de liberarse el objeto original y crearse otro nuevo. En general, un programa que vaya a modificar con cierta frecuencia el contenido de una o más cadenas de caracteres obtendrá mejor rendimiento de esta forma que con el tipo String
original.
Como apuntaba antes, habitualmente cada ventaja conlleva algún tipo de inconveniente. La flexibilidad de los tipos StringBuilder
y StringBuffer
también tiene su contrapartida. Al contar con un contenido mutable, StringBuilder
no es un tipo de dato seguro para aplicaciones con múltiples hilos de ejecución. Si dos hilos acceden simultáneamente para cambiar algo en la cadena, el resultado puede ser totalmente inesperado.
¿Qué hacer si necesitamos trabajar con cadenas de caracteres mutables en un entorno multi-hilo? Usar el tipo StringBuffer
en lugar de StringBuilder
. Ambos son prácticamente idénticos en cuanto a funcionalidad se refiere, pero internamente la implementación de todos los métodos que alteran la cadena está sincronizada en el caso de StringBuffer
. Es decir, este último tipo es seguro (thread-safe) para múltiples hilos, mientras que StringBuilder
no lo es. Esta seguridad se obtiene a costa del rendimiento, ya que la sincronización provoca que las operaciones sobre cadenas con StringBuffer
sean más lentas que con StringBuilder
o que con String
.
Cuando el compilador se ocupa de optimizar por nosotros
A la vista de lo explicado hasta ahora, podríamos pensar en recurrir a los tipos de cadenas mutables exclusivamente en aquellos casos en que vamos a llevar a cabo reiteradas operaciones de modificación, como puede ser la concatenación de un conjunto de resultados generados en un bucle. Por ejemplo:
String resultado = "Cuadrados\n";
for(int i = 1; i <= 100; i++)
resultado = resultado + "El cuadrado de " + i + " es " + i*i + "\n";
En el anterior bucle, dado que está utilizándose una variable de tipo String
como destinataria, se liberaría el objeto actual y se crearía otro nuevo en cada ciclo. Esto, con un número de iteraciones grande, conlleva mucho trabajo de asignación y recolección de objetos, por lo que convendría optimizar cambiando String
por StringBuilder
.
El compilador de Java es suficientemente inteligente como para identificar estos casos de forma automática, llevando a cabo la sustitución indicada en el bucle y, una vez finalizado este, convirtiendo el resultado a String
y guardándolo en la variable original. La conclusión a la que podemos llegar es que, en general, usaremos el tipo String
salvo en casos muy concretos, cuando vayan a efectuarse muchos cambios sobre la cadena y estos no puedan ser identificados y optimizados por el compilador de Java.