Blog

Valores e referências em Java: primitivos, objetos e a armadilha do Integer ==

Toda variável em Java guarda um valor primitivo ou uma referência a um objeto. Saber qual dos dois explica o overflow silencioso, a passagem por valor, == versus equals e por que dois Integer com 128 não são iguais.

Uma variável em Java guarda um de dois tipos de coisa. Uma variável de tipo primitivo, como int ou double, guarda o próprio valor. Uma variável de qualquer outro tipo guarda uma referência a um objeto que mora em outro lugar. A maioria das surpresas de quem está começando em Java vem de confundir os dois.

Este post cobre os oito tipos primitivos, overflow e aritmética, conversões, var, referências, passagem por valor, == versus equals, boxing e a armadilha do Integer ==, e o que final realmente fixa. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e execute java Main.java.

Os oito tipos primitivos do Java

Java tem exatamente oito tipos primitivos: seis para números, um para caracteres e um para verdadeiro ou falso. Os tamanhos são fixados pela linguagem, então um int tem 32 bits em qualquer máquina. Você não precisa decorar os intervalos, porque a classe wrapper de cada tipo guarda esses limites como constantes:

void main() {
    IO.println("byte    " + Byte.MIN_VALUE + " to " + Byte.MAX_VALUE);
    IO.println("short   " + Short.MIN_VALUE + " to " + Short.MAX_VALUE);
    IO.println("int     " + Integer.MIN_VALUE + " to " + Integer.MAX_VALUE);
    IO.println("long    " + Long.MIN_VALUE + " to " + Long.MAX_VALUE);
    IO.println("float   largest " + Float.MAX_VALUE);
    IO.println("double  largest " + Double.MAX_VALUE);
    IO.println("char    " + (int) Character.MIN_VALUE + " to " + (int) Character.MAX_VALUE);
    IO.println("bits    " + Byte.SIZE + " " + Short.SIZE + " " + Integer.SIZE + " " + Long.SIZE
            + " " + Float.SIZE + " " + Double.SIZE + " " + Character.SIZE);
    boolean done = true;
    IO.println("boolean " + done + " or " + !done);
}

Ele imprime:

byte    -128 to 127
short   -32768 to 32767
int     -2147483648 to 2147483647
long    -9223372036854775808 to 9223372036854775807
float   largest 3.4028235E38
double  largest 1.7976931348623157E308
char    0 to 65535
bits    8 16 32 64 32 64 16
boolean true or false

A maior parte do código usa int para números inteiros, long quando dois bilhões não bastam, double para medidas e boolean para condições. Um char é um número de 16 bits, e é por isso que o intervalo dele aparece como 0 a 65535.

Escrevendo literais numéricos

Literais numéricos aceitam underscores, um sufixo para o tipo e um prefixo para hexadecimal ou binário. Os underscores são só para os seus olhos, e o compilador os ignora:

void main() {
    int million = 1_000_000;
    long worldPopulation = 8_100_000_000L;
    float ratio = 1.5f;
    double price = 19.99;
    int mask = 0xFF;
    int flags = 0b1010;
    char grade = 'A';
    IO.println(million + " " + worldPopulation + " " + ratio + " " + price);
    IO.println(mask + " " + flags + " " + grade);
}

Ele imprime:

1000000 8100000000 1.5 19.99
255 10 A

0xFF é 255 em hexadecimal, e 0b1010 é 10 em binário. Os sufixos são a parte que vale aprender. Um literal inteiro é int, a não ser que termine em L, e um literal decimal é double, a não ser que termine em f. Esqueça o L num número grande demais para um int e o programa não compila:

void main() {
    long worldPopulation = 8100000000;
    IO.println(worldPopulation);
}

O build falha com:

Main.java:2: error: integer number too large
    long worldPopulation = 8100000000;
                           ^

A variável é long, mas isso não ajuda. O compilador lê o literal primeiro, como int, e ele não cabe. Use L maiúsculo, porque um l minúsculo parece o dígito 1.

Campos recebem valores padrão, variáveis locais não

