O Formato de Arquivo .class: Header, Constant Pool, Fields e Methods
Objective
Entender o arquivo .class: o formato binário que o javac produz a partir do código-fonte Java e a única coisa que o JVM efetivamente carrega — uma sequência fixa de seções (magic number, versão, constant pool, access flags, fields, methods, attributes) que permite ao JVM verificar e interpretar uma classe sem nunca ver o código .java original.
Use Cases
- Ler a saída de
javap -vpara entender para o que uma classe realmente foi compilada, em vez de adivinhar pelo código-fonte. - Diagnosticar
UnsupportedClassVersionErrorsabendo o que o número da major version do arquivo.classsignifica e como ele mapeia para uma release do Java. - Entender por que decompiladores, bibliotecas de manipulação de bytecode (ASM, ByteBuddy) e frameworks que fazem classpath scanning todos começam analisando o mesmo layout fixo.
- Explicar por que os nomes de field ou method de um objeto aparecem em mensagens de erro e stack traces mesmo que o JVM "não entenda Java" — eles ficam armazenados como entradas UTF-8 no constant pool.
- Ler um descriptor bruto como
[[Ljava/lang/String;ou(II)Idireto de um stack trace ou disassembly, sem precisar dojavappara traduzir em português claro. - Explicar por que
List<String>eList<Integer>são indistinguíveis no nível de bytecode — ambos compilam para o mesmo descriptor apagado (erased).
Deep Dive
Do source ao bytecode: o pipeline do compilador
O JVM nunca lê o source .java. O javac compila para um arquivo .class, e é esse arquivo binário — não o código original — que o class loader lê:
plaintextHelloWorld.java → javac → HelloWorld.class → JVM class loader → execução
Todo arquivo .class, independente de como era o source, é organizado na mesma sequência fixa de seções: magic number, versão, constant pool, access flags, this class / super class, interfaces, fields, methods, attributes.
Magic Number: identificando um class file válido
Os primeiros 4 bytes de todo arquivo .class são uma assinatura fixa, CAFEBABE, verificada antes de qualquer outra coisa ser interpretada. Ela é visível em um dump hexadecimal bruto, mas não aparece como uma linha Magic: rotulada na saída de javap -v nos JDKs atuais — o javap verbose relata os metadados do arquivo em disco (data de última modificação, tamanho e um checksum SHA-256 dos bytes), e vai direto para a declaração da classe:
plaintext$ xxd HelloWorld.class | head -1 00000000: cafe babe 0000 0041 0013 0a00 0200 0307 .......A........
plaintext$ javap -v HelloWorld.class | head -4 Classfile /home/user/HelloWorld.class Last modified Aug 19, 2026; size 428 bytes SHA-256 checksum 3a1f...e29c Compiled from "HelloWorld.java"
Se esses primeiros 4 bytes não baterem — um download truncado, um arquivo de texto renomeado para .class — o JVM lança ClassFormatError antes de tentar ler qualquer outra coisa no arquivo. A linha SHA-256 do javap (adicionada via -sysinfo, que -v implica) é uma conveniência para confirmar a integridade do arquivo — ela não tem papel nenhum no carregamento da classe em si; só as próprias verificações do verifier, começando pelo magic number, decidem se o JVM aceita o arquivo.
Version: minor e major
Logo após o magic number vêm dois campos de 2 bytes, minor_version e major_version. major_version identifica o formato do bytecode e aumenta a cada release do Java que o introduziu:
| major | Java |
|---|---|
| 52 | Java 8 |
| 55 | Java 11 |
| 61 | Java 17 |
| 65 | Java 21 |
plaintext$ javap -v HelloWorld.class | grep version minor version: 0 major version: 65
O JVM compara esse número com o que ele suporta no momento do carregamento, antes de executar uma única instrução do arquivo.
Constant Pool: a tabela de referências simbólicas da classe
O constant pool é uma tabela com todo nome de classe, assinatura de method, nome de field, string literal e constante numérica que a classe referencia. Nada mais no arquivo armazena esses valores diretamente — todos são referenciados por índice nessa tabela:
javapublic class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello");
}
}plaintext$ javap -v HelloWorld.class Constant pool: #1 = Methodref #6.#15 // java/lang/Object."<init>":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // Hello #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // HelloWorld ...
O bytecode de main não contém a string "Hello" nem o nome da classe java.io.PrintStream inline — ele referencia as entradas #3 e #2 do constant pool por índice. O pool é um catálogo que outras seções do arquivo apontam, não o programa em si.
Access Flags
access_flags é uma bitmask logo após o constant pool descrevendo a própria classe:
plaintext$ javap -v HelloWorld.class | grep flags flags: (0x0021) ACC_PUBLIC, ACC_SUPER
| flag | significado |
|---|---|
ACC_PUBLIC |
a classe é public |
ACC_FINAL |
a classe não pode ser estendida |
ACC_SUPER |
flag histórica que afeta a resolução de invokespecial para chamadas de method da superclasse |
ACC_INTERFACE |
o arquivo descreve uma interface, não uma classe |
ACC_ABSTRACT |
a classe é abstract |
ACC_SYNTHETIC |
a classe foi gerada pelo compilador, não escrita diretamente no source |
Fields: instance vs. static
A entrada de um field (field_info) armazena um índice de nome e um índice de descriptor no constant pool, além de suas próprias access_flags. Comparar um instance field com um static mostra que a única diferença estrutural é essa flag:
javapublic class Counter {
int value; // instance field
static int instances; // class field
}plaintext$ javap -p -v Counter.class | grep -A2 'value\|instances' int value; descriptor: I flags: (0x0000) static int instances; descriptor: I flags: (0x0008) ACC_STATIC
Um instance field ganha seu próprio armazenamento por objeto — cada Counter tem seu próprio value. Um static field fica armazenado uma única vez na classe e é compartilhado por todas as instâncias — é exatamente isso que ACC_STATIC diz ao JVM para fazer de forma diferente ao alocar e resolver esse field.
Methods: parâmetros, tipo de retorno e bytecode
A entrada de um method armazena seu nome, descriptor (tipos dos parâmetros + tipo de retorno, codificados como uma string), access flags e — para qualquer method com corpo — um attribute Code contendo as instruções de bytecode reais:
javapublic int add(int a, int b) {
return a + b;
}plaintext$ javap -v Calc.class | grep -A6 'public int add' public int add(int, int); descriptor: (II)I flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=3, args_size=3 0: iload_1 1: iload_2 2: iadd 3: ireturn
O descriptor (II)I diz "recebe dois ints, retorna um int" — V nessa posição significa void, e tipos de referência usam a forma totalmente qualificada Lpackage/Class;, como visto antes em (Ljava/lang/String;)V para println. O attribute Code é o que o JVM efetivamente executa; tudo mais no arquivo existe para permitir que o JVM resolva e verifique isso corretamente.
Descriptors de field e method: o alfabeto de códigos de tipo
Todo descriptor de field e method no constant pool é construído a partir de um pequeno conjunto fixo de códigos de uma letra para primitivos, mais dois prefixos estruturais para tudo que não é primitivo:
| código | tipo |
|---|---|
B |
byte |
C |
char |
D |
double |
F |
float |
I |
int |
J |
long |
S |
short |
Z |
boolean |
L ClassName ; |
um reference type — totalmente qualificado, separado por barra, terminado por ; |
[ |
uma dimensão de array — prefixado no descriptor de qualquer que seja o tipo do elemento |
Compilar uma classe de exemplo e ler seus descriptors de field mostra o padrão diretamente — tipos de array simplesmente empilham [ na frente do descriptor do elemento, uma vez por dimensão:
javabyte b;
int[] intArray;
String[][] stringMatrix;plaintextbyte b; descriptor: B int[] intArray; descriptor: [I java.lang.String[][] stringMatrix; descriptor: [[Ljava/lang/String;
Generics não têm sintaxe de descriptor própria — List<String> e List<Integer> compilam para o mesmo descriptor bruto Ljava/util/List;. O argumento de tipo genérico é preservado separadamente, num attribute Signature opcional (Ljava/util/List<Ljava/lang/String;>;) que só ferramentas como javac e reflection consultam; o próprio bytecode, e o verifier, só veem o Ljava/util/List; apagado. Isso é a type erasure feita concreta no nível do descriptor: o JVM não tem instrução ou código de descriptor que distinga um List<String> de um List<Integer>.
Trade-offs
- Indireção vs. tamanho — toda referência simbólica no bytecode é um índice do constant pool em vez de um valor inline, o que permite reutilizar a mesma string ou referência de method em várias instruções em vez de duplicá-la, ao custo de um lookup no momento de link/resolução.
plaintext2: invokevirtual #4 // Method println:(Ljava/lang/String;)V — resolvido pelo pool, não inline
- A checagem de versão é unidirecional — um JVM se recusa a carregar um class file cujo
major_versionseja mais novo do que ele suporta, mas carrega sem problemas um arquivo compilado para uma release mais antiga:
plaintext$ java HelloWorld Error: HelloWorld has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0
ACC_SUPERé um bit de compatibilidade histórico — toda classe compilada desde o Java 1.0.2 tem essa flag setada automaticamente, existindo só para que um JVM moderno continue resolvendo corretamente chamadasinvokespecialda forma como class files pré-1.0.2 esperavam; não há razão para se preocupar com isso em código escrito hoje.- Entradas do constant pool começam em 1 e nunca existe a entrada
#0— o índice0é reservado como um valor explícito de "sem referência" (usado, por exemplo, por uma classe sem superclasse), então as entradas do pool sempre começam a contar em#1, o que costuma pegar quem escreve um parser à mão assumindo indexação baseada em 0.