Java StringBuilder

Uma String do Java nunca muda depois de criada. Cada texto = texto + "x" cria uma String nova, copiando tudo o que já existia. Num laço com milhares de voltas, isso fica lento de verdade. O StringBuilder ("construtor de strings") é um texto que pode mudar: você vai acrescentando pedaços e, no fim, pega a String pronta.

Você vai aprender
  • O que quer dizer "a String é imutável" e o que acontece no + dentro de um laço.
  • Medir a diferença de velocidade entre + e StringBuilder.
  • Os métodos do StringBuilder: append, insert, reverse, deleteCharAt, delete, replace, setLength, toString.
  • Encadear chamadas (sb.append(a).append(b)).
  • A diferença entre StringBuilder e StringBuffer, e quando o + comum é a melhor escolha.

A ideia antes do código

Imagine que a Ana escreve uma lista de compras a caneta, num cartão. Para acrescentar "leite", ela não pode escrever no cartão antigo (não há espaço e caneta não apaga): pega um cartão novo, copia tudo e escreve "leite" no fim. Para acrescentar "pão", outro cartão novo, copia tudo de novo… Com 3 itens, tudo bem. Com 10 mil, ela passa o dia copiando.

Agora imagine um caderno a lápis: ela só escreve o item novo no fim da página. Pode apagar uma letra, inserir uma palavra no meio. Quando a lista fica pronta, ela passa a limpo uma vez.

A String é o cartão a caneta. O StringBuilder é o caderno a lápis, e o toString() é "passar a limpo".


A String não muda

Você viu em Métodos de String que métodos como toUpperCase() devolvem uma String nova. Veja o que acontece quando o resultado não é guardado, e compare com um StringBuilder:

public class Main {
    public static void main(String[] args) {
        String nome = "Ana";
        nome.toUpperCase();
        System.out.println(nome);
        nome = nome.toUpperCase();
        System.out.println(nome);

        StringBuilder sb = new StringBuilder("Ana");
        sb.reverse();
        System.out.println(sb);
    }
}
$ java Main.java
Ana
ANA
anA

A linha 4 criou "ANA" e jogou fora: nome continuou "Ana". Só na linha 6, guardando o resultado na variável, nome passou a apontar para o texto novo. Já o sb.reverse() mudou o próprio objeto: não foi preciso guardar nada. Dizemos que a String é imutável e o StringBuilder é mutável.


O + no laço: medindo

Vamos montar um texto com n letras "x", primeiro com String e +, depois com StringBuilder, e cronometrar com System.nanoTime() (um relógio em nanossegundos; dividindo por 1.000.000 temos milissegundos, ms):

public class Main {
    static long comString(int n) {
        long inicio = System.nanoTime();
        String texto = "";
        for (int i = 0; i < n; i++) {
            texto = texto + "x";
        }
        return (System.nanoTime() - inicio) / 1_000_000;
    }

    static long comStringBuilder(int n) {
        long inicio = System.nanoTime();
        StringBuilder texto = new StringBuilder();
        for (int i = 0; i < n; i++) {
            texto.append("x");
        }
        String resultado = texto.toString();
        return (System.nanoTime() - inicio) / 1_000_000;
    }

    public static void main(String[] args) {
        int[] tamanhos = {10_000, 20_000, 40_000, 80_000};
        for (int n : tamanhos) {
            System.out.println(n + " letras: String " + comString(n)
                + " ms, StringBuilder " + comStringBuilder(n) + " ms");
        }
    }
}
$ javac Main.java
$ java Main
10000 letras: String 43 ms, StringBuilder 1 ms
20000 letras: String 58 ms, StringBuilder 0 ms
40000 letras: String 170 ms, StringBuilder 1 ms
80000 letras: String 618 ms, StringBuilder 1 ms

Os números mudam de computador para computador (e de uma execução para outra), mas o padrão é sempre o mesmo:

  • Com StringBuilder, o tempo é praticamente zero em todos os tamanhos.
  • Com +, cada vez que o tamanho dobra, o tempo fica perto de quatro vezes maior (170 → 618 ms). Na volta 40.000, o Java copia 40.000 letras para acrescentar uma; somando todas as voltas, são centenas de milhões de cópias. Com 1 milhão de letras, esse programa levaria minutos.
