Klasse geladen

Java Classloader

Lars Wunderlich

Ein bisschen sind die virtuelle Maschine und der Classloader wie die zwei Seiten einer Medaille, wie Feuer und Wasser, wie Kälte und Hitze. Der eine macht ohne den anderen wenig Sinn. Der Classloader ist quasi der Türsteher, an dem der Bytecode vorbei muss, um zum Leben und zur Auswirkung erweckt zu werden, quasi die Geburtsstätte des Java-Programms. Ohne es pathetisch wirken zu lassen, umgibt alle ein Mythos. Die virtuelle Maschine (VM) ist die kleine geheime Hexenküche, der Bytecode diese unleserliche Form von Konstantenpool-Verpointerung und der Classloader, das Element, das beide zusammen bringt. Dieser Artikel vermittelt Grundkenntnisse über Sinn und Zweck des Java Classloaders und zeigt Einsatzgebiete sowie Stärken und Schwächen des Konzepts an sich.

Kapitel 5 der Java VM-Spezifikation beschreibt die Aufgaben des Classloaders: Laden, Linken und Initialisieren. Das Laden beschreibt den Prozess der Suche und des Auffindens einer binären Repräsentation einer Klasse: Wo liegt der Bytecode und wie bekommt man ihn in die Anwendung? Linken bedeutet die Verbindung zur virtuellen Maschine herzustellen, sodass aus dem Konglomerat aus binären Zeichen etwas Ausführbares wird. Linken zerfällt genau genommen in Verification, Preparation und Resolution.

Die Verification hat die Aufgabe die Binärdaten der Javatypen auf strukturelle Integrität zu prüfen (gültige Bytecodes, Prüfung von Methodenverläufen/Signaturen etc.). Verifikation ist wichtig um zu prüfen, ob der Bytecode nach der Erstellung durch den Java-Compiler nachträglich in einer Art und Weise modifiziert wurde, dass er zur Laufzeit zum Absturz oder illegalen Zugriffen der VM führen könnte.

In der Preparation-Phase beginnt die Interpretation des Bytecodes. Dies enthält die Erzeugung und Initialisierung von statischen Feldern. Hier wird noch kein Maschinencode ausgeführt, weshalb explizite Initialisierungsaktionen mit Code-Ausführung zunächst nicht durchgeführt werden.

Unter Resolution versteht man die Auflösung von Referenzen auf andere Klassen, Interfaces, Methodensignaturen usw. Ist diese Auflösung nicht möglich, bekommt man im Allgemeinen eine Fehlermeldung, dass eine symbolische Referenz nicht gefunden werden kann. Symbolische Referenzen können je nach VM-Typ in einem lazy- bzw. late resolution-Vorgang durch direkte Referenzen ersetzt oder bereits beim initialen Laden der Hauptklasse aufgebaut werden. Dies führt zu dem Effekt, dass die Resolution einmal nach der Preparation, aber auch zu einem späteren Zeitpunkt im Programmverlauf erfolgen und dann zu einem Fehler führen kann.

Initialization schließlich umfasst die Ausführung statischer Initialisierungsroutinen innerhalb einer Klasse und die Initialisierung statischer Felder der Klasse. Die Initialisierung setzt die vorherige Initialisierung der jeweils direkten Superklassen voraus. Reiht sich die Klasse somit in eine Vererbunghierarchie ein, werden die Klassen entlang dieser Hierarchie nacheinander initialisiert, um die zu ladende Klasse vorzubereiten.

Nachdem der Classloader diese drei Prozessschritte durchlaufen hat, steht die Klasse in einem geladenen Zustand zur Verfügung. Von ihr können jetzt Instanzen erzeugt und verwendet werden.

Ganz schön kompliziert?

Nun einerseits ja, andererseits nein, denn von den genannten Prozessschritten werden die meisten in der virtuellen Maschine selbst vorgenommen und verschwinden im Dunst der nativen Methoden und sind somit zwar dem Classloader zugehörig aber faktisch nicht änderbar. Die wirklich tollen Zaubertricks, die man mit Klassen machen kann und darunter fällt z.B. das Ersetzen der gesamten Klasse und deren Bytecode zur Laufzeit der Applikation, also eine adhoc-Neuprogrammierung sozusagen, hat gar nichts mit dem Classloader zu tun, sondern mit der VM. Sie muss für die meisten Kniffe, die heutige IDEs anbieten, tiefen Einblick hinter ihre Kulissen mittels des JVM Tool Interface (JVMTI) erlauben und sich mit nativen Aufrufen im Debug-Modus zu eigentlich ziemlich unangenehmen Verrenkungen animieren lassen. Diese Zugriffe werden auch über das java.lang.instrumenation-Package definiert, stehen aber jeweils nur Hersteller-abhängig für die VM bereit.

Wenn wir also mit dem Bytecode spielen wollen, an Annotations oder aspektorientierter Programmierung Gefallen finden, dann macht es Sinn, den Java-Source vor dem Kompilieren oder den Bytecode nach dem Kompilieren zu manipulieren. Tools wie AspectJ oder die Byte Code Engineering Library (BCEL) sind hier vortreffliche und artverwandte Tools. Nachdem der Bytecode einmal in der VM angelangt ist, helfen nur noch oben genannte Tricks weiter. Der Classloader ist daher eher eine Art Einbahnstraße, alles was hier hineingerät ist bis zu seiner Vernichtung geladen und an ihn gebunden.

Einsatzbereiche

Der Classloader ist eine gute Stelle, um verschiedene Dinge zu tun. Die typischsten dürften sein, dass er gemeinhin gezippte JAR-Dateien liest, in Verzeichnisbäumen nach binären Zeichenketten sucht und diese nach Extraktion der VM zur Verarbeitung vorwirft. Nun lassen sich Klassen ja bekanntlich aus verschiedensten Quellen laden, somit gibt es den Bootstrap-Classloader, der die rudimentären JRE-Klassen lädt, den Extension-Classloader, der sich für die JAR-Dateien im Extension-Verzeichnis (z.B. C:ProgrammeJavajre1.6.0libext) interessiert oder auch den Application Classloader, der die Classpath-Einträge nach der gewünschten Klasse absucht. Sie alle stehen zueinander in einer Hierarchiebeziehung.

Wer eine Klasse geladen hat, lässt sich über die jeweils zugehörige Klasseninstanz über getClass().getClassLoader() erfragen (Listing 1). Dies funktioniert grundsätzlich mit selbst geschriebenen Klassen. Interessant ist in diesem Zusammenhang, dass der gleiche Aufruf auf einer Instanz von java.lang.String ominöserweise null zurückliefert. Ist der Classloader null, wurde die Klasse vom Bootstrapclassloader geladen.

package test;
public class Test {
public static void main(String[] args) {
Test test = new Test();
ClassLoader cl = test.getClass().getClassLoader();
while (cl != null) {
System.out.println(cl);
cl = cl.getParent();
}
}
}
sun.misc.Launcher$AppClassLoader@1a7bf11
sun.misc.Launcher$ExtClassLoader@1f12c4e

Jede Klasse hat einen Classloader, durch den sie geladen wurde. Wie genau er nun an den Bytecode gekommen ist, ob er ihn wirklich aus einer Java-Source oder einer fremden Programmiersprache erhielt, ob die binären Daten verschlüsselt oder komprimiert waren oder hart-codiert vorlagen, ist vollkommen irrelevant. 

Durch die Fähigkeit den Bytecode aus verschiedenen Quellen zu lesen, zu prüfen und zu verändern, ergeben sich typische Funktionen, die heute in einem Classloader zu finden sind:

  • Verifikation digitaler Signaturen zur Prüfung auf vertrauenswürdigen Code samt Realisierung einer Sandbox (wie im Falle von Java Applets)
  • Transparente Entschlüsselung von Sourcecode
  • Spezielle Archivierungs- und Kompressionsformate jenseits von JARs
  • Selbstextrahierende Programme aus ausführbaren Dateien
  • Manipulation eingelesenen Bytecodes (Bytecode-Enhancing)
  • Dynamische Erzeugung von Quellcode bei Bedarf
  • Absichtliche Unterdrückung des Ladens/des Ladeversuchs bestimmter Klassen/Resourcen

Der Classloader ermöglicht somit interessante Hintertüren, denn der Bytecode kann trotz Verifikation, Verschlüsselung oder Signierung durchaus manipuliert werden, denn es ist der Classloader, der ihn aus der Quelle lädt und er ist es auch, der ihn beim späteren Besitzer, der VM abgibt. Dies erlaubt es, Bytecode ebenso in Bildern oder Worddateien zu verstecken und ihn aus den unwirklichsten FTP- oder Http-Quellen zu beziehen, wie ihn auch mit AOP-Mechanismen zu erweitern und zu manipulieren – dies sogar mit Unterstützung von Sun, die nicht nur einen Apache BCEL Classloader ins JRE integrierten, sondern auch noch Bytecode-Manipulation über so genannte ClassFileTransformer salonfähig machten. Die ClassFileTransformer wurden mit Java 5 eingeführt und erlauben die Redefinition und den Umbau von Bytecode vor dem (erneuten) Laden der Klasse.

Der ClassLoader ist quasi der rechtmäßige Eigentümer einer Klasse laut getClass().getClassLoader() und definiert, wie diese Klasse bzw. dieses Interface aussieht. Das verhindert aber mitnichten, dass ein und dieselbe Klasse von unterschiedlichen Classloadern geladen werden darf und hierin liegt der Knackpunkt und gleichzeitig die Basis der heutigen Java EE-Welt.

Wird eine Klasse bzw. deren Bytecode von zwei verschiedenen Classloadern in die VM geladen – was mittels eigener Classloader zunächst kein Problem ist – sind beide trotz gleichem Codes und gleichem Namens zueinander inkompatibel.

Classloader-Hierarchien

Da es wenig Sinn macht, Klassen wie String oder Integer mehrfach zu laden und da es in höchstem Maße auch verwerflich wäre, sie zu manipulieren oder zu reorganisieren, definiert man bei Java das Prinzip der Delegation. Delegation heißt, der Classloader schaut erst einmal, ob er eine angefragte Klasse schon geladen hat, wenn er um einen Ladevorgang gebeten wird (z.B. Class.forName()), und wenn dies nicht der Fall ist, versucht er die unangenehme Arbeit an seinen Parent zu delegieren. Dies führt dazu, dass wenn wir z.B. den Application Classloader bitten, uns die Klasse String zu laden, dieser die Aktion scheinbar durchführt, nur beim Vergleich mit dem Classloader, der die Klasse String geladen hat, entdeckt man Unstimmigkeiten (Listing 2).

Grundsätzlich verhindert die Delegation, dass Klassen, die von Classloadern in der Hierarchie darüber geladen wurden, noch einmal geladen werden. Dies führt bei einigen Entwicklern zu der Idee, bestimmte Bibliotheken wie beispielsweise Datenbanktreiber in das Extension-Verzeichnis des JREs zu legen. Somit kann jede Applikation automatisch die Datenbank-Verbindung aufbauen, ohne Treiber selbst mitzuliefern. Dumm nur für den Fall, dass jemand tatsächlich eigene Treiber ausliefert und nun Versionskonflikte oder Mischungen aus alten und neuen Klassen verschiedener Classloader entstehen.

