Aus Linux-Magazin 09/2012

Thread-Programmierung mit Java

© nena2112, photocase.com

Seit der ersten Version von Java sind Threads ein fester Bestandteil der Sprache. Das macht vieles einfacher als in anderen Programmiersprachen. Neuere Versionen der Java-Bibliothek bieten darüber hinaus viele nützliche Klassen für Locking und Synchronisierung.

Als die erste Java-Version im Jahr 1995 erschien, gab es kaum einen Desktop-Rechner mit mehr als einem Prozessor. Trotzdem war die Unterstützung von Threads von Anfang an ein fester Bestandteil der Sprache und für den zentralen Anwendungsfall notwendig. Der Hintergrund war, dass Java als Frontend für Serveranwendungen dienen und das Frontend nicht durch langsame Dateitransfers lahmgelegt werden sollte.

Weitsicht

Die weitere Entwicklung von Java ging dann zwar in eine andere Richtung – Java-Applets spielen kaum mehr eine Rolle –, doch von der Weitsicht der Sprachentwickler profitieren Java-Programmierer nach wie vor.

Die Grundlage aller Threadprogramme sind das Interface »java.lang.Runnable« und die Klasse »java.lang.Thread« . Das Interface definiert als einzige Schnittstelle die »run()« -Methode. Eigene Threads erweitern entweder »Thread« oder implementieren »Runnable« und starten den Thread mit der statischen Methode »Thread.start(Runnable r)« .

Der gesicherte Zugriff auf gemeinsam genutzte Ressourcen gelingt mit Hilfe des »synchronized« -Schlüsselworts. Der Programmierer kann Synchronisierung auf drei Ebenen durchführen. Möglich sind die Synchronisierung der gesamten Klasse (etwa »public synchronized class Foo{…}« ), einzelner Methoden (»public synchronized int getID() {…}« oder einzelner Code-Abschnitte.

Die Beispielklasse in Listing 1 vergibt für jedes neu geschaffene Objekt eine eindeutige ID aus einer globalen Sequenznummer. In diesem trivialen Beispiel ist natürlich die »getID()« -Methode nicht wirklich nötig, es würde reichen, wenn der Konstruktor synchronisiert wäre. Die »main()« -Methode ab Zeile 21 erzeugt 100 Threads mit einer anonymen Runnable-Klasse. Wer das Verhalten ohne »synchronized« testen will, der entfernt dieses Schlüsselwort aus Zeile 9 und führt folgende Zeilen aus:

Listing 1

Schlüsselwort synchronized

01 import java.lang.*;
02 import java.io.*;
03
04 class ObjectID {
05   private static int seq=0;
06
07   private int id;
08
09   private synchronized int getID() {
10     return seq++;
11   }
12
13   public ObjectID() {
14     id = getID();
15   }
16
17   public void print() {
18     System.out.println("ID: " + id);
19   }
20
21   public static void main(String[] args) {
22     for (int i=0; i<100; ++i) {
23       new Thread(new Runnable() {
24         public void run() {
25           try {
26             Thread.currentThread().sleep(100);
27           } catch (Exception e) {
28           }
29           ObjectID o = new ObjectID();
30           o.print();
31         }
32       }).start();
33     }
34   }
35 }
javac -d . ObjectID.java
java -classpath . ObjectID | sort | uniq -c

Nicht bei jedem Lauf kommt es zu Problemen, je nach Geschwindigkeit des Rechners und der vorhandenen Prozessoren fällt es sogar schwer, zwei Objekte mit gleicher ID zu provozieren (Abbildung 1). Dies ist übrigens einer der Gründe, warum Fehler in parallelen Programmen häufig schwer zu finden und zu korrigieren sind.

Abbildung 1: Ohne »synchchronized« erzeugt <link href="#article_l1" class="listing" srcset=

Listing 1 gelegentlich zwei Objekte mit der gleichen ID, zu erkennen an der Zwei in der ersten Spalte.” width=”300″ height=”165″ /> Abbildung 1: Ohne »synchchronized« erzeugt Listing 1 gelegentlich zwei Objekte mit der gleichen ID, zu erkennen an der Zwei in der ersten Spalte.

Die Wahl der Synchronisationsebene wirkt sich auf die Effizienz der Programme aus. Je kleiner der Geltungsbereich des Lock ist, desto geringer die Auswirkungen auf andere, eventuell unbeteiligte Programmteile.

Die Java-Sprachdesigner bewiesen zwar Weitsicht mit der Integration des Threadings. Ihnen ist jedoch beim ersten Wurf ein folgenschwerer Fehler unterlaufen. So enthält die Threadklasse mit »Thread.suspend()« , »Thread.resume()« , »Thread.stop()« und »Thread.destroy()« eine Reihe scheinbar sehr nützlicher Funktionen für das Threadmanagement. Dummerweise sind diese Methoden inhärent fehlerhaft und wurden schon in der Java-Version 1.1 als »deprecated« erklärt. Die Dokumentation des JDK enthält dazu sogar ein eigenes Dokument, das die Problematik beschreibt [1].

Die sichere Methode, einen Thread zu stoppen, besteht darin, eine Instanzvariable innerhalb des Thread zu ändern. Die »run()« -Methode muss diese Variable regelmäßig auf Änderungen überwachen. Alternativ ruft der Programmierer die Methode »Thread.interrupt()« auf. Threads, die auf Ein- oder Ausgaben warten, reagieren aber nicht darauf.

Eine weitere Methode der Threadklasse, die je nach Implementierung anders tickt, ist »Thread.setPriority()« . Die Prioritätssteuerung hat nichts mit irgendwelchen Nice-Werten unter Linux zu tun, sondern dient allein dem internen Thread-Dispatcher der JVM. Ob und wie er die einzelnen Prioritäten behandelt, ist Implementationssache.

Java befindet sich nach wie vor in ständiger Weiterentwicklung, die Sprache hat mit jeder Version weitere Features im Multithreading-Bereich gewonnen. In Version 1.2 kam zum Beispiel die Klasse »ThreadLocal« hinzu. Sie erlaubt Klassenvariablen, die unterschiedlich für jeden Thread sind.

Collections

Eine wichtige Erweiterung von Java 2 war zudem das Collection-Framework. Collections sind nicht per se Thread-sicher, besitzen aber so genannte Fail-Fast-Iteratoren. Das bedeutet, dass Iteratoren sofort und sauber eine Exception werfen, wenn ein anderer Thread eine Collection verändert hat, während der Programmierer über die Collection iteriert. Braucht er tatsächlich eine komplett Thread-sichere Collection, dann liefern diese verschiedene Factory-Methoden wie etwa »List.synchronizedList()« .

Mit den Thread-APIs von Java 1.2 hatte der Entwickler alles, was er für Multithreaded-Programme braucht. Trotzdem war das Programmieren eher mühselig, denn es handelte sich um Low-Level-APIs. Wichtige Fragen, die in jedem Programm immer wieder auftauchen, blieben unbeantwortet: Wie sollen Abbrüche behandelt werden? Muss das Programm für jede Aufgabe einen neuen Thread erzeugen? Wie verhindere ich, dass zu viele Threads das System lahmlegen? Wie synchronisieren sich mehrere Threads am sinnvollsten? Wie implementiere ich atomare Operationen?

Concurrency-Framework

Mit Java 5 (also JDK 1.5) hielt das Concurrency-Framework Einzug in die Java-Bibliotheken. Dieses Framework gibt die Antworten auf die oben gestellten Fragen und bügelt so die verbliebenen Unzulänglichkeiten der Thread-APIs aus. Die folgenden Abschnitte führen in einige zentrale Klassen ein. Weil das Framework aber recht umfangreich ist, sollte der Entwickler unbedingt in der Java-Dokumentation im Package »java.util.concurrent« stöbern (Abbildung 2). Viele der Klassen sind dort mit eingängigen Codeschnipseln erläutert.

Abbildung 2: Es lohnt sich für den Java-Programmierer, die Javadoc zu den Concurrency-Klassen zu lesen.

Abbildung 2: Es lohnt sich für den Java-Programmierer, die Javadoc zu den Concurrency-Klassen zu lesen.

Ressourcenkontrolle

Seit Java 5 hat der Entwickler die verfügbaren Ressourcen viel besser im Griff. Threads sind zwar leichtgewichtiger als native Prozesse, trotzdem ist die ständige Neuerzeugung (kurzlaufender) Threads eine Verschwendung. Threadpools sorgen für die Wiederverwendung von Threads. Die Verwaltung übernehmen vorgefertigte Klassen wie »ThreadPoolExecutor« .

Threads im Pool

Im4java [2] beispielsweise, ein Projekt des Autors, nutzt eine Subklasse von »ThreadPoolExecutor« . Das Programm (Abbildung 3) ist ein Java-Wrapper um Imagemagick und kann mehrere Bilder auf den verfügbaren Prozessoren eines Systems parallel bearbeiten.

Abbildung 3: Im4java, ein Wrapper für Imagemagick, kann mehrere Bilder auf den verfügbaren Prozessoren parallel bearbeiten.

Abbildung 3: Im4java, ein Wrapper für Imagemagick, kann mehrere Bilder auf den verfügbaren Prozessoren parallel bearbeiten.

Der Executor stellt dabei sicher, dass maximal die per »pProcs« angeforderte Anzahl an Threads parallel läuft. In Listing 2 sorgt ein Timeout-Wert von 60 Sekunden dafür, dass sich untätige Threads im Threadpool nach einer Minute der Inaktivität automatisch beenden. Das letzte Argument für den Konstruktor des »ThreadPoolExecutor« ist eine Umsetzung der so genannten »BlockingQueue« . Dabei handelt es sich um ein Interface des Concurrency-Framework. Mit diesen Queues lassen sich die Arbeitsaufgaben verwalten. Neben Fifo-Queues und anderen existiert auch eine »PriorityBlockingQueue« , die eine feingranulare Prioritätensteuerung bietet.

Listing 2

Im4java

01 public class ProcessExecutor extends ThreadPoolExecutor {
02   public ProcessExecutor(int pProcs) {
03     // create superclass with pProcs threads
04     super(pProcs,pProcs,60,TimeUnit.SECONDS,
05           new LinkedBlockingQueue<Runnable>());
06     prestartAllCoreThreads();
07 }
08 [...]
09 }

Die Anwendung zeigt Listing 3. Für jedes Bild erstellt die Schleife in Zeile 7 eine Task – »ProcessTask« ist eine Subklasse von »FutureTask« – und übergibt die Task an den Executor, der sie dann in die Queue einspeist und abarbeitet, sobald ein entsprechender Thread aus dem Pool frei ist. Nach der Schleife beendet das Programm den Executor in Zeile 10. Dieser Aufruf blockiert nicht. Das Programm könnte jetzt mit einem Aufruf von »exec.awaitTermination()« auf das Ende der Verarbeitung warten.

Listing 3

ThreadPoolExecutor()in Aktion

01 // load all files
02 iImages = load();
03
04 ProcessExecutor exec = new ProcessExecutor();
05 for (String img:iImages) {
06   ConvertCmd cmd = new ConvertCmd();
07   ProcessTask pt = cmd.getProcessTask(...);
08   exec.execute(pt);
09 }
10 exec.shutdown();

Die im vorigen Absatz erwähnte »FutureTask« ist ebenfalls eine neue Klasse des Concurrency-Framework. Objekte dieser Klasse sind Runnables, die zusätzlich sowohl Zugriff auf das Ergebnis der asynchronen Berechnung bieten (Methode »get()« ), also auch Lifecycle-Methoden wie »cancel()« oder »isDone()« . Die Get-Methode blockiert, falls die Berechnung noch nicht fertig ist.

Locking und Synchronisation

Parallele Codeteile einfach zu verwalten und auszuführen ist aber nur ein Teil der Aufgabe. Häufig müssen Threads Ergebnisse weiterreichen oder Informationen austauschen. Dabei kommen mehrere Arten von Lock- und Synchronisationsobjekten ins Spiel. Locking mittels »synchronized« ist dabei nur ein Spezialfall für ein simples Reentrant-Lock. Die Klasse »Semaphore« des Concurrency-Framework steuert den Zugriff auf Ressourcen. Je nach Initialisierung arbeitet ein Semaphor binär (ähnlich einem Mutex) oder erlaubt den Zugriff mehrfach. Da die Semaphor-Implementation von Java keinen Owner kennt, können auch fremde Threads die Ressourcen wieder freigeben.

Details zu dieser Klasse gibt es wie üblich in der Javadoc. Das Beispiel in Listing 4 simuliert einen kritischen Abschnitt, den maximal drei Threads parallel betreten dürfen. Der Code nutzt außerdem einen der mit dem Framework eingeführten atomaren Datentypen, die eine Implementierung von Zählern einfacher gestalten als in Listing 1.

Listing 4

Paralleler Zugriff mit Semaphoren

01 import java.io.*;
02 import java.util.concurrent.*;
03 import java.util.concurrent.atomic.*;
04
05 public class SemTest {
06   private static Semaphore sem = new Semaphore(3,true);
07   private static AtomicInteger counter = new AtomicInteger(0a);
08
09   public void doCriticalWork() {
10     try {
11       sem.acquire();
12       int currentCounter = counter.addAndGet(1);
13       Thread.currentThread().sleep(100);
14       System.out.println("current counter: " + currentCounter);
15       counter.addAndGet(-1);
16       sem.release();
17     } catch (Exception e) {
18     }
19   }
20
21   public static void main(String[] args) {
22     for (int i=0; i<100; ++i) {
23       new Thread(new Runnable() {
24         public void run() {
25           SemTest st = new SemTest();
26           st.doCriticalWork();
27         }
28       }).start();
29     }
30   }
31 }

Objekte tauschen

Java bietet aber noch mehr: Ein Objekt vom Typ »Exchanger« synchronisiert zwei Threads, die dabei Objekte tauschen. Anwendungsfälle sind alle Arten von Objekten, die ein Thread erstellt und ein zweiter Thread abarbeitet. Nun speichert der erste Thread die erzeugten Objekte in einem Puffer und übergibt den vollen Puffer mittels eines Exchangers an einen Verarbeitungsthread. Gleichzeitig bekommt er einen leeren Puffer zurück. Die Anwendung arbeitet also mit zwei Puffern und entkoppelt Erstellung und Verarbeitung.

Ein anderer Typ von Synchronisationsobjekten ist »CyclicBarrier« (siehe Abbildung 4). An einer Barriere wartet eine konfigurierbare Anzahl von Threads, bevor es weitergeht. Solche Objekte sind nützlich, wenn der Entwickler ein Problem in eine feste Anzahl von Teilen aufspalten kann und diese von jeweils einem Thread bearbeitet lässt.

Abbildung 4: An einer »CyclicBarrier« warten die Threads, bis alle fertig sind. Das geschieht mehrmals, wobei jeder Durchgang die Teilergebnisse aggregiert.

Abbildung 4: An einer »CyclicBarrier« warten die Threads, bis alle fertig sind. Das geschieht mehrmals, wobei jeder Durchgang die Teilergebnisse aggregiert.

Läuft jeder Thread nur genau einmal, bedarf es natürlich keines Synchronisationsobjekts – hier wartet das Hauptprogramm nur, bis alle Threads fertig sind. Soll das Programm aber iterativ immer wieder Teilergebnisse aggregieren, dann erlaubt es »CyclicBarrier« , eine Lösung einfach umzusetzen.

Countdown

Etwas anders verhält sich »CountDownLatch« . Hier warten Threads, bis ein Zähler auf Null steht. Ein Anwendungsfall dafür ist ein Hauptprogramm, das eine Reihe von Threads aufsetzt und startet, aber gleichzeitig verhindern will, dass sie schon loslaufen, bevor der Initialisierungsprozess fertig ist.

Hierzu erzeugt das Hauptprogramm ein Objekt vom Typ »CountDownLatch« und initialisiert den Zähler mit Eins. Die Threads warten nach dem Start, bis der Zähler Null ist (Listing 5). Das Beispiel implementiert das Verfahren “Synchronisierung mit Bedingungsvariablen” (Abbildung 5) aus dem C++-Artikel im Linux-Magazin 08/12 [3]. Die Java-Umsetzung fällt jedoch deutlich kompakter aus, da die Sprache Klassen auf höherem Abstraktionsniveau bereitstellt. Im Gegensatz zur »CyclicBarrier« ist ein »CountDownLatch« nicht erneut verwendbar (siehe hierzu auch [4]).

Listing 5

Synchronisierung mit CountDownLatch

01 import java.lang.*;
02 import java.io.*;
03 java.util.concurrent.*;
04
05 public class Boss {
06
07    public static void main(String[] args) {
08      try {
09        CountDownLatch prepSignal  = new CountDownLatch(6);
10        CountDownLatch startSignal = new CountDownLatch(1);
11        CountDownLatch endSignal   = new CountDownLatch(6);
12
13        System.out.println("\nBOSS: PREPARE YOUR WORK\n");
14        new Thread(new Worker("Herb",prepSignal,startSignal,endSignal)).start();
15        new Thread(new Worker("Scott",prepSignal,startSignal,endSignal)).start();
16        new Thread(new Worker("Bjarne",prepSignal,startSignal,endSignal)).start();
17        new Thread(new Worker("Andrei",prepSignal,startSignal,endSignal)).start();
18        new Thread(new Worker("Andrew",prepSignal,startSignal,endSignal)).start();
19        new Thread(new Worker("David",prepSignal,startSignal,endSignal)).start();
20        prepSignal.await();
21
22        System.out.println("\nBOSS: START YOUR WORK\n");
23        startSignal.countDown();
24
25        endSignal.await();
26        System.out.println("\nBOSS: GO HOME\n");
27      } catch (Exception e) {
28      }
29    }
30  }
31
32 class Worker implements Runnable {
33    private String iName;
34    private CountDownLatch iPrepSignal, iStartSignal, iEndSignal;
35
36    Worker(String pName, CountDownLatch pPrepSignal,
37           CountDownLatch pStartSignal, CountDownLatch pEndSignal) {
38      iName = pName;
39      iPrepSignal  = pPrepSignal;
40      iStartSignal = pStartSignal;
41      iEndSignal   = pEndSignal;
42    }
43
44    public void run() {
45      try {
46      long prepareTime = 500 + (long) (1500*Math.random());
47      Thread.currentThread().sleep(prepareTime);
48      System.out.println(iName + " work prepared after " +
49                          prepareTime + " milliseconds");
50      iPrepSignal.countDown();
51      iStartSignal.await();
52      long workTime = 200 + (long) (200*Math.random());
53      Thread.currentThread().sleep(workTime);
54      System.out.println(iName + " work done after " +
55                         workTime + " milliseconds");
56      iEndSignal.countDown();
57      } catch (InterruptedException ex) {
58      }
59    }
60  }
Abbildung 5: Mit Hilfe des »CountDownLatch« lässt sich folgender Arbeitsablauf abbilden: Die Arbeiter geben Bescheid, wenn sie bereit oder fertig sind, der Chef veranlasst den Arbeitsbeginn und den Feierabend.

Abbildung 5: Mit Hilfe des »CountDownLatch« lässt sich folgender Arbeitsablauf abbilden: Die Arbeiter geben Bescheid, wenn sie bereit oder fertig sind, der Chef veranlasst den Arbeitsbeginn und den Feierabend.

Gut einzufädeln

Noch nie war die parallele Programmierung unter Java so einfach. Dank Concurrency-Framework existiert eine ganze Reihe von High-Level-APIs, die die Umsetzung verschiedenster paralleler Algorithmen stark vereinfachen und damit sicherer machen.

Die Entwicklung setzt sich weiter fort: Auch die Java-Versionen 6 und 7 haben neue Klassen bekommen, wenn auch die Veränderungen im Vergleich zu Java 5 deutlich kleiner ausfallen. Wer in die parallele Programmierung unter Java einsteigen will, sollte sich unbedingt mit dem zugehörigen Java-Tutorial [5] beschäftigen. Es vermittelt einen schnellen Einstieg in die Gedankenwelt des Framework. (mhu)

Der Autor

Bernhard Bablok betreut bei der Allianz Managed&Operations Services SE ein großes Datawarehouse mit technischen Performance-Messdaten von Mainframes bis zu Servern. Wenn er nicht Musik hört, mit dem Radl oder zu Fuß unterwegs ist, beschäftigt er sich mit Themen rund um Linux und Objektorientierung.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben