DEV Community

Edy Silva
Edy Silva

Posted on • Originally published at codesilva.com

Em Java, usar equals sempre foi remédio. Ninguém te contou a doença

Em algum momento da sua vida com Java você escreveu isso e levou um susto:

Integer i = 1, j = 1;
i == j        // true

Integer x = 1996, y = 1996;
x == y        // false
Enter fullscreen mode Exit fullscreen mode

Aí você fez o que todo mundo faz: jogou no Google.

E achou rápido, porque a pergunta está no Stack Overflow desde novembro de 2009, com duzentos e poucos votos. A resposta é sempre a mesma: Integer x = 1996 não chama new Integer(1996), chama Integer.valueOf(1996). E o valueOf tem um cache de instâncias prontas, de -128 até 127. Dentro dessa faixa você recebe sempre o mesmo objeto de volta. Fora dela, um objeto novo a cada chamada.

Fez sentido. Você anotou mentalmente que wrapper se compara com equals, fechou a aba e seguiu a vida.

E é fácil descartar esse caso. Boxing é escolha sua: usa int, para de comparar wrapper com ==, e o problema evapora. Pegadinha de entrevista, curiosidade de laboratório.

Mas e quando não dá pra usar primitivo?

LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d2 = d1.plusYears(30);      // 2026-01-23
LocalDate d3 = d2.minusYears(30);     // 1996-01-23

d1.equals(d3)   // true
d1 == d3        // false
Enter fullscreen mode Exit fullscreen mode

LocalDate não tem literal. Não tem versão primitiva. Se você precisa de uma data, você é obrigado a usar objeto.

E aqui não tem cache nenhum na história pra culpar. d1 e d3 são a mesma data, com o mesmo ano, o mesmo mês e o mesmo dia. O equals concorda. O == diz que não.

Então "usa equals" é remédio, não diagnóstico. A pergunta que ninguém respondeu é por que == compara endereço de memória em primeiro lugar.

E ela tem resposta. No dia 31 de julho de 2026 um commit de 208 mil linhas entrou no OpenJDK e mudou essa resposta pela primeira vez desde o Java 1.0.

Identidade é conseguir distinguir duas coisas idênticas

Antes do Java, pensa em duas coisas do mundo físico.

Ninguém liga pra qual cédula de cinquenta reais você recebeu de troco. Se eu trocar a sua por outra igual enquanto você não olha, não aconteceu nada. Duas notas de cinquenta são intercambiáveis.

Agora o seu carro. Você empresta pro vizinho e quer AQUELE carro de volta, não um igualzinho.

A diferença entre os dois casos é o que a gente chama de identidade: a capacidade de distinguir duas coisas que têm exatamente o mesmo conteúdo.

E repara numa coisa. Identidade só serve pra coisa que muda.

O carro importa individualmente porque ele acumula história: quilometragem, amassado, tanque vazio. Se dois carros fossem congelados e nunca mudassem, tanto faria qual você recebe de volta. Vira nota de cinquenta.

Daí sai a frase de onde vem todo o resto: identidade é uma capacidade que só dado mutável usa.

Você já conhece isso por outro nome

Se você faz DDD, essa distinção não é novidade nenhuma. É Entity versus Value Object, uma ideia que o Ward Cunningham já descrevia em 1994 e que o Fowler catalogou antes do Evans colocar ela no centro da modelagem.

Entity é o carro. O pedido #4712 continua sendo o mesmo pedido depois de mudar de status três vezes.

Value Object é a nota de cinquenta. R$ 50,00 é R$ 50,00, um CPF é definido pelos onze dígitos.

Só que em DDD isso sempre foi disciplina de design, e nada mais. Você escrevia Money imutável, sem setter, com equals na mão, e documentava que aquilo era um value object. O time entendia. O compilador não. A JVM muito menos.

Em runtime, o seu value object era uma entity igual a todas as outras: endereço próprio, header, identidade, e == mentindo pra você. Você desenhava a distinção no diagrama de domínio e pagava o preço cheio na memória.

É essa distância que o JEP 401 fecha. Pela primeira vez value é uma palavra que o compilador lê, e não um comentário na documentação.

O Java achou que tudo era carro

E aqui está a decisão que o Java tomou: todo objeto tem identidade. Sem exceção, desde 1995.

Foi uma escolha de design, e na época ela era razoável, porque objeto em Java nasceu mutável por padrão.

O problema é que ela virou uma lei da física da linguagem. E lei da física tem consequências que caem por gravidade, querendo você ou não.