E o + numa linha só?

Uma expressão como "Olá, " + nome + "! Você tem " + idade + " anos." é perfeitamente boa: o compilador já a transforma numa montagem eficiente. O problema é só o + que se repete dentro de um laço, acumulando na mesma variável. Para juntar texto numa linha, continue usando + (veja Concatenação) ou String.format.


Usando o StringBuilder

StringBuilder sb = new StringBuilder();
Cria um "caderno" vazio. Também dá para começar com um texto: new StringBuilder("Ana"). Fica em java.lang: sem import.
sb.append(x)
"Acrescentar": coloca x no fim. Aceita texto, números, char, boolean, objetos.
sb.toString()
Devolve uma String com o conteúdo atual. O println(sb) chama o toString() sozinho.

Os outros métodos, um por um, no JShell (acompanhe o texto mudando a cada linha):

$ jshell
|  Welcome to JShell -- Version 25.0.4.1
|  For an introduction type: /help intro

jshell> StringBuilder sb = new StringBuilder();
sb ==>

jshell> sb.append("Ana")
$2 ==> Ana

jshell> sb.append(": ").append(9.5)
$3 ==> Ana: 9.5

jshell> sb.insert(0, "Aluna ")
$4 ==> Aluna Ana: 9.5

jshell> sb.length()
$5 ==> 14

jshell> sb.charAt(0)
$6 ==> 'A'

jshell> sb.indexOf(":")
$7 ==> 9

jshell> sb.replace(6, 9, "Carla")
$8 ==> Aluna Carla: 9.5

jshell> sb.deleteCharAt(sb.length() - 1)
$9 ==> Aluna Carla: 9.

jshell> sb.setCharAt(0, 'a')

jshell> sb
sb ==> aluna Carla: 9.

jshell> sb.delete(0, 6)
$12 ==> Carla: 9.

jshell> String texto = sb.toString();
texto ==> "Carla: 9."

jshell> new StringBuilder("roma").reverse()
$14 ==> amor

jshell> /exit
|  Goodbye
LinhaO que aconteceu
append("Ana")Acrescentou no fim. O JShell mostra o resultado porque o append devolve o próprio sb
append(": ").append(9.5)Encadeamento: como cada append devolve o sb, dá para chamar o próximo em seguida. O 9.5 (um double) virou texto
insert(0, "Aluna ")Inseriu na posição 0 (o começo). As posições contam a partir de 0, como na String
length(), charAt(0), indexOf(":")Iguais aos da String: tamanho, caractere numa posição, onde está um trecho
replace(6, 9, "Carla")Trocou o trecho das posições 6 a 8 (Ana) por Carla. O fim (9) não entra
deleteCharAt(sb.length() - 1)Apagou o último caractere (o 5)
setCharAt(0, 'a')Trocou um caractere. Não devolve nada (void), por isso o JShell não mostrou resultado
delete(0, 6)Apagou as posições 0 a 5 (aluna )
toString()"Passou a limpo": uma String comum (repare nas aspas na resposta do JShell)
reverse()Inverteu o texto

Missão: o programa monta a contagem regressiva com + num laço. Reescreva usando um StringBuilder e append. A saída deve continuar sendo 5 4 3 2 1 Fogo!.

public class Main {
    public static void main(String[] args) {
        String contagem = "";
        for (int i = 5; i >= 1; i--) {
            contagem = contagem + i + " ";
        }
        contagem = contagem + "Fogo!";
        System.out.println(contagem);
    }
}
public class Main {
    public static void main(String[] args) {
        StringBuilder contagem = new StringBuilder();
        for (int i = 5; i >= 1; i--) {
            contagem.append(i).append(' ');
        }
        contagem.append("Fogo!");
        System.out.println(contagem);
    }
}

Um exemplo completo: o relatório da turma

