Programación

String, StringBuffer y StringBuilder en Java: diferencias y cuándo usar cada uno

5 min de lectura

En Java hay tres clases para trabajar con texto: String, StringBuilder y StringBuffer. La diferencia cabe en una línea: String no se puede modificar, las otras dos sí, y de esas dos solo StringBuffer está sincronizada para usarse desde varios hilos. Lo interesante es lo que eso significa en la práctica, y aquí lo vemos con ejemplos probados en Java 25.

StringStringBuilderStringBuffer
¿Se puede modificar?No (inmutable)SíSí
¿Segura entre hilos?Sí, porque no cambiaNoSí, sus métodos están sincronizados
Velocidad al construir textoLenta si se concatena en un bucleLa más rápidaAlgo más lenta que StringBuilder
DesdeJava 1.0Java 5Java 1.0
Úsala para…Casi todo: valores, claves, parámetrosConstruir texto por partesCódigo antiguo o un búfer compartido entre hilos

String es inmutable

Un objeto String no cambia nunca después de crearse. Cuando parece que lo modificas, por ejemplo con +, replace o toUpperCase, en realidad Java crea un String nuevo y deja el original como estaba. Con StringBuilder pasa lo contrario: append modifica el mismo objeto, y cualquier variable que apunte a él ve el cambio.

public class Inmutable {
    public static void main(String[] args) {
        String saludo = "Hola";
        String otro = saludo;
        saludo = saludo + " mundo";      // crea un String nuevo

        System.out.println(saludo);      // Hola mundo
        System.out.println(otro);        // Hola: el original no cambió

        StringBuilder sb = new StringBuilder("Hola");
        StringBuilder mismo = sb;
        sb.append(" mundo");             // modifica el mismo objeto

        System.out.println(sb);          // Hola mundo
        System.out.println(mismo);       // Hola mundo: es el mismo objeto
    }
}
java InmutableHola mundo
Hola
Hola mundo
Hola mundo

La inmutabilidad no es un capricho. Permite que Java comparta las cadenas literales en memoria, que un String sirva sin riesgo como clave de un HashMap y que se pueda pasar a cualquier método o hilo sabiendo que nadie lo va a alterar. Lo explicamos con más detalle en qué es un objeto inmutable en Java.

El problema de concatenar en un bucle

La inmutabilidad tiene un coste cuando se construye un texto poco a poco. Cada s += algo crea un String nuevo y copia en él todo lo que había antes. En un bucle de cien mil vueltas, eso son cien mil copias de un texto cada vez más largo. StringBuilder, en cambio, va añadiendo al final de un búfer interno que solo crece de vez en cuando.

public class Bucle {
    static final int N = 100_000;

    static String conMas() {
        String s = "";
        for (int i = 0; i < N; i++) {
            s += i % 10;                 // copia todo el texto en cada vuelta
        }
        return s;
    }

    static String conBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < N; i++) {
            sb.append(i % 10);           // añade al final del mismo búfer
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        for (int ronda = 0; ronda < 3; ronda++) {   // la primera ronda calienta la JVM
            long t0 = System.nanoTime();
            String a = conMas();
            long t1 = System.nanoTime();
            String b = conBuilder();
            long t2 = System.nanoTime();
            System.out.printf("ronda %d: con + %d ms | con StringBuilder %,.2f ms | iguales: %b%n",
                    ronda + 1, (t1 - t0) / 1_000_000, (t2 - t1) / 1e6, a.equals(b));
        }
    }
}
java Bucleronda 1: con + 751 ms | con StringBuilder 3.91 ms | iguales: true
ronda 2: con + 559 ms | con StringBuilder 0.59 ms | iguales: true
ronda 3: con + 499 ms | con StringBuilder 0.46 ms | iguales: true

El resultado es el mismo texto, pero con + tarda unas doscientas veces más en la primera ronda y más de mil veces más en las siguientes. El tiempo exacto depende del equipo y cambia en cada ejecución, pero la diferencia de escala no: con + el trabajo crece con el cuadrado del número de vueltas, y con StringBuilder, de forma lineal. La primera ronda es más lenta en los dos casos porque la máquina virtual todavía no ha optimizado el código.

¿Y fuera de los bucles? Concatenar con + en una sola expresión, como "Hola, " + nombre + ".", está bien y es lo más legible. El texto original de esta entrada, de 2012, decía que Java lo convierte internamente en un StringBuffer o un StringBuilder. Eso era cierto hasta Java 8. Desde Java 9, el compilador genera una llamada a makeConcatWithConstants y la máquina virtual elige la mejor forma de unir las piezas al ejecutar el programa. Se puede ver con javap.
public class Concat {
    static String saludo(String nombre, int edad) {
        return "Hola, " + nombre + ". Tienes " + edad + " años.";
    }
    public static void main(String[] args) {
        System.out.println(saludo("Ana", 30));
    }
}
javap -c Concat (extracto)  static java.lang.String saludo(java.lang.String, int);
    Code:
         0: aload_0
         1: iload_1
         2: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
         7: areturn

Los métodos de StringBuilder

StringBuilder y StringBuffer tienen exactamente los mismos métodos. Los más usados son append para añadir al final, insert para meter texto en una posición, delete y replace para quitar o sustituir un tramo, setCharAt para cambiar un carácter, reverse para darle la vuelta y toString para obtener el String final.

public class Metodos {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("decodigo");
        sb.append(".com");               // decodigo.com
        sb.insert(0, "https://");        // https://decodigo.com
        System.out.println(sb);

        sb.replace(0, 8, "");            // quita "https://"
        sb.setCharAt(0, 'D');            // Decodigo.com
        System.out.println(sb);

