Einführung in Thread Pools in Java

Einführung in Thread-Pools in Java

1. Einführung

Dieser Artikel befasst sich mit Thread-Pools in Java - angefangen bei den verschiedenen Implementierungen in der Standard-Java-Bibliothek bis hin zur Guava-Bibliothek von Google.

2. Der Thread-Pool

In Java werden Threads Threads auf Systemebene zugeordnet, die die Ressourcen des Betriebssystems darstellen. Wenn Sie Threads unkontrolliert erstellen, können diese Ressourcen schnell ausgehen.

Der Kontextwechsel zwischen Threads wird ebenfalls vom Betriebssystem durchgeführt, um Parallelität zu emulieren. Eine vereinfachende Sichtweise ist: Je mehr Threads Sie erzeugen, desto weniger Zeit verbringt jeder Thread mit der eigentlichen Arbeit.

Das Thread-Pool-Muster hilft, Ressourcen in einer Multithread-Anwendung zu sparen und die Parallelität in bestimmten vordefinierten Grenzen zu halten.

Wenn Sie einen Thread-Pool verwenden, erhalten Siewrite your concurrent code in the form of parallel tasks and submit them for execution to an instance of a thread pool. Diese Instanz steuert mehrere wiederverwendete Threads zum Ausführen dieser Aufgaben. 2016-08-10_10-16-52-1024x572

Mit dem Muster können Siecontrol the number of threads the application is creating, ihren Lebenszyklus sowie die Ausführung von Aufgaben planen und eingehende Aufgaben in einer Warteschlange halten.

3. Thread-Pools in Java

3.1. Executors,Executor undExecutorService

Die HilfsklasseExecutorsenthält verschiedene Methoden zum Erstellen vorkonfigurierter Thread-Pool-Instanzen für Sie. Diese Kurse sind ein guter Ausgangspunkt - verwenden Sie sie, wenn Sie keine benutzerdefinierte Feinabstimmung vornehmen müssen.

Die SchnittstellenExecutor undExecutorService werden verwendet, um mit verschiedenen Thread-Pool-Implementierungen in Java zu arbeiten. Normalerweise sollten Siekeep your code decoupled from the actual implementation of the thread pool verwenden und diese Schnittstellen in Ihrer gesamten Anwendung verwenden.

DieExecutor-Schnittstelle verfügt über eine einzelneexecute-S-Methode, umRunnable-Instanzen zur Ausführung zu übermitteln.

Here’s a quick example, wie Sie dieExecutors-API verwenden können, um eineExecutor-Instanz abzurufen, die von einem einzelnen Thread-Pool und einer unbegrenzten Warteschlange zum sequentiellen Ausführen von Aufgaben unterstützt wird. Hier führen wir eine einzelne Aufgabe aus, die einfach "Hello World" auf dem Bildschirm druckt. Die Aufgabe wird als Lambda (eine Java 8-Funktion) übergeben, von der alsRunnable abgeleitet wird.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

DieExecutorService-Schnittstelle enthält eine große Anzahl von Methoden fürcontrolling the progress of the tasks and managing the termination of the service. Über diese Schnittstelle können Sie die Aufgaben zur Ausführung senden und ihre Ausführung auch mithilfe der zurückgegebenenFuture-Instanz steuern.

In the following example erstellen wirExecutorService, senden eine Aufgabe und verwenden dann dieget-MethodeFuture, um zu warten, bis die übermittelte Aufgabe abgeschlossen ist und der Wert zurückgegeben wird:

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

Natürlich möchten Sie in einem realen Szenariofuture.get()normalerweise nicht sofort aufrufen, sondern den Aufruf verschieben, bis Sie tatsächlich den Wert der Berechnung benötigen.

Diesubmit-Methode ist überladen, um entwederRunnable oderCallable zu verwenden. Beide sind funktionale Schnittstellen und können als Lambdas übergeben werden (beginnend mit Java 8).

Die einzelne Methode vonRunnablelöst keine Ausnahme aus und gibt keinen Wert zurück. Die Schnittstelle vonCallableist möglicherweise bequemer, da sie es ermöglicht, eine Ausnahme auszulösen und einen Wert zurückzugeben.

Schließlich - damit der Compiler auf den TypCallablechließen kann, geben Sie einfach einen Wert aus dem Lambda zurück.

Weitere Beispiele zur Verwendung der Schnittstelle und der Zukunft vonExecutorServicefinden Sie unter „A Guide to the Java ExecutorService“.

3.2. ThreadPoolExecutor

ThreadPoolExecutor ist eine erweiterbare Thread-Pool-Implementierung mit vielen Parametern und Hooks zur Feinabstimmung.

Die Hauptkonfigurationsparameter, die wir hier diskutieren werden, sind:corePoolSize,maximumPoolSize undkeepAliveTime.

Der Pool besteht aus einer festen Anzahl von Kern-Threads, die die ganze Zeit im Inneren verbleiben, und einigen übermäßigen Threads, die möglicherweise erzeugt und dann beendet werden, wenn sie nicht mehr benötigt werden. Der ParametercorePoolSize gibt die Anzahl der Kernthreads an, die instanziiert und im Pool gespeichert werden. Wenn alle Kernthreads ausgelastet sind und mehr Aufgaben gesendet werden, kann der Pool aufmaximumPoolSize anwachsen.

Der ParameterkeepAliveTime ist das Zeitintervall, für das die übermäßigen Threads (d. H. Threads, die übercorePoolSize hinaus instanziiert werden, dürfen im Ruhezustand existieren.

Diese Parameter decken einen weiten Bereich von Anwendungsfällen ab, jedochthe most typical configurations are predefined in the Executors static methods.

For example,newFixedThreadPool Methode erstellt einThreadPoolExecutor mit gleichencorePoolSize undmaximumPoolSize Parameterwerten und einer NullkeepAliveTime. Dies bedeutet, dass die Anzahl der Threads in Dieser Thread-Pool ist immer der gleiche:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());

Im obigen Beispiel instanziieren wir aThreadPoolExecutor mit einer festen Threadanzahl von 2. Das heißt, wenn die Anzahl der gleichzeitig ausgeführten Aufgaben zu jedem Zeitpunkt kleiner oder gleich zwei ist, werden sie sofort ausgeführt. Ansonstensome of these tasks may be put into a queue to wait for their turn.

Wir haben dreiCallableAufgaben erstellt, die schwere Arbeit imitieren, indem wir 1000 Millisekunden lang schlafen. Die ersten beiden Tasks werden sofort ausgeführt, und der dritte Task muss in der Warteschlange warten. Wir können dies überprüfen, indem wir die MethodengetPoolSize() undgetQueue().size() unmittelbar nach dem Senden der Aufgaben aufrufen.

Ein weiteres vorkonfiguriertesThreadPoolExecutor kann mit der MethodeExecutors.newCachedThreadPool() erstellt werden. Diese Methode empfängt überhaupt keine Anzahl von Threads. corePoolSize wird tatsächlich auf 0 gesetzt, undmaximumPoolSize wird für diese Instanz aufInteger.MAX_VALUE gesetzt. DaskeepAliveTime beträgt für dieses 60 Sekunden.

Diese Parameterwerte bedeutenthe cached thread pool may grow without bounds to accommodate any amount of submitted tasks. Wenn die Threads jedoch nicht mehr benötigt werden, werden sie nach 60 Sekunden Inaktivität entsorgt. Ein typischer Anwendungsfall ist, wenn Ihre Anwendung viele kurzlebige Aufgaben enthält.

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());

Die Warteschlangengröße im obigen Beispiel ist immer Null, da intern eineSynchronousQueue-Instanz verwendet wird. InSynchronousQueue treten Paare voninsert- undremove-Operationen immer gleichzeitig auf, sodass die Warteschlange eigentlich nie etwas enthält.

DieExecutors.newSingleThreadExecutor()-API erstellt eine andere typische Form vonThreadPoolExecutor, die einen einzelnen Thread enthält. The single thread executor is ideal for creating an event loop. Die ParametercorePoolSize undmaximumPoolSize sind gleich 1 undkeepAliveTime ist Null.

Die Aufgaben im obigen Beispiel werden nacheinander ausgeführt, sodass der Flag-Wert nach Abschluss der Aufgabe 2 beträgt:

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});

Darüber hinaus ist diesesThreadPoolExecutor mit einem unveränderlichen Wrapper dekoriert, sodass es nach der Erstellung nicht neu konfiguriert werden kann. Beachten Sie, dass dies auch der Grund ist, warum wir es nicht inThreadPoolExecutor umwandeln können.

3.3. ScheduledThreadPoolExecutor

DasScheduledThreadPoolExecutor erweitert dieThreadPoolExecutor-Klasse und implementiert auch dieScheduledExecutorService-Schnittstelle mit mehreren zusätzlichen Methoden:

  • Mit der Methodeschedulekann eine Aufgabe nach einer bestimmten Verzögerung einmal ausgeführt werden.

  • Mit der MethodescheduleAtFixedRatekann eine Aufgabe nach einer bestimmten Anfangsverzögerung ausgeführt und dann mit einer bestimmten Zeit wiederholt ausgeführt werden. Das Argumentperiod ist die Zeitmeasured between the starting times of the tasks, daher ist die Ausführungsrate fest.

  • Die Methode vonscheduleWithFixedDelay ähnelt der vonscheduleAtFixedRate, da sie die angegebene Aufgabe wiederholt ausführt, die angegebene Verzögerung jedochmeasured between the end of the previous task and the start of the next beträgt. Die Ausführungsrate kann abhängig von der Zeit variieren, die zum Ausführen einer bestimmten Aufgabe benötigt wird.

DieExecutors.newScheduledThreadPool()-Methode wird typischerweise verwendet, um einScheduledThreadPoolExecutor mit einem gegebenencorePoolSize, unbegrenztenmaximumPoolSize und nullkeepAliveTime zu erzeugen. So planen Sie eine Aufgabe für die Ausführung in 500 Millisekunden:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);

Der folgende Code zeigt, wie eine Aufgabe nach einer Verzögerung von 500 Millisekunden ausgeführt und anschließend alle 100 Millisekunden wiederholt wird. Nachdem wir die Aufgabe geplant haben, warten wir mit derCountDownLatch-Sperre,, bis sie dreimal ausgelöst wird, und brechen sie dann mit derFuture.cancel()-Methode ab.

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);

3.4. ForkJoinPool

ForkJoinPool ist der zentrale Teil des in Java 7 eingeführtenfork/join Frameworks. Es löst ein häufiges Problem vonspawning multiple tasks in recursive algorithms. Mit einem einfachenThreadPoolExecutor gehen Ihnen schnell die Threads aus, da für jede Aufgabe oder Unteraufgabe ein eigener Thread erforderlich ist.

In einemfork/join-Framework kann jede Aufgabe eine Reihe von Unteraufgaben erzeugen (fork) und mit derjoin-Methode auf deren Abschluss warten. Der Vorteil desfork/join-Frameworks besteht darin, dass esdoes not create a new thread for each task or subtask ist und stattdessen den Work Stealing-Algorithmus implementiert. Dieses Framework wird im Artikel „Guide to the Fork/Join Framework in Java“ ausführlich beschrieben.

Schauen wir uns ein einfaches Beispiel für die Verwendung vonForkJoinPool an, um einen Knotenbaum zu durchlaufen und die Summe aller Blattwerte zu berechnen. Hier ist eine einfache Implementierung eines Baums, der aus einem Knoten, einemint-Wert und einer Reihe von untergeordneten Knoten besteht:

static class TreeNode {

    int value;

    Set children;

    TreeNode(int value, TreeNode... children) {
        this.value = value;
        this.children = Sets.newHashSet(children);
    }
}

Wenn wir nun alle Werte in einem Baum parallel summieren möchten, müssen wir eineRecursiveTask<Integer>-Schnittstelle implementieren. Jede Aufgabe empfängt ihren eigenen Knoten und addiert ihren Wert zur Summe der Werte ihrerchildren. Um die Summe derchildren-Werte zu berechnen, führt die Task-Implementierung Folgendes aus:

  • überträgt die eingestelltenchildren,

  • ordnet diesen Stream zu und erstellt für jedes Element ein neuesCountingTask.

  • Führt jede Unteraufgabe aus, indem sie gegabelt wird.

  • sammelt die Ergebnisse, indem die Methodejoinfür jede gegabelte Aufgabe aufgerufen wird.

  • summiert die Ergebnisse mit dem KollektorCollectors.summingInt.

public static class CountingTask extends RecursiveTask {

    private final TreeNode node;

    public CountingTask(TreeNode node) {
        this.node = node;
    }

    @Override
    protected Integer compute() {
        return node.value + node.children.stream()
          .map(childNode -> new CountingTask(childNode).fork())
          .collect(Collectors.summingInt(ForkJoinTask::join));
    }
}

Der Code zum Ausführen der Berechnung für einen tatsächlichen Baum ist sehr einfach:

TreeNode tree = new TreeNode(5,
  new TreeNode(3), new TreeNode(2,
    new TreeNode(2), new TreeNode(8)));

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));

4. Implementierung des Thread-Pools in Guava

Guava ist eine beliebte Google-Bibliothek mit Dienstprogrammen. Es gibt viele nützliche Parallelitätsklassen, einschließlich mehrerer praktischer Implementierungen vonExecutorService. Auf die implementierenden Klassen kann nicht direkt instanziiert oder in Unterklassen unterteilt werden. Der einzige Einstiegspunkt für die Erstellung ihrer Instanzen ist die HilfsklasseMoreExecutors.

4.1. Hinzufügen von Guave als Maven-Abhängigkeit

Fügen Sie Ihrer Maven-pom-Datei die folgende Abhängigkeit hinzu, um die Guava-Bibliothek in Ihr Projekt aufzunehmen. Sie finden die neueste Version der Guava-Bibliothek im Repository vonMaven Central:


    com.google.guava
    guava
    19.0

