|
||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | ||||||||||||||||||||
|
Java Multithread Support - Threadpools
|
|||||||||||||||||||
Der Threadpool ist eine wichtige neue Abstraktion in Java 5.0. Threadpools
erlauben es, Tätigkeiten auf bereitstehende Threads verteilen und
sogar ggf. Tätigkeiten in einer Taskqueue zu speichern, falls keine
Poolthreads verfügbar sind. Bisher mußte man solche Threadpools
selber implementieren, wenn man sie brauchte. Das wird in der Zukunft
mit Java 5.0 nicht mehr nötig sein, weil der Threadpool in Java 5.0
äußerst flexibel und konfigurierbar ist. In diesem Beitrag sehen
wir uns die Details des Threadpools in Java 5.0 und die damit zusammenhängenden
Abstraktionen an.
ThreadpoolsEin Threadpool ist eine Abstraktion, die eine Anzahl von erzeugten und bereits gestarteten Thread vorrätig hält, um mit ihnen asynchrone Tätigkeiten auszuführen. Zum Beispiel wird ein Application Server einen Threadpool enthalten, um die hereinkommenden Serviceanfragen asynchron damit auszuführen. Die Nutzung eines Threadpools in einem solchen Kontext hat zwei Vorteile:
Executor, ExecutorService, AbstractExecutorService und ThreadPoolExcecutorExecutor ist ein relativ unspektakuläres Interface, das die Funktionalität anbietet, eine Tätigkeit, die Runnable implementiert hat, auszuführen. Die einzige Methode, die es dazu anbietet ist:public void execute(Runnable command) Abgeleitet davon gibt es das Interface ExecutorService. Es vereint zwei Aspekte: die Beendigung des Services und den Umgang mit Tätigkeiten, die ein Ergebnis liefern. Das Interface ExecutorService bietet also zum einen Service Management Funktionalität an, um den Service zu beenden, seinen Status abzufragen, usw. Die Namen der Methoden sind dabei selbsterklärend: shutdown(), shutdownNow(), awaitTermination(), isShutdown(), isTerminated(). Daneben bietet der ExecutorService Methoden an, mit denen sich Callable<V>s ausführen lassen. 1) Die zentrale Methode ist: public <V> Future<V> submit(Callable<V> task) Da Callable<V>s Ergebnisse produzieren, liefert die submit() Methode Zugriff auf das Ergebnis in Form eines Future<V> zurück . Man kann also eine Tätigkeit direkt als Callable<V> an einen ExecutorService zur Ausführung übergeben und danach mit Future<V>.get() auf das Ergebnis warten. Neben der oben gezeigten submit() Methode gibt es noch zwei Varianten, mit denen man Runnables übergeben kann, für die man dann ebenfalls ein Future zurückbekommt, mit dem man auf die Beendigung der Tätigkeit warten kann. Außerdem gibt es noch die Methode invokeAll(), mit der man gleich mehrere Tätigkeiten als Collection<Callable<T>> auf einmal übergeben kann. Man bekommt dann entsprechend eine Liste von Futures zurück. Für die submit() und invokeAll() Funktionalität gibt es eine Defaultimplementierung, die von der abstrakten Klasse AbstractExecutorService zur Verfügung gestellt wird. Die Implementierung ist nicht sonderlich aufregend. Die Tätigkeiten werden in FutureTasks verpackt und an die execute() Methode aus dem Superinterface Executor übergeben. Für die Bulk-Operation invokeAll() wird außerdem die Liste der Futures aufgebaut.
Richtig interessant wird es aber, wenn wir zu der Klasse kommen, die
die eigentliche Threadpool-Funktionalität implementiert. Das ist die
Klasse ThreadPoolExecutor. Sie liefert nun endlich die offizielle
Threadpool-Implementierung im JDK, nachdem es seit dem Erscheinen von Java
immer wieder Diskussionen in Artikeln, Büchern und dem Internet gab,
wie ein Threadpool in Java zu implementieren sei.
Funktionweise des ThreadPoolExecutorsWie man schon an dieser Diskussion sehen kann, ist eines der wichtigsten Kriterien eines möglichst breit einsetzbaren Threadpools, neben seiner Performance, seine umfangreiche Konfigurierbarkeit und Flexibilität in der Nutzung. Bevor wir uns aber die Tuningparameter und die Benutzungsoptionen des ThreadPoolExecutor ansehen können, sollten wir ein grundsätzliches Verständnis von seiner Funktionsweise haben. Mit der Methodevoid execute(Runnable r) wird dem ThreadPoolExecutors eine Tätigkeit (abgeleitet von Runnable) übergeben, die von einem der internen Pool-Threads ausgeführt werden soll. Da die submit() und invokeAll() Methoden letztlich auf die execute() Methode aufsetzen, können wir unsere Erläuterungen auf die execute() Methode beschränken.
Im ersten Schritt wird die Tätigkeit in einer internen Queue abgelegt.
Dabei kann der ThreadPoolExecutor so konfiguriert werden, dass verschiedene
Queuetypen aus den Erweiterungen des JDK 5.0 (siehe /
KRE7
/)
zu verschiedenem Verhalten des ThreadPoolExecutor führen können.
Dazu später mehr. In einem zweiten Schritt wird die Tätigkeit
aus der Queue genommen und an einen freien Pool-Thread zur Ausführung
übergeben. Abbildung 4 zeigt dieses zweistufige Vorgehen (wobei auch
zusätzliche Elemente dargestellt sind, auf die wir noch kommen werden).
Für die Anzahl der Pool-Threads im ThreadPoolExecutors sind zwei Konfigurationsparameter von Bedeutung: die Kerngröße des Pools (core pool size) und die Maximalgröße des Pools (maximum pool size). Nach dem Erzeugen einer ThreadPoolExecutor Instanz ist erst einmal gar kein Thread enthalten. Mit jedem Aufruf von execute() wird ein neuer Thread erzeugt, auch wenn ein freier Thread im Pool vorhanden ist, solange bis die Kerngröße erreicht ist. Wenn die Poolgröße gleich der Kerngröße ist und execute() aufgerufen wird, können drei Fälle unterschieden werden:
void rejectedExecution(Runnable r, ThreadPoolExecutor executor) aufgerufen. Dabei ist der erste Parameter die Tätigkeit, die nicht angenommen werden konnte, und der zweite Parameter der Threadpool, der sie nicht annehmen konnte. Wenn die Anzahl der Pool-Threads größer als die Kerngröße ist und ein Pool-Thread frei wird und ihm keine neue Tätigkeit zugewiesen werden kann, weil die Queue leer ist, dann hat dieser Thread nichts zu tun. Damit dieser Thread nicht ewig arbeitslos rumsitzt und nutzlos wertvolle Ressourcen verbraucht, wird dieser Thread nach einer konfigurierbaren Zeit, der sogenannten Idle-Time, aus dem Pool genommen und beendet. Konfiguration des ThreadPoolExecutorsNachdem jetzt klar ist, wie der ThreadPoolExecutor funktioniert und wie die Konfigurationsparameter in seine Funktionsweise eingehen, wollen wir uns die Parameter noch einmal genauer ansehen. Alle Konfigurationsparameter werden bei der Konstruktion des ThreadPoolExecutor übergeben. Alle Konstruktoren benötigen mindestens die folgenden Parameter:
Ein weiterer optionaler Konfigurationparameter, den wir bisher noch gar nicht erwähnt haben, ist eine ThreadFactory, die der ThreadPoolExecutor zum Erzeugen seiner Pool-Threads benutzt. Damit ist der ThreadPoolExecutor äußerst flexibel, was die Art der Threaderzeugung angeht. Eine Threadfactory implementiert die Methode: Thread newThread(Runnable r) Im Defaultfall, wenn keine eigene ThreadFactory übergeben wird, wird new Thread() zum Erzeugen der Pool-Threads genutzt.
Als Queue-Parameter kann dem ThreadPoolExecutor jede Queue, die das
Interface BlockingQueue<Runnable> implementiert, übergeben werden.
Ab der Version 5.0 gibt es verschiedene Queue-spezifische Abstraktionen
im JDK. Darunter sind verschiedene Klassen im Package java.util.concurrent,
die der Synchronisation zwischen zwei Threads dienen, einem lesenden und
einem schreibenden Thread (siehe /
KRE7
/).
Bei einer dynamisch wachsenden Queue wird der Pool niemals mehr Threads enthalten, als durch die Kerngröße spezifiziert wurde. Der Grund liegt im bereits oben diskutierten Verhalten des ThreadPoolExecutor: es werden erst mehr Threads erzeugt, als durch die Kerngröße spezifiziert, wenn die Queue voll ist. Dies ist bei einer dynamisch wachsenden Queue aber nie der Fall. Wie sollte man jetzt die Queue und die Poolgrößen wählen? Eine Queue, die keine Elemente aufnehmen kann, macht dann Sinn, wenn relativ gut vorhersehbar ist, wie viele parallele Tätigkeiten ablaufen werden, und diese Anzahl relativ klein ist. Man wird in diesem Fall für jede Tätigkeit einen Pool-Thread vorsehen. Die Nutzung einer dynamisch wachsenden Queue ist sinnvoll, um kurzzeitige, erratische Lastspitzen abzufangen. Sie ist aber nicht sinnvoll, wenn über längere Zeit mehr Aufträge an den Threadpool übergeben werden, als abgearbeitet werden können, denn dann würde die Queue extrem wachsen. In allen anderen Fällen ist eine Queue mit fester Größe und einem passenden RejectedExecutionHandler die beste Wahl. Hier stellt sich dann die Frage, wie Poolgröße und Queuegröße zueinander zu wählen sind. Dabei kann man sich an zwei Extremen orientieren, die es zu vermeiden gilt:
Alle Konfigurationsparameter, außer der Queue, können übrigens
auch dynamisch, in der ThreadPoolExecutor Instanz geändert werden.
D.h. die dynamische Anpassung muss nicht allein der ThreadPoolExecutor
Instanz selbst überlassen werden, sondern kann zusätzlich noch
von außen gesteuert werden. Dies macht eine Lösung aber natürlich
auch noch deutlich komplexer und wird gerade auf Grund der hohen Flexibilität
des ThreadPoolExecutor nur in Ausnahmefällen nötig sein.
Factory Methoden zur Erzeugung von ThreadPoolExecutor InstanzenWer sich nicht mit den Details der Konfiguration des ThreadPoolExecutor auseinandersetzen will, dem bietet die Helper-Klasse Executors verschiedene statische Factory-Methoden an, mit denen sich Threadpools mit spezifischen Charakteristika einfach erzeugen lassen. Da gibt es zum Beispiel die Methode newSingleThreadExecutor(), die einen Threadpool mit einem Thread und unbegrenzt wachsender Queue erzeugt. Damit ist sichergestellt, dass alle übergebenen Tätigkeiten streng sequenziell abgearbeitet werden und auch in Fällen von Lastspitzen alle Tätigkeiten gespeichert werden können. Daneben gibt es die Methode newFixedThreadPool(), die einen Threadpool mit einem einer festen Anzahl Threads und unbegrenzt wachsender Queue erzeugt. Mit der Methode newCachedThreadPool() wird ein Threadpool erzeugt, der Threads bei Bedarf erzeugt, wenn keine freien vorhanden sind. Threads, die für 60 Sekunden nicht benutzt wurden, werden aus dem Pool entfernt. Da jedesmal bei Bedarf Threads erzeugt werden, eignet sich die Abstraktion im wesentlichen für kurzlebige Tätigkeiten, die asynchron zueinander ablaufen dürfen und unter Umständen mit starken Schwankungen auftreten können.Ableiten vom ThreadPoolExecutorWem all diese Konfigurationsmöglichkeiten nicht ausreichen, der kann natürlich auch von ThreadPoolExecutor ableiten, denn die Klasse ist nicht final. Sie bietet vielmehr drei Templatemethoden (im Sinne des gereichnamigen GOF-Pattern / GOF /) an. Sie sind protected und können dem Pattern entsprechend von abgeleiteten Klassen überschrieben werden.Zwei der Templatemethoden werden im Zusammenhang mit der Ausführung einer Tätigkeit durch einen Pool-Thread benutzt. Die Methode: void beforeExecute(Thread t, Runnable r) wird aufgerufen, bevor der Thread t die asynchrone Tätigkeit r ausführt und void afterExecute(Thread t, Runnable r) wird aufgerufen, nachdem der Thread t die asynchrone Tätigkeit r ausgeführt hat. Überschreibt man zusätzlich zu diesen beiden Templatemethoden noch die execute() Methode und hängt einen passenden RejectedExecutionHandler ein, so lässt sich damit zum Beispiel sehr präzise mittracen, wie und wann die einzelnen Tätigkeiten durch den ThreadPoolExecutor verarbeitet werden. Die dritte und letzte Template Methode ist: void terminated()
Sie wird aufgerufen, nachdem der ThreadPoolExecutor beendet worden ist.
ThreadPoolExecutor APIBis jetzt haben wir im wesentlichen die innere Funktionsweise sowie die Konfigurierbarkeit des ThreadPoolExecutor diskutiert. Kommen wir abschließend noch einmal zu einer kurzen Zusammenfassung des ThreadPoolExecutor API. Dieser besteht erst einmal aus den Methoden des Executor und ExecutorService, die wir beide bereits oben diskutiert haben.Weiter enthält der API die Methoden, um Konfigurationsparameter (z. B. die Kerngröße des Pools) zu lesen und zu schreiben. Die Methoden heißen setCorePoolSize(), getKeepAliveTime(), usw. Man kann einen oder alle Pool-Threads vorab starten, so dass sie bereits auf Tätigkeiten warten. Dazu dienen die Methoden prestartAllCoreThreads() und prestartCoreThread(). Daneben gibt es noch verschiedene Methoden, die dem Benutzer Profiling-Information über den ThreadPoolExecutor geben:
Insgesamt ist die Threadpool-Implementierung, die mit der Klasse ThreadPoolExecutor
zur Verfügung steht, äußerst flexibel und dynamisch konfigurierbar
(durch Parameter und Spezialisierung per Vererbung). Dazu hat die
Threadpool-Implementierung ein breites API für die Verwaltung des
Threadpools. Das einzige, was noch fehlt, ist Unterstützung
beim Aufsammeln der Ergebnisse, die von den Tätigkeiten u.U. erzeugt
werden. Diese Funktionalität ist in eine separate Abstraktion,
den CompletionService, ausgelagert.
CompletionService und ExecutorCompletionServiceEs kann vorkommen, dass man das Erzeugen und Starten von Tätigkeiten entkoppeln möchte vom Abholen und Verarbeiten der Ergebnisse, die diese Tätigkeiten erzeugt haben. Das ist beispielsweise der Fall, wenn mit asynchroner I/O gearbeitet wird. Dann könnte ein Teil der Applikation die Lese-Tasks erzeugen und einem Threadpool zur Ausführung übergeben, während ein ganz anderer Teil der Applikation die eingelesenen Daten später in Empfang nimmt und verarbeitet. Diese Arbeitsteilung wird vom Ergebnisservice (engl. Completion Service) unterstützt.Definiert ist die Funktionalität durch das Interface CompletionService. Es hat submit() Methoden, mit denen man ähnlich wie beim ExecutorService Callable<V>s und Runnables zur Ausführung übergeben kann.
Future<V> submit(Callable<V> task)
Die Idee dabei ist, dass ein Ergebnisservice in der Regel einen Threadpool verwenden wird, der die Tätigkeiten ausführt. Zusätzlich hat das CompletionService Interface eine take() und zwei poll() Methoden, mit denen das jeweils nächste Ergebnis abgeholt werden kann.
Future<V> take()
Die Methode take() wartet, falls kein Ergebnis da ist und die Methode poll() kehrt sofort, oder nach einem Timeout, zurück und liefert null, wenn kein Ergebnis da war. Das Ergebnis wird wie immer in Form eines Future<V> geliefert. Man bekommt also nicht direkt das Ergebnis, sondern ein Future, über das man das Ergebnis abholen kann oder auch darauf warten kann. Die Idee für take() und poll() ist, das der Ergebnisservice intern eine Ergebnisqueue hält, in die er das Future jeder Tätigkeit stellt, sobald diese fertig ist. Dabei ist es unerheblich, ob die Tätigkeit normal beendet oder abgebrochen wurde. Die Futures erscheinen also in der Ergebnisqueue in der Reihenfolge, in der sie fertig geworden sind, und diese Reihenfolge ist im Allgemeinen anders als die Reihenfolge, in der sie mit submit() zur Ausführung übergeben wurden. Die Klasse ExecutorCompletionService implementiert das CompletionService Interface. Sie hat Konstruktoren, denen man einen Executor (den Threadpool) und optional eine BlockingQueue<Future<V>> (die Ergebnisqueue) mitgibt. Wenn keine Queue mitgegeben wird, dann wird defaultmäßig eine LinkedBlockingQueue<Future<V>> verwendet.
Sehen wir uns ein einfaches Anwendungsbeispiel an. Nehmen wir an, wir hätten eine Reihe von Tätigkeiten und wir wollen diese Tätigkeiten parallel ablaufen lassen. Anschließend wollen wir uns die Ergebnisse ansehen und verarbeiten. In unserem Beispiel wird die Tätigkeit eine Primzahlberechnung sein und das Ergebnis ist jeweils eine Liste von Integers, nämlich die berechneten Primzahlen. Das Beispiel ist nicht besonders realistisch, weil die Primzahlen redundant berechnet werden, aber für die Illustration des Prinzips ist es ausreichend.
public final class PrimeNumberCalculator implements Callable<List<Integer>>
{
public PrimeNumberCalculator(int i) { upperBound = i; }
public List<Integer> call() {
private static CompletionService<List<Integer>>
public static void main(String[] args) {
In der Klasse Solver sieht man eine typische Zerlegung der Aufgabe in eine Scatter-Phase, in der die Tätigkeiten gestartet werden, und eine Gather-Phase, in der die Ergebnisse abgeholt und verarbeitet werden. Die Methode scatter() erzeugt einen Threadpool mit Hilfe einer der Factory-Methoden (in Zeile 1), übergibt ihn an einen Ergebnisservice mit Default-Ergebnisqueue (in Zeile 2) und übergibt dann alle Tasks über den Ergebnisservice an den Threadpool (in Zeile 3). Die Methode gather() holt sich aus der Ergebnisqueue des Ergebnisservice alle Ergebnisse ab (in Zeile 4) und verarbeitet sie. Das Resultat könnte dann beispielsweise so aussehen:
[]
Man sieht deutlich, dass die Reihenfolge der Ergebnisse von der Reihenfolge abweicht, in der die Primzahlberechnungen gestartet wurden. ScheduledExecutorService und ScheduledThreadPoolExecutorBisher nicht betrachtet haben wir solche Tätigkeiten, die entweder periodisch oder aber nach einer bestimmten Zeitspanne ausgeführt werden sollen. Dafür gibt es einen speziellen Threadpool, beschrieben über das Interface ScheduledExecutorService und die implementierende Klasse ScheduledThreadPoolExecutor, jeweils abgeleitet von ihren Namensvettern. In dieser Art von Threadpool gibt es zusätzlich zu den Methoden execute() und submit(), die wir aus dem normalen Threadpool kennen, diverse schedule() Methoden, mit denen sich das gewünschte Timing erreichen läßt. Eine ausführliche Diskussion des Threadpools für periodische oder terminabhängige Tätigkeiten würde den Rahmen des Artikels sprengen. Nur ganz kurzein paar Bemerkungen zur Abgrenzung gegen den Timer aus dem JDK 1.3.Eine ähnliche Abstraktion für die Ausführung von periodischen und terminabhängigen Tätigkeiten gibt es schon seit JDK 1.3 mit java.util.Timer. Der Timer führt alle Tätigkeiten in einem einzigen Thread aus, wohingegen der ScheduledThreadPoolExecutor ein Threadpool ist, der je nach Konfiguration beliebig viele Threads für das termingerechte Ausführen der Tätigkeiten zur Verfügung hat. Das ist natürlich sicherer, weil es bei Verwendung eines Pools nicht vorkommen kann, dass eine Tätigkeit alle anderen aufhält, weil sie schrecklich lange dauert. Beim Timer, der ja nur einen einzigen Thread zur Verfügung hat, kann es tatsächlich passieren, dass sich die Tätigkeiten stauen und deshalb nicht zum gewünschten Termin ausgeführt werden können, sondern erst mit Verspätung. In solchen Situationen ist der ScheduledExecutorService natürlich die bessere Alternative. Tuning-TippsJetzt haben wir uns ausführlich alle Features des Standard-Threadpools in Java 5.0 angesehen. Da stellt sich abschließend die Frage: wann braucht man überhaupt einen Threadpool oder wann sollte man die Verwendung eines Pools vermeiden? Der Threadpool ist eine gute Lösung in Anwendungen, in denen eine große Anzahl von kurzlebigen, voneinander unabhängigen Aufgaben erledigt werden muß. Das ist typisch für Server, die jeden Request von einem eigenen Worker-Thread bearbeiten lassen.Eine ganz andere Situation liegt vor, wenn die Anwendung eine geringe Zahl von lang andauernden Aufgaben auf Threads verteilen will. Dann ist der Threadpool nicht unbedingt die ideale Lösung. Eine lang andauernde Tätigkeit, die beispielsweise auf I/O wartet oder auf Benutzereingaben an einer Benutzeroberfläche, blockiert einen Pool-Thread unnötig lange. Der blockierte Pool-Thread könnte wesentlich effektiver genutzt werden, um andere kurzlebige Tätigkeiten erledigen. Um eine höhere Auslastung des Pools zu erreichen, kann man die langwierigen Tätigkeiten nach einer gewissen Zeit abbrechen und das Scheitern melden, oder die Aufgabe einfach noch einmal in die Queue stellen für eine erneute Bearbeitung zu einem späteren Zeitpunkt. Das muß aber explizit so programmiert werden; automatisch passiert das im Standard-Threadpool nicht. Lang-andauernde Aufgaben sollten daher nur mit Bedacht an einen Pool übergeben werden. Prinzipiell kann man auch lang andauernden Tätigkeiten über einen Standard-Threadpool erledigen lassen, aber dieser Pool muß anders konfiguriert sein als ein Pool, der viele kurzlebige Aufgaben zu erledigen hat. Für ein effektives Tuning eines Threadpools ist daher immer eine gute Einschätzung der Eigenschaften der Aufgaben erforderlich. Sind die Aufgaben CPU-intensiv, oder warten sie lange auf I/O oder anderes? Dauern sie lange oder geht es schnell? Wenn es in der Anwendung radikal verschiedenen Aufgaben gibt, dann ist es u.U. sinnvoll, anstelle von nur einem Threadpool mehrere unterschiedlich konfigurierte Threadpools zu verwenden. Vorsicht ist nicht nur bei den langwierigen Aufgaben geboten, sondern auch bei Tätigkeiten, die stark voneinander abhängen. Wenn Aufgaben auf einen Threadpool verteilt werden, die aufeinander warten., dann kann es passieren, daß alle Pool-Threads eine Aufgabe erledigen, die auf ein Ergebnis einer anderen Aufgabe warten, die noch in der Task-Queue hängt und nicht ausgeführt werden kann, weil es keine, freien Pool-Thread mehr gibt. Das ist eine Art Deadlock, weil die aktiven Pool-Threads auf ein Ergebnis warten, das aber nicht produziert werden kann, weil alle verfügbaren Threads mit Warten „beschäftigt“ sind. Ein Threadpool ist deshalb für kooperierende Thread nicht unbedingt die geeignete Lösung. Man sollte daher darauf achten, daß die an einen Pool übergebenen Aufgaben nach Möglichkeit voneinander unabhängig sind.
Ideal ist der Threadpool, wie schon gesagt, für Anwendungen, in
denen eine große Anzahl von kurzlebigen, voneinander unabhängigen
Aufgaben erledigt werden muß.
ZusammenfassungFür das Ausführen von Threads sind in Java 5.0 viele neue Abstraktionen zum JDK hinzugekommen. Es gibt eine flexibel konfigurierbare Threadpool-Implementierung, mit deren Hilfe sich Tätigkeiten auf bereitstehende Threads verteilen und sogar ggf. in einer Taskqueue cachen lassen. Hinzu kommt der Ergebnisservice, der die Resultate in einer Ergebnisqueue zur Abholung bereitstellt. Zusammen mit den Queues und den Synchronisatoren, die wir im vorletzten Artikel besprochen haben, ist es jetzt relativ einfach, stabile und fehlerfreie Multithread-Programme zu schreiben. Viele der fehleranfälligen Details der Synchronisation sind jetzt in den neuen Abstraktionen des java.util.concurrent Packages gekapselt und dort zuverlässig gelöst.
Damit endet unsere Reihe über Multithread-Programmierung.
In der nächsten Ausgabe wenden wir uns einem neuen Themenkreis zu.
Literaturverweise
Fußnoten
|
||||||||||||||||||||
© Copyright 1995-2008 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/20.ThreadPools/20.ThreadPools.html> last update: 26 Nov 2008 |