Um die Performance des Frameworks mit seinen verschiedenen Konfigurationen messen zu können, reichen bereits bestehende SJF-Fähigkeiten wie z. B. Dynamic Proxy nicht aus. Neben der eigentlichen Performance der Algorithmen sollte noch der Speicherverbrauch , Anzahl der geladenen Objekte, GC Verhalten usw. gemessen werden. Aus dem Grund werde ich hier ein kommerzielles Programm JProbe vorstellen, mit dem das SJF selber getestet wurde. Mit dieser Software ist es möglich, alle oben genannte Features zu testen. Neben JProbe (http://www.sitraka.com/) gibt es noch eine Reihe anderer Tools wie OptimizeIt oder NuMega mit ähnlichem Leistungsumfang.
Beim JProbe handelt es sich um eine Java-Anwendung, innerhalb der beliebige Anwendungen getestet werden können. Die zu prüfende Anwendung wird einfach in der JProbe JVM gestartet. Die modifizierte JVM sammelt alle benötigten Informationen, die dann ausgewertet werden. Um das SJF zu starten, müssen noch einige Einstellungen vorgenommen werden. Das SJF wird dabei mit den File-Properties gestartet, die aus einer Datei z. B. c:/framework.properties gelesen werden. Das Framework wird standardmäßig mit dem com.abien.Starter initialisiert. Aus der Kommandozeile müsste man das SJF so starten: java -Dproperties=file_properties -Dlocation=c:/framework.properties com.abien.Starter . Ähnlich muss auch JProbe konfiguriert werden. Zuerst sollte das ausführbare Objekt im Feld »Class file« angegeben werden. Diese Klasse sollte die statische Methode main(String args[]) deklariert haben. Als »Working directory« wurde hier das Verzeichnis gewählt, in das SJF kompiliert wurde. Somit sind alle Klassen des SJF bekannt.
Der Reiter VM ermöglicht eine Reihe von weiteren Einstellungen, wie JDK-Version, die JVM-Argumente und das Snapshot-Verzeichnis. Die Anforderung des SJF ist mindestens die JDK 1.3 (SJF Vorgabe). Im Textfeld »Arguments« lassen sich die -D-Parameter des SJF festlegen. Nach dieser Konfiguration kann man das SJF Framework starten. Um die Ausgaben des Profilers zu minimieren, sollte man die Filterfunktionalität des Reiters »Performance« aktivieren. Ansonsten werden Unmengen an Daten produziert, die für das Testen von SJF irrelevant sind. Dabei sollte man die SUN-eigenen Packages wie java.* , javax.* , com.sun.* deaktiviert haben. Diese Einstellung ist wichtig, ansonsten würden die Ausgaben dieser Packages dominieren. Ferner haben wir nur bedingten Einfluss auf die Implementierung dieser Klassen, sie werden ja bereits mitgeliefert. Falls man aber in den »Nachforschungen« Problemzonen bereits entdeckt hat, sollte man diese Packages wieder aktivieren, da man auch an diesen Ausgaben interessiert sein könnte. Bestes Beispiel wäre die Ersetzung von langsamen String-Konkatenationen durch die Aufrufe append des StringBuffers ...
|
|
Zuerst untersuchen wir nur die Startphase des Frameworks. Am einfachsten lässt sich das mit leicht modifizierter Klasse com.abien.Starter realisieren. Nach der Initialisierung wird sofort die JVM des Frameworks mit dem System.exit(0) Aufruf beendet. Bereits in der Startphase kann der Speicherverbrauch der Anwendung getraced werden.
|
|
Nach der Sortierung aller Methoden nach Method Time stellen wir fest, dass uns die ersten Objekte unbekannt sind. Dem Methodennamen nach handelt es sich dabei um Character-Byte-Konvertierung. Da diese Klasse nicht aus unserem Projekt stammt, handelt es sich offensichtlich um JDK-Klassen. Die Implementierung der Klasse CharToSingleByte lässt sich deswegen nicht ändern, aber wir können überprüfen, welche SJF-Klasse diese Funktionalität verwendet. Um die Darstellung überschaubarer zu halten, schalten wir in den Grafikmodus um. Hier lassen sich bequem alle vom Root ausgehenden Methodenaufrufe verfolgen. Da die Ausführung der Anwendung durch den Profiler teilweise drastisch verlangsamt wird, ist die Einstellung »CPU Time« des Performance-Reiters wichtig. Dabei wird die Zeit relativ, in CPU-Zyklen, gemessen. Somit sind die Messungen rechnerunabhängig.
|
|
|
|
|
|
Erstaunlicherweise wird die Methode CharToSingleByte.convert innerhalb der Methode println der Klasse FPrintStream aufgerufen. Diese Klasse kann, abhängig von der Konfiguration des SJF, indirekt für die FSytem.out- , FSystem.deb- , FSystem. err- , FSystem.sys- Ausgaben verwendet werden. Wenn wir den Graph weiter nach links verfolgen, sehen wir, dass die statische Methode FPrintStream.println nur vom StartupManager und dem ConfigurableCreator aufgerufen wurde. Wichtig wäre es zu wissen, wie es innerhalb der Methode println ausschaut. Mit JProbe ist auch diese Option möglich, allerdings muss der Pfad zu dem Sourcecode der Anwendung in der Konfiguration des Profilers angegeben werden. Um in den Sourcecode der Methode FPrintStream.println zu schauen, wird der Sourcecode Panel des Profilers aufgerufen.
|
|
Erstaunlicherweise benötigt der Aufruf der Methode this.stream.println(this. format.format(arr).replace(,\n','|')) 68,7 % der Zeit, die in der Methode println() verbracht wurde. Auch der Aufruf this.stream.print(new Date().toString() + " " )) ist relativ »teuer«, er dauert nämlich 28,1 % der Gesamtzeit. Falls man die Performance des FPrintStream steigern möchte, müsste man diese beiden Aufrufe optimieren, sie benötigen nämlich ganze 96,8 % der Gesamtzeit. Dank der modularen Bauweise lassen sich durch den folgenden Konfigurationseintrag die Streams austauschen. Den FFilePrintStream tauschen wir mit dem FNullPrintStream aus, der nur eine leere Implementierung repräsentiert.
com.abien.framework.base.FSystemStreamFactory.
debugStreamType
=null
com.abien.framework.base.FSystemStreamFactory.
standardStreamType
=null
com.abien.framework.base.FSystemStreamFactory.
errorStreamType
=null
com.abien.framework.base.FSystemStreamFactory.
systemStreamType
=null
Nach dem erneuten Starten des Frameworks befindet sich der Aufruf FNullPrintStream.println() nicht einmal unter den Top 20. Erst nach einer Volltextsuche wurde diese Klasse gefunden. Obwohl die Anzahl der Aufrufe konstant blieb (135), verbringt man durchschnittlich 0 % der Gesamtzeit in der Methode println() . Null Prozent bedeutet hier, dass die Messung außerhalb des Messbereichs des Profilers liegt. Die Methodenaufrufe »kosten« natürlich trotzdem ein wenig CPU-Zeit.
Nach der Ersetzung des FFilePrintStreams durch den FNullPrintStream gibt es plötzlich zwei andere Objekte, die uns hier Sorge bereiten: die ConfigurableFactory und den ConfigurableCreator . Zuerst wird die Methode getConfigurable der ConfigurableFactory untersucht.
public abstract ConfigurableFactory ....{
//...
public ConfigurableIF getConfigurable(String className, String name) throws DynamicInstantiationException {
ConfigurableIF retVal = null;
if (className == null)
throw new DynamicInstantiationException(this, "The className should not be null !");
retVal =
ConfigurableCreator.instantiateConfigurable(getPropertiesManager(), className, name);
FSystem.numberOfCreatedConfigurables++;
FSystem.numberOfConfigurables++;
return retVal;
}
}
In dieser Methode verbraucht der Aufruf instantiateConfigurable() des ConfigurableCreator 100% der Gesamtzeit. Diese Methode wird sofort danach untersucht. Wie schon vermutet ist für die schlechte Performance der Methode das dynamische Laden und Erzeugen von Configurable- Instanzen verantwortlich.
|
if (FrameworkContext.getDefaultFrameworkContext().isWrapping() && !(retVal instanceof NotWrappable)) |
Interessant dabei ist, dass die if-Abfrage 15,2 % der Gesamtzeit verbraucht hat. Nach genaueren Untersuchung der geladenen Objekte hat sich gezeigt, dass nur die wenigsten das Marker-Interface com.abien.framework.base.NotWrappable implementieren. Die if-Abfrage wurde somit leicht modifiziert, damit das geladene Objekte zuerst auf den Typ NotWrappable überprüft wird. Da nur wenige Objekte dieses Interface implementieren, wird meistens die isWrapping()- Prüfung gar nicht mehr stattfinden.
|
if (!(retVal instanceof NotWrappable) && FrameworkContext.getDefaultFrameworkContext().isWrapping() ) |
Diese Maßnahme hat die Performance der if-Abfrage nahezu verdoppelt. Nach der Modifikation verbraucht die if-Abfrage lediglich 6,2 % der Zeit.
Als Nächstes wird die Methode getName() der ConfigurableFactory untersucht.
public Name getName() {
String name = this.getPropertiesManager().getPropertiesForObject(this).getProperty(NAME_KEY);
try {
if (name != null)
return new CompositeName(name);
else
return null;
} catch (Exception e) {
return null;
}
}
Die relativ teure Methode getPropertiesForObject() wird hier 24-mal aufgerufen. Die Parameter des Aufrufs bleiben aber dieselben. Die Methode getPropertiesForObject des com.abien.framework.util.PropertiesManager s verbraucht hier ganze 100%.
|
this.getPropertiesManager().getPropertiesForObject(this). |
Da sich die Parameter des getPropertiesForObject nicht ändern, stimmen auch die Rückgabewerte des Aufrufs überein. Die Idee ist es, diese Funktionalität der Methode in den Konstruktor auszulagern. Sie wird dann ein einziges Mal aufgerufen. Nur eine Referenz auf das Objekt javax.naming.Name wird bei jedem Aufruf der Methode zurückgegeben.
Die leicht modifizierte ConfigurableFactory sieht dann so aus:
public abstract class ConfigurableFactory extends ReConfigurable implements ConfigurableFactoryIF, NamingRegistable {
private Name name = null;
public ConfigurableFactory(PropertiesManagerIF propertiesManager) throws RemoteException {
super(propertiesManager);
this.init();
}
public ConfigurableFactory(PropertiesManagerIF propertiesManager, String name) throws RemoteException {
super(propertiesManager, name);
this.init();
}
private void init(){
String tempName = this.getPropertiesManager().getPropertiesForObject(this).getProperty(NAME_KEY);
try {
if (tempName != null)
name = new CompositeName(tempName);
else
name = null;
} catch (Exception e) {
name = null;
}
}
public Name getName() {
return this.name;
}
//..
}
Diese Modifizierung bewirkt, dass nahezu überhaupt keine Prozessorzeit in der Methode getName »verbraten« wird. Stattdessen hat man diese Funktionalität in die private Methode init() ausgelagert. Die Methode init() wird nur einmal, beim Aufruf von einem der Konstruktoren, aufgerufen. Dieser relativ teure Aufruf findet also bei der Initialisierung der ConfigurableFactory statt. Die Rechnung geht hier auf, da die Methode getName mindestens einmal aufgerufen wird. Sie wird insgesamt sogar 24-mal aufgerufen, die private Methode init() nur 9-mal. Wir haben uns also ganze 15 teure Aufrufe gespart.
Nach der Umschaltung von »Cumulative Time « auf »Method Time « finden wir die Methode getPropertiesForObject des PropertiesManager an der ersten Stelle. Nach der genauen Untersuchung des Sourcecodes stellen wir fest, dass sich der Code nicht mehr spürbar optimieren lässt, da man hier »teure« Stringoperationen benötigt. Der Aufruf key.startsWith(prefix) benötigt 50 % der Gesamtzeit, die in der Methode getPropertiesForObject verbracht wurde. Dieser Aufruf ist aber absolut notwendig, da man nur so die benötigte Konfiguration objektbezogen extrahieren kann.
Zugegebenermaßen kann die Optimierung des Startverhaltens des Frameworks in Frage gestellt werden. Da das SJF eher auf dem Server zum Einsatz kommt, ist die Länge der Startphase des Frameworks nahezu egal. Allerdings ermöglichen solche Untersuchungen den Einblick in die internen Abläufe des Servers.
Nach den Untersuchungen des Startverhaltens des Frameworks widmen wir uns der Optimierung der Laufzeitperformance . Dabei müssen folgende zwei Szenarien getestet werden:
|
|
|
|
Zuerst testen wir die lokale Konfiguration des Frameworks. Dazu wird die Klasse com.abien.Starter leicht modifiziert. Es wird eine neue Methode test hinzugefügt.
public class Starter{
public Starter() {
try{
StartupManager startUpManager = new StartupManager();
startUpManager.enterLevel(StartupManager.HIGHEST_LEVEL);
this.test();
}catch(Exception e){}
System.exit(0);
}
private void test() throws Exception{
String test = "aaaaabbbbbcccccdddddeeeeefffffggggghhhhhiiiii";
PersistenceServiceIF hugo = PersistenceServiceFactory.getExistingInstance().getPersistenceService("hugo");
hugo.store("test1","hallo1");
hugo.store("test2","hallo2");
hugo.store("huge",test);
hugo.getValue("test1");
hugo.getValue("test2");
hugo.getValue("huge");
hugo.getValue("nothing");
}
}
In dieser Methode werden einige Testdaten erzeugt, die in PersistenceServiceIF abgelegt werden. Danach werden diese Daten gelesen.
Zuerst wird die aktuelle Implementierung des Interfaces com.abien.framework. persistence.PersistenceServiceIF aus der Konfiguration gelesen und dann instanziiert. Die Konfiguration sieht so aus:
com.abien.framework.persistence.PersistenceServiceFactory.PersistenceServiceType=
➥com.abien.framework.persistence.DurablePersistenceService
com.abien.framework.persistence.PersistenceServiceFactory.
➥PersistenceServiceType.loadOnStartup=2
hugo*com.abien.framework.persistence.DurablePersistenceService.fileName=c:/➥fdatabase1.ser
hugo*com.abien.framework.persistence.DurablePersistenceService.forceUpdate=false
hugo*com.abien.framework.persistence.DurablePersistenceService.jndiName=
➥PersistenceService2
hugo*com.abien.framework.persistence.DurablePersistenceService.loadOnStartup=1
Eine Instanz der Klasse com.abien.framework.persistence.DurablePersistenceService wird erzeugt und im JNDI-Namespace abgelegt. Überraschenderweise stellt sich heraus, dass die Erzeugung der Implementierung über zwei Drittel der Zeit kostet.
|
PersistenceServiceFactory.getExistingInstance().getPersistenceService(»hugo«); |
|
Ferner ist das Speichern beinahe doppelt so aufwändig wie das Lesen der Werte. Dieses Verhalten hängt aber von der Implementierung des Services ab.
public class DurablePersistenceService extends Configurable implements PersistenceServiceIF {
public Object getValue(Object entry) throws StorageException {
FSystem.sys.println("----------------------Trying to find: " + entry.toString());
if (this.storage.containsKey(entry))
FSystem.sys.println("---------------------------------entry found ➥!!!!");
return this.storage.get(entry);
}
public void store(Object entry, Object value) throws StorageException {
synchronized(this.storage) {
try {
this.storage.put(entry, value);
this.serialSupport.storeObject(this.storage, this.fileName, true);
} catch (Exception e) {
throw new StorageException(this, e.toString());
}
FSystem.sys.println("Entry " + entry.toString() + " Value :" + value + ➥" successfull stored !");
}
}
}
Beim Lesen wird lediglich die interne Struktur java.util.Hashtable abgefragt. Dieses Objekt übernimmt hier die Cache-Funktionalität der Implementierung. Um die Konsistenz der Daten zu gewährleisten, wird bei jedem Schreibzugriff der Inhalt des Caches auf die Festplatte geschrieben. Die Instanz der Klasse Hashtable wird serialisiert. Zu diesem Zweck wird die Utility-Klasse com.abien.framework.util.SerializationSupport verwendet, die diese Aufgabe übernimmt.
public class SerializationSupport {
//..
public static synchronized void storeObject( Object o,String fileName,
➥boolean override) throws Exception{
File objectFile = new File( fileName );
if(objectFile.exists() && !override )
throw new Exception("SerialSupport.storeObject(Object object,String path)" + fileName + " already exists !");
ObjectOutputStream stream = new ObjectOutputStream(new FileOutputStream(fileName));
stream.writeObject(o);
stream.flush();
stream.close();
}
Die Implementierung der Methode storeObject ist auch alles andere als optimal. Bei jedem Zugriff wird zuerst eine Instanz der Klasse java.io.File erzeugt. Dieser Vorgang benötigt bereits 27,3 % der Gesamtzeit der Methode storeObject. Fast die ganze verbleibende Zeit wird für das Erzeugen der Instanz java.io.ObjectOutputStream benötigt (72,7%). Da es sich in unserem Beispiel nur um kleine Objekte handelt, fällt das Schreiben ( writeObject ) gar nicht mehr ins Gewicht. Die Implementierung des DurablePersistenceService hat noch ein anderes Problem. Diese Implementierung »cached« alle Einträge in einem Hashtable . Die Schlüssel mit ihren Werten werden einfach im Speicher gehalten. Bei sehr großen Datenmengen kann es zu Speicherproblemen kommen, was sich sogar durch die java.lang.OutOfMemoryException äußern kann. Interessant wäre es hier, nur diesen Cache, ohne die Zugriffe auf den Sekundärspeicher, zu testen. Zu diesem Zweck stellen wir die Konfiguration des Frameworks um:
com.abien.framework.persistence.PersistenceServiceFactory.
persistenceServiceType
=
➥com.abien.framework.persistence.VolatilePersistenceService
An Stelle des DurablePersistenceServices wird com.abien.framework.persistence. VolatilePersistenceService geladen. Dieser kann als Wrapper oder Adapter für die Hashtable gesehen werden. Die Implementierung dieses Services besteht lediglich aus der Umsetzung der PersistenceServiceIF- Aufrufe auf die Hashtable- Aufrufe.
public class VolatilePersistenceService extends Configurable implements PersistenceServiceIF {
private HashMap hashMap = null;
public VolatilePersistenceService(PropertiesManagerIF properties) throws RemoteException{
super(properties);
this.hashMap = new HashMap();
}
public VolatilePersistenceService(PropertiesManagerIF properties, String name) throws Exception {
super(properties, name);
this.hashMap = new HashMap();
}
public Object getValue(Object entry){
return this.hashMap.get(entry);
}
public void store(Object entry, Object value){
this.hashMap.put(entry,value);
}
public void removeAll(){
this.hashMap = new HashMap();
}
public void remove( Object entry ){
this.hashMap.remove(entry);
}
public int size(){ return this.hashMap.size(); }
public boolean containsKey( Object entry ){
return this.hashMap.containsKey(entry);
}
}
Das Laufzeitverhalten ändert sich drastisch. Ganze 88,5 % der Zeit werden für die Erzeugung der Klasse VolatilePersistenceService benötigt .
|
PersistenceServiceFactory.getExistingInstance().getPersistenceService("hugo"); |
|
Die Schreibzugriffe brauchen lediglich 2,3 % der Methodenzeit und die Lesezugriffe sind kaum messbar. Die eigentliche Ursache muss in der Art der Erzeugung des DurablePersistenceService liegen. Seine Konstruktoren sollten aus diesem Grund näher untersucht werden.
public VolatilePersistenceService(PropertiesManagerIF properties) throws ➥RemoteException{
super(properties);
this.hashMap = new HashMap();
}
public VolatilePersistenceService(PropertiesManagerIF properties, String name) ➥throws Exception {
super(properties, name);
this.hashMap = new HashMap();
}
Diese sind aber fast leer, sie beschränken sich lediglich auf die Initialisierung der java.util.HashMap . Warum ist die Instanziierung des VolatilePersistenceServices so teuer? Die Antwort finden wir wieder in der Implementierung. Diesmal ist nicht der VolatilePersistenceService selber, sondern seine Factory der Übeltäter. Der folgende Aufruf PersistenceServiceFactory.getExistingInstance().getPersistenceService("hugo") veranlasst unsere com.abien.framework.peristence.PersistenceServiceFactory zur Erzeugung einer neuen Instanz der Implementierung. Eine neue Instanz der VolatitePersistenceService- Klasse wird also zu Laufzeit, und zwar dynamisch, mit der ConfigurableFactory erzeugt.
public PersistenceServiceIF getPersistenceService() {
return this.persistenceService;
}
public PersistenceServiceIF getPersistenceService(String name) throws DynamicInstantiationException {
return (PersistenceServiceIF)getConfigurableForKey(PERSISTENCE_SERVICE_TYPE,name);
}
In diesem Fall wird die Methode getPersistenceService(String name) der PersistenceServiceFactory aufgerufen. Diese erzeugt eine neue Instanz der aktuellen Implementierung des Interfaces PersistenceServiceIF . Zunächst wird noch der Name (»hugo«) an die Instanz übergeben, damit der PropertiesManager die Gelegenheit erhält, die speziellen Properties für das gerade erzeugte Objekt zu lesen. Im Fall von »hugo« werden die folgende Informationen gelesen.
hugo*com.abien.framework.persistence.VolatilePersistenceService.jndiName=
➥VolatileService
hugo*com.abien.framework.persistence.VolatilePersistenceService.loadOnStartup=1
Wir führen noch mal eine Messung, diesmal ohne eine neue Instanz zu erzeugen, durch.
|
PersistenceServiceFactory.getExistingInstance().getPersistenceService(); |
|
Der Messung nach kostet die Erzeugung der Instanz VolatilePersistenceService rein gar nichts. Diese Aussage ist nur teilweise richtig, da die Instanz bereits beim Hochfahren des Servers erzeugt wurde. Wie wir gerade feststellen, verteilt sich aber die übrige Zeit genauso auf die Methodenaufrufe wie im vorherigen Experiment. Insgesamt wird diese Methode also schneller abgearbeitet. Um die Implementierung DurablePersistenceService und die VolatilePersistenceService miteinander zu vergleichen, wird noch mal die gleiche Messung mit der persistenten Variante durchgeführt.
|
PersistenceServiceFactory.getExistingInstance().getPersistenceService(); |
|
Auch hier wird einfach die bestehende Instanz der Implementierung DurablePersistenceService zurückgegeben. Der erste Schreibzugriff ist allerdings doppelt so teuer wie die folgenden. Dieses Phänomen lässt sich mit Optimierungen des Betriebssystems erklären.
Die relativen Messungen eignen sich hervorragend für Vergleiche der eingesetzten Algorithmen. Bei dem Vergleich der beiden Implementierungen wäre es besser, die echte, nicht die CPU-Zeit zu messen. Wir führen diese Messung also noch mal für die beiden Implementierungen durch. Allerdings wird die Messmethode des JProbe Profilers von CPU Time auf »Elapsed Time « umgeschaltet.
Die Messung zeigt, dass der VolatilePersistenceService bis zu Faktor 25,6 schneller ist als der DurablePersistenceService . Besonders langsam sind im DurablePersistenceService die Schreibzugriffe. Die Lesezugriffe sind aber auch bis zu Faktor 25,6 langsamer als die des VolatilePersistenceServices . Eigentlich sollten hier die Laufzeiten identisch sein, da die »Leselogik« der beiden Implementierungen identisch ist. Woran liegt es? Die Antwort liegt wiederum in der Implementierung des DurablePersistenceServices .
|
FSystem.sys.println("----------------------Trying to find: " + entry.toString()); |
|
|
FSystem.sys.println("---------------------------------entry found !!!!"); |
|
Die Loggingausgaben des DurablePersistenceServices verbrauchen insgesamt fast 90% der Methodenzeit. Die eigentliche Arbeit wird hier in 31,6 Mikrosekunden erledigt. Vergleichsweise verbraucht der VolatilePersistenceService 34,9 Mikrosekunden.
In der lokalen Installation des SJF reichte es aus, nur die Performance der Testanwendung zu messen. Es handelte sich um lokale Methodenaufrufe, so dass man aus der Testanwendung auch die Methoden des Frameworks untersuchen konnte. In der verteilten Installation muss man sowohl die Testanwendung als auch den SJF untersuchen. Die Testanwendung sollte untersucht werden, da es sehr interessant ist, den Kommunikationsaufwand zu bestimmen. Dabei wird das RMI-Protokoll JRMP für die Client-Server Kommunikation gewählt. Die Factories des Namingsdienstes müssen noch dementsprechend konfiguriert werden.
com.abien.framework.naming.NamingManagerFactory.namingManager=com.abien.framework.➥naming.RMINamingManager
com.abien.framework.naming.RMINamingManager.java_naming_factory_initial=com.sun.
➥jndi.rmi.registry.RegistryContextFactory
com.abien.framework.naming.RMINamingManager.java_naming_provider_url=rmi://➥localhost:1099
Nicht uninteressant ist auch der Aufwand, der auf dem Server entsteht. Die ankommenden Methodenaufrufe erreichen zuerst das Skeleton. Dieses ruft dann mittels Reflection die Methoden des Servants auf. Die Rückgabewerte des Servants müssen auch noch entgegengenommen und dann verpackt werden, bevor sie zum Stub geschickt werden.
Das Testprogramm sieht der lokalen Version sehr ähnlich. Lediglich die Erzeugung der Implementierung für das Interfaces PersistenceServiceIF ist unterschiedlich. Da hier für die Kommunikation die RMI-Technologie gewählt wurde, muss zuerst das »Remote«-Objekt referenziert werden. Das geschieht mit dem Aufruf Naming.lookup .
public class TestStarter {
public TestStarter() throws Exception {
this.test();
}
private void test() throws Exception{
String test = "aaaaabbbbbcccccdddddeeeeefffffggggghhhhhiiiii";
PersistenceServiceIF hugo = (PersistenceServiceIF)Naming.lookup("rmi://localhost/PersistenceService");
hugo.store("test1","hallo1");
hugo.store("test2","hallo2");
hugo.store("huge",test);
hugo.getValue("test1");
hugo.getValue("test2");
hugo.getValue("huge");
hugo.getValue("nothing");
}
Die Konfiguration des Servers brauchen wir nicht zu verändern, da hier alle geladenen Objekte standardmäßig im JNDI-Dienst angemeldet werden. Die jeweilige Implementierung des Naming-Managers sorgt dann für die Erzeugung der Registry und die Registrierung aller interessierter Instanzen.
|
|
Zuerst wird das Framework selber untersucht. Dazu wird wieder die Klasse com.abien.Starter im JProbe-Profiler gestartet. Die Testanwendung com.abien.TestStarter wird aus der Kommandozeile gestartet. Die Profilierung des SJF ist in diesem Fall viel komplizierter, da man auch die Systemklassen untersuchen muss. Aus diesem Grund ist die Verwendung der Graph-Darstellung sehr hilfreich, da man leichter die unbekannten Systemklassen (z. B. Skeletons) finden kann. Wie sich herausgestellt hat, benötigt der DurablePersistenceService lediglich ein Drittel der Zeit für die Erledigung der Arbeit. Die verbleibende Zeit wird für die RMI-Funktionalität verwendet.
Ab JDK 1.2 ist die Verwendung von generierten Skeletons nur optional. Standardmäßig wird ein generischer Skeleton verwendet. Da es sich dabei um einen generischen Skeleton handelt, muss er nicht jedes Mal generiert werden. Ein generischer Skeleton muss allerdings für alle Servants gelten. Dieses Feature lässt sich nur mit der Reflection realisieren.
|
|
Der generische Skeleton ist in der Lage, mit allen generierten Stubs zu kommunizieren. Je nach den empfangenen Daten werden dann dementsprechend ausgewählte Methoden der Servants aufgerufen. Da dieser Aufruf mit Reflection realisiert wird, ist er auch ziemlich teuer. In unserer Auswertung wird dieser Aufruf als Method.invoke sichtbar.
Wir generieren nochmals die Stub und Skeletons des
DurablePersistenceService
, diesmal aber mit dem -v1.1
Schalter. Es werden also Stubs und »echte« Skeletons generiert, die nur mit JDK1.1 kompatibel sind. Der Profiler wird noch einmal gestartet, um das Verhalten des Klasse
DurablePersistenceService_Skel
mit dem generischem Skeleton zu vergleichen. Für beide Tests wird die Zeit gemessen, die der
UnicastServerRef
benötigt, um den Servant aufzurufen.
|
|
Den Messergebnissen nach ist die Variante JDK 1.1 performanter als die JDK 1.2. Woran liegt das? Die Antwort liegt im generierten Skeleton. Der JDK 1.1 Skeleton unterscheidet sich nämlich wesentlich vom generischen Skeleton.
// Skeleton class generated by rmic, do not edit.
// Contents subject to change without notice.
package com.abien.framework.persistence;
public final class DurablePersistenceService_Skel
implements java.rmi.server.Skeleton{
com.abien.framework.persistence.DurablePersistenceService server = (com.abien.framework.persistence.DurablePersistenceService) obj;
switch (opnum) {
case 0: // containsKey(Object)
{
java.lang.Object $param_Object_1;
try {
java.io.ObjectInput in = call.getInputStream();
$param_Object_1 = (java.lang.Object) in.readObject();
} catch (java.io.IOException e) {
throw new java.rmi.UnmarshalException("error unmarshalling arguments", e);
} catch (java.lang.ClassNotFoundException e) {
throw new java.rmi.UnmarshalException("error unmarshalling arguments", e);
} finally {
call.releaseInputStream();
}
boolean $result =
server.containsKey($param_Object_1);
try {
java.io.ObjectOutput out = call.getResultStream(true);
out.writeBoolean($result);
} catch (java.io.IOException e) {
throw new java.rmi.MarshalException("error marshalling return", e);
}
break;
}
case 1: // getName()
{
call.releaseInputStream();
javax.naming.Name $result =
server.getName();
try {
java.io.ObjectOutput out = call.getResultStream(true);
out.writeObject($result);
} catch (java.io.IOException e) {
throw new java.rmi.MarshalException("error marshalling return", e);
}
break;
}
case 2: // getValue(Object)
{
java.lang.Object $param_Object_1;
try {
java.io.ObjectInput in = call.getInputStream();
$param_Object_1 = (java.lang.Object) in.readObject();
} catch (java.io.IOException e) {
throw new java.rmi.UnmarshalException("error unmarshalling arguments", e);
} catch (java.lang.ClassNotFoundException e) {
throw new java.rmi.UnmarshalException("error unmarshalling arguments", e);
} finally {
call.releaseInputStream();
}
java.lang.Object $result =
server.getValue($param_Object_1);
Anders als im JDK 1.2 werden die Methoden nicht per Reflection aufgerufen, sondern mit Hilfe der echten Referenz des Servants. Der Aufwand, der bei einem Reflectionaufruf entsteht, fällt hier komplett weg. Fairerweise muss man hier noch die Performancesteigerung der Java 2 Platform insbesondere JDK 1.3 erwähnen. Da JDK 1.3 spürbar schneller ist als die Version JDK 1.1, spielt der generische Aufruf in der Gesamtanwendung fast keine Rolle.
Nach der Untersuchung des Servers werfen wir einen Blick auf unseren Client, also die Anwendung TestStarter . Der Server wird wie gewöhnlich mit JDK 1.3 aus der Kommandozeile gestartet. Zuerst testen wir die Performance des DurablePersistenceService auf der Serverseite.
|
Source |
|
|---|---|
|
(PersistenceServiceIF)Naming.lookup("rmi://localhost/PersistenceService"); |
|
|
(PersistenceServiceIF)Naming.lookup("rmi://localhost/PersistenceService"); |
|
Wie wir gerade festgestellt haben, spielt aus Clientsicht die serverseitige Implementierung des Services fast keine Rolle. Über 80% der Gesamtzeit wird benötigt, um die entfernte Referenz überhaupt zu finden. Dieser Aufruf wäre beispielsweise ein Kandidat für die Auslagerung in den Initialisierungsblock der Komponenten. Das könnte die Methode init des Servlets oder des Framelets übernehmen. Die Geschäftslogikaufrufe sind nicht mehr so teuer. Allerdings sind sie viel aufwändiger als in der lokalen Konfiguration.
Wie wir gerade festgestellt haben, ist in unserem Fall der Mehraufwand des Remote-Aufrufs viel höher als die auf dem Server erledigte Arbeit. Besonders teuer ist die »Implementierungssuche« mit dem Aufruf Naming.lookup . Der Aufruf lookup ist sogar um ein Vielfaches teurer als die Erzeugung der Implementierung in der Startphase des Servers. Die Auslagerung der Funktionalität aus einem gewöhnlichen Client auf einen leistungsfähigen Server würde sich hier nicht lohnen. Erst bei komplizierteren Algorithmen wäre die Auslagerung sinnvoll. Die Implementierungen des Interfaces PersistenceServiceIF spielen hier eine besondere Rolle. Mit diesen ist erst die zentrale Datenhaltung möglich. Um diese Funktionalität nutzen zu können, muss man in diesem Fall leider den Performanceoverhead in Kauf nehmen. Da der Mehraufwand bei jedem Aufruf entsteht, wäre hier der Einsatz des » Value-Object «-Patterns möglich. Dazu müsste man auf der Clientseite die Parameter in einem Container-Objekt zusammenfassen. Zu diesem Zweck eignet sich besonders gut die HashMap- oder Properties- Klasse. Beide sind serialisierbar, was die »per-Value«-Übergabe voraussetzt.
Unsere Anwendung TestStarter hat bis jetzt jeden Wert einzeln gespeichert und dann gelesen.
hugo.store("test1","hallo1");
hugo.store("test2","hallo2");
hugo.store("huge",test);
hugo.getValue("test1");
hugo.getValue("test2");
hugo.getValue("huge");
Da hier immer ein Stub aufgerufen wird, handelt es sich um echte »Remote«-Aufrufe. Diese Aufrufe sind, im Vergleich zu den lokalen Aufrufen, ziemlich teuer. Um die Performance der Anwendung zu steigern, nimmt man hier oft Codierungsoverhead in Kauf. Die »Remote«-Aufrufe sollten vermieden werden. Zu diesem Zweck wird ein »Value Object« definiert, das die Parameter mehrerer Aufrufe sammelt. Dieses »Value Object« wird dann durch einen »Remote«-Aufruf übergeben. Diese Vorgehensweise erfordert aber mehr Handarbeit. Die teuren, »Remote«-Aufrufe werden durch mehrere, aber schnellere lokale Aufrufe ersetzt.