Eine Anleitung zum Java ExecutorService

Übersicht

ExecutorService ist eine JDK-API, die das Ausführen von Aufgaben im asynchronen Modus vereinfacht. Im Allgemeinen stellt ExecutorService automatisch einen Pool von Threads und eine API für die Zuweisung von Aufgaben zu diesem Pool bereit.

Weitere Lektüre:

Leitfaden zum Fork/Join-Framework in Java

Eine Einführung in das in Java 7 vorgestellte Fork/Join-Framework und die Werkzeuge, die helfen, die parallele Verarbeitung zu beschleunigen, indem versucht wird, alle verfügbaren Prozessorkerne zu nutzen.
Lesen Sie mehr →

Übersicht über das java.util.concurrent

Entdecken Sie den Inhalt des java.util.concurrent-Pakets.
Lesen Sie mehr →

Guide to java.util.concurrent.Locks

In diesem Artikel untersuchen wir verschiedene Implementierungen der Lock-Schnittstelle und die in Java 9 neu eingeführte StampedLock-Klasse.
Lesen Sie mehr →

Instantiierung von ExecutorService

2.1. Factory-Methoden der Executors-Klasse

Der einfachste Weg, einen ExecutorService zu erstellen, ist die Verwendung einer der Factory-Methoden der Executors-Klasse.

Die folgende Codezeile erzeugt zum Beispiel einen Thread-Pool mit 10 Threads:

ExecutorService executor = Executors.newFixedThreadPool(10);

Es gibt noch einige andere Factory-Methoden, um einen vordefinierten ExecutorService zu erstellen, der bestimmte Anwendungsfälle erfüllt. Um die beste Methode für Ihre Bedürfnisse zu finden, konsultieren Sie die offizielle Dokumentation von Oracle.

2.2. Direktes Erstellen eines ExecutorService

Da ExecutorService eine Schnittstelle ist, kann eine Instanz einer seiner Implementierungen verwendet werden. Es gibt mehrere Implementierungen im java.util.concurrent-Paket, aus denen Sie wählen können, oder Sie können Ihre eigenen erstellen.

Die Klasse ThreadPoolExecutor hat beispielsweise einige Konstruktoren, mit denen wir einen ExecutorService und seinen internen Pool konfigurieren können:

ExecutorService executorService = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

Sie werden feststellen, dass der obige Code dem Quellcode der Factory-Methode newSingleThreadExecutor() sehr ähnlich ist. Für die meisten Fälle ist eine detaillierte manuelle Konfiguration nicht notwendig.

Assigning Tasks to the ExecutorService

ExecutorService kann Runnable und Callable Tasks ausführen. Um die Dinge in diesem Artikel einfach zu halten, werden wir zwei primitive Tasks verwenden. Beachten Sie, dass wir hier Lambda-Ausdrücke anstelle von anonymen inneren Klassen verwenden:

Runnable runnableTask = () -> { try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); }};Callable<String> callableTask = () -> { TimeUnit.MILLISECONDS.sleep(300); return "Task's execution";};List<Callable<String>> callableTasks = new ArrayList<>();callableTasks.add(callableTask);callableTasks.add(callableTask);callableTasks.add(callableTask);

Wir können dem ExecutorService Aufgaben zuweisen, indem wir mehrere Methoden verwenden, darunter execute(), die von der Executor-Schnittstelle geerbt wird, und auch submit(), invokeAny() und invokeAll().

Die Methode execute() ist ungültig und bietet keine Möglichkeit, das Ergebnis der Ausführung einer Aufgabe zu erhalten oder den Status der Aufgabe zu überprüfen (läuft sie):

executorService.execute(runnableTask);

submit() übergibt eine Callable- oder eine Runnable-Aufgabe an einen ExecutorService und gibt ein Ergebnis vom Typ Future zurück:

Future<String> future = executorService.submit(callableTask);

invokeAny() weist einem ExecutorService eine Sammlung von Tasks zu, veranlasst deren Ausführung und gibt das Ergebnis einer erfolgreichen Ausführung eines Tasks zurück (falls es eine erfolgreiche Ausführung gab):

String result = executorService.invokeAny(callableTasks);

invokeAll() weist einem ExecutorService eine Sammlung von Aufgaben zu, veranlasst deren Ausführung und gibt das Ergebnis aller Aufgabenausführungen in Form einer Liste von Objekten vom Typ Future zurück:

List<Future<String>> futures = executorService.invokeAll(callableTasks);