A Ana quer montar um relatório com uma linha por aluno e, no fim, a lista dos nomes separados por vírgula. O truque clássico da vírgula: acrescentar ", " depois de cada nome e, no fim, cortar os dois últimos caracteres com setLength ("definir o tamanho").

public class Main {
    public static void main(String[] args) {
        String[] alunos = {"Ana", "Bruno", "Carla"};
        double[] notas = {9.5, 7.0, 8.5};

        StringBuilder relatorio = new StringBuilder();
        relatorio.append("Notas da turma\n");
        for (int i = 0; i < alunos.length; i++) {
            relatorio.append("- ").append(alunos[i]).append(": ").append(notas[i]).append('\n');
        }

        StringBuilder nomes = new StringBuilder();
        for (String a : alunos) {
            nomes.append(a).append(", ");
        }
        nomes.setLength(nomes.length() - 2);
        relatorio.append("Alunos: ").append(nomes);

        System.out.println(relatorio);
    }
}
$ java Main.java
Notas da turma
- Ana: 9.5
- Bruno: 7.0
- Carla: 8.5
Alunos: Ana, Bruno, Carla
append('\n')
Um char de quebra de linha (veja Caracteres Especiais). Cada aluno ganha a sua linha.
nomes.setLength(nomes.length() - 2)
Antes, nomes era "Ana, Bruno, Carla, ". Encurtar o tamanho em 2 corta o ", " que sobrou no fim.
relatorio.append("Alunos: ").append(nomes)
Um StringBuilder pode receber outro.
Atalhos prontos para casos comuns

Para juntar itens com separador, o Java já tem String.join(", ", alunos), que dá Ana, Bruno, Carla sem o truque da vírgula. Para repetir um texto, "x".repeat(80000). Use o StringBuilder quando a montagem tem lógica no meio (ifs, cálculos, formatos diferentes por linha).


StringBuilder × StringBuffer

Você vai encontrar, em código antigo e em livros, o StringBuffer. Ele tem os mesmos métodos (append, insert, reverse…). A diferença é interna:

StringBuilderStringBuffer
DesdeJava 5 (2004)Java 1.0 (1996)
Seguro para várias threads ao mesmo tempoNãoSim (métodos synchronized)
VelocidadeMais rápidoUm pouco mais lento (paga pela sincronização em toda chamada)
Quando usarQuase sempre: um texto montado dentro de um métodoRaramente: quando várias threads escrevem no mesmo objeto (e mesmo aí há opções melhores)

Resumo prático: use StringBuilder. O StringBuffer existe por compatibilidade com código antigo; ao ver um, saiba que ele funciona igual.

Qual usar: +, StringBuilder ou outro

SituaçãoUse
Juntar algumas partes numa expressão+ ou String.format
Acumular texto dentro de um laçoStringBuilder
Inserir, apagar, inverter, trocar caracteresStringBuilder
Juntar uma lista com separadorString.join
Repetir um texto"…".repeat(n)
Várias threads escrevendo no mesmo textoStringBuffer

Quando dá errado

"StringBuilder cannot be converted to String"
public class Main {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("Ana");
        String nome = sb;
        System.out.println(nome);
    }
}
$ java Main.java
Main.java:4: error: incompatible types: StringBuilder cannot be converted to String
        String nome = sb;
                      ^
1 error
error: compilation failed

O que aconteceu: StringBuilder e String são classes diferentes. O println aceita os dois, mas uma variável String só aceita String.

Como resolver: String nome = sb.toString();.

Dois StringBuilder com o mesmo texto não são equals
public class Main {
    public static void main(String[] args) {
        StringBuilder a = new StringBuilder("uva");
        StringBuilder b = new StringBuilder("uva");
        System.out.println(a.equals(b));
        System.out.println(a.toString().equals(b.toString()));
        System.out.println(a.compareTo(b) == 0);
    }
}
$ java Main.java
false
true
true

O que aconteceu: o StringBuilder não redefine o equals (veja A Classe Object): para ele, equals é o mesmo que ==, "é o mesmo objeto?". Como é um texto que muda, comparar conteúdos não faz muito sentido.