4.2. Direct Executor und Direct Executor Service

Manchmal möchten Sie die Aufgabe entweder im aktuellen Thread oder in einem Thread-Pool ausführen, abhängig von bestimmten Bedingungen. Sie möchten lieber eine einzelneExecutor-Schnittstelle verwenden und einfach die Implementierung wechseln. Obwohl es nicht so schwer ist, eine Implementierung vonExecutor oderExecutorService zu finden, die die Aufgaben im aktuellen Thread ausführt, muss dennoch ein Teil des Boilerplate-Codes geschrieben werden.

Gerne stellt uns Guava vordefinierte Instanzen zur Verfügung.

Here’s an example, das die Ausführung einer Aufgabe im selben Thread demonstriert. Obwohl die bereitgestellte Aufgabe 500 Millisekunden lang im Ruhezustand ist, beträgt sieblocks the current thread, und das Ergebnis ist sofort verfügbar, nachdem der Aufruf vonexecutebeendet wurde:

Executor executor = MoreExecutors.directExecutor();

AtomicBoolean executed = new AtomicBoolean();

executor.execute(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    executed.set(true);
});

assertTrue(executed.get());

Die von derdirectExecutor()-Methode zurückgegebene Instanz ist tatsächlich ein statischer Singleton. Die Verwendung dieser Methode verursacht also überhaupt keinen Overhead bei der Objekterstellung.

Sie sollten diese MethodeMoreExecutors.newDirectExecutorService()vorziehen, da diese API bei jedem Aufruf eine vollwertige Executor-Service-Implementierung erstellt.

4.3. Executor Services beenden

Ein weiteres häufiges Problem istshutting down the virtual machine, während ein Thread-Pool seine Aufgaben noch ausführt. Selbst wenn ein Abbruchmechanismus vorhanden ist, kann nicht garantiert werden, dass sich die Tasks ordnungsgemäß verhalten und ihre Arbeit beenden, wenn der Executor-Service heruntergefahren wird. Dies kann dazu führen, dass JVM unbegrenzt hängt, während die Aufgaben weiterarbeiten.

Um dieses Problem zu lösen, führt Guava eine Familie von Exit-Executor-Diensten ein. Sie basieren aufdaemon threads which terminate together with the JVM.

Diese Dienste fügen außerdem einen Shutdown-Hook mit derRuntime.getRuntime().addShutdownHook()-S-Methode hinzu und verhindern, dass die VM für eine konfigurierte Zeitspanne beendet wird, bevor aufgegebene Aufgaben aufgegeben werden.

Im folgenden Beispiel senden wir die Aufgabe, die eine Endlosschleife enthält, verwenden jedoch einen Exiting Executor-Dienst mit einer konfigurierten Zeit von 100 Millisekunden, um nach Beendigung der VM auf die Aufgaben zu warten. Ohne dieexitingExecutorService würde diese Aufgabe dazu führen, dass die VM auf unbestimmte Zeit hängen bleibt:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService =
  MoreExecutors.getExitingExecutorService(executor,
    100, TimeUnit.MILLISECONDS);

executorService.submit(() -> {
    while (true) {
    }
});

4.4. Dekorateure hören

Mit Listening Decorators können Sie dieExecutorService umschließen undListenableFuture Instanzen bei der Aufgabenübermittlung empfangen, anstatt einfacheFuture Instanzen. DieListenableFuture-Schnittstelle erweitertFuture und verfügt über eine einzige zusätzliche MethodeaddListener. Mit dieser Methode können Sie einen Listener hinzufügen, der bei der zukünftigen Fertigstellung aufgerufen wird.

Sie möchten die MethodeListenableFuture.addListener()elten direkt verwenden, aber es istessential to most of the helper methods in the Futures utility class. Mit der MethodeFutures.allAsList() können Sie beispielsweise mehrereListenableFuture Instanzen in einem einzigenListenableFuture kombinieren, der nach erfolgreichem Abschluss aller kombinierten Futures abgeschlossen wird:

ExecutorService executorService = Executors.newCachedThreadPool();
ListeningExecutorService listeningExecutorService =
  MoreExecutors.listeningDecorator(executorService);

ListenableFuture future1 =
  listeningExecutorService.submit(() -> "Hello");
ListenableFuture future2 =
  listeningExecutorService.submit(() -> "World");

String greeting = Futures.allAsList(future1, future2).get()
  .stream()
  .collect(Collectors.joining(" "));
assertEquals("Hello World", greeting);

5. Fazit

In diesem Artikel haben wir das Thread-Pool-Muster und seine Implementierungen in der Standard-Java-Bibliothek und in der Guava-Bibliothek von Google erläutert.

Der Quellcode für den Artikel istover on GitHub verfügbar.