Carregamento de Classes em Java
Como a JVM encontra e carrega classes com class loaders — bootstrap, platform, system e loaders personalizados.
Antes que a JVM possa executar uma única linha do seu código, ela precisa encontrar o arquivo .class, ler seu bytecode, verificá-lo e transformá-lo em um objeto Class vivo na memória. Essa tarefa pertence a um class loader. O carregamento de classes é o que torna java.lang.String disponível sem que você precise fazer nada, o que permite que um JAR no classpath apareça em tempo de execução, e o que alimenta sistemas de plugins, servidores de aplicação e ferramentas de hot-reload. Este capítulo mostra como os loaders são organizados, como funciona a delegação e por que a identidade de uma classe vai além do seu nome.
A hierarquia de class loaders
Os loaders são organizados como uma cadeia de pais, cada um responsável por uma fonte diferente de classes. Em um JDK moderno (9+), há três loaders integrados:
| Loader | Carrega | Reportado como |
|---|---|---|
| Bootstrap | Classes principais do JDK (java.*, módulos base javax.*) | null |
| Platform | O restante dos módulos de plataforma do JDK | um PlatformClassLoader |
| System / Application | Seu código do classpath/module path | um AppClassLoader |
Toda classe lembra o loader que a definiu. Você pode perguntar a qualquer classe qual loader a produziu:
ClassLoader appLoader = MyApp.class.getClassLoader(); // AppClassLoader
ClassLoader strLoader = String.class.getClassLoader(); // null = bootstrap
ClassLoader parent = appLoader.getParent(); // PlatformClassLoaderO loader bootstrap é escrito em código nativo, não em Java, por isso String.class.getClassLoader() retorna null em vez de um objeto — não existe uma instância Java de ClassLoader para devolver.
O modelo de delegação
Os class loaders seguem o modelo de delegação pai-primeiro. Quando solicitado a carregar uma classe, um loader não tenta encontrá-la imediatamente. Ele primeiro pergunta ao seu pai, que pergunta ao seu pai, até chegar ao bootstrap. Apenas se nenhum ancestral puder fornecer a classe é que o loader original tenta defini-la por conta própria.
// Conceptual shape of ClassLoader.loadClass:
protected Class<?> loadClass(String name, boolean resolve) {
Class<?> c = findLoadedClass(name); // already loaded? reuse it
if (c == null) {
try {
c = parent.loadClass(name); // delegate UP first
} catch (ClassNotFoundException e) {
c = findClass(name); // only now load it myself
}
}
return c;
}Essa delegação garante que os tipos centrais sejam carregados uma vez, pelo loader mais alto que possa fornecê-los. É por isso que você não pode sobrescrever java.lang.String colocando seu próprio String.class no classpath — o loader bootstrap reclama o nome primeiro.
Carregamento, vinculação, inicialização
Trazer uma classe à vida acontece em três fases, e elas não são a mesma coisa:
- Carregamento — ler o bytecode e criar o objeto
Class. - Vinculação — verificar se o bytecode está bem formado, preparar campos estáticos com valores padrão e resolver referências simbólicas.
- Inicialização — executar inicializadores estáticos e atribuições de campos estáticos (o método
<clinit>da classe).
O fato prático mais importante: a inicialização é lazy e acontece exatamente uma vez. Uma classe só é inicializada no primeiro uso ativo — o primeiro new, a primeira chamada de método estático ou a primeira leitura de um campo estático não constante.
class Config {
static final Map<String, String> SETTINGS = load(); // runs once, on first touch
static Map<String, String> load() {
System.out.println("Config initialized");
return Map.of("env", "prod");
}
}
// "Config initialized" prints only when Config is first actively used.Class loaders personalizados
Você pode estender ClassLoader para carregar classes de qualquer lugar — um banco de dados, um fluxo de rede, bytecode gerado ou um JAR criptografado. Os dois métodos que importam são findClass (localizar e definir os bytes) e defineClass (entregar os bytes brutos para a JVM, que retorna um Class).
class BytesLoader extends ClassLoader {
private final byte[] bytecode;
BytesLoader(byte[] bytecode) { this.bytecode = bytecode; }
@Override
protected Class<?> findClass(String name) {
return defineClass(name, bytecode, 0, bytecode.length);
}
}URLClassLoader é a versão integrada dessa ideia — aponte-o para JARs ou diretórios e ele carrega classes sob demanda:
URL jar = Path.of("plugin.jar").toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(new URL[]{ jar })) {
Class<?> plugin = loader.loadClass("com.example.Plugin");
Object instance = plugin.getDeclaredConstructor().newInstance();
}Identidade de classe: nome mais loader
Aqui está a sutileza que pega as pessoas de surpresa: a identidade em tempo de execução de uma classe é seu nome totalmente qualificado e o loader que a definiu. Carregue bytes para Widget por meio de dois loaders diferentes e você obtém dois objetos Class distintos — não iguais, não compatíveis por atribuição — mesmo que ambos tenham vindo de bytecode idêntico. É exatamente assim que os servidores de aplicação isolam dois aplicativos implantados que carregam uma classe chamada com.acme.Util.
Um exemplo prático: loaders, delegação, lazy e identidade
Este programa não precisa de classes externas — ele usa os loaders já presentes em qualquer JVM. Ele percorre a cadeia de loaders, prova que classes principais vêm do loader bootstrap, mostra a delegação retornando o mesmo objeto Class, observa um inicializador estático ser disparado de forma lazy e apenas uma vez, e depois define o mesmo bytecode construído manualmente por dois loaders para provar a regra de identidade nome-mais-loader.
O que observar na execução:
- A cadeia de loaders impressa é a hierarquia de class loaders ativa com seu código na base:
ClassLoadingDemofoi definido por um loader de nível de aplicação cujogetParent()é o próximo loader acima. Cada loader conhece apenas o seu pai, e a cadeia sempre sobe em direção ao bootstrap. String.class.getClassLoader()imprimenull, a forma da JVM de dizer "carregado pelo loader bootstrap". Tipos principais do JDK sempre reportamnullaqui; um objeto implicaria que vieram de um loader inferior, o que nunca acontece.app.loadClass("java.lang.StringBuilder") == StringBuilder.classétrue. A delegação enviou a solicitação para o loader que já possuiStringBuilder, então você recebeu de volta o objetoClassidêntico, não uma duplicata — prova de que a delegação impede que os tipos centrais sejam carregados duas vezes.Lazy <clinit> runningé impresso uma vez, entre o marcador--- referencing Lazy now ---e o primeiroLazy.VALUE = 42, e nunca mais na segunda leitura. A inicialização é lazy (esperou até o primeiro uso) e idempotente (o bloco estático executa exatamente uma vez por loader).aebtêm ambos o nomeWidget, masa == béfalseea.isAssignableFrom(b)éfalse. Dois loaders definiram o mesmo bytecode em dois tipos distintos — prova concreta de que a identidade de classe em tempo de execução é nome totalmente qualificado mais loader definidor, o mecanismo por trás do isolamento de classpath em servidores de aplicação.
Prática
Tópicos relacionados
O carregamento de classes fica na fronteira entre a JVM e o seu código, por isso toca vários tópicos vizinhos:
- Arquitetura da JVM — onde o subsistema de class loader se encaixa entre o motor de execução e as áreas de dados em tempo de execução.
- Modelo de Memória Java — como as classes carregadas e seus dados estáticos vivem na memória.
- Coleta de Lixo — os class loaders (e suas classes) podem ser descarregados quando não há mais referências a eles.
- Introdução a Módulos — no module path, o carregamento é orientado pela legibilidade de módulos em vez de um classpath plano.
- Introdução a Reflection —
Class.forNameeloadClasssão os pontos de entrada sobre os quais a reflection se baseia.