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
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
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:
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:
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
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
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) {}
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; }
}
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
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
}
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:
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
LocalDateTimenão cabe e volta a ser ponteiro. -
Objectmata 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
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(); }
}
Rodando, sai isso:
App[8145] has started
App[0]: Completed 'main'
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
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
- JEP 401: Value Objects e JEP 539: Strict Field Initialization, os dois Integrated no JDK 28
-
Commit
cc278dbb, de 31/07/2026, e odoc/value-class-preview.mdque veio junto -
EA do Valhalla, build
27-jep401ea3+1-1, usado nos testes deste post - A pergunta original no Stack Overflow, de 2009



Top comments (0)