        sb.delete(sb.length() - 4, sb.length());   // quita ".com"
        System.out.println(sb.reverse());          // ogidoceD
        System.out.println("longitud " + sb.length() + ", capacidad " + sb.capacity());
    }
}
java Metodoshttps://decodigo.com
Decodigo.com
ogidoceD
longitud 8, capacidad 24

La capacidad es el tamaño del búfer interno: al crear un StringBuilder a partir de un texto, Java reserva su longitud más 16 caracteres, por eso sale 24. Cuando el texto no cabe, el búfer se amplía solo. Si sabes de antemano que vas a construir un texto grande, puedes indicar la capacidad inicial con new StringBuilder(10_000) y te ahorras esas ampliaciones.

StringBuffer y los hilos

La única diferencia entre StringBuffer y StringBuilder es que los métodos de StringBuffer están sincronizados: si dos hilos llaman a append a la vez, uno espera a que termine el otro. En este ejemplo, dos hilos añaden cien mil caracteres cada uno al mismo objeto.

public class Hilos {
    public static void main(String[] args) throws InterruptedException {
        for (int prueba = 1; prueba <= 3; prueba++) {
            StringBuilder builder = new StringBuilder();
            StringBuffer buffer = new StringBuffer();
            Runnable tarea = () -> {
                for (int i = 0; i < 100_000; i++) {
                    builder.append('x');
                    buffer.append('x');
                }
            };
            Thread h1 = new Thread(tarea), h2 = new Thread(tarea);
            h1.start(); h2.start();
            h1.join(); h2.join();
            System.out.printf("prueba %d: StringBuilder %d | StringBuffer %d (esperado 200000)%n",
                    prueba, builder.length(), buffer.length());
        }
    }
}
java Hilosprueba 1: StringBuilder 193695 | StringBuffer 200000 (esperado 200000)
prueba 2: StringBuilder 196891 | StringBuffer 200000 (esperado 200000)
prueba 3: StringBuilder 176761 | StringBuffer 200000 (esperado 200000)

Con StringBuilder se pierden caracteres, y en cada ejecución una cantidad distinta: los dos hilos escriben a la vez en la misma posición del búfer. A veces el programa incluso falla con una ArrayIndexOutOfBoundsException. Con StringBuffer siempre salen los 200000.

Entonces, ¿uso StringBuffer? Casi nunca. Lo normal es que cada hilo construya su propio texto en una variable local, y para eso StringBuilder es lo correcto y no necesita sincronización. Compartir un búfer de texto entre hilos es raro, y la sincronización de StringBuffer solo protege cada llamada por separado: dos append seguidos de un hilo pueden quedar intercalados con los de otro. StringBuffer existe sobre todo por compatibilidad con el código anterior a Java 5, cuando no había otra opción.

Cuidado al comparar

Un error clásico: StringBuilder no redefine equals, así que dos objetos con el mismo texto no son «iguales» para Java. Para comparar el contenido, hay que pasarlos a String, usar compareTo, disponible desde Java 11, o contentEquals.

public class Comparar {
    public static void main(String[] args) {
        StringBuilder a = new StringBuilder("java");
        StringBuilder b = new StringBuilder("java");

        System.out.println(a.equals(b));                 // false: compara si son el mismo objeto
        System.out.println(a.toString().equals(b.toString()));  // true
        System.out.println(a.compareTo(b) == 0);         // true (desde Java 11)
        System.out.println("java".contentEquals(a));     // true
    }
}
java Compararfalse
true
true
true

Alternativas modernas

Muchas veces ni siquiera hace falta un StringBuilder. Para unir una lista con un separador están String.join y Collectors.joining; para repetir un texto, repeat; para dar formato, formatted; y para textos de varias líneas, los bloques de texto con tres comillas. Por debajo usan StringBuilder o algo equivalente, y el código queda más claro.

import java.util.List;
import java.util.stream.Collectors;

public class Modernos {
    public static void main(String[] args) {
        List<String> lenguajes = List.of("Java", "Go", "Rust");

        System.out.println(String.join(", ", lenguajes));                         // Java, Go, Rust
        System.out.println(lenguajes.stream().collect(Collectors.joining(" | ", "[", "]")));
        System.out.println("-".repeat(20));                                        // Java 11
        System.out.println("%s tiene %d letras".formatted("Java", 4));            // Java 15

        String json = """
                {"lenguaje": "Java", "version": 25}
                """;                                                               // bloque de texto, Java 15
        System.out.print(json);
    }
}
java ModernosJava, Go, Rust
[Java | Go | Rust]
--------------------
Java tiene 4 letras
{"lenguaje": "Java", "version": 25}

Cuál usar

  • String para casi todo: guardar un texto, pasarlo entre métodos o usarlo como clave. Y + para concatenar en una sola expresión.
  • StringBuilder cuando construyes un texto en un bucle o por partes. Es la opción por defecto para texto que cambia.
  • StringBuffer solo si mantienes código antiguo que ya lo usa o si de verdad varios hilos escriben en el mismo búfer, y aun así conviene pensar si no es mejor que cada hilo tenga el suyo.
  • String.join, Collectors.joining, repeat o los bloques de texto cuando encajan, porque hacen lo mismo con menos código.

Si estás empezando con Java, en el curso de Java tienes los tipos y métodos explicados desde cero, y en medir el rendimiento en Java, cómo comparar tiempos como los de este artículo con más rigor.

Todos los ejemplos se compilaron y ejecutaron con Java 25 (Eclipse Temurin 25.0.4). Los tiempos y los recuentos de la prueba con hilos cambian en cada ejecución. Referencias: documentación de StringBuilder y StringBuffer, y JEP 280: concatenación con invokedynamic.

7 comentarios en «String, StringBuffer y StringBuilder en Java: diferencias y cuándo usar cada uno»

Deja un comentario