EJB 3.1 Singletons considered harmful
Bevor wir zum Problem vorstoßen, zunächst einige Worte zu Singletons und Pooling. EJB hat vor 3.1 nur ein Modell mit Pooling von Bean angeboten. Der Grund für diese Entscheidung ist, dass man sich so nicht mit Multi-Threading in seiner Anwendung beschäftigen muss. Jeder Thread bekommt seine Instanz aus dem Pool, daher wird jede Instanz nur von einem Thread genutzt. Spring hingegen nutzt Singletons - von jeder Spring Bean gibt es typischerweise nur eine Instanz. Spring bietet auch die Option für Pooling - aber dieses Feature würde ich für den Preis des am wenigsten genutzten Features in Spring nominieren. Ich selber habe es bisher nur einmal produktiv verwendet - und nicht etwa, um Thread-Problemen zu entgehen, sondern um Ressourcen zu poolen. Die Nutzung von Singletons stellt bezüglich den Threads nämlich meistens kein Problem dar - weil die Beans fast immer Thread-Safe sind. Sie erhalten die Daten als Parameter, haben keinen eigenen Zustand und Ressourcen wie die Datenbankverbindung wird von Spring automatisch an den Thread gekoppelt. Dieses Modell wird in vielen Anwendungen genutzt und hat sich als sehr skalierbar erwiesen.
EJB 3.1 bietet nun als Neuerung auch ein Singelton-Modell an. Obwohl es aber mittlerweile unstrittig ist, dass Pooling praktisch nie wirklich benötigt wird, bleibt das Pooling der Default. Singletons sind vor allem dafür gedacht, um Datenstrukturen zu implementieren, die man nur einmal haben möchte. Dazu gehören zum Beispiel Caches. Es macht kaum Sinn, mehrere Instanzen eines Caches vorzuhalten. Man sollte lieber für alle Zugriffe dieselbe Instanz nutzen, weil man sonst mehrere Cache befüllen muss. Ein einfaches Singleton kann zum Beispiel folgendermaßen aussehen:
@Singleton
public class MyCache {
private Map cache = new ConcurrentHashMap();
public Object get(String key) {
return cache.get(key);
}
public void store(String key, Object value) {
cache.put(key, value);
}
}
Sieht jemand das Problem? Nun, wenn man diese Implementierung so nutzt, wird man ein erhebliches Skalierungsproblem haben. Überrascht? Lesen Sie weiter.
EJB 3.1 Singletons haben ein interessantes Feature. In der EJB-3.1-Spezifikation ist das Problem der Nebenläufigkeit gelöst worden. Dieses Thema dort anzusprechen ist gleich aus mehreren Gründen spannend:
- In Java gibt es mit synchronized und dem Package java.util.concurrent ausgesprochen gute Werkzeuge für Nebenläufigkeit. Es muss in diesem Bereich also keine Lücke gefüllt werden.
- Nehmen wir dennoch an, dass die Unterstützung für Nebenläufigkeit in EJB 3.1 eine sinnvolle Ergänzung ist. Warum ist sie dann auf EJB 3.1 beschränkt? Nebenläufigkeit ist ein fundamentales Problem und gehört nicht in eine spezialisierte API wie EJB.
- Außerdem ist Nebenläufigkeit bei den Singletons meist kein Problem, wenn man die Erfahrungen mit Spring zu Grunde legt. Zugegebenermaßen fokussiert EJB 3.1 aber auf Anwendungsfälle, bei denen man Datenstrukturen verwendet, die von mehreren Threads genutzt werden sollen, wie den Cache im Beispiel oben. Dort kann es dann sicher zu Nebenläufigkeitsproblemen kommen.
Das erinnert an eines der größten Probleme von EJB in Version 1.x und 2.x. Damals wurde mit Container Managed Persistence das Persistenz-Problem gelöst. Dabei wurden die bekannten Ansätze von O/R-Mappern ignoriert - so wie man jetzt die vorhandenen Nebenläufigkeits-APIs ignoriert. Und man hat eine Lösung in EJB implementiert, so dass man sie nur in einem Application Server nutzen kann - obwohl Persistenz genau wie heute Nebenläufigkeit ein viel allgemeineres Problem ist.
Wie funktioniert nun die Nebenläufigkeit bei EJB 3.1? Der Default ist Container Managed Concurrency. Alternativ kann man durch Bean Managed Concurrency die üblich Java-Bordmittel nutzen. Bei Container Managed Concurrency kann eine Methode mit
@Lock(READ) oder
@Lock(WRITE) markieren. Nur eine Methode mit einem WRITE-Lock kann zu einem Zeitpunkt aufgerufen werden. Keine andere Methode - weder mit READ- noch mit WRITE-Lock - darf dann in einem anderen Thread ausgeführt werden. Parallele Abarbeitung von Methoden mit READ-Locks ist hingegen erlaubt. Die Nebenläufigkeit an Methoden fest zu machen ist alleine schon Problem genug. Der Schlüssel zu einem skalierbaren System ist, dass man Locks so kurz wie möglich hält.
Ich hatte vor einiger Zeit einen Consulting-Einsatz wegen eines Skalierungsproblems. Das habe ich gelöst, indem ich diesen Code:
...
synchronized(cache) {
if (cache.get(key)==null) {
cache.put(aValue);
}
}
...
durch solchen Code ersetzt habe:
...
if (cache.get(key)==null) {
synchronized(cache) {
if (cache.get(key)==null) {
cache.put(aValue);
}
}
}
...
Dadurch wird der Cache nur noch gelockt, wenn er den Wert noch nicht enthält. Da der Cache irgendwann gefüllt ist, wird der Cache dann praktisch gar nicht mehr gelockt und man kann der Code dann mit wesentlich besserer Nebenläufigkeit betrieben werden. Wie man hier sieht, kann das Verschieben des Locking um eine Zeile schon einen erheblichen Unterschied machen. Wenn man solche Sachen mit den EJB-3.1-Annotationen machen will, wird man nicht weiterkommen, da sie nur auf Methoden wirken.
Schlimmer wird es nun dadurch, dass der Default ist, dass jede Methode ohne Annotationen so behandelt wird, als wäre sie mit
@Lock(WRITE) versehen. Das bedeutet, dass in dem
gesamten EJB-3.1-Singleton - wenn man nicht anderes definiert - nur ein Thread zur Zeit aktiv sein kann. Der oben mit EJB 3.1 implementierte Cache kann also nur von einem Thread parallel genutzt werden. Das wird in der Realität bei vielen parallelen Threads zu erheblichen Problemen führen. Da
@Lock(WRITE) der Default ist, sieht man gar nicht, was passiert, und muss man sehr genau verstehen, was hinter den Kulissen vor sich geht. Wer also einfach so mit EJB 3.1 los programmiert, wird in diese Falle tappen.
Besonders ironisch ist natürlich, dass der oben definierte Cache durch die
ConcurrentHashMap sowieso von mehreren Threads parallel genutzt werden kann. EJB 3.1 schüzt uns also ganz umsonst von der ach-so-komplizierten Welt der Nebenläufigkeit.
Labels: EJB 3.1, Pooling, Singletons
Über den Sinn und Unsinn von Pooling
In letzter Zeit tauchte bei meinen Trainings und Beratungen wieder das Thema Pooling auf.
Wir erinnern uns: Man schreibt das Jahr 1998. Alle Welt baut CORBA-Systeme, meistens mit C++. Thread-Sicherheit - gerade mit C++ - ist kompliziert. Neue Objekte zu bauen ist teuer. Also führt man jeden Request mit einem Thread in einem eigenen Objekt aus und um nicht andauernd neue Objekte zu bauen, poolt man sie - denn Objekterzeugung ist teuer. Die Implementierung dazu baut man manuell. Basierend darauf führt EJB dann dieses Modell in die Java-Welt ein, so dass man es einfach nutzen kann, statt es ständig erneut zu implementieren.
Mittlerweile schreiben wir das Jahr 2009. Verschiedene Dinge haben sich geändert:
- Pooling nimmt an, dass das Erzeugen und Zerstören von Objekten teuer ist - sonst könnte man sie ja jedesmal erzeugen und zerstören, statt sie zu poolen. Bei Objekten wie Datenbank-Verbindungen und Threads stimmt das auch nach wie vor. Aber Objekt-Erzeugung und Zerstörung einfacher Java-Objekte ist mittlerweile sehr billig. Kurzlebige Objekte sind praktisch umsonst. Die Hotspot-FAQ warnt sogar vor Pooling: http://java.sun.com/docs/hotspot/HotSpotFAQ.html#gc_pooling
Der Hintergrund ist die Speicherverwaltung von Java. Für ein neues Objekt holt Java sich bei den neueren VMs einfach nur einen neuen Speicherbereich in der Young Generation durch Erhöhen eines Pointers. Wenn die Young Generation voll ist, wird durch einen Copying Garbage Collector die überlebenden Objekte, auf die noch Referenzen existieren, umkopiert. Die Komplexität dieses Garbage Collectors hängt von der Anzahl überlebender Objetke ab. Kurzlebige Objekte bedeuten also nur, dass diese Garbage Collection öfter läuft, aber nicht, das sie länger dauert. Beim Pooling hingegen hat man langliebige Objekte, bei denen die Garbage Collection länger dauert, und durch das Ausgreifen aus dem Pool auch ein Overhead. Übrigens muss C++ bei der Objekt-Erzeugung den Heap nach einem passend großen Speicherplatz durchforsten. Das ist wesentlich komplexer als die Pointer-Manipulation bei Java. Ein Umkopieren der Objekte ist bei C++ nicht möglich und somit ist der Heap nach einiger Zeit fragmentiert, weil Objekte wieder freigegeben werden und so Lücken entstehen. Java und Garbage Collection können also gerade bei Speicherverwaltung effizienter sein als C++.
- Pooling geht ja davon aus, dass nur ein Thread zur Zeit ein Objekt nutzen kann. Aber die typischen Services, DAOs und Repositories heutiger Systeme sind von mehreren Threads parallel nutzbar. Sie sind zustandslos, so dass sie die wesentlichen Informationen über Parameter bekommen oder die Informationen hängen am Thread (zum Beispiel die aktuelle Transaktion oder die Datenbankverbindung). Nur in Ausnahmesituationen ist das Objekt nicht thread-safe - zum Beispiel wenn man statt eine lokalen Variable eine Instanz-Variable nutzt, die dann bei paralleler Ausführung der Methode in mehreren Threads überschrieben wird. Wenn man allerdings die Instanz-Variablen durch lokale ersetzt, hat man kein Problem - also ist das ein handwerklicher Programmierfehler.
Übrigens ist es interessant, dass kaum ein Spring-Entwickler weiß, wie man mit Spring Pooling aktiviert. Das bedeutet, dass es in der Praxis offensichtlich nicht benötigt wird - sonst würden es ja mehr Leute kennen.
Und noch ein Hinweis: EJB 3.1 definiert mittlerweile auch Singletons, wie sie bei Spring schon lange genutzt werden - weil Pooling eben oft unnötig ist. Aber die Annahme der Spezifikation ist offensichtlich, dass man sehr häufig nebenläufigen Zugriff auf die Singletons haben wird, die synchronisiert werden müssen. Wie bereits erwähnt, halt ich das wegen der typischen Zustandslosigkeit für die extreme Ausnahme. Diese Ausnahmen kann man sehr gut mit den Java-Mitteln wie dem
synchronized Schlüsselwort und den Klassen aus
java.util.concurrent zu Leibe rücken. Gerade diese Features heben ja Java von C++ ab. EJB 3.1 führt zusätzlich Annotationen ein, um diesem Problem zu begegnen. Warum die vorhandenen Mittel nicht reichen, ist zumindest mir unklar und ich glaube auch, dass man in den meisten Systemen meistens Singletons haben wird, die eh thread-safe sind. Und: Wenn schon ein neues Concurrency-Modell - warum dann nur für EJBs?
Einer der Gründe, warum SpringSource die Java-EE-6-Spezifikation nicht angenommen hat, sondern sich bei der Abstimmung enthalten hat, ist übrigens, dass dieser Ansatz für Concurrency mit Annotationen noch nie in der Praxis ausprobiert worden ist. Ein weiterer wichtiger Grund ist sicher, dass das ursprünglich geplante minimale Web-Profil nun in der Spezifikation doch nicht enthalten ist - obwohl genau dies die am meisten verwendete Plattform für Enterprise-Java-Entwicklung ist. Die vollständige Abstimmung inkl. unserem Kommentar findet sich unter
http://jcp.org/en/jsr/results?id=4821.
Labels: EJB, Pooling, Spring