Bevor wir weitergehen, müssen wir noch zwei Punkte besprechen: das Beenden eines ExecutorService und den Umgang mit Future-Rückgabetypen.

Beenden eines ExecutorService

Im Allgemeinen wird der ExecutorService nicht automatisch zerstört, wenn es keine Aufgabe zu bearbeiten gibt. Er bleibt am Leben und wartet auf neue Aufgaben.

In manchen Fällen ist das sehr hilfreich, z. B. wenn eine App Aufgaben verarbeiten muss, die in unregelmäßigen Abständen auftreten, oder die Menge der Aufgaben zur Kompilierzeit nicht bekannt ist.

Auf der anderen Seite könnte eine App ihr Ende erreichen, aber nicht gestoppt werden, weil ein wartender ExecutorService die JVM weiterlaufen lässt.

Um einen ExecutorService richtig herunterzufahren, gibt es die APIs shutdown() und shutdownNow().

Die Methode shutdown() führt nicht zur sofortigen Zerstörung des ExecutorService. Sie bewirkt, dass der ExecutorService keine neuen Aufgaben mehr annimmt und heruntergefahren wird, nachdem alle laufenden Threads ihre aktuelle Arbeit beendet haben:

executorService.shutdown();

Die Methode shutdownNow() versucht, den ExecutorService sofort zu zerstören, aber sie garantiert nicht, dass alle laufenden Threads gleichzeitig gestoppt werden:

List<Runnable> notExecutedTasks = executorService.shutDownNow();

Diese Methode gibt eine Liste von Aufgaben zurück, die darauf warten, bearbeitet zu werden. Es liegt im Ermessen des Entwicklers, was mit diesen Aufgaben geschehen soll.

Eine gute Möglichkeit, den ExecutorService herunterzufahren (die auch von Oracle empfohlen wird), ist die Verwendung dieser beiden Methoden in Kombination mit der awaitTermination()-Methode:

executorService.shutdown();try { if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException e) { executorService.shutdownNow();}

Bei diesem Ansatz hört der ExecutorService zunächst auf, neue Aufgaben anzunehmen und wartet dann bis zu einer bestimmten Zeitspanne, bis alle Aufgaben abgeschlossen sind. Wenn diese Zeit abläuft, wird die Ausführung sofort gestoppt.

Die Future-Schnittstelle

Die Methoden submit() und invokeAll() geben ein Objekt oder eine Sammlung von Objekten vom Typ Future zurück, was uns erlaubt, das Ergebnis der Ausführung einer Aufgabe zu erhalten oder den Status der Aufgabe zu überprüfen (läuft sie).

Die Future-Schnittstelle bietet eine spezielle blockierende Methode get(), die ein aktuelles Ergebnis der Ausführung der Callable-Aufgabe oder null im Fall einer Runnable-Aufgabe zurückgibt:

Future<String> future = executorService.submit(callableTask);String result = null;try { result = future.get();} catch (InterruptedException | ExecutionException e) { e.printStackTrace();}

Der Aufruf der Methode get(), während die Aufgabe noch läuft, führt dazu, dass die Ausführung blockiert wird, bis die Aufgabe ordnungsgemäß ausgeführt wurde und das Ergebnis verfügbar ist.

Bei sehr langem Blockieren durch die get()-Methode kann sich die Leistung einer Anwendung verschlechtern. Wenn die resultierenden Daten nicht entscheidend sind, kann ein solches Problem durch die Verwendung von Timeouts vermieden werden:

String result = future.get(200, TimeUnit.MILLISECONDS);

Wenn die Ausführungszeit länger als angegeben ist (in diesem Fall 200 Millisekunden), wird eine TimeoutException geworfen.

Wir können die Methode isDone() verwenden, um zu prüfen, ob die zugewiesene Aufgabe bereits abgearbeitet wurde oder nicht.

Die Future-Schnittstelle bietet auch die Möglichkeit, die Ausführung einer Aufgabe mit der Methode cancel() abzubrechen und den Abbruch mit der Methode isCancelled() zu überprüfen:

boolean canceled = future.cancel(true);boolean isCancelled = future.isCancelled();

Die Schnittstelle ScheduledExecutorService

Der ScheduledExecutorService führt Aufgaben nach einer vordefinierten Verzögerung und/oder periodisch aus.

Der beste Weg, einen ScheduledExecutorService zu instanziieren, ist wiederum die Verwendung der Factory-Methoden der Klasse Executors.

Für diesen Abschnitt verwenden wir einen ScheduledExecutorService mit einem Thread:

ScheduledExecutorService executorService = Executors .newSingleThreadScheduledExecutor();

Um die Ausführung einer einzelnen Aufgabe nach einer festen Verzögerung zu planen, verwenden Sie die Methode scheduled() des ScheduledExecutorService.

Mit zwei scheduled()-Methoden können Sie Runnable- oder Callable-Aufgaben ausführen:

Future<String> resultFuture = executorService.schedule(callableTask, 1, TimeUnit.SECONDS);

Mit der Methode scheduleAtFixedRate() können wir eine Aufgabe periodisch nach einer festen Verzögerung ausführen. Der obige Code verzögert für eine Sekunde, bevor er callableTask ausführt.

Der folgende Codeblock führt eine Aufgabe nach einer anfänglichen Verzögerung von 100 Millisekunden aus. Und danach wird dieselbe Aufgabe alle 450 Millisekunden ausgeführt:

Future<String> resultFuture = service .scheduleAtFixedRate(runnableTask, 100, 450, TimeUnit.MILLISECONDS);

Wenn der Prozessor für die Ausführung einer zugewiesenen Aufgabe mehr Zeit benötigt als der Parameter period der Methode scheduleAtFixedRate(), wartet der ScheduledExecutorService, bis die aktuelle Aufgabe abgeschlossen ist, bevor er die nächste startet.

Wenn eine Verzögerung fester Länge zwischen den Iterationen der Aufgabe erforderlich ist, sollte scheduleWithFixedDelay() verwendet werden.

Der folgende Code garantiert beispielsweise eine Pause von 150 Millisekunden zwischen dem Ende der aktuellen Ausführung und dem Beginn einer weiteren:

service.scheduleWithFixedDelay(task, 100, 150, TimeUnit.MILLISECONDS);

Gemäß den Methodenverträgen scheduleAtFixedRate() und scheduleWithFixedDelay() endet die Ausführung der Aufgabe beim Beenden des ExecutorService oder wenn während der Ausführung der Aufgabe eine Exception geworfen wird.

ExecutorService vs. Fork/Join

Nach der Veröffentlichung von Java 7 haben sich viele Entwickler entschieden, das ExecutorService-Framework durch das Fork/Join-Framework zu ersetzen.

Dies ist jedoch nicht immer die richtige Entscheidung. Trotz der Einfachheit und der häufigen Leistungssteigerungen, die mit fork/join verbunden sind, reduziert es die Kontrolle des Entwicklers über die gleichzeitige Ausführung.

ExecutorService gibt dem Entwickler die Möglichkeit, die Anzahl der erzeugten Threads und die Granularität der Aufgaben, die von separaten Threads ausgeführt werden sollen, zu steuern. Der beste Anwendungsfall für ExecutorService ist die Abarbeitung unabhängiger Aufgaben, wie z. B. Transaktionen oder Anfragen nach dem Schema „ein Thread für eine Aufgabe“

Im Gegensatz dazu wurde fork/join laut der Dokumentation von Oracle entwickelt, um Arbeit zu beschleunigen, die rekursiv in kleinere Teile zerlegt werden kann.

Fazit

Trotz der relativen Einfachheit von ExecutorService gibt es ein paar häufige Fallstricke.

Fassen wir sie zusammen:

Einen unbenutzten ExecutorService am Leben zu halten: Siehe die ausführliche Erklärung in Abschnitt 4, wie man einen ExecutorService herunterfährt.

Falsche Thread-Pool-Kapazität bei Verwendung eines Thread-Pools mit fester Länge: Es ist sehr wichtig zu bestimmen, wie viele Threads die Anwendung benötigt, um Aufgaben effizient auszuführen. Ein zu großer Thread-Pool verursacht unnötigen Overhead, nur um Threads zu erstellen, die sich meist im Wartemodus befinden werden. Zu wenige können dazu führen, dass eine Anwendung wegen langer Wartezeiten auf Tasks in der Warteschlange nicht mehr reagiert.

Aufruf der get()-Methode einer Future nach einem Taskabbruch: Der Versuch, das Ergebnis einer bereits abgebrochenen Aufgabe zu erhalten, löst eine CancellationException aus.

Unerwartet langes Blockieren mit der get()-Methode von Future: Wir sollten Timeouts verwenden, um unerwartete Wartezeiten zu vermeiden.

Wie immer ist der Code für diesen Artikel im GitHub-Repository verfügbar.

Starten Sie mit Spring 5 und Spring Boot 2, durch den Learn Spring Kurs:

>> CHECK OUT THE COURSE

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.