try {
Test test = new Test();
ClassLoader cl = test.getClass().getClassLoader();
Class stringClazz = cl.loadClass("java.lang.String");
// entspricht:
stringClazz = Class.forName("java.lang.String", false, cl);
System.out.println(stringClazz.getClassLoader() == cl);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
Java EE-Classloader

Hierarchisierung und die damit verbundene Delegation sind in mehrerlei Hinsicht ein sehr delikates Konzept, denn Hierarchisierung beruht auf zwei Annahmen:

  1. Mehrere Applikationen wollen sich überhaupt in der Hierarchie darüber liegende Klassen teilen und
  2. die Classloader-Implementierung hält sich auch an diese Idee.

Ad absurdum führte die Idee der Delegation das so genannte Delegation Inversion Model, das mit Webapplikationen, eine der frühen Entwicklungen der Java EE, die Ausnahme zur Regel manifestierte. Dort findet sich in der Java Servlet Spezifikation 2.4 im Abschnitt SRV.9.7.2 zum Web Application ClassLoader, der für das Laden des WARs zuständig ist, der dann doch eher zurückhaltende Hinweis, dass der für diese Funktion geschaffene Classloader die Java SE und Java EE eigenen Bibliotheksteile nicht neu laden – sprich redefinieren – sollte. Im letzten Satz des zugehörigen Abschnittes heißt es, es werde empfohlen, den Applikations-Classloader (hier für das WAR zuständig) so zu implementieren, dass Klassen innerhalb des WARs Vorzug vor Container-weiten Bibliotheken gegeben wird. Im Klartext heißt dies, dass bei Java SE/EE-Bestandteilen (inklusive dem Containerprodukt) die Delegation einzuhalten und auf Wunsch bei allen anderen Klassen der Inversion der Delegation nachzukommen ist. Das heißt, jede Webapplikation kann ihren eigenen Datenbanktreiber mitbringen und kann den allgemeinen, wenn er denn im Container existiert, überschreiben, was auch sinnvoll ist, wenn der Application-Server selbst eine Datenbank mitbringt.

Einfacher macht es das Thema Classloading allerdings nicht. Die Konfusion um die unterschiedlichen Sichtbarkeiten im Java EE-Bereich, welche JSP nun welches Business-Interface von welcher EJB, aber nicht ihre Implementierung sehen darf und was mit den Third-Party-Bibliotheken außerhalb und innerhalb eines Containers und dem Container selbst passieren soll, wird um eigen eingebrachte herstellerspezifische Lösungen noch erweitert. Der Sun Application Server für Java EE 5 bringt beispielsweise nicht weniger als zehn verschiedene Classloader ins Spiel und von einigen gibt es jeweils nur eine und von anderen dann auch gleich multiple Instanzen. Zuweilen ist es schwer zu begreifen, wenn man nicht die Anleitung zur Hand hat, in welches Verzeichnis man sein JAR oder seine Klasse denn nun unbekümmert fallen lassen darf, um sie sichtbar zu machen und gleichzeitig versteckt zu halten.

Eigene Classloader

Will man nun einen eigenen Classloader implementieren, gilt es zu entscheiden, welches dafür die wichtigsten Methoden sind. Wichtigste Methode ist die loadClass()-Methode, die man beim Erweitern des Classloaders implementiert. Hier kommen der Name der zu ladenden Klasse hinein und ein Parameter, ob auch Referenzen auf andere Klassen aufzulösen sind. Der Rest ist abhängig vom Ziel der eigenen Implementierung. Zunächst wird man mit einem findLoadedClass() nachschauen, ob die Klasse bereits geladen wurde und ggf. weitere Verbindungen zu anderen Klassen über resolveClass() auflösen (siehe Extrakt in Listing 3). Die nachfolgenden Schritte sind abhängig von der Implementierung. Hier kann an einen Parent delegiert, aus Dateien, Verzeichnissen oder Inputstreams gelesen oder anderweitige Logiken angewandt oder der System-Classloader bemüht werden. Es empfiehlt sich auch ggf. entsprechende Hilfsmethoden vorzusehen, die aus Jar-Dateien lesen oder beispielsweise Inputstreams zu Bytearrays machen können (im Beispiel mit readClassFromFile() und inputStreamsToBytes() angedeutet).

Wurde der Bytecode geladen, muss er durch Aufruf der Methode protected final Class<?> defineClass(String name, byte[] b, int off, int len) zur Klasse transformiert werden. Diese nicht überschreibbare und intern durch die VM realisierte Klasse vermag in den oben genannten Auflösungs- und Prüfschritten aus den Bytecode eine Klasse zu machen. Wie das passiert, bleibt Implementierungsgeheimnis der virtuellen Maschine. Tatsache ist aber, dass die Klasse nach dieser Prozedur eindeutig geladen und unwiderruflich dem aktuell aufrufenden Classloader zugeordnet ist.

public final class MyClassLoader extends ClassLoader {
[...]
protected synchronized Class loadClass(String name, boolean resolve)
throws ClassNotFoundException {
Class c = findLoadedClass(name);
if (c != null) {
if (resolve) {
resolveClass(c);
}
return c;
}
try {
byte[] data = null;
// lesen der Klassen per Datei
data = this.readClassFromFile(name);
if (data == null) {
if (c == null) {
try {
// lesen per InputStream
String fileName = "";
fileName = name.replace('.', '/');
fileName = fileName + ".class";
InputStream is =
this.getClass().
getClassLoader().
getResourceAsStream(fileName);
if (is != null) {
data = inputStreamToBytes(is);
is = null;
is.close();
}
// lesen über den SystemClassLoader
try {
c = findSystemClass(name);
if (c != null &&
(name.startsWith("java.")
|| name.startsWith("sun.")
|| name.startsWith("javax.")) {
return c;
}
} catch (ClassNotFoundException ignore) {
}
if (data == null) {
throw new ClassNotFoundException(name);
}
} catch (AccessControlException e) {
e.printStackTrace();
}
}
}
try {
CodeSource myCs = null;
try {
myCs =
new CodeSource(
new URL("http://www.test.de"),
(Certificate[]) null);
} catch (MalformedURLException e) {
}
PermissionCollection pc =
Policy.getPolicy().getPermissions(myCs);
pc.add(new AllPermission());
ProtectionDomain pd =
new ProtectionDomain(myCs, pc, this, null);
c = defineClass(name, data, 0, data.length, pd);
} catch (ClassFormatError e) {
e.printStackTrace();
throw e;
}
data = null;
if (c == null) {
throw new ClassNotFoundException(name);
}
return c;
} catch (ClassNotFoundException e) {
System.err.println("ERROR RESOLVING CLASS: " + name);
throw e;
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException("Class unknown: " + name);
}
}
[...]
}

Security ausgehebelt?

Besonders bei Java Applets hat man sich mit code-centric Policies – also der Zuordnung von Berechtigungen für Klassen anhand der Quelle, aus der sie stammen – sehr viel Mühe gegeben. Die Logik beruht darauf, dass in einem java.policy-File angegeben ist, welche Permissions, also Berechtigungen, für welchen Code aus welcher Quelle gelten. Innerhalb eines Applets wird sofort beim Start der AppletClassLoader aktiv. Er verhindert, dass Klassen aus irgendwelchen Quellen gezogen werden. Außerdem wird gleich ein SecurityManager installiert, der jeden Aufruf auf den JRE-Basisklassen wie z.B. das Lesen oder Schreiben einer Datei sofort prüft. Dafür hat jede Klasse, die so geladen wurde, eine eigene ProtectionDomain. Soll beispielsweise über die java.io.File-Klasse eine Datei erzeugt werden (Listing 4), wird zunächst abgefragt, ob ein SecurityManager existiert. Er autorisiert den Zugriff, indem er innerhalb des Stacks abfragt, welche Klasse den Zugriff anfordert und über deren ProtectionDomain ihre Berechtigungen ausliest. Diese wiederum werden durch die java.policy-Datei für Klassen aus bestimmten Quellen definiert.
Wir finden bei Applets also eine lange Kette von Sicherheitsmechanismen. Angefangen von der Signierung der Klassen, die notwendig für die Identifikation der Quelle des Bytecodes ist, über das Policy-File, bis hin zur Instanziierung des SecurityManagers und der Nutzung des Applet-Classloaders.

Wie feingranular man diese Berechtigungen in der Policy-Datei steuern muss, sieht man erstmals, wenn man alle einzelnen Benennungen und Grants- (also Berechtigungsgruppen) darin aufbauen muss. Das ganze Security-Konzept beruht aber letztendlich nicht auf der VM oder dem JRE als solchem, sondern mehr auf den Fähigkeiten des Applet-Classloaders und es ist möglich diesen ebenfalls nach Berechtigung durch das Policy-File zur Laufzeit zu ersetzen.

Hat man ab diesem Zeitpunkt die vollständige Kontrolle über das Laden des Bytecodes erlangt, ist die Logik der Policies natürlich hinfällig. Die ProtectionDomain lässt sich selbst frei definieren, die Signatur der JARs hat keine Bedeutung mehr und den Klassen können beliebige Berechtigungen zugewiesen werden, egal, ob der SecurityManager aktiv ist oder nicht (Listing 3, Define-Abschnitt).

  public class File 
  implements Serializable, Comparable<File> 
  { 
  [...] 
  public boolean createNewFile() throws IOException { 
  SecurityManager security = System.getSecurityManager(); 
  if (security != null) security.checkWrite(path); 
  return fs.createFileExclusively(path); 
  } 
  [...] 
  }




Classloading – ein Dauerbrenner

Leider ist der Platz in diesem Artikel begrenzt, den die komplette Vorstellung einer Eigenimplementierung von Classloadern, der Wechsel zwischen Klassen und Aufrufen zwischen unterschiedlichen Classloadern oder auch die dynamische Instanziierung fremder Klassen per RMI würde viel mehr Raum beanspruchen. Tatsache ist aber, dass der Classloader ein so immanent wichtiges Konzept ist, dass er für viele Lösungsvarianten herhält und andere Securityansätze, neue Reloading-Strategien als auch aspektorientierten Bytecodemanipulationen die Tür öffnet – eine oft vollkommen ungewohnte Welt der Programmierung und des Umgangs mit laufender Software.

Es macht Sinn sich mit ihr auseinanderzusetzen und es ist bei weitem spannender und gar nicht so langweilig, als das Mysterium Classloader manchmal anzumuten scheint.

Lars Wunderlich arbeitet als System Architekt im Bereich des Einsatzes der Java EE-Plattform in Großprojekten der TUI InfoTec GmbH in Hannover. Er ist Autor zahlreicher Bücher und Artikel zu unterschiedlichen Themengebieten der Java-Welt.    
Geschrieben von
Lars Wunderlich
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.