== compara endereço porque o endereço é a identidade. Dois objetos distintos precisam morar em lugares distintos, senão não dá pra distinguir.

Todo objeto carrega um header, e ele existe porque identidade precisa morar em algum lugar. É nele que ficam o estado de lock e o identity hash.

Não precisa acreditar em mim. O JOL, de Java Object Layout, é uma ferramenta do OpenJDK que lê o layout que a JVM de verdade escolheu pro objeto, campo por campo, em vez de estimar. Passei ela num LocalDate do JDK 28:

Anatomia de um objeto LocalDate na memória, com as faixas desenhadas em escala. No topo, uma faixa escura e alta rotulada header, de 8 bytes, com a anotação lock state mais identity hash. Abaixo, três faixas azuis agrupadas por uma chave rotulada your data: y igual a 1996 com 4 bytes, m igual a 1 com 1 byte e d igual a 23 com 1 byte. Por último, uma faixa hachurada de padding com 2 bytes. Embaixo, o total: 16 bytes.

Dezesseis bytes de objeto pra carregar seis bytes de data. O header sozinho é maior que o dado, e ainda sobram dois bytes de padding.

E essa já é a versão magra. No JDK 28 o HotSpot liga os compact object headers por padrão, que dobram o ponteiro de classe pra dentro da mark word. Rodando com -XX:-UseCompactObjectHeaders, o mesmo LocalDate volta a ocupar 24 bytes. A JVM já vinha brigando com esse custo por outro caminho, e mesmo depois de encolher o header ele continua sendo o maior pedaço do objeto.

Guarda essa figura, porque o header volta no fim do post. É nele que mora o lock, e é por isso que synchronized vai deixar de funcionar.

Array de objeto é array de ponteiro. Se cada elemento precisa de endereço próprio, o array não consegue guardar os dados: ele guarda o caminho até eles.

Compara um int[5] com um LocalDate[5], que é o exemplo que o próprio JEP usa:

Comparação de layout de memória. À esquerda, int de cinco posições: um único bloco contíguo com os valores 1996, 2006, 1996, 1 e 23, marcado como contiguous. À direita, LocalDate de cinco posições: um bloco de células onde cada uma guarda uma seta apontando pra fora, e as setas se cruzam até objetos soltos e espalhados, cada um com uma faixa escura de header no topo e os campos y, m e d embaixo. Marcado como pointers, scattered.

O array de int é um bloco só. O de LocalDate não guarda datas, guarda ponteiros, e cada objeto foi parar onde o alocador achou espaço, carregando o próprio header junto. Percorrer isso é uma sequência de saltos de memória com cache miss em cada um.

Medindo os dois com o JOL: 32 bytes contra 80. Duas vezes e meia mais memória pra representar a mesma coisa.

Tudo pra carregar um int e dois bytes de informação útil por data.

O cache do Integer é uma gambiarra pra não pagar identidade

Agora dá pra entender de onde veio o susto do começo.

Identidade custa. Cada new é uma alocação, um header, um endereço, e mais um objeto pro GC visitar depois. Como boxing de int acontece o tempo todo, alguém decidiu que valia a pena reaproveitar as instâncias mais comuns em vez de criar objeto novo toda vez.

Daí o cache de -128 a 127.

O detalhe é que reaproveitar instância significa reaproveitar identidade. E identidade é observável por ==.

Ou seja: a pegadinha do Integer é o modelo aparecendo. Uma otimização de alocação vazou pra semântica da linguagem, e só pôde vazar porque == fala de identidade em vez de valor.

Você não estava confuso. O modelo é que estava estranho. Eu já escrevi sobre por que Java parece difícil, e boa parte da resposta é essa: a linguagem cobra que você entenda decisões antigas que ninguém te conta.

Mas dado imutável nunca precisou disso

LocalDate é imutável. Integer é imutável. Optional, Duration, BigDecimal, o Money que você escreveu semana passada.

Nenhum deles é carro. São todos nota de cinquenta.

Você nunca, em nenhum código que já escreveu, precisou saber qual instância de 23 de janeiro de 1996 você tem na mão. Só que a JVM não tinha como saber disso, então ela cobrava identidade de todo mundo, no preço cheio, por garantia.

É isso que o JEP 401 conserta. Ele te dá uma saída.

O JEP 401 deixa você abrir mão da identidade

O commit cc278dbb implementa dois JEPs de uma vez, os dois como preview no JDK 28: o JEP 401 (Value Objects) e o JEP 539 (Strict Field Initialization). São 208.011 linhas adicionadas, 13.161 removidas, 300 arquivos, 64 co-autores e 14 revisores. É a maior entrega do Project Valhalla até hoje.

E a API disso é uma palavra:

jshell> value record Point(int x, int y) {}
|  created record Point

jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]

jshell> Objects.hasIdentity(p)
$3 ==> false

jshell> new Point(17, 3) == p
$4 ==> true
Enter fullscreen mode Exit fullscreen mode

O modificador value traz três coisas de graça. Os campos viram final, a classe vira final, e == passa a comparar campo a campo.

Os termos são parecidos e significam coisas diferentes:

  • value class é o que você declara, com o modificador
  • value object é a instância dela, o objeto sem identidade
  • Value Objects é o nome da feature, e Project Valhalla é o guarda-chuva

E a plataforma já migrou 30 classes:

Pacote Classes
java.lang Integer, Long, Float, Double, Byte, Short, Character, Boolean, Number, Record
java.util Optional, OptionalInt, OptionalLong, OptionalDouble
java.time LocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetTime, OffsetDateTime, Duration, Instant, Period, Year, YearMonth, MonthDay
java.time.chrono MinguoDate, HijrahDate, JapaneseDate, ThaiBuddhistDate

Que é o que responde o susto do começo. Output de verdade, rodado num build com o JEP 401 ligado:

Integer 1996 == 1996      : true
d1 == d3                  : true
Objects.hasIdentity(d1)   : false
Objects.hasIdentity("abcd"): true
Enter fullscreen mode Exit fullscreen mode

Segura essa última linha, ela volta no fim do post.

Escrevendo as suas próprias

Se o seu dado já é um record, é uma palavra:

value record Point(int x, int y) {}
Enter fullscreen mode Exit fullscreen mode

Record é transparente: os campos são exatamente os componentes do construtor. Quando você guarda o estado de um jeito e expõe de outro, tipo dinheiro como um long de centavos, aí é value class normal:

value class EURCurrency {
    private long cs;  // implicitamente final
    public EURCurrency(long e, int c) { cs = e * 100 + c; }
    public long euros() { return cs / 100; }
    public int cents() { return (int) cs % 100; }
}
Enter fullscreen mode Exit fullscreen mode

O value fecha portas, e o compilador é direto sobre quais:

error: cannot assign a value to final variable x
error: cannot inherit from final V
error: The concrete class Base is not allowed to be a super class
       of the value class E either directly or indirectly
Enter fullscreen mode Exit fullscreen mode

Campo vira final, classe vira final, e herdar de uma classe com identidade seria herdar identidade junto. Hierarquia você ainda tem: dá pra implementar interface, e dá pra estender uma abstract value class, que é como Integer e BigInteger convivem hoje debaixo de Number.

A regra que mais pega gente é a do construtor. Value object precisa estar completo antes de qualquer um ver ele, então o corpo inteiro roda antes do super(), e ali this não existe:

value class Name {
    String name;
    int length;
    private int strLength() { return name.length(); }

    Name(String n) {
        name = n;
        length = strLength();   // error: reference to strLength() may only
    }                           // appear after an explicit constructor invocation
}
Enter fullscreen mode Exit fullscreen mode

A saída é tornar o método static, ou chamar super() na mão depois de setar todos os campos. E com preview ligado isso vale pra todos os records, value ou não, então record que usa this no construtor canônico para de compilar.

Pra saber o que marcar, a regra é curta: estado imutável e você nunca precisa distinguir duas instâncias com o mesmo conteúdo. Se você modela por DDD, a sua pasta de value objects é o primeiro lugar pra olhar. Fica fora o que é mutável, o que serve de lock e o que guarda dado sensível, já que == compara campo privado.

Sem identidade, a JVM não precisa mais dar endereço

Volta na lista de consequências lá de cima e inverte cada uma.

Se o objeto não tem identidade, ele não precisa ser distinguível. Se não precisa ser distinguível, não precisa de endereço próprio. E aí a JVM ganha duas liberdades.

Flattening é jogar os campos direto pra dentro do array ou do campo que referencia o objeto:

Diagrama de antes e depois. À esquerda, BEFORE: um array cujas células apontam com setas pra objetos soltos e espalhados, cada um com faixa escura de header e os campos y, m e d. Uma seta grande aponta pra direita. À direita, AFTER: um único bloco contíguo de cinco linhas, cada linha com os valores 1, 1996, 01 e 23 escritos direto dentro dela, sem seta nenhuma e sem header. Marcado como flattened.

Zero ponteiro, zero header, tudo contíguo, com o primeiro bit dizendo se a referência é null. O JEP diz que esse array pode passar a ter características de performance parecidas com as de um int[].

Isso é o que o JEP descreve. Eu quis ver acontecendo.

Aloquei um LocalDate[] de dois milhões de posições, todas com datas diferentes, e medi a heap. Mesmo programa, mesmo JDK, mesma máquina, mudando só o --enable-preview:

bytes por elemento total
sem preview, LocalDate é identity 28,5 56,9 MB
com preview, LocalDate é value 8,4 16,8 MB

Oito bytes por elemento, que é exatamente a palavra de 64 bits que o JEP previu.

E não precisa confiar na minha medição de heap pra aceitar isso, porque a aritmética fecha sozinha: se cada elemento ainda fosse um ponteiro pra um objeto separado, só os objetos já ocupariam dois milhões vezes 16 bytes, que é o tamanho mínimo de um objeto na heap. Dá 32 MB. Não cabe em 16,8.

Os objetos não estão lá. Só os valores.

Agora, uma ressalva que vale mais que a medição: isso não está na spec. O JEP 401 não tem sequer uma seção de Specification, e diz com todas as letras que flattening e scalarization são "optimizations, not language features", feitas a critério da JVM. Garantir layout de memória é não-objetivo declarado.

O que o JEP garante é semântica. Value object não tem identidade, == compara campos, sincronizar lança exceção. Isso é contrato.

O array contíguo é permissão, não promessa. O JEP tira o que impedia a JVM de achatar, e cada implementação decide se acha. Eu mostrei uma que achatou.

Scalarization é o passo seguinte, dentro do JIT. Quando o objeto está numa variável local ou num parâmetro, ele é decomposto em valores soltos. O plusYears compilado para de receber um ponteiro e passa a receber (boolean isNull, int year, byte month, byte day), devolvendo outra tupla igual.

O objeto simplesmente nunca existe na memória.

Escape analysis já fazia algo parecido com objeto comum, mas basta um caminho de código comparar identidade pra otimização evaporar. Com value class a garantia é estática, e ela atravessa fronteira de método.

Menos alocação é menos GC

Aqui é onde isso encosta no seu Grafana.

Cada objeto que a JVM não aloca é um objeto que o GC não precisa marcar, varrer nem mover. Um LocalDate[] de um milhão de posições deixa de ser um milhão de objetos vivos na heap e passa a ser um bloco de memória.

O laço que você escreveu sem pensar, criando um LocalDate por iteração pra jogar fora logo em seguida, para de gerar lixo. Não é que o GC ficou mais rápido: é que não tem mais o que coletar. E dado contíguo ainda é dado que o CPU busca com menos cache miss, o que costuma valer mais que o tempo de alocação em si.

E como não é promessa, tem jeito de não acontecer. Três coisas atrapalham na prática:

  • Campo mutável tem teto de 64 bits, porque leitura e escrita precisam ser atômicas. Um LocalDateTime não cabe e volta a ser ponteiro.
  • Object mata o flattening. Integer[] é achatável, Object[] não é, e genérico apagado cai no mesmo caso. Não muda semântica, só layout.
  • Código antigo precisa recompilar, porque a JVM depende de um atributo novo no class file pra saber a tempo que a classe é value class.

O que você perde é exatamente o que dependia de identidade

E o preço tem uma lógica por trás: é a mesma decisão de design cobrando na saída o que ela cobrava na entrada. Tudo que quebra é coisa que precisava distinguir instância. Rodei cada um pra pegar a mensagem real:

synchronized via Object : java.lang.IdentityException:
                          Cannot synchronize on an instance of value class java.time.LocalDate
d.notify()              : java.lang.IllegalMonitorStateException: java.time.LocalDate
new WeakReference<>(d)  : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class
weakHashMap.put(d, "x") : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class
Enter fullscreen mode Exit fullscreen mode

Lock mora no header, naquela mark word lá do começo, então synchronized deixa de funcionar. O Java Concurrency in Practice inteiro parte do princípio de que qualquer objeto serve de lock, e agora não serve mais. Junto com ele caem wait e notify, que dependem desse mesmo lock, e também o WeakHashMap e o java.lang.ref inteiro, porque referência fraca precisa apontar pra uma instância específica.

Fora isso, tem o que continua funcionando mas diferente do que você espera.

O == agora compara os campos internos, então ele pode divergir do seu equals, que talvez olhe outra coisa. Virou também uma operação com custo, porque a comparação é recursiva e árvore profunda de value objects chega a estourar StackOverflowError. E como ele lê campo privado, virou um canal de inferência. O JEP avisa na lata: value object não foi feito pra proteger dado sensível.

E até o == de identity object ficou um tiquinho mais caro, porque o bytecode if_acmpeq agora precisa de um teste extra pra detectar value object. O caminho de identidade virou fast path, mas ele existe, e é cobrado do código que não usa nada disso.

A peça que faltava: JEP 539

Ainda tinha um buraco. Value object promete que o valor nunca muda, mas em Java um campo pode ser lido antes de ser inicializado, valendo 0 ou null.

O exemplo do JEP é uma dependência circular:

class App {
    public static final long appID = Log.currentPID();
    public static void main() {
        IO.println("App[" + appID + "] has started");
        Log.log("Completed 'main'");
    }
}

class Log {
    private static final String prefix = "App[" + App.appID + "]: ";
    public static void log(String msg) { IO.println(prefix + msg); }
    public static long currentPID() { return ProcessHandle.current().pid(); }
}
Enter fullscreen mode Exit fullscreen mode

Rodando, sai isso:

App[8145] has started
App[0]: Completed 'main'
Enter fullscreen mode Exit fullscreen mode

Duas leituras do mesmo campo final, dois valores diferentes. Log é inicializada no meio da inicialização de App, lê appID valendo o default 0, e cola o zero no prefix. E repara na sacanagem: se Log fosse inicializada primeiro, o bug sumia. É o tipo de bug que desaparece quando você vai investigar.

Um campo final que dá dois valores diferentes destrói a premissa inteira de value object. Por isso o JEP 539 cria o flag ACC_STRICT_INIT: campo marcado assim não tem valor default e precisa ser escrito antes de qualquer leitura. O javac marca todos os campos de value class com ele, e é por isso que os dois JEPs entraram no mesmo commit.

Se for rodar o exemplo, não espere ele consertar sozinho: eu liguei o --enable-preview e o App[0] continua lá. Impor inicialização estrita ao código que já existe é não-objetivo declarado do JEP 539, então só campo de value class recebe o flag.

Testando hoje

Aqui tem uma pegadinha de logística, e eu só descobri porque fui rodar.

O caminho óbvio é pegar o early-access do JDK 28 em jdk.java.net/28. Não funciona ainda. O build 9 saiu em 31/07/2026, mesmo dia da integração, e foi cortado antes dela entrar: value record dá erro de sintaxe, Objects.hasIdentity não existe, e o Integer 1996 == 1996 continua false.

O que roda hoje é o early-access do próprio Valhalla, em jdk.java.net/valhalla. O build 27-jep401ea3+1-1 implementa o JEP 401, e foi nele que eu rodei tudo que tem output neste post.

javac --release 27 --enable-preview Demo.java
java --enable-preview Demo
Enter fullscreen mode Exit fullscreen mode

Preview precisa estar ligado dos dois lados, e não dá pra escolher a versão identity do LocalDate nesse modo: ou é tudo, ou é nada.

O que não mudou

Duas coisas ficaram para trás, e as duas são engraçadas.

O cache do Integer continua existindo. O doc/value-class-preview.md que veio no commit conta que ele foi mantido de propósito, por performance, e que agora não tem mais impacto semântico nenhum. A gambiarra que criou a pegadinha continua rodando embaixo do capô. Você é que não consegue mais enxergar ela.

E String não migrou. A classe tem dependências de identidade na API e na implementação, então Objects.hasIdentity("abcd") continua devolvendo true. A pegadinha mais famosa do Java, == em String, segue de pé.

Fora isso, o resto é fundação. O JEP 402 vai melhorar o boxing de primitivos em cima disso, e o JEP 218 vai deixar genérico especializar layout quando parametrizado com value class, que é o List<int> sem boxing.

Mas a mudança grande já aconteceu, e ela é conceitual antes de ser técnica.

== deixou de perguntar "vocês dois moram no mesmo endereço?" e passou a perguntar "dá pra distinguir vocês dois?". Pra carro, a resposta continua sendo o endereço. Pra nota de cinquenta, agora é o valor.

Aquele susto que você levou lá no começo nunca teve como resposta "usa equals", e muito menos "usa primitivo". Pro LocalDate nunca existiu primitivo pra usar.

A resposta era que aquela data nunca precisou de identidade, e que você vinha pagando por ela desde sempre.

Por hoje é só.


Referências

Top comments (0)