Como resolver: compare os textos prontos (a.toString().equals(b.toString())) ou use a.compareTo(b) == 0.

StringIndexOutOfBoundsException ao apagar
public class Main {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("uva");
        sb.deleteCharAt(sb.length());
    }
}
$ java Main.java
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Index 3 out of bounds for length 3
	at java.base/jdk.internal.util.Preconditions$1.apply(Preconditions.java:55)
	at java.base/jdk.internal.util.Preconditions$1.apply(Preconditions.java:52)
	at java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:213)
	at java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:210)
	at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:98)
	at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:106)
	at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:302)
	at java.base/java.lang.String.checkIndex(String.java:4904)
	at java.base/java.lang.AbstractStringBuilder.deleteCharAt(AbstractStringBuilder.java:1081)
	at java.base/java.lang.StringBuilder.deleteCharAt(StringBuilder.java:298)
	at Main.main(Main.java:4)

O que aconteceu: "uva" tem tamanho 3 e posições 0, 1 e 2. A posição 3 não existe. O último caractere é sempre length() - 1.

Como resolver: sb.deleteCharAt(sb.length() - 1), e só se o texto não estiver vazio (if (sb.length() > 0)).


Referência

MétodoO que fazDevolve
new StringBuilder() / new StringBuilder("txt")Cria vazio / com texto inicial
append(x)Acrescenta no fim (qualquer tipo)o próprio sb
insert(pos, x)Insere na posiçãoo próprio sb
delete(ini, fim)Apaga de ini até fim - 1o próprio sb
deleteCharAt(pos)Apaga um caractereo próprio sb
replace(ini, fim, "txt")Troca um trechoo próprio sb
reverse()Inverteo próprio sb
setCharAt(pos, c)Troca um caracterenada (void)
setLength(n)Corta (ou completa) até o tamanho n; setLength(0) esvazianada
length(), charAt(pos), indexOf("txt")Como na Stringint, char, int
toString()O texto prontoString

Exercícios

1. Acrescentar

Complete para acrescentar "!" no fim:

sb.("!");

2. Passar a limpo

Complete para obter a String:

String pronto = sb.;

3. Inverter

O que new StringBuilder("Ana").reverse() produz?

4. Por que texto = texto + "x" dentro de um laço grande é lento?

5. Qual a principal diferença entre StringBuilder e StringBuffer?

6. StringBuilder sb = new StringBuilder("abc"); sb.deleteCharAt(0); Quanto vale sb?

Missão final: um palíndromo é uma palavra que se lê igual de trás para frente. Complete o laço: inverta cada palavra com new StringBuilder(p).reverse().toString() e compare com a original ignorando maiúsculas (equalsIgnoreCase). Mostre arara é palíndromo, banana não é palíndromo (ananab) e Reviver é palíndromo.

public class Main {
    public static void main(String[] args) {
        String[] palavras = {"arara", "banana", "Reviver"};
        for (String p : palavras) {
            String invertida = "";
            // inverta p e compare
        }
    }
}
public class Main {
    public static void main(String[] args) {
        String[] palavras = {"arara", "banana", "Reviver"};
        for (String p : palavras) {
            String invertida = new StringBuilder(p).reverse().toString();
            if (p.equalsIgnoreCase(invertida)) {
                System.out.println(p + " é palíndromo");
            } else {
                System.out.println(p + " não é palíndromo (" + invertida + ")");
            }
        }
    }
}

Resumo

  • String é imutável: todo "alterar" cria uma String nova. StringBuilder é mutável: os métodos mudam o próprio objeto.
  • + acumulando texto num laço é lento (dobrar o tamanho quadruplica o tempo). Use StringBuilder e append; + numa expressão só continua ótimo.
  • Métodos: append, insert, delete, deleteCharAt, replace, reverse, setCharAt, setLength, toString. A maioria devolve o próprio sb, o que permite encadear.
  • Para virar String: toString(). Para comparar: compare os toString() (o equals do StringBuilder não compara texto).
  • StringBuffer é a versão antiga e sincronizada; prefira StringBuilder.