Java Best Practice (Teil IX) Concurrency – Durchsatz steigern (II) Der Kunde fordert einen Durchsatz von 20 Nachrichten pro Sekunde, ihr Algorithmus schafft gerade mal zwei. Zehn parallel laufende Algorithmen können vielleicht helfen. Keine einfache Aufgabe. Doch Java hat mit dem Paket java.util.concurrent wichtige Hilfsmittel an Bord: ThreadPool, BlockingQueue und atomare Klassen werden exemplarisch vorgestellt. Wann lohnt sich die Parallelisierung? Der Motor der Datenverarbeitung ist der Prozessor. Dieser wird durch Lese- oder Schreibprozesse von der Arbeit abgehalten. Durch parallele Verarbeitungsprozesse werden diese Arbeitslücken genutzt. Während eine Verarbeitung auf Daten wartet, kann die zweite weiter rechnen. In dem gewöhnlichen Dreikampf Eingabe, Verarbeitung und Ausgabe liegt genug Potential zur Steigerung des Durchsatzes. Ein Gefühl für das Potential gibt folgende, grobe Faustformel: Ein Zugriff auf einen Datenbankserver bringt mindestens 5 ms Wartezeit mit sich. In 1 ms können etwa 1.000 Zeilen Quellcode ausgeführt werden. Und dann gibt es noch die Prozessoren mit mehreren Kernen sowie Rechner mit mehreren Prozessoren. Hier herrscht also häufig ein Überangebot an CPU-Kapazität. Wozu spezielle Bibliotheken nutzen? Java verfügt über die Grundbausteine der Parallelisierung. In unserem ersten Link [1] wird erläutert, dass die Parallelverarbeitung Herausforderungen mit sich bringt. Parallele Zugriffe auf dasselbe Objekt führen zu unerwünschten Zuständen. Diese lassen sich durch Synchronisierung vermeiden – das aber kann Dead-Locks nach sich ziehen. Das Paket java.util.concurrent liefert fertige Komponenten, die helfen solche Probleme zu vermeiden. Einige dieser Komponenten werden im Folgenden vorgestellt. Arbeit zentral verwalten: Thread-Pool Der Thread-Pool übernimmt wesentliche Aufgaben der Thread-Verwaltung: � Threads ausführen � Anzahl parallel laufender Threads überwachen � Threads in eine Warteschlange übernehmen � Threads nach Gebrauch wiederverwerten � Geordnetes Herunterfahren des Pools Damit stellt der Thread-Pool eine Lösung für wiederkehrende Aufgaben der parallelen Programmierung zur Verfügung. Die Abbildung 1 zeigt einen Programm-Code zur Nutzung eines Thread-Pools. Im Beispiel wird dieser durch die Executors-Methode newFixedThreadPool erzeugt. Die Ausführung eines Programmteils wird durch den Aufruf der Methode execute und Übergabe eines Runnable-Objekts angestoßen. Dabei startet die Ausführung erst dann, wenn ein Thread frei ist. Ansonsten kommt der Auftrag zunächst in eine Warteschlange. Damit beim Ende der Anwendung keine Aufträge im Executor-Service verloren gehen, wird die Methode shutdown aufgerufen. Neue Aufträge werden nicht entgegengenommen, Aufträge in der Warteschlange abgearbeitet und der Service beendet. Producer an Consumer vermitteln: Blocking Queue Das Muster Producer and Consumer beschreibt einen Lösungsansatz in der Architektur zur Strukturierung paralleler Prozesse: der Producer-Algorithmus erzeugt Request- Objekte. Der Consumer-Algorithmus führt aufgrund dieser Request-Objekte eine Aktion aus. Ein Request-Objekt kann beispielsweise eine Bestellung sein. Durch Einsatz dieses Architekturmusters werden Producer und Consumer voneinander entkoppelt. Der gemeinsame Zugriff auf Objekte wird vermieden. Die Leistungsfähigkeit des Consumers ist unabhängig vom Producer skalierbar. Eine Queue (java.util.Queue) ist zur Implementierung des Architekturmusters gut geeignet (siehe Abbildung 2): Der Producer stellt den Request in die Queue ein. Durch die Verwendung der Methode put wird sichergestellt, dass kein Request verloren geht. Ist die Queue voll, so wird so lange gewartet, bis die Queue den Request wieder aufnehmen kann. Der Consumer holt mit take den Request aus der Queue. Ist die Queue leer, so wird auf einen Request gewartet. Für die unter dem dritten Link [3] eingesetzten BlockingQueue gibt es Zugriffsmethoden, die entweder auf die Antwort der BlockingQueue warten oder ohne Ergebnis zurückkehren. Daten sammeln: Concurrent Map In manchen Fällen lässt sich der Zugriff auf gemeinsame Objekte nicht vermeiden. Nicht selten werden Collections verwendet, um Daten aus unterschiedlichen Threads zu sammeln und zur Verfügung zu stellen. In diesem Fall ist die Collection das Objekt, das durch parallele Threads verändert wird. Synchronisierte Collections (z.B. mit SynchronizedMap) werden mit der Helper- Klasse java.util.Collections erstellt. Durch die Synchronisierung der Zugriffsmethoden ist die Konsistenz der Collection sichergestellt. Vorsicht ist beim Einsatz von Iteratoren geboten. Hier muss der Entwickler selbst für den synchronisierten Zugriff sorgen. Die java.util.concurrent.ConcurrentMap liefert weitere synchronisierte Methoden, die in der Konkurrenzsituation behilflich sein können: � remove(key, value) sichert zu, dass ein Element aus der Map nur dann gelöscht wird, wenn key und value mit den Angaben übereinstimmen. � replace(key, oldValue, newValue) ersetzt das durch key und oldValue spezifizierte Element durch key und newValue. � putIfAbsent(key, value) fügt nur dann das durch key und value spezifizierte Element ein, wenn es zu dem key noch keinen anderen Wert gibt. Die Ausführung dieser Methoden erfolgt atomar, vergleichbar mit einer Datenbanktransaktion. Arbeiten ohne Lock: Atomic Variables Durch Synchronisation von Methoden können unerwünschte parallele Zugriffe verhindert werden – Deadlocks oder Performance-Einbußen können aber die Folge sein [1]. Als eine Alternative zum Sperren wird das Optimistic Locking im Datenbankumfeld eingesetzt. Ein Datensatz wird gelesen und verarbeitet. Beim Speichern wird überprüft, ob der Datensatz noch dieselbe Version hat. Hat sich der Datensatz in der Zwischenzeit verändert, so ist ein Konkurrent schneller gewesen - die Verarbeitung muss erneut erfolgen. Das Optimistic Locking verzichtet vollständig auf eine Sperre während der Verarbeitung. Optimistic ist dieses Verfahren, weil angenommen wird, dass ein konkurrierender Zugriff nur in Ausnahmefällen stattfindet. Deadlocks können nicht vorkommen. In Java kann eine Art Optimistic Locking für konkurrierende Prozesse mit Hilfe des Pakets java.util.concurrent.atomic durchgeführt werden. Dazu gibt es die Methode compareAndSet. Diese Methode verändert das Objekt nur dann, wenn es noch den erwarteten Wert hat. Das entspricht im Datenbankbereich dem Statement update mit Versionsvergleich: where version=myVersion. Die Methode compareAndSet ist zwar atomar implementiert. Der Entwickler muss aber den Fall berücksichtigen, dass der Wert des Objekts zwischenzeitlich verändert wurde. Neben dem compareAndSet werden in der Klasse AtomicInteger eine Reihe atomarer Methoden angeboten: addAndGet fügt eine Zahl hinzu und liefert unmittelbar das Ergebnis zurück. Die Methoden decrementund incrementAndGet helfen beispielsweise beim Runter- oder Raufzählen. Neben den Typen Boolean, Integer und Long wird für allgemeine Klassen die AtomicReference angeboten. Diese Klasse verwaltet ein Objekt von beliebigem Typ. Neben der Methode compareAndSet wird die atomare Methode getAndSet angeboten. Fazit Dieser Artikel zeigt exemplarisch, dass das Paket java.util.concurrent für die Implementierung paralleler Ausführungen hilfreiche Klassen zur Verfügung stellt. Die Verwendung dieser Komponenten spart Entwicklungszeit und reduziert die Risiken der Entwicklung erheblich. Betont werden muss jedoch, dass der Einsatz dieser Klassen nicht die Lösung aller Probleme bedeutet. Die Implementierung paralleler Algorithmen stellt immer eine Herausforderung dar. In den folgenden Artikeln dieser Serie werden konkrete Lösungen für typische Anforderungen der Client- und Server-Entwicklung vorgestellt. Sicher haben wir auch für Ihre Anforderungen passende Lösungsansätze. Dr. Stefan Koch (info@ordix.de). Glossar Concurrency Konkurrierender Zugriff auf Objekt-Attribute. DBMS Datenbank Management System – Programme für den Umgang mit Datenbanken (z.B. Oracle, Informix, DBS). Pool In einem Pool werden Ressourcen für die Nutzung vorgehalten. Ziel ist es, die Menge der Ressourcen zu kontrollieren sowie durch Recycling den Aufwand für die Erstellung und Vernichtung der Ressourcen zu minimieren. Thread Ausführungsprozess innerhalb einer Java-Anwendung.