JAVA Compiler und Decompiler

Werbung
JAVA Compiler und Decompiler
Sebastian Fehrenbacher, Björn Strobel
Studiengang Informationstechnik
Berufsakademie Stuttgart – Außenstelle Horb a.N.
Florianstraße 15
72160 Horb a.N.
[email protected]
[email protected]
Abstract: Ein Nachteil der JAVA‐Technologie ist das schlechte Laufzeitverhalten des interpretierten Bytecodes gegenüber kompilierten Programmen. Die folgende Ausarbeitung beschreibt Lösungsansätze, diesen Laufzeitnachteil zu minimieren. Dabei werden im Wesentlichen zwei Wege eingeschlagen. JAVA‐Programme können zum Einen durch Optimierungen an der virtuellen Maschine erheblich beschleunigt werden. Zum Anderen können JAVA‐Programme auch in native Programme übersetzt werden. Die Plattformunabhängigkeit geht hierbei aber zumindest zum Teil verloren. Als Beispiele werden die beiden Compilerwerkzeuge Excelsior JET und der GNU/GCJ‐Compiler näher betrachtet. Ein weiterer Aspekt dieser Ausarbeitung ist das Dekompilieren von JAVA‐Programmen. Während die Dekompilierung von Bytecode relativ einfach ist und die Semantik des Codes nur durch Obfuscator‐
Werkzeuge verschleiert werden kann, sind nativ kompilierte JAVA‐
Programme genauso schwer zu entschlüsseln wie kompilierte C/C++‐
Programme. 1
Inhalt
1
Motivation ......................................................................................................3
2
Warum JAVA langsam ist ................................................................................5
3
Lösungsansätze ...............................................................................................7
3.1
3.1.1
Excelsior JET......................................................................................7
3.1.2
GNU Compiler für JAVA (GCJ) ........................................................13
3.2
4
5
6
Ahead-of-time Kompilierung .....................................................................7
Optimierungen ........................................................................................14
3.2.1
Die Just in Time Kompilierung.........................................................14
3.2.2
Die Hotspot-Optimierung................................................................16
3.2.3
Benchmark .....................................................................................19
JAVA Decompiler ..........................................................................................21
4.1
Gefahr und Nutzen..................................................................................21
4.2
Beispiel für die Anwendung.....................................................................21
4.3
Vorgehensweise ......................................................................................21
4.4
Praxisbeispiel ...........................................................................................25
Obfuscating ..................................................................................................28
5.1
Nachteile der Verschleierung ...................................................................28
5.2
Abstraktion von Daten.............................................................................29
5.3
Abstraktion von Kontrollflüssen...............................................................29
Fazit und Ausblick .........................................................................................30
2
1
Motivation
Einer der Gründe für den Erfolg der JAVA‐Technolgie ist ihre Plattformunabhängigkeit. Ein Compiler erzeugt aus dem Quellcode einen Zwischencode, den so genannten Bytecode. Dieser beinhaltet in Form der Klassendateien Anweisungen für einen virtuellen Prozessor. Der virtuelle Prozessor wird im Rahmen einer virtuellen Maschine, der JAVA Virtual Machine (JVM) bereitgestellt. Ein JAVA‐Programm ist auf jeder Rechner‐
Plattform lauffähig, für die eine virtuelle Maschine verfügbar ist. Die Plattformunabhängigkeit der JAVA‐Technologie beschränkt sich also auf die Verfügbarkeit der virtuellen Maschine für die jeweilige Plattform. Die virtuelle Maschine interpretiert den Bytecode und führt ihn aus. JAVA wird also sowohl kompiliert als auch interpretiert. Ein Vorteil der virtuellen Maschine ist, dass auf ihr laufende Programme vom restlichen System abgeschottet werden können. Dies ist besonders für Anwendungen wie Applets von Vorteil, die nur eingeschränkte Rechte auf dem ausführenden System haben dürfen. Die Ausführung von Programmen über die virtuelle Maschine hat aber natürlich auch Nachteile: •
JAVA‐Programme laufen im Allgemeinen langsamer als nativ kompilierte Programme. Unter nativ kompilierten Programmen versteht man Programme, die direkt in die Maschinensprache des Zielsystems übersetzt sind und ohne weitere Übersetzung lauffähig sind. Da die virtuelle Maschine die Bytecode‐Befehle erst zur Laufzeit erkennt und dekodiert, entsteht ein gewisser Laufzeitnachteil. Auch muss zur Ausführung des Programms die virtuelle Maschine gestartet werden, was zusätzlich Zeit kostet. Die Fachwelt streitet sich indes darüber, ob JAVA‐Programme, die auf der virtuellen Maschine laufen, wirklich immer langsamer sind als nativ kompilierte Programme. Inzwischen verfügt die virtuelle Maschine nämlich über einige Techniken, die den Laufzeitnachteil minimieren bzw. wettmachen sollen. •
JAVA‐Programme benötigen mehr Speicher zur Ausführung als nativ kompilierte Programme. Neben dem Programm selbst muss auch die JVM geladen werden. Die Übersetzung zur Laufzeit benötigt weiteren Speicher. 3
•
JAVA Bytecode ist sehr einfach zu dekompilieren. Unter Dekompilierung versteht man das Zurückführen des übersetzten Programms in seinen ursprünglichen Quellcode. Bei der Dekompilierung eines JAVA‐Programms gehen im Allgemeinen nur lokale Variablennamen verloren. Dieses mühelose Zurückübersetzen ist besonders bei kommerzieller Software unerwünscht. 4
2
Warum JAVA langsam ist
Um ein JAVA‐Programm auszuführen wird die virtuelle Maschine gestartet. Diese erhält als Argument die Klassendatei mit der main‐Methode. Die virtuelle Maschine lädt diese Klasse, verifiziert den Bytecode und lädt dann dynamisch die dort referenzierten Klassen, die auf dieselbe Art abgearbeitet werden, so dass rekursiv alle Abhängigkeiten der Anwendung aufgelöst werden. Dies stellt einen erheblichen Aufwand dar und ist mit verantwortlich für den zähen Start von JAVA‐Programmen. Die Instruktionen eines JAVA‐Programms liegen nicht in Maschinensprache vor. Deshalb können sie nicht direkt ausgeführt werden. Vielmehr bestehen sie aus Instruktionen für einen virtuellen Prozessor, den die virtuelle Maschine bereitstellt. Die Instruktionen werden zur Laufzeit von der virtuellen Maschine interpretiert und ausgeführt. Da diese Übersetzung zusätzlich zur eigentlichen Ausführung zur Laufzeit stattfinden muss, ist es zunächst offensichtlich, dass JAVA‐Programme langsamer sind als entsprechende native Programme. Man halte sich hierzu vor Augen, wie lange es dauert, ein größeres Projekt einer Hochsprache zu kompilieren. Besonders Programme, die sehr hardwarenahe Operationen ausführen, z.B. Bitmanipulationen, laufen als interpretiertes JAVA‐Programm erheblich langsamer. Dies macht JAVA in der Praxis ungeeignet für Anwendungen wie etwa Mikroprozessorsimulatoren. Hier sind nativ kompilierte Programme bis zu einem Faktor von 30 schneller. Auch viele Eigenschaften der Sprache, die das Programmieren komfortabler und sicherer machen, wirken sich nachteilig auf das Laufzeitverhalten aus. In JAVA wird jede Variable bei der Deklaration initialisiert, jeder Arrayzugriff wird auf Bereichsüberschreitungen überprüft, Container werden auf Typeignung ihres Inhalts überprüft. Auch die komfortable Garbage Collection ist rechenaufwändiger als eine effiziente Zerstörung von Objekten von Hand. Außerdem kann ihr Verhalten nur schwer vorhergesagt werden, was JAVA prinzipiell untauglich für Echtzeit‐
Anwendungen macht. Die Tatsache, dass JAVA als plattformunabhängige Sprache entworfen wurde und deshalb keine systemspezifischen Funktionen unterstützt, disqualifiziert JAVA für die Programmierung von Systemtools oder Treibern. Allerdings wurde JAVA für solche Anwendungen auch nicht konzipiert. Die folgenden Benchmark‐Ergebnisse sind der Seite [os04] entnommen. 5
int math
long math
double
math
Trig
I/O
total
Visual C++
9,6
18,8
6,4
3,5
10,5
48,8
Visual C#
9,7
23,9
17,7
4,1
9,9
65,3
gcc C
9,8
28,8
9,5
14,9
10,0
73,0
Visual Basic
9,8
23,7
17,7
4,1
30,7
85,9
Visual J#
9,6
23,9
17,5
4,2
35,1
90,4
JAVA 1.3.1
14,5
29,6
19,0
22,1
12,3
97,6
JAVA 1.4.2
9,3
20,2
6,5
57,1
10,1
103,1
Testkonfiguration: Dell Latitude C640 Notebook, Pentium 4-M 2GHz, 768MB RAM, IBM Travelstar
20GB/4500RPM (NTFS), Radeon Mobility 7500/32MB, Windows XP Pro SP 1
Tabelle 1: Laufzeitvergleiche
120
int math
long math
100
double math
trig
80
I/O
Laufzei
total
60
40
20
0
Visual C++
Visual C#
gcc C
Visual Basic
Visual J#
Java 1.3.1
Java 1.4.2
Sprache
Abbildung 1: Laufzeitvergleiche
6
3
Lösungsansätze
Um diese Nachteile der JAVA‐Technologie zu überwinden, existieren zwei Lösungsansätze: •
Das JAVA‐Programm wird komplett in nativen Code übersetzt. Die Plattformunabhängigkeit geht hierbei natürlich verloren. •
Die virtuelle Maschine wird optimiert, um den Laufzeitnachteil schrumpfen zu lassen. Das Problem des einfachen Dekompilierens bleibt aber bestehen. 3.1
Ahead-of-time Kompilierung
Ein Ahead‐of‐time Compiler erzeugt nativen Maschinencode aus Quell‐ oder Bytecodedateien. Dabei gibt es verschiedene Ansätze. Als Beispiele werden im Folgenden die Lösungen JET von Excelsior und der GNU Compiler für JAVA (GCJ) vorgestellt. 3.1.1
Excelsior JET
ʺJET is great, I highly recommend its use to everyone who wants to use JAVA but needs final exes that are smaller and faster than what JAVA alone can provide.ʺ Matt Jones, Flashbulb Studios Das Produkt Excelsior JET stellt eine Komplettlösung dar, um JAVA‐
Anwendungen beschleunigen, schützen und verteilen zu können. Dieses Gesamtpaket teilt sich in den JET‐Optimizer, die JET‐Runtime und das JET‐
Installation‐Toolkit auf. Der Optimizer generiert eine ausführbare Datei auf einer Windows‐ bzw. UNIX‐Plattform. Die Laufzeitumgebung JET‐Runtime verwirklicht eine eigenständige virtuelle Maschine, die sowohl mit class‐Dateien als auch mit vom Optimizer erzeugten ausführbaren Dateien arbeitet. Das Installation‐Toolkit dient zur Generierung von Paketen für die Zielplattform. Vertrieben wird das Produkt in zwei Variationen: Standard und Professional. Eine Übersicht über die Funktionalität und den Unterschied befindet sich auf [ex05]. 7
Abbildung 2: JET Oberfläche
8
Installation
Durch eine Zertifizierung bei SUN Microsystems konnte die JAVA‐API in das Produkt von Excelsior implementiert werden. Während in älteren Versionen die Installation mit der Aufbereitung der JVM Basisbibliotheken abgeschlossen wurde, ist dies ab der Version 4.0 nicht mehr zwingend erforderlich. Sollen jedoch verschiedene Laufzeitumgebungen verwaltet werden, ist eine Aufbereitung notwendig, die circa eine Stunde Zeit in Anspruch nimmt. Die Verwaltung der einzelnen Laufzeitumgebungen geschieht im Profil‐Manager. Bei der Installation muss kein eigenes Profil mehr angelegt werden, dies erklärt den erheblichen Zeitaufwand. Die neueste Version 4.0 unterstützt nur noch die Laufzeitumgebung J2SE 5.0. Eine Version, die J2SE 1.4.2 unterstützt, wird noch vertrieben, jedoch nicht mehr weiterentwickelt. Aufgrund der Erweiterung der Lizenzbestimmungen von SUN Microsystems ist es nicht mehr erlaubt, Teile der JAVA API gegen Teile aus dem Excelsior Set auszutauschen. Dies bedeutet, dass keine ausführbare Datei mehr erstellt werden kann, die alle zur Ausführung notwendigen Teile enthält. Jedoch ist es möglich ein Verzeichnis zu erstellen, das alle Komponenten zur Ausführung enthält. Funktionalität im Vergleich zu GCJ
GCJ
JET
Übersetzt freie Bibliotheken:
SWT
;
;
Übersetzt kommerzielle
Bibliotheken: AWT
–
;
Tabelle 2: Funktionsumfang: GCJ und JET im Vergleich
Während GCJ eine gezielte Auswahl von Möglichkeiten für die Optimierung bietet, sind bei JET stets alle Optimierungsmöglichkeiten aktiviert. Die Entwickler von JET begründen dies folgendermaßen: Nur beim Durchlaufen aller Optimierungsroutinen kann gewährleistet werden, dass eine fehlerfreie Compilerübersetzung stattgefunden hat. 9
Die Vorgehensweise im Überblick
Bei dem Compiler und Linker handelt es sich um reine Konsolenanwendungen. Das Control Panel ist deren grafische Komponente zur Bedienung. Hier geschieht die Zusammenstellung von Projekten, die Einbindung von Sprachpaketen und Bildern, die Übersetzung von Paketen sowie die Zuweisung des Speicherbedarfs. Für das Kompilieren wird der Bytecode ausgewählt und der JET‐Compiler erzeugt nativen Maschinencode. Der Importscanner überprüft das Projekt auf Konflikte. Nach jedem Übersetzungsvorgang werden zwei Skripte erzeugt. Das erste dient zur Ausführung, das zweite führt die Übersetzung auf der Kommandozeile ohne GUI durch. Der JET‐Compiler ist dabei als nachgelagerter Prozess gedacht. Der Hersteller empfiehlt die konventionelle Vorgehensweise der Entwicklung: Entwicklung und Test des plattformunabhängigen Programmcodes. Die Skripte lassen sich also nicht mit einer make‐Datei vergleichen. Die Programmteile, die nicht in nativen Code übersetzbar sind, werden vom Just‐in‐time Compiler dynamisch ausgeführt. Dabei werden kommerzielle Bibliotheken im Bytecode verwendet und der Rest des Programms wird im Maschinencode ausgeführt. Erzeugt werden wahlweise ausführbare Dateien, dynamische Bibliotheken oder Windows‐Services. Verbesserung des Laufzeitverhaltens
Die Motivation des JET ist die Tatsache, dass alle am Markt erhältlichen Optimierungswerkzeuge ihre Verbesserungen der Laufzeit während der Ausführung des Programms, also zur Laufzeit, durchführen. Excelsior vertreibt einen statischen Compiler, der nativen Maschinencode erzeugt. Dies findet im Ahead‐Of‐Time Verfahren statt, also vor der Ausführung. Dadurch entsteht hochoptimierter Code, der vergleichbar mit Code von C/C++ sein soll. •
•
•
Durch das Ahead‐Of‐Time Verfahren lassen sich bessere Optimierungsergebnisse erzielen. Optimierungen erfordern erheblichen Prozessor‐ und Speicherbedarf, welcher während der Laufzeit jedoch nicht zur Verfügung steht. Im Vergleich zur HOTSPOT‐Optimierung, bei der lediglich oft verwendete Methoden nativ kompiliert werden, versucht der JET‐
Compiler alle Methoden in Maschinencode zu übersetzen. Wird z.B. eine ausführbare Datei erzeugt, ist es möglich, dass überhaupt keine Bytecode‐Interpretation mehr erforderlich ist. 10
Schutz vor Dekompilierung
„Aus einer class‐Datei lesbaren Code zu erzeugen ist trivial.“ Aufgrund der großen Nachteile eines Obfusators besteht große Nachfrage nach einer Möglichkeit nicht rücktransformierbaren Maschinencode zu erzeugen. Ist Code, der durch einen Obfuscator erzeugt wurde, noch relativ leicht rücktransformierbar, erfordert das Disassembling von nativem Maschinencode einen hohen Zeit‐ und Kostenaufwand. Aus diesem Assembler‐Code hochwertigen JAVA‐Programmcode zu erzeugen, scheint fast unmöglich bzw. unverhältnismäßig zu dem Aufwand einer etwaigen Neuimplementierung. Auslieferung der Anwendung
Der JET erzeugt komplett selbstentpackende Installationsdateien. Diese Dateien erfordern keine vorinstallierten Komponenten auf einem Zielsystem. Die Generierung erfolgt vollautomatisch und erfordert keine weiterführenden Schritte, wie etwa das Schreiben von Skripts für die Installation auf dem Zielsystem. Durch den Verzicht auf das Benutzen schon vorinstallierter Komponenten werden Konflikte, die durch den Austausch bzw. Erweiterung dieser Komponenten entstehen können, automatisch ausgeschlossen. Weiterhin sind mit diesem Werkzeug Updates für die erstellten Komponenten realisierbar. Unterstützung verschiedener Sprachen
Das Excelsior‐Werkzeug erlaubt eine Integration von COM‐Objekten, Bibliotheken aus C/C++, FORTRAN, VB, etc. Außerdem können dynamische Bibliotheken erzeugt werden. Somit kann der einzelne Vorteil der verschiedenen Sprachen genutzt werden. Ein Beispiel hierfür wäre eine Anwendung, die als dynamische Bibliothek erzeugt wurde und als API die main‐Methode einer C++‐
Anwendung benutzt. Messergebnisse
Bei nahezu allen Tests fällt die Beschleunigung des Programmstarts auf. Über den Speicherbedarf lassen sich keine genauen Aussagen treffen. Er unterscheidet sich von Test zu Test sehr stark. 11
Benchmark
JET
GCJ
Bytecode
Nicht kompilierbar
14 s
Programmstart mit leerem Cache
Polygraph
4s
Programmstart mit der Anwendung im Cache
Polygraph
2s
Nicht kompilierbar
2s
Nicht kompilierbar
49,373 KByte
Speicherbedarf
SwingSet
43,740 KByte
Ausführungsgeschwindigkeit
Ackermann, N=8
47 ms
78 ms
78 ms
Eratosthenes, N =900
94 ms
157 ms
147 ms
Fibonacci, N=32
62 ms
141 ms
109 ms
Heapsort, N=80000
109 ms
47 ms
47 ms
Lists, N=16
187 ms
516 ms
844 ms
63 ms
78 ms
93 ms
Matrix, N=300
Tabelle 3: Leistungsumfang: GCJ und JET im Vergleich
12
3.1.2
GNU Compiler für JAVA (GCJ)
Der GCJ Compiler ist ein Frontend für die GNU Compiler Collection (GCC). Für Windows steht er als Bestandteil des MinGW zur Verfügung. Er kann JAVA‐
Quellcode in Bytecode übersetzen und Bytecode in nativen Maschinencode, also auch JAVA‐Quellcode in nativen Maschinencode. Er benutzt hierzu die Klassenbibliothek libgcj. Dort versuchen Open‐Source‐Programmierer, die kommerziellen Bibliotheken der Sun JRE nachzubilden. Es existiert ein Schwesterprojekt GNU Classpath, das darauf abzielt, freie Klassenbibliotheken für freie virtuelle Maschinen und Compiler zur Verfügung zu stellen. Die beiden Projekte werden momentan zusammengeführt. Da GCJ ein Frontend für GCC ist, nutzt er im Wesentlichen auch dessen eigentlichen Compiler, Linker und Debugger. Überdies verfügt er über einen Bytecode‐Interpreter, mit dem es möglich ist, Klassendateien dynamisch zu laden. Somit sind auch Programme lauffähig, die sowohl aus nativen Klassen als auch aus Bytecode‐Klassen bestehen. Befindet sich eine vom Programm benötigte Klasse nicht in den Programmdateien oder den gelinkten Shared Libraries, sucht der Classloader im Classpath nach einer entsprechenden Bytecode‐Version. Der Bytecode‐
Interpreter kann auch unabhängig vom Compiler aufgerufen werden, um reine Bytecode‐Programme zu starten. Beim Aufruf des Compilers muss die Klasse angegeben werden, die die auszuführende main‐Methode enthält: gcj --main=HelloWorld –o HelloWorld HelloWorld.JAVA
Das Programm wird dann als ausführbare Datei gestartet: ./HelloWorld
Sollen Bytecode‐Dateien erstellt werden, muss die Option –C angegeben werden: gcj –C HelloWorld.JAVA
Diese kann dann interpretiert werden: gij HelloWorld
13
3.2
Optimierungen
Um der Forderung nach Plattformunabhängigkeit zu genügen und trotzdem ein besseres Laufzeit‐Verhalten zu erzielen, muss die virtuelle Maschine optimiert werden. Dies geschieht im Wesentlichen, indem das auszuführende Programm von der virtuellen Maschine zur Laufzeit kompiliert wird. Es gibt hierzu verschiedene Ideen: •
Die Just‐in‐time Kompilierung: Es werden zur Laufzeit immer nur diejenigen Programmteile kompiliert, die gerade benötigt werden. Diese Technik ist seit einiger Zeit Standard bei virtuellen Maschinen für JAVA. •
Die Hotspot Kompilierung: Als eine Erweiterung der Just‐in‐time Kompilierung werden zur Laufzeit nur diejenigen Programmteile in nativen Code übersetzt, für die sich dieser Aufwand auch lohnt. Die Hotspot Kompilierung ermöglicht eine wesentlich „aggressivere“ Optimierung des Codes als beispielsweise die Ahead‐of‐time Kompilierung. 3.2.1
Die Just in Time Kompilierung
Die Motivation der Just‐In‐Time Kompilierung ist die Verbesserung der Laufzeit von Programmen, die in Bytecode vorliegen. Die JAVA Virtual Machine enthält einen JIT‐Compiler. Der Bytecode des Programms wird nicht mehr vom Laufzeitsystem interpretiert, sondern zuerst in nativen Code kompiliert und dann ausgeführt. Die Kompilierung erfolgt dynamisch zur Laufzeit und methodenweise. Da Optimierungen des Maschinencodes sehr zeitaufwändig sind und diese zur Laufzeit erfolgen müssen, wird auf diese weitestgehend verzichtet, um die durch die native Ausführung des Codes gewonnene Laufzeit nicht wieder durch die zur Optimierung benötigte Zeit zu verlieren. Allerdings bietet die Tatsache, dynamisch Code zu erzeugen auch wesentlich einfachere Möglichkeiten, Code zu optimieren. Der Compiler kann zum Beispiel erkennen, dass sich eine Variable über eine hohe Anzahl von Schleifendurchläufen nicht ändert. Die Variable wird deshalb als Konstante kompiliert. Wird diese „Konstante verändert“, erkennt dies der Compiler und kompiliert die Methode neu. Dies kann unter Umständen einen erheblichen Laufzeitvorteil mit sich bringen, so dass just‐in‐time erzeugter Code mitunter sogar schneller sein kann als komplett nativ kompilierter. 14
Jeder Just‐in‐Time Compiler muss naturgemäß eine komplette Laufzeitumgebung mitbringen. Dies führt auch zu der Ansicht, dass vielmehr das Laufzeitsystem einen Just‐in‐time Compiler mitbringt. Die Laufzeitumgebung muss nun das Laden, Linken und Initialisieren der gewünschten Klassen übernehmen. Der Compiler lädt zunächst beim Start die Klasse mit der main‐Methode und löst deren Referenzen auf. Um die Klassen zu linken, baut der Compiler Tabellen mit Zeigern auf Routinen, die bei Bedarf den Compiler starten oder direkt auf die native Methode zugreifen. Bevor die Klasse nun in den Zustand „active use“ (Aufruf von Methoden der Klasse und Zugriff auf Attribute erlaubt) wechseln darf, müssen, wie bei der Interpretierung auch, die statischen Elemente der Klasse initialisiert werden. Natürlich muss der Compiler auch eine Garbage Collection implementieren. Diese ist je nach Compiler unterschiedlich umgesetzt. Das Problem bei der Übersetzung von Bytecode in nativen Maschinencode ist, dass es sich bei der virtuellen Maschine um eine Stackmaschine handelt. Die meisten gebräuchlichen Rechner sind Registermaschinen. Bei einer Stackmaschine wird immer entsprechend der Instruktionen ein Stapel auf‐ und abgebaut. So werden zum Beispiel bei einer Addition von zwei Zahlen mit anschließender Speicherung des Ergebnisses, die beiden Zahlen zuerst auf dem Stack abgelegt, dann wieder entnommen und addiert und das Ergebnis wieder auf dem Stack abgelegt. Dies unterscheidet sich grundsätzlich von der Arbeitsweise einer Registermaschine, die die einzelnen Register direkt auslesen, addieren und das Ergebnis wieder in einem Register speichern kann. Eine direkte Übersetzung der Instruktionen des Bytecode liefert deshalb nur unbefriedigende Ergebnisse. Um doch effizienten Code zu erhalten, wird nun der Bytecode zunächst in eine Zwischensprache übersetzt, die theoretisch unendlich viele Register implementiert. Eine detaillierte Erläuterung der Umsetzung von Bytecode‐Befehlen in Assemblerbefehle ist sehr komplex und erfordert tief greifende Kenntnisse über die Hardwarearchitektur der Zielplattform und soll an dieser Stelle nicht näher erläutert werden. 15
3.2.2
Die Hotspot-Optimierung
Die Strategie der Hotspot‐Optimierung basiert auf einer nativen Kompilierung während der Laufzeit, von für die Laufzeit sinnvollen Programmteilen. Diese Art der Kompilierung erfordert grundsätzlich mehr Zeit, als eine herkömmliche Kompilierungsstrategie. Durch die adaptive Auswahl der zu kompilierenden Programmteile überwiegt jedoch der Vorteil des damit schnelleren Programmcodes. Die Kompilierung erfolgt in einem eigenen Thread, der beispielsweise auf Mehrprozessorsystemen auf einer eigenen, gesonderten CPU durchgeführt wird. Die kleinste Kompiliereinheit ist die Methode. Abbildung 3: Hotspot Optimierung
Der Kompilierungsvorgang lässt sich in die folgenden drei Schritte unterteilen, welche nacheinander ablaufen: Erkennung, Kompilierung und Übersetzung. Erkennung
Eine Methode kann nativ oder in Bytecode übersetzt werden. Die JVM trifft diese Entscheidung anhand einer Richtlinie. Diese Richtlinie ist konfigurierbar, was in der Praxis jedoch selten angewandt wird. Zur Entscheidungsfindung werden zwei Schwellwerte herangezogen: 1. Wie oft wird eine Methode aufgerufen bzw. 2. wie oft wird eine Schleife dieser Methode durchlaufen. 16
Kompilierung
Diejenigen Methoden, welche im Schritt Erkennung für die Kompilierung ausgewählt wurden, werden jetzt in eine Abfolge für die Abarbeitung gebracht. Der sog. „Compile Broker“ legt die Reihenfolge, das Durchführen sowie die Überwachung der Abarbeitung fest. Je nach Art der Anwendung stehen verschiedene Compiler zur Verfügung. Der „Client Compiler“ ist für kurzlebige Anwendungen konzipiert und geht dementsprechend weniger aggressiv bei der Optimierung vor, um die Laufzeit nicht durch die Optimierungszeit noch zu erhöhen. Der „Server Compiler“ bietet mehr Optimierung. Diese zahlt sich aber nur bei lange laufenden Anwendungen aus. Die JVM kennt immer den gesamten geladenen Bytecode und dessen Semantik und kann deshalb Entscheidungen zur Optimierung treffen. Die einzelnen Phasen der Abarbeitung
1.
2.
3.
4.
5.
6.
7.
Parsen des Bytecodes: Hier findet die Zerlegung und syntaktische Prüfung der Eingabedaten sowie die Generierung eines Ableitungsbaumes statt Löschen nicht genutzter Knoten Auswählen der Regeln Optimierung des Ableitungsbaumes Grafische Darstellung der Kontrollstrukturen Register Allokation Optimierung auf Assemblerebene 17
Übersetzung
Der erzeugte Maschinencode wird zusammen mit dem entsprechenden Bytecode in speziellen Dateien abgelegt. In diesen Dateien werden die jeweiligen Assemblerbefehle, die Größe von Registern, die Adressierungen etc. abstrakt beschrieben. Alle Assemblerbefehle werden zusammen mit einer Referenz auf dem entsprechenden Bytecode in einem Zwischenspeicher abgelegt. Diese Referenzen werden dann benötigt, wenn während der Laufzeit Methoden, die nativ kompiliert wurden, durch neue ersetzt werden. Der erzeugte Maschinencode enthält kleine Codefragmente, mit denen er mit der JVM kommunizieren kann. Während der Laufzeit kann jetzt die nativ kompilierte Methode gegen die Methode in Bytecode ausgetauscht werden. Dazu ist eine Synchronisation der JVM mit dem Prozessor des ausführenden Systems notwendig. Hierfür gibt es spezielle Speicherstellen im Bytecode. An diesen Speicherstellen, und nur an diesen, wird der Bytecode gegen den kompilierten Teil ausgetauscht. Garbage Collection
Die Hotspot‐Technologie verfügt über eine optimierte Garbage Collection. Der Speicher wird hierzu in mehrere Bereiche unterteilt. Je nach Lebensdauer der Objekte werden sie in die verschiedenen Bereiche verschoben. Der Bereich der kurzlebigen Objekte wird häufiger auf nicht referenzierte Objekte gescannt als der Bereich langlebiger Objekte. Mit dem Wissen, dass rund 95 Prozent aller Objekte kurzlebig sind, ergibt sich daraus ein Laufzeitvorteil. 18
3.2.3
Benchmark
Wir versuchen nun, anhand einiger Benchmarks die Leistungsfähigkeit der einzelnen Technologien zu messen. Überprüft werden einfache mathematische Operationen und Dateizugriffe. Die Operationen umfassen 32‐bit Integer‐, 64‐bit Long‐Integer‐, 64‐bit Fließkomma‐, und 64‐bit Trigonometrieoperationen. Da keine GUI‐Tests durchgeführt werden, ist das Benchmark für Anwendungen mit grafischer Benutzeroberfläche nicht unbedingt repräsentativ. Gegenübergestellt werden sowohl verschiedene Sprachen als auch die unterschiedlichen Technologien im JAVA‐Umfeld. Die verschiedenen Quellcodes haben wir dem bereits erwähnten Artikel [os05] entnommen. Die während des Benchmarks zu erledigenden Aufgaben wurden in den Sprachen C, C++, C# und JAVA verfasst. Unter Windows XP Pro SP2 wurden die Programme in den Sprachen C, C++ und C# gestestet und das JAVA‐Programm einmal als Bytecode‐Programm mit JRE 1.5 und als native Anwendung mit Excelsior JET. Die C‐Programme wurden mit dem Microsoft C Compiler übersetzt. Unter Ubuntu Linux 5.10 wurde die JAVA‐Anwendung angepasst (Dateizugriffe) und einmal mit GCJ zu einer nativen Anwendung und zu Bytecode kompiliert. Das Bytecodeprogramm wurde mit GIJ getestet. Um die Anzahl der Läufe überschaubar zu halten, werden den Compilern keine optimierenden Parameter mitgegeben. Die Benchmarks wurden alle auf derselben Maschine ausgeführt (HP Notebook NX8220 PG804ET). Die Quelltexte können auf der Seite [os05] eingesehen werden. 32-bit
Integer
64-bit
Integer
64-bit Float
64-bit Trig
Dateizugriff
Insgesamt
C
4485
8468
21000
4735
7797
46485
C++
4422
8469
21344
4671
7141
46047
C#
6921
27031
20937
2937
11062
68888
JRE1.5/Win
5203
8407
18578
38688
8219
79095
JRE1.5/Lin
5591
7984
24290
34288
3306
75459
JET
4641
11079
15063
35547
6234
72564
GCJ
8125
8935
16692
23966
6799
64517
GIJ
25870
95723
79623
28742
7775
237744
Tabelle 4: Benchmarkergebnisse
19
Laufzeiten
120000
100000
32-bit Integer
Zeit [ms
80000
64-bit Integer
64-bit Float
60000
64-bit Trig
Dateizugriff
40000
Insgesamt
20000
G
IJ
G
C
J
JE
T
JR
E1
.5
/L
in
C#
JR
E1
.5
/W
in
C+
+
C
0
Sprache
Abbildung 4: Benchmarkergebnisse
Bei Betrachtung der Ergebnisse fällt zunächst auf, dass die nativ kompilierten JAVA‐Programme nicht wesentlich schneller laufen, als ihre von der JVM interpretierten Pendants. Dies lässt den Schluss zu, dass die JVM inzwischen so weit optimiert wurde, dass kaum noch Unterschiede zu nativ kompilierten Programmen festzustellen sind. Dies bestätigt auch das Ergebnis des Interpreters GIJ, der mit weniger technischen Raffinessen aufwartet als die Sun JRE. Des Weiteren fällt auf, dass sogar der Laufzeitnachteil gegenüber den hardwarenahen Programmiersprachen beachtlich geschrumpft ist. Der dennoch vorhandene Laufzeitnachteil lässt sich nur durch die Sprache JAVA als solche erklären, deren Features, die das Programmieren in der Sprache so angenehm machen, ihren Tribut an die Laufzeit zollen. Die zahlreichen Prüfungen, die ein JAVA‐Programm ohne Zutun des Programmierers ausführt, schlagen sich hier nieder. 20
4
JAVA Decompiler
Unter Dekompilierung versteht man den Übersetzungsvorgang, der aus kompiliertem Code wieder Quellcode erzeugt. Der Decompiler verdreht also die Arbeitsweise eines Compilers, welcher aus dem Quellcode eine Klassendatei erzeugt. 4.1
Gefahr und Nutzen
Die Dekompilierung kann unter Umständen als Diebstahl geistigen Eigentums betrachtet werden. Wenn man einen dekompilierten Quelltext betrachtet fallen zunächst die verloren gegangenen Kommentare sowie der Verlust der lokalen Variablennamen auf. Diesen Quelltext zu verstehen kann unter Umständen sehr schwer fallen. Der Aufwand einen fremden Quelltext zu verstehen und eventuelle Änderungen daran vorzunehmen ist beachtlich. Wurde der Quelltext dann noch mittels eines Obfuscators nachhaltig verändert, schränkt sich die Lesbarkeit weiter ein. Je nach Programm könnte eine Neuimplementierung erheblich weniger Aufwand bedeuten. 4.2
Beispiel für die Anwendung
Eine Klimaanlage, deren Steuerung in JAVA implementiert wurde, verweigert ihren Dienst. Aufgrund erster Untersuchungen steht fest, dass das Problem in der Software der Steuerung zu suchen ist. Die Firma, welche die Software entwickelte, musste Konkurs anmelden. Die Dokumentation ist unauffindbar. Mittels Dekompilierung kann nun ein Techniker vor Ort an der Maschine den Bytecode in Quelltext zurückführen und eine Analyse des Problems vornehmen. Dies kann erheblichen Zeit‐ und Kostenaufwand sparen. 4.3
Vorgehensweise
Analog zu einem Kompiliervorgang kann ein Dekompiliervorgang in verschiedene Phasen unterteilt werden. Diese Phasen können folgendermaßen eingeteilt werden: 1.
2.
Bytecode wird eingelesen Generierung einer Zwischendarstellung 21
3.
4.
5.
6.
Kontrollflussgraphen werden erzeugt Analyse des Datenfluss Analyse des Kontrollfluss Erstellung des Quelltextes In der Praxis werden Phase 1 und Phase 2 zusammengefasst. Während des Einlesens wird direkt die Generierung der Zwischendarstellung durchgeführt. Diese Generierung erfolgt in Form eines Baumes. Diese Baumdarstellung könnte so aussehen: Strukturbaum für die Bytecodebefehle:
iload_2
bipush_2
imul
istore_3
L10 = L11 * 2
Abbildung 5: Dekompilierung Phase 1 und 2
Für Analyse der Programmstruktur ist die Generierung der Kontrollflussgraphen in Phase 3 erforderlich. Kontrollflussgraphen können folgendermaßen dargestellt werden: 22
Darstellung einer if-then-else Konstruktion
Darstellung einer while Konstruktion
Abbildung 6: Dekompilierung Phase 3
In der vierten Phase, der Datenflussanalyse, wird versucht, komplexe Konstrukte einer Hochsprache zu analysieren, welche keine direkte Entsprechung im Bytecode finden. Hierzu zählen z.B. die Initialisierung von Feldern. Auch muss in dieser Phase eine Wiederherstellung der Typen short, byte und boolean aus den Integer‐Werten des Bytecodes erfolgen. Wird keine Typrekonstruktion vorgenommen, entstehen Typfehler, wie folgendes Beispiel anschaulich darstellt: Quelltext
Bytecode
boolean gleich(short a,
short b)
{
return a == b;
} method boolean
gleich(short, short)
{
0 iload_1
1 iload_2
2 if_icmpeq 7
5 iconst_0
6 ireturn
7 iconst_1
8 ireturn
} Dekompiliert
Boolean gleich(short x,
short y)
{
if (x != y)
return 0;
else
return 1;
}
Abbildung 7: Dekompilierung - Typrekontruktionen
23
Die fünfte Phase, die Kontrollflussanalyse, erzeugt aus den zuvor generierten Kontrollflussgraphen entsprechende Kontrollstrukturen. Hier können folgende Konflikte entstehen: •
Verwandte Kontrollstrukturen werden auf gleiche Kontrollgraphen abgebildet. • Durch einen stark optimierenden Compiler entstehen zum Teil Kontrollgraphen, die mit keiner Kontrollstruktur einer Hochsprache korrespondieren. Eine Lösung der Konflikte kann z.B. durch die Darstellung einer for‐ durch eine while‐Schleife bzw. die Umsetzung eines Sprungbefehls durch eine switch‐case‐
Konstruktion geschehen. Quelltext Bytecode for (z=0; z<v; z++)
{
sum += z;
}
2
3
4
7
8
9
10
11
14
15
16
iconst_0
istore_2
goto 14
ifload_3
iload_2
iadd
istore_3
iinc 2 1
iload_2
iload_1
if_icmplt 7
while(z<v)
{
sum += z;
z++;
}
19
20
21
24
25
26
27
28
31
32
33
iconst_0
istore_2
goto_31
iload_3
iload_2
iadd
istore_3
iinc 2 1
iload_2
iload_1
if_icmplt 24
Abbildung 8: Dekompilierung - Korrespondierende Schleifenkonstruktionen
24
4.4
Praxisbeispiel
Der in Abbildung 9 dargestellte Originalquelltext wurde zunächst kompiliert und später mittels eines Decompilers zurückübersetzt. Abbildung 10 zeigt das Resultat der Rückführung. Sehr gut zu sehen sind das Fehlen der Kommentare, das explizite Anlegen des Standardkonstruktors sowie die Veränderung der Kontrollstrukturen. Original Quelltext
/* Fibonacci und der Stein der Weisen*/
public class Fibonacci
{
private static int dummy=0;
// REKURSION
public static int fib(int n)
{
if(n == 0)
return 0;
else if(n == 1)
return 1;
else
{
return fib(n - 2) + fib(n - 1);
}
}
//HAUPTPROGRAMM
public static void main _
(String[] args)
{
System.out.println("Fibonacci von 10:" + fib(10));
}
}
Abbildung 9: Dekompilierung, Praxisbeispiel: Originalquelltext
25
Dekompilierter Quelltet
import JAVA.io.PrintStream;
public class Fibonacci
{
private static int dummy = 0;
public Fibonacci()
{
}
public static int fib(int i)
{
if(i == 0)
{
return 0;
}
if(i == 1)
{
return 1;
} else
{
return fib(i - 2) + fib(i - 1);
}
}
public static void main(String args[])
{
System.out.println((new StringBuilder()).append("Fibonacci von
10:").append(fib(10)).toString());
}
} Abbildung 10: Dekompilierung, Praxisbeispiel: Dekompilierter Quelltext
26
Abbildung 11: Oberfläche eines Decompilers
Das Beispiel wurde mit der Software cavaj in der Version 1.1 dekompiliert. 27
5
Obfuscating
Unter Obfuscating versteht man das Verändern (Verschleierung) von Quellcode. Erreicht werden soll dadurch vor allem ein Schutz vor Dekompilierung. JAVA‐
Bytecode lässt sich mit recht einfachen Mitteln dekompilieren, so dass ein Schutz von Know‐how bzw. von nicht für die Allgemeinheit bestimmten Programmcodes nicht gewährleistet werden kann. Mittels eines Obfuscators lassen sich alle Methoden‐ und Variablennamen durch nicht aussagekräftige Namen ersetzen. Dadurch werden ein besserer Schutz, sowie eine Verkleinerung der Applikationsgröße möglich. Letzteres kann z.B. die Ladezeit eines Applets entscheidend beeinflussen. Ein „verschleierter“ Bytecode nur schwer wieder in lesbaren Programmcode umgewandelt werden. 5.1
Nachteile der Verschleierung
•
•
•
Das Verändern von Kontrollstrukturen kann durch unvorteilhafte Umwandlungen (im Sinne der Laufzeit) in z.B. einer Schleifenbedingung auch zu erheblich längeren Laufzeiten führen. Beim Aufrufen der Reflection‐API werden die statischen Methodennamen durch den Obfuscator verändert. Ein Aufruf der dann unbekannten Methode führt zum Laufzeitfehler. Werden in späteren JAVA‐Versionen weitere bzw. neue Konventionen beschlossen wie JAVA‐Code auszusehen hat, wird sich ein veränderter Code schwer tun, diesen meist restriktiven Richtlinien zu genügen.
28
5.2
Abstraktion von Daten
Die Abstaktion von Daten wird in der Tabelle 5 veranschaulicht. Diese Abstraktion ist als Beispiel zu betrachten, d.h. es wurde ohne die Zuhilfenahme eines Obfuscators erstellt. Das Beispiel zeigt, wie aus selbsterklärendem Programmcode „verschleierter“ Quelltext wird, ohne dass sich das Resultat der Ausführung unterscheidet. Vorher
Nachher
int i = 1;
int i=11;
while (i < 8003) {
... A[(i-3)/8] ...;
i += 8;
}
while (i < 1000) {
... A[i] ...;
i ++;
}
Tabelle 5: Obfuscator - Vorher und Nachher: Daten
5.3
Abstraktion von Kontrollflüssen
Die Abstraktion der Kontrollflüsse ist in der Tabelle 6 dargestellt. Im Beispiel wird die Abbruchbedingung der Schleife um einen in diesem Kontext wirkungslosen Ausdruck ergänzt. Vorher
Nachher
int i = 1;
while (i < 1000) {
...
i ++;
}
int i = 1;
while ((i < 1000) || (i % 1000 ==0))
{
...
i ++;
}
Tabelle 6: Obfuscator - Vorher und Nachher: Kontrollflüsse
29
6
Fazit und Ausblick
Die Entscheidung, ein JAVA‐Programm in nativen Maschinencode zu kompilieren unterliegt verschiedensten Kriterien. Zum einen haben die Messergebnisse gezeigt, dass eine native Kompilierung nicht immer erhebliche Geschwindigkeitsvorteile mit sich bringt. Spielt z.B. die Dateigröße eine Rolle, ist von einer nativen Kompilierung eher abzusehen. Weiterhin sollte in jedem Fall vor einer nativen Kompilierung die entwickelte Anwendung zuerst in der Bytecode Variante entwickelt und getestet werden. Die native Kompilierung muss immer als nachgelagerter, gesonderter Prozess behandelt werden. Sie bietet einen erheblich besseren Schutz vor Dekompilierung als die oft umstrittene Verschleierung des Quelltextes durch einen sogenannten Obfuscator bzw. Scrambler. Sollen Programme entwickelt werden, die häufig hardwarenahe Operationen wie Bitmanipulationen ausführen, sollte von einer Programmierung in JAVA abgeraten werden. Jedoch liegen die Stärken von JAVA auch in einem anderen Bereich. Betrachtet man etwa die Netzwerkprogrammierung im Client‐Server Bereich ist JAVA nahezu unschlagbar. Für die Zukunft stellt sich die Frage, in wie weit bewährte Strategien wie etwa die HOTSPOT‐Optimierung noch verbessert werden können bzw. ob die Möglichkeit besteht dass JAVA‐Programme in allen Belangen mit C++‐Programmen verglichen werden können. Zumindest konnte verdeutlicht werden, dass eben nicht die Interpretation des Quelltextes für das schlechtere Laufzeitverhalten, sondern eher Dinge wie die Garbage Collection, die verschiedenen Typprüfungen, etc. dafür verantwortlich sind. Dieser Nachteil kann im Hinblick auf die damit wesentlich einfachere und von Haus aus sicherere Programmierung auch als Vorteil von JAVA gelten. 30
Die gängigen JAVA‐Decompiler erzeugen erstaunlich gut lesbaren Quelltext. Zum Schutz des geistigen Eigentums haben hier Obfuscator ihre Berechtigung. Der schlechte Stil der Syntax könnte jedoch in Zukunft, in der höhere Anforderungen an die Art und Weise der Darstellung des Quelltextes gestellt werden zu Komplikationen bei der Interpretierung führen. Zwar kann durch die geringere Dateigröße z.B. eine kürzere Ladezeit einer Anwendung erreicht werden, jedoch kann sich dieser Laufzeitvorteil durch die schlechte Nachvollziehbarkeit, z.B. eine „verschleierte“ Schleife, schnell zu einem erheblichen Laufzeitnachteil umkehren. Hier ist eine native Kompilierung der elegantere und vor allen Dingen sicherere Weg. Eine native Umsetzung ist aber immer mit erheblichem Aufwand verbunden. Sei es durch den zusätzlichen Aufwand bei eventuell erforderlichen Anpassungen, den finanziellen Kosten oder beidem. In diesem Projekt konnten die Kenntnisse im Bereich der Kompilierung und Dekompilierung vertieft werden. Durch die Durchführung von Benchmarks konnte der Laufzeitunterschied verschiedener, nativ kompilierter Programme von Hochsprachen gezeigt werden. Das Vergleichen von Werkzeugen zur Kompilierung hat den Bedarf dieser Technologien am Markt gezeigt. 31
Literaturverzeichnis
[os04] http://www.osnews.com/story.php?news_id=5602 Christopher W. Cowell‐Shah Nine Language Performance Round‐up Abruf Oktober 2005 [ms98] www‐ti.informatik.uni‐
tuebingen.de/~heim/lehre/proseminar_ss98/marcus/ Marcus Schiesser: Just‐In‐Time Compiler Universität Tübingen Abruf Oktober 2005 [gc05] http://gcc.gnu.org/java/gcj2.html GCC/GCJ Compiler Dokumentation Abruf Oktober 2005 [lm97] http://www.linux‐magazin.de Die virtuelle Java Maschine Linux‐Magazin Ausgabe 05/97 Linux‐Magazin Ausgabe 06/97 Linux‐Magazin Ausgabe 08/97 Linux‐Magazin Ausgabe 09/97 Abruf Oktober 2005 [ex05] http://www.excelsior‐usa.com/home.html Excelsior JET Homepage Abruf Oktober 2005 [ix04] Bernhard Steppan: Nachbrenner – Übersetzung von Java‐Programmen, iX Ausgabe 9/04 [cs04] http://www.cowell‐shah.com/research/benchmark/code Christopher W. Cowell‐Shah Benchmark Codes Abruf Oktober 2005 [cd05] http://www.bysoft.se/sureshot/cavaj/ cavaj Decompiler Homepage Abruf Oktober 2005 32
Abbildungsverzeichnis
Abbildung 1: Laufzeitvergleiche..............................................................................6
Abbildung 2: JET Oberfläche ..................................................................................8
Abbildung 3: Hotspot Optimierung ......................................................................16
Abbildung 4: Benchmarkergebnisse......................................................................20
Abbildung 5: Dekompilierung Phase 1 und 2........................................................22
Abbildung 6: Dekompilierung Phase 3..................................................................23
Abbildung 7: Dekompilierung - Typrekontruktionen .............................................23
Abbildung 8: Dekompilierung - Korrespondierende Schleifenkonstruktionen........24
Abbildung 9: Dekompilierung, Praxisbeispiel: Originalquelltext .............................25
Abbildung 10: Dekompilierung, Praxisbeispiel: Dekompilierter Quelltext...............26
Abbildung 11: Oberfläche eines Decompilers........................................................27
33
Tabellenverzeichnis
Tabelle 1: Laufzeitvergleiche ...................................................................................6
Tabelle 2: Funktionsumfang: GCJ und JET im Vergleich ..........................................9
Tabelle 3: Leistungsumfang: GCJ und JET im Vergleich.........................................12
Tabelle 4: Benchmarkergebnisse...........................................................................19
Tabelle 5: Obfuscator - Vorher und Nachher: Daten .............................................29
Tabelle 6: Obfuscator - Vorher und Nachher: Kontrollflüsse..................................29
34
Herunterladen