Classloading: O Modelo de Delegação e Class Loaders Customizados
Objective
Entender como a JVM transforma um arquivo .class em um tipo vivo e utilizável: loading (carregamento) encontra os bytes e cria um objeto Class, linking (linkedição) verifica e prepara a classe, e initialization (inicialização) executa seus inicializadores estáticos — três fases distintas, disparadas em momentos diferentes e muitas vezes surpreendentes. O carregamento de classes também é delegado através de uma hierarquia de instâncias de ClassLoader que, por padrão, sempre perguntam primeiro ao seu pai — uma regra que existe especificamente para que código de aplicação não consiga substituir silenciosamente classes core do JDK. Esse é o mecanismo por baixo do module system coberto em Java Platform Module System; o JPMS muda quais pacotes um loader tem permissão de enxergar, não o pipeline de loading/linking/initialization em si.
Use Cases
- Explicar por que acessar uma constante
static finalnunca dispara o inicializador estático de uma classe, mas chamar um de seus métodos estáticos sempre dispara. - Ler um stack trace que diz
NoClassDefFoundErrore saber procurar por umExceptionInInitializerErroranterior, ou por um JAR que estava presente em tempo de compilação mas está ausente do classpath em runtime — em vez de confundir isso comClassNotFoundException. - Escrever um
ClassLoadercustomizado para carregar plugins de aplicação a partir de um diretório, isolar as classes de cada plugin das demais, ou dar suporte a hot-reload de uma classe sem reiniciar a JVM. - Diagnosticar um
ClassCastExceptionentre dois objetos que imprimem exatamente o mesmo nome de tipo — um sintoma clássico da mesma classe ter sido carregada duas vezes, por dois class loaders diferentes, em um app server ou sistema de plugins. - Decidir se uma dependência sensível a segurança ou isolamento precisa do próprio loader, ou se basta ser mais uma entrada no classpath da aplicação.
Deep Dive
As três fases: loading, linking, initialization
A JVM Specification (§5.3–§5.5) divide a transformação de um nome de classe em um tipo pronto para uso em três fases. Loading lê os bytes da classe e cria um objeto Class. Linking é, em si, três etapas: verificação (o bytecode é estrutural e type-safe), preparação (campos estáticos recebem seus valores padrão zero/null, a memória é alocada) e resolução (referências simbólicas a outros tipos são opcionalmente resolvidas, muitas vezes de forma preguiçosa). Initialization é a fase que os desenvolvedores realmente veem: ela executa blocos de inicialização estática e atribuições de campos estáticos, e acontece de forma preguiçosa, no primeiro "uso ativo" — não no momento do loading:
javaclass Lazy {
static final int MAX = 100; // compile-time constant, inlined by javac
static {
System.out.println("Lazy initialized");
}
static void ping() {
System.out.println("ping");
}
}
public class Demo {
public static void main(String[] args) {
int max = Lazy.MAX; // no output at all: javac inlined the literal 100
// into Demo's own bytecode; Lazy is never touched
Lazy.ping(); // NOW Lazy is initialized: prints "Lazy initialized"
// then "ping"
}
}MAX é uma expressão constante em tempo de compilação (JLS §15.29), então o compilador copia seu valor diretamente em cada ponto de chamada, e a classe que a referencia nem precisa carregar Lazy para lê-la. Já uma chamada a um método static, em contraste, não pode ser eliminada por inlining — ela força o loading, linking e initialization de Lazy antes que a chamada prossiga. O mesmo vale para new Lazy(), para acessar um campo estático não-constante, ou para invocar reflexivamente um método estático.
A hierarquia de delegação: bootstrap, platform, application
A JVM padrão vem com uma pequena hierarquia de loaders, cada um com seu próprio escopo:
javapublic class LoaderChain {
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
// null — String is loaded by the bootstrap loader, which has no
// Java-side ClassLoader object at all
System.out.println(java.sql.Driver.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@... —
// a platform module (java.sql), loaded by the platform loader
ClassLoader cl = LoaderChain.class.getClassLoader();
while (cl != null) {
System.out.println(cl);
cl = cl.getParent();
}
// jdk.internal.loader.ClassLoaders$AppClassLoader@... (this class)
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@... (its parent)
// (loop ends: the platform loader's parent is bootstrap, reported as null)
}
}Antes do Java 9, esse loader se chamava extension class loader e cobria jre/lib/ext; o JPMS o reaproveitou como loader platform, responsável pelos próprios módulos de plataforma do JDK (java.sql, java.desktop e similares). O loader application (também chamado "system") é o que carrega tudo que está no classpath ou module path que o seu próprio código distribui — é o que Class.getSystemClassLoader() retorna, e o pai padrão de qualquer loader customizado que você escrever.
Delegação parent-first: por que ela existe, como funciona
ClassLoader.loadClass(String, boolean) implementa a estratégia padrão parent-first: antes de um loader tentar encontrar e definir uma classe por conta própria, ele pede ao seu pai para tentar primeiro, até chegar ao bootstrap. Só se todos os ancestrais falharem em encontrar a classe é que o loader recorre ao seu próprio findClass. O objetivo é proteção, não conveniência: isso impede que código de aplicação sombreie uma classe core como java.lang.String só por distribuir uma classe com o mesmo nome no classpath.
Mesmo um loader customizado que deliberadamente inverte a ordem para child-first — checando a si mesmo antes de delegar — não consegue redefinir um pacote pertencente ao JDK, porque o próprio defineClass recusa:
javapublic class ChildFirstLoader extends ClassLoader {
public ChildFirstLoader(ClassLoader parent) { super(parent); }
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.startsWith("java.")) {
return super.loadClass(name, resolve); // still must delegate for java.*
}
// child-first for everything else — try to define it ourselves before asking the parent
try {
return findClass(name);
} catch (ClassNotFoundException e) {
return super.loadClass(name, resolve);
}
}
}Se esse loader tentasse fazer defineClass("java.lang.String", bytes, 0, bytes.length) mesmo assim, a JVM rejeitaria de imediato:
plaintextjava.lang.SecurityException: Prohibited package name: java.lang
A ordem de delegação é uma escolha de política que um loader pode sobrepor; a proibição de definir classes em pacotes de sistema protegidos é aplicada de forma independente, no próprio defineClass.
Escrevendo um ClassLoader customizado: findClass, defineClass e um loader de plugins
Um loader customizado quase sempre sobrescreve findClass, não loadClass — isso mantém intacta a lógica de delegação parent-first e só muda de onde vêm os bytes quando a delegação falha. O objeto Class propriamente dito é criado com o defineClass herdado:
javapublic class PluginClassLoader extends ClassLoader {
private final Path pluginDir;
public PluginClassLoader(Path pluginDir, ClassLoader parent) {
super(parent);
this.pluginDir = pluginDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Path classFile = pluginDir.resolve(name.replace('.', '/') + ".class");
try {
byte[] bytes = Files.readAllBytes(classFile);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}javaPluginClassLoader loader = new PluginClassLoader(Path.of("plugins/report-exporter"),
PluginClassLoader.class.getClassLoader());
Class<?> pluginClass = loader.loadClass("com.example.plugin.ReportExporterPlugin");
Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
plugin.run();Essa é a forma padrão de uma arquitetura de plugins: cada plugin ganha seu próprio loader, então dois plugins podem depender cada um de uma versão diferente da mesma biblioteca sem colidir. Criar uma nova instância de PluginClassLoader e recarregar o mesmo nome de classe também é como funciona o hot-reload — a JVM não "recarrega" uma classe no lugar; uma nova definição do mesmo nome, carregada por uma nova instância de loader, é simplesmente um objeto Class distinto vivendo ao lado (ou substituindo todas as referências a) do antigo, que se torna elegível para garbage collection assim que nada mais o referenciar.
ClassNotFoundException vs. NoClassDefFoundError
ClassNotFoundException é uma exceção checked lançada quando código explicitamente pede para carregar uma classe pelo nome e esse nome não consegue ser resolvido — tipicamente Class.forName(...) ou ClassLoader.loadClass(...):
javatry {
Class.forName("com.example.MissingPlugin");
} catch (ClassNotFoundException e) {
// the requested class genuinely does not exist anywhere on the search path
}NoClassDefFoundError é um Error unchecked, lançado quando a JVM tenta carregar uma classe implicitamente — como efeito colateral de executar outro código que a referencia — e essa tentativa falha, mesmo que a classe existisse e tivesse feito o linking corretamente em tempo de compilação. A causa mais comum na prática é um JAR de dependência presente em tempo de compilação, mas ausente do classpath em runtime. Uma causa mais sutil é uma classe cujo inicializador estático já falhou uma vez:
javaclass Broken {
static {
if (true) throw new RuntimeException("boom");
}
}
public class Demo {
public static void main(String[] args) {
try {
new Broken(); // first attempt: static init runs and throws
} catch (ExceptionInInitializerError e) {
System.out.println("first: " + e.getCause());
}
try {
new Broken(); // second attempt: init is NOT retried
} catch (NoClassDefFoundError e) {
System.out.println("second: " + e.getMessage());
// second: Could not initialize class Broken
}
}
}Depois que a inicialização de uma classe falha, a JVM a marca como permanentemente errônea — ela nunca tenta rodar o inicializador estático de novo, e toda tentativa posterior de usar a classe lança NoClassDefFoundError em vez de reexecutar (e falhar de novo) o mesmo código.
ClassCastException entre dois loaders: identidade inclui o loader
A noção da JVM de "o mesmo tipo" é (nome totalmente qualificado, ClassLoader que o define), não só o nome. Carregue os mesmos bytes .class através de duas instâncias de loader diferentes e você obtém dois tipos distintos e mutuamente incompatíveis:
javaClassLoader loaderA = new PluginClassLoader(pluginDir, parent);
ClassLoader loaderB = new PluginClassLoader(pluginDir, parent);
Class<?> widgetA = loaderA.loadClass("com.example.Widget");
Class<?> widgetB = loaderB.loadClass("com.example.Widget");
Object instance = widgetA.getDeclaredConstructor().newInstance();
widgetB.cast(instance);
// java.lang.ClassCastException: class com.example.Widget cannot be cast to class
// com.example.Widget (com.example.Widget is in unnamed module of loader
// PluginClassLoader @1b6d3586; com.example.Widget is in unnamed module of loader
// PluginClassLoader @4f2a9c11)Ambas as classes se chamam com.example.Widget, compiladas exatamente do mesmo código-fonte — a JVM ainda assim as trata como tipos não relacionados porque foram definidas por instâncias de loader diferentes. Esse é o modo de falha clássico em application servers e sistemas de plugins estilo OSGi: uma interface compartilhada carregada uma única vez por um ancestral comum funciona bem, mas uma classe de implementação carregada por acidente duas vezes — uma vez por loader de plugin em vez de uma única vez, compartilhada — quebra qualquer código que tente fazer cast entre as duas cópias.
Trade-offs
- Quebrar a delegação parent-first para sombrear uma classe do JDK não funciona de fato — mesmo um loader child-first é bloqueado de definir uma classe em um pacote protegido como
java.lang:
plaintextjava.lang.SecurityException: Prohibited package name: java.lang
- Um class loader vivo mantém tudo que carregou vivo — todo
Classque um loader definiu, e todo campo estático que essas classes mantêm, permanece alcançável enquanto o próprio loader for alcançável. Um sistema de plugins que esquece de soltar sua última referência a umPluginClassLoaderdepois de descarregar um plugin vaza o grafo de classes inteiro do plugin e seu estado estático pela vida inteira da JVM; essa é uma causa real e recorrente deOutOfMemoryError: Metaspaceem app servers de longa duração que recarregam plugins ou aplicações web repetidamente. - A mesma classe carregada por dois loaders é dois tipos incompatíveis — um bug sutil e difícil de identificar quando um serviço "singleton" ou uma interface compartilhada acaba sendo carregada duas vezes em vez de uma:
javawidgetB.cast(instance); // ClassCastException, even though both classes are named identicallyNoClassDefFoundErrordepois de um inicializador estático que falhou pode enganar a depuração — o primeiro stack trace (ExceptionInInitializerError, com a causa real) é o que vale a pena guardar; todoNoClassDefFoundError: Could not initialize class ...subsequente para a mesma classe é só a JVM se recusando a tentar de novo, e perseguir esse em vez da causa original é perda de tempo.- Chamadas customizadas a
defineClassaceitam bytecode arbitrário — um loader de plugins que lê arquivos.classde um diretório com permissão de escrita é, na prática, uma superfície de execução de código arbitrário; a verificação (a fase de "linking") pega bytecode estruturalmente malformado, mas não diz nada sobre o que uma classe bem formada, porém maliciosa, foi projetada para fazer em runtime. - Inicialização preguiçosa é fácil de raciocinar errado — assumir que referenciar uma classe sempre executa seu inicializador estático leva a surpresas com constantes de tempo de compilação, que são incorporadas por inlining no ponto de chamada e nunca disparam o carregamento da classe que as declara.