Um campo de uma classe começa com um valor padrão: zero para números, false para boolean e null para referências. Uma variável local dentro de um método não recebe valor padrão nenhum.

class Account {
    int balance;
    double rate;
    boolean frozen;
    String owner;
}

void main() {
    var account = new Account();
    IO.println(account.balance + " " + account.rate + " " + account.frozen + " " + account.owner);
}

Ele imprime:

0 0.0 false null

Tente o mesmo com uma variável local, e o compilador não deixa você ler a variável antes de atribuir um valor a ela:

void main() {
    int count;
    IO.println(count);
}

O build falha com:

Main.java:3: error: variable count might not have been initialized
    IO.println(count);
               ^

Java verifica todos os caminhos do método antes de ele rodar. Se algum caminho chega à leitura sem uma atribuição, o build falha.

Overflow de inteiro dá a volta em silêncio

Quando uma conta com int passa do maior int, Java não para nem avisa. O valor dá a volta até o int mais negativo e continua a partir dali.

void main() {
    int max = Integer.MAX_VALUE;
    IO.println(max + 1);
    IO.println(max * 2);

    long wrong = Integer.MAX_VALUE + 1;
    long right = Integer.MAX_VALUE + 1L;
    IO.println(wrong);
    IO.println(right);
}

Ele imprime:

-2147483648
-2
-2147483648
2147483648

O segundo par é a armadilha que vale lembrar. wrong é um long, que tem espaço de sobra, e mesmo assim guarda um número negativo. O lado direito é calculado primeiro, com aritmética de int, e já deu a volta antes de o resultado ser ampliado para long. Tornar um dos operandos long, com 1L, faz a soma inteira ser aritmética de long.

Quando overflow seria um bug, como em dinheiro ou contagens, peça uma exceção. Math.addExact, Math.multiplyExact e parecidos lançam uma exceção quando o resultado não cabe:

void main() {
    int stock = 2_000_000_000;
    IO.println("before: " + stock);
    stock = Math.addExact(stock, 500_000_000);
    IO.println("after: " + stock);
}

Ele imprime e para:

before: 2000000000
Exception in thread "main" java.lang.ArithmeticException: integer overflow

Um crash com uma mensagem clara é melhor que um estoque de menos 1,8 bilhão.

Divisão, resto e ponto flutuante

Dividir dois inteiros dá um inteiro, e tudo depois da vírgula é jogado fora. Não arredonda. Trunca em direção a zero, o que faz diferença assim que o número é negativo.

void main() {
    IO.println(7 / 2);
    IO.println(-7 / 2);
    IO.println(7 / 2.0);
    IO.println(-7 % 3);
    IO.println(7 % -3);
    IO.println(Math.floorMod(-7, 3));
    IO.println(0.1 + 0.2);
    IO.println(1.0 / 0);
    IO.println(0.0 / 0);
}

Ele imprime:

3
-3
3.5
-1
1
2
0.30000000000000004
Infinity
NaN

Linha por linha:

  • 7 / 2 é 3, e -7 / 2 é -3, não -4. A divisão trunca em direção a zero.
  • 7 / 2.0 é 3.5, porque um dos operandos é double, então a divisão também é.
  • % fica com o sinal do operando da esquerda. -7 % 3 é -1. Se você usa % para dar a volta num índice ou escolher um dia da semana, uma entrada negativa dá uma resposta negativa. Math.floorMod sempre dá um resultado com o sinal do divisor, que costuma ser o que você queria.
  • 0.1 + 0.2 não é 0.3. Um double guarda frações binárias, e 0.1 não tem forma binária exata, assim como 1/3 não tem forma decimal exata. Para dinheiro, use long em centavos ou BigDecimal.
  • Divisão de ponto flutuante por zero não lança exceção. Ela dá Infinity ou NaN. Já a divisão inteira por zero lança uma ArithmeticException.

Convertendo entre tipos

Java converte um tipo menor para um maior sozinho, o que se chama widening (ampliação). O caminho inverso, chamado narrowing (estreitamento), precisa de um cast, e o cast pode perder dados sem erro nenhum.

void main() {
    int small = 42;
    long wide = small;
    double wider = wide;
    IO.println(wide + " " + wider);

    long tooBig = 3_000_000_000L;
    IO.println((int) tooBig);
    IO.println((byte) 200);
    IO.println((int) 3.99);
    IO.println((int) -3.99);
    IO.println((int) 1e20);

    int exact = 16_777_217;
    float approx = exact;
    IO.println(approx);
    IO.println((int) approx);

    char letter = 'A';
    IO.println(letter + 1);
    IO.println((char) (letter + 1));
}

Ele imprime:

42 42.0
-1294967296
-56
3
-3
2147483647
1.6777216E7
16777216
66
B

Os casts a partir de long e int mantêm só os bits de baixo. É por isso que 3 bilhões vira um número negativo e 200 vira -56 como byte. Um cast de double para int se comporta de outro jeito: descarta a fração, e um valor grande demais fica travado em Integer.MAX_VALUE em vez de dar a volta.

As linhas do float nos surpreenderam. Atribuir um int a um float não precisa de cast, porque Java conta isso como widening. Mesmo assim, 16.777.217 voltou como 16.777.216. Um float tem só 24 bits para os dígitos de um número, então acima de uns 16 milhões ele não consegue guardar todo número inteiro. O mesmo vale de long para double acima de uns 9 quatrilhões. O widening nunca perde a grandeza de um número, mas pode perder os últimos dígitos.

As duas últimas linhas mostram que um char é um número. 'A' + 1 é o int 66, o código de A mais um, e o cast de volta para charB.

Mais uma coisa que notamos nos testes. short s = 1; s += 70000; compila, porque uma atribuição composta como += inclui um cast escondido. Com javac -Xlint:all, porém, o Java 25 avisa implicit cast from int to short in compound assignment is possibly lossy. Essa verificação fica desligada a não ser que você peça, e vale a pena ligá-la.

var: o compilador escreve o tipo por você

var declara uma variável local e deixa o compilador descobrir o tipo a partir do valor que você atribui. Chegou no Java 10.

void main() {
    var count = 10;
    var price = 19.99;
    var name = "Ana";
    var scores = new ArrayList<Integer>();
    scores.add(90);
    IO.println(count + " " + price + " " + name + " " + scores);
}

Ele imprime:

10 19.99 Ana [90]

count é um int, price é um double, name é uma String e scores é um ArrayList<Integer>. Esses tipos são fixados em tempo de compilação, exatamente como se você os tivesse escrito. var não é tipagem dinâmica. Tente guardar uma string em count depois:

void main() {
    var count = 10;
    count = "ten";
    IO.println(count);
}

O build falha com:

Main.java:3: error: incompatible types: String cannot be converted to int
    count = "ten";
            ^

var também precisa de um valor para olhar. var total; falha com cannot use 'var' on variable without initializer, e var nothing = null; falha porque null não diz qual tipo você quis.

Use var quando o tipo já está na mesma linha, como em var scores = new ArrayList<Integer>(). Evite quando o tipo é a parte útil. var total = calculate(order); não diz nada a quem lê sobre total ser um int, um long ou um BigDecimal, e com dinheiro essa diferença importa.

Uma variável de tipo classe guarda uma referência

Uma variável cujo tipo é uma classe não guarda o objeto. Ela guarda uma referência, que é a informação de que Java precisa para achar esse objeto. Atribuir uma variável a outra copia a referência, então as duas variáveis levam ao mesmo objeto.

class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }
}

void main() {
    int first = 1;
    int second = first;
    second = 5;
    IO.println("ints:   " + first + " " + second);

    var a = new Point(1, 2);
    var b = a;
    b.x = 5;
    IO.println("points: " + a + " " + b);

    b = new Point(9, 9);
    IO.println("points: " + a + " " + b);
}

Ele imprime:

ints:   1 5
points: Point(5, 2) Point(5, 2)
points: Point(5, 2) Point(9, 9)

As duas metades parecem iguais e se comportam de forma diferente. Com os ints, second = first copiou o número 1, então mudar second não mexeu em first. Com os pontos, var b = a copiou a referência. Continuava existindo um só Point, então b.x = 5 mudou também o objeto a que a leva.

A última atribuição é diferente de novo. b = new Point(9, 9) não muda objeto nenhum. Faz b se referir a um novo, e a continua se referindo ao primeiro.

Explicado como se você tivesse dez anos

Imagine uma fileira de armários de escola. Uma variável que guarda um primitivo é uma caixa com o número dentro. Se você copia a caixa, ganha uma segunda caixa com o mesmo número, e rabiscar numa não mexe na outra.

Uma variável que guarda uma referência é um papelzinho com o número de um armário escrito nele. O brinquedo está no armário, não no papel. Quando você copia o papel, passa a ter dois papéis dizendo “armário 14”. Se o seu amigo usa o papel dele para abrir o armário 14 e pinta o brinquedo de vermelho, você vai encontrar um brinquedo vermelho quando abrir o armário 14 com o seu.

Agora o seu amigo apaga o papel dele e escreve “armário 30”. Isso não move o brinquedo nem muda o seu papel. O seu continua dizendo 14.

A versão precisa

Os valores em Java são de dois tipos. Um valor primitivo é o próprio número, caractere ou boolean. Um valor de referência aponta para um objeto no heap, ou é null. Uma variável de tipo primitivo guarda um valor primitivo. Uma variável de qualquer tipo classe, interface, record, enum ou array guarda um valor de referência.

A atribuição sempre copia o valor que está na variável. Para um primitivo, é o número. Para uma referência, é a referência, então depois disso duas variáveis se referem ao mesmo objeto. Usar . numa referência, como em b.x = 5, segue a referência até o objeto e age sobre o objeto. Atribuir à própria variável, como em b = new Point(9, 9), troca a referência em b e não mexe em objeto nenhum.

Onde a analogia falha: o número de um armário é algo que você pode ler e usar em contas. Uma referência Java não é. Você não pode imprimi-la, somar a ela nem escolher a qual objeto ela se refere, e o garbage collector é livre para mover o objeto pelo heap sem que as suas referências mudem. E quando nenhum papel menciona mais um armário, o garbage collector o esvazia.

Vendo duas variáveis compartilharem um objeto

A animação percorre o mesmo código do Point, mostrando as duas variáveis como setas para o heap:

variáveis heap a ref Point x = 1 x = 5 y = 2 b ref Point x = 9 y = 9 a.x é 1 a.x é 1, b.x é 1 a.x é 5, b.x é 5 a.x é 5, b.x é 9 var a = new Point(1, 2): a guarda uma referência var b = a: copia a referência, não o objeto b.x = 5: segue a seta de b e muda o objeto a.x também é 5: a vê o mesmo objeto b = new Point(9, 9): só a seta de b muda a ainda aponta o primeiro objeto, x = 5

Duas variáveis, um objeto. var b = a copia a referência, então b.x = 5 também muda o objeto para o qual a aponta. b = new Point(9, 9) move só a seta de b, e a continua apontando para o primeiro objeto.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. var a = new Point(1, 2) cria um objeto Point no heap, e a guarda uma referência a ele. a.x é 1.
  2. var b = a copia a referência de a para b. Nenhum objeto novo é criado, então as duas setas levam ao mesmo Point.
  3. b.x = 5 segue a referência de b até esse objeto e muda o x dele para 5.
  4. a.x agora também é 5. Nada aconteceu com o próprio a: ele leva ao mesmo objeto, e o objeto mudou.
  5. b = new Point(9, 9) cria um segundo objeto e coloca uma referência a ele em b. Só a seta de b se move.
  6. a continua se referindo ao primeiro objeto, que ainda tem x igual a 5.

Java sempre passa argumentos por valor

Quando você chama um método, Java copia o valor de cada argumento para o parâmetro do método. Para um objeto, o valor copiado é a referência. Essa única regra explica por que algumas mudanças dentro de um método aparecem para quem chamou e outras não.

class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }
}

void reassign(Point p) {
    p = new Point(0, 0);
    IO.println("inside reassign: " + p);
}

void mutate(Point p) {
    p.x = 100;
    IO.println("inside mutate:   " + p);
}

void main() {
    var point = new Point(1, 2);
    reassign(point);
    IO.println("after reassign:  " + point);
    mutate(point);
    IO.println("after mutate:    " + point);
}

Ele imprime:

inside reassign: Point(0, 0)
after reassign:  Point(1, 2)
inside mutate:   Point(100, 2)
after mutate:    Point(100, 2)

Nas duas chamadas, p começa como uma cópia da referência em point. Em reassign, p = new Point(0, 0) substitui essa cópia. O point de quem chamou nem fica sabendo. Em mutate, p.x = 100 segue a cópia até o único objeto compartilhado e o muda, então quem chamou vê a mudança.

Às vezes as pessoas dizem “Java passa objetos por referência”. Não passa. Se passasse, reassign teria mudado a variável de quem chamou. Um método pode mudar um objeto que você entrega a ele, mas nunca pode fazer a sua variável se referir a outro objeto.

== compara referências, equals compara conteúdo

Entre dois primitivos, == compara valores. Entre duas referências, == pergunta se elas se referem exatamente ao mesmo objeto, e equals pergunta se dois objetos contam como iguais.

class Money {
    final long cents;

    Money(long cents) {
        this.cents = cents;
    }
}

class Price {
    final long cents;

    Price(long cents) {
        this.cents = cents;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof Price p && p.cents == cents;
    }

    @Override
    public int hashCode() {
        return Long.hashCode(cents);
    }
}

void main() {
    String typed = "hello";
    String built = new StringBuilder("hel").append("lo").toString();
    IO.println(typed == built);
    IO.println(typed.equals(built));

    IO.println(new Money(500) == new Money(500));
    IO.println(new Money(500).equals(new Money(500)));

    IO.println(new Price(500) == new Price(500));
    IO.println(new Price(500).equals(new Price(500)));
}

Ele imprime:

false
true
false
false
false
true

As duas strings têm as mesmas letras, mas são dois objetos separados, então ==false. equals compara os caracteres e diz true. Compare strings sempre com equals. Dois literais com o mesmo texto podem compartilhar um objeto, então == às vezes parece funcionar, o que é pior do que falhar sempre.

Money mostra o que acontece quando uma classe não define equals. Ela herda o de Object, que não faz nada além de ==. Price sobrescreve equals para comparar cents, então dois preços de 500 são iguais. Sobrescreve hashCode também, porque os dois precisam concordar. A parte sobre equals, hashCode e coleções explica por quê. Records escrevem os dois métodos para você.

Boxing e a armadilha do Integer ==

Todo tipo primitivo tem uma classe wrapper que guarda um valor como objeto: Integer para int, Long para long, Double para double, e assim por diante. Java converte entre eles automaticamente. Transformar um int em Integer se chama boxing, e o contrário é unboxing. Coleções precisam dos wrappers, porque List<int> não é permitido.

Como um Integer é um objeto, == entre dois deles compara referências. Isso produz a surpresa mais famosa do Java:

void main() {
    Integer a = 127, b = 127;
    IO.println("127 == 127: " + (a == b));

    Integer c = 128, d = 128;
    IO.println("128 == 128: " + (c == d));
    IO.println("128 equals: " + c.equals(d));

    int plain = 128;
    IO.println("Integer == int: " + (c == plain));

    Long big1 = 127L, big2 = 127L;
    Long big3 = 128L, big4 = 128L;
    IO.println("Long 127: " + (big1 == big2) + ", Long 128: " + (big3 == big4));
}

Ele imprime:

127 == 127: true
128 == 128: false
128 equals: true
Integer == int: true
Long 127: true, Long 128: false

Mesmo código, um número de diferença, e outra resposta. O boxing chama Integer.valueOf, e valueOf mantém um cache de objetos Integer para valores pequenos. Faça boxing de 127 duas vezes e você recebe o mesmo objeto do cache, então ==true. Faça boxing de 128 duas vezes e você recebe dois objetos novos, então ==false.

A Java Language Specification exige que o cache cubra de -128 a 127 para boxes de int, short e byte, e até 127 para char. Long usa o mesmo intervalo na prática, como mostra a última linha. Uma implementação pode fazer cache de mais valores. No HotSpot, você pode aumentar o limite do Integer com uma flag da JVM, e aí 128 compara true:

java -XX:AutoBoxCacheMax=1000 Main.java

É por isso que o bug é difícil de achar. Os testes com números pequenos passam, e depois os IDs de verdade passam de 127.

A linha Integer == inttrue porque um dos lados é primitivo. Java faz unboxing do Integer e compara números. A regra para wrappers é simples: compare com equals, ou faça unboxing para primitivos antes.

Unboxing de null lança exceção

Uma variável Integer pode guardar null, e um int não pode. Quando Java faz unboxing de um null para obter um int, não há número para obter, então lança NullPointerException. O código que lança nem parece mexer num objeto:

Map<String, Integer> stock = new TreeMap<>();

Integer lookup(String item) {
    return stock.get(item);
}

void main() {
    stock.put("apples", 3);
    int apples = lookup("apples");
    IO.println("apples: " + apples);
    int pears = lookup("pears");
    IO.println("pears: " + pears);
}

Ele imprime e para:

apples: 3
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "Main.lookup(String)" is null

Map.get retorna null para uma chave que não existe. A linha int pears = lookup("pears") tem uma chamada escondida a intValue(), e é essa a chamada que a mensagem cita. JVMs modernas imprimem uma mensagem útil como essa por padrão, e aqui ela diz exatamente o que aconteceu. Se você queria “zero quando não existir”, diga isso com stock.getOrDefault(item, 0).

final fixa a referência, não o objeto

Uma variável final só pode receber uma atribuição. Quando a variável guarda uma referência, isso significa que ela sempre se refere ao mesmo objeto. Não impede ninguém de mudar esse objeto.

void main() {
    final List<String> names = new ArrayList<>();
    names.add("Ana");
    names.add("Ben");
    names.remove("Ana");
    IO.println(names);
}

Ele imprime:

[Ben]

A lista mudou três vezes por uma variável final, e o compilador não reclamou. O que final proíbe é apontar a variável para outro lugar:

void main() {
    final List<String> names = new ArrayList<>();
    names.add("Ana");
    names = new ArrayList<>();
    IO.println(names);
}

O build falha com:

Main.java:4: error: cannot assign a value to final variable names
    names = new ArrayList<>();
    ^

Se você quer uma lista que ninguém possa mudar, precisa de um objeto não modificável, não de uma variável final. List.of("Ana", "Ben") cria um, e chamar add nele lança UnsupportedOperationException.

O que lembrar

  • Java tem oito tipos primitivos com tamanhos fixos. Leia os limites em constantes como Integer.MAX_VALUE, não de memória.
  • Aritmética de int dá a volta em silêncio no overflow, mesmo quando você guarda o resultado num long. Use Math.addExact e parecidos quando overflow seria um bug.
  • A divisão inteira trunca em direção a zero, % fica com o sinal do operando da esquerda, e double não consegue guardar 0.1 exatamente.
  • var infere um tipo fixo em tempo de compilação. Não é tipagem dinâmica.
  • Uma variável de tipo classe guarda uma referência. Atribuir ou passar essa variável copia a referência, então mudanças no objeto aparecem por todas as cópias, mas reatribuir uma cópia não muda mais nada.
  • Compare objetos, incluindo String e Integer, com equals. Integer faz cache de -128 a 127, então == funciona nos testes e falha em produção.
  • Unboxing de null lança NullPointerException, e final impede reatribuição, não mutação.

Uma variável primitiva guarda um valor, e toda outra variável guarda uma referência a um objeto.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.