A Guide to the Java ExecutorService

Overview

ExecutorService is een JDK API die het uitvoeren van taken in asynchrone modus vereenvoudigt. In het algemeen biedt ExecutorService automatisch een pool van threads en een API om taken aan toe te wijzen.

Verder lezen:

Gids voor het Fork/Join Framework in Java

Een introductie tot het fork/join framework dat in Java 7 wordt gepresenteerd en de hulpmiddelen om parallelle verwerking te versnellen door te proberen alle beschikbare processorkernen te gebruiken.
Lees meer →

Overzicht van de java.util.concurrent

Ontdek de inhoud van het java.util.concurrent pakket.
Lees meer →

Gids voor java.util.concurrent.Locks

In dit artikel verkennen we verschillende implementaties van de Lock interface en de nieuw geïntroduceerde in Java 9 StampedLock klasse.
Lees meer →

Instantiating ExecutorService

2.1. Factory Methods of the Executors Class

De eenvoudigste manier om een ExecutorService te maken is door gebruik te maken van een van de factory methods van de Executors class.

De volgende regel code maakt bijvoorbeeld een thread pool met 10 threads:

ExecutorService executor = Executors.newFixedThreadPool(10);

Er zijn diverse andere factory methods om een voorgedefinieerde ExecutorService te maken die voldoet aan specifieke use cases. Om de beste methode voor uw behoeften te vinden, raadpleegt u de officiële documentatie van Oracle.

2.2. Maak direct een ExecutorService

Omdat ExecutorService een interface is, kan een instantie van elke implementatie ervan worden gebruikt. Er zijn verschillende implementaties om uit te kiezen in het java.util.concurrent pakket, of je kunt je eigen maken.

Bijv. de ThreadPoolExecutor klasse heeft een paar constructors die we kunnen gebruiken om een executor service en zijn interne pool te configureren:

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

Je zult misschien opmerken dat de code hierboven erg lijkt op de broncode van de factory method newSingleThreadExecutor(). Voor de meeste gevallen is een gedetailleerde handmatige configuratie niet nodig.

Taken toewijzen aan de ExecutorService

ExecutorService kan Runnable en Callable taken uitvoeren. Om het in dit artikel eenvoudig te houden, zullen we twee primitieve taken gebruiken. Merk op dat we hier lambda expressies gebruiken in plaats van anonieme inner classes:

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);

We kunnen taken aan de ExecutorService toewijzen met verschillende methoden, waaronder execute(), die geërfd is van de Executor interface, en ook submit(), invokeAny() en invokeAll().

De execute() methode is ongeldig en geeft geen mogelijkheid om het resultaat van de uitvoering van een taak te krijgen of om de status van de taak te controleren (loopt hij):

executorService.execute(runnableTask);

submit() dient een Callable of een Runnable taak bij een ExecutorService in en geeft een resultaat van het type Future terug:

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

invokeAny() wijst een verzameling taken aan een ExecutorService toe, waardoor elke taak wordt uitgevoerd, en retourneert het resultaat van een succesvolle uitvoering van één taak (als er een succesvolle uitvoering was):

String result = executorService.invokeAny(callableTasks);

invokeAll() wijst een verzameling taken toe aan een ExecutorService, waardoor elke taak wordt uitgevoerd, en retourneert het resultaat van alle taakuitvoeringen in de vorm van een lijst met objecten van het type Future:

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

Voordat we verder gaan, moeten we nog twee items bespreken: het afsluiten van een ExecutorService en het omgaan met Future return types.

Een ExecutorService afsluiten

In het algemeen wordt de ExecutorService niet automatisch vernietigd als er geen taak meer is om te verwerken.

In sommige gevallen is dit erg handig, zoals wanneer een app taken moet verwerken die op een onregelmatige basis verschijnen of wanneer de hoeveelheid taken niet bekend is bij het compileren.

Aan de andere kant kan een app zijn einde bereiken, maar niet worden gestopt omdat een wachtende ExecutorService ervoor zorgt dat de JVM blijft draaien.

Om een ExecutorService op de juiste manier af te sluiten, hebben we de shutdown() en shutdownNow() APIs.

De shutdown() methode veroorzaakt geen onmiddellijke vernietiging van de ExecutorService. Het zorgt ervoor dat de ExecutorService stopt met het accepteren van nieuwe taken en sluit af nadat alle lopende threads klaar zijn met hun huidige werk:

executorService.shutdown();

De methode shutdownNow() probeert de ExecutorService onmiddellijk te vernietigen, maar garandeert niet dat alle draaiende threads op hetzelfde moment worden gestopt:

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

Deze methode retourneert een lijst met taken die wachten om te worden verwerkt. Het is aan de ontwikkelaar om te beslissen wat er met deze taken moet gebeuren.

Een goede manier om de ExecutorService af te sluiten (die ook door Oracle wordt aanbevolen) is het gebruik van deze beide methoden in combinatie met de awaitTermination() methode:

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

Met deze aanpak stopt de ExecutorService eerst met het aannemen van nieuwe taken en wacht dan tot een gespecificeerde periode tot alle taken zijn voltooid. Als die tijd verstrijkt, wordt de uitvoering onmiddellijk gestopt.

De Future Interface

De methoden submit() en invokeAll() retourneren een object of een verzameling objecten van het type Future, waarmee we het resultaat van de uitvoering van een taak kunnen opvragen of de status van de taak kunnen controleren (is hij bezig).

De Future interface biedt een speciale blokkeringsmethode get(), die een actueel resultaat van de uitvoering van de Callable taak retourneert of null in het geval van een Runnable taak:

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

Aanroepen van de get() methode terwijl de taak nog loopt, zorgt ervoor dat de uitvoering wordt geblokkeerd totdat de taak goed is uitgevoerd en het resultaat beschikbaar is.

Bij zeer lange blokkeringen als gevolg van de get() methode kan de prestatie van een toepassing verslechteren. Als de resulterende gegevens niet van cruciaal belang zijn, kan een dergelijk probleem worden voorkomen door gebruik te maken van timeouts:

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

Als de uitvoeringsperiode langer is dan gespecificeerd (in dit geval 200 milliseconden), wordt een TimeoutException gegooid.

We kunnen de methode isDone() gebruiken om te controleren of de toegewezen taak al is verwerkt of niet.

De Future interface voorziet ook in het annuleren van de taakuitvoering met de cancel() methode en het controleren van de annulering met de isCancelled() methode:

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

De ScheduledExecutorService Interface

De ScheduledExecutorService voert taken uit na een of andere vooraf gedefinieerde vertraging en/of periodiek.

Wederom is de beste manier om een ScheduledExecutorService te instantiëren het gebruik van de factory methods van de Executors class.

Voor deze sectie gebruiken we een ScheduledExecutorService met één thread:

ScheduledExecutorService executorService = Executors .newSingleThreadScheduledExecutor();

Om de uitvoering van een enkele taak na een vaste vertraging te plannen, gebruik je de scheduled() method van de ScheduledExecutorService.

Twee scheduled() methoden maken het mogelijk om Runnable of Callable taken uit te voeren:

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

Met de methode scheduleAtFixedRate() kunnen we een taak periodiek na een vaste vertraging laten uitvoeren. De code hierboven vertraagt een seconde voordat callableTask wordt uitgevoerd.

Het volgende blok code voert een taak uit na een initiële vertraging van 100 milliseconden. En daarna wordt dezelfde taak elke 450 milliseconden uitgevoerd:

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

Als de processor meer tijd nodig heeft om een toegewezen taak uit te voeren dan de periodeparameter van de scheduleAtFixedRate() methode, zal de ScheduledExecutorService wachten tot de huidige taak is voltooid voordat de volgende wordt gestart.

Als het nodig is om een vaste lengte vertraging tussen iteraties van de taak te hebben, moet scheduleWithFixedDelay() worden gebruikt.

De volgende code garandeert bijvoorbeeld een pauze van 150 milliseconden tussen het einde van de huidige uitvoering en het begin van een volgende:

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

Volgens de scheduleAtFixedRate() en scheduleWithFixedDelay() methodecontracten eindigt de periode-uitvoering van de taak bij het beëindigen van de ExecutorService of als tijdens de uitvoering van de taak een exception wordt gegooid.

ExecutorService vs Fork/Join

Na de release van Java 7 hebben veel ontwikkelaars besloten om het ExecutorService framework te vervangen door het fork/join framework.

Dit is echter niet altijd de juiste beslissing. Ondanks de eenvoud en de frequente prestatiewinst die fork/join met zich meebrengt, vermindert het de controle van de ontwikkelaar over gelijktijdige uitvoering.

ExecutorService geeft de ontwikkelaar de mogelijkheid om het aantal gegenereerde threads en de granulariteit van taken die door afzonderlijke threads moeten worden uitgevoerd, te controleren. De beste use case voor ExecutorService is de verwerking van onafhankelijke taken, zoals transacties of verzoeken volgens het schema “één thread voor één taak.”

In tegenstelling daarmee is fork/join volgens de documentatie van Oracle ontworpen om werk te versnellen dat recursief in kleinere stukken kan worden verdeeld.

Conclusie

Ondanks de relatieve eenvoud van ExecutorService, zijn er een paar veel voorkomende valkuilen.

Laten we ze samenvatten:

Het in leven houden van een ongebruikte ExecutorService: Zie de gedetailleerde uitleg in Sectie 4 over het afsluiten van een ExecutorService.

Foute thread-pool capaciteit bij gebruik van fixed length thread-pool: Het is erg belangrijk om te bepalen hoeveel threads de applicatie nodig zal hebben om taken efficiënt uit te voeren. Een te grote thread pool zal onnodige overhead veroorzaken, alleen maar om threads te creëren die meestal in de wachtstand zullen staan. Te weinig threads kan een applicatie onresponsief laten lijken vanwege de lange wachttijden voor taken in de wachtrij.

Het aanroepen van de methode get() van een Future nadat een taak is geannuleerd: Pogingen om het resultaat van een reeds geannuleerde taak op te halen, triggeren een CancellationException.

Onverwacht lang blokkeren met Future’s get() methode: We moeten timeouts gebruiken om onverwachte wachttijden te voorkomen.

Zoals altijd is de code voor dit artikel beschikbaar in de GitHub repository.

Ga aan de slag met Spring 5 en Spring Boot 2, via de Learn Spring-cursus:

>> CHECK OUT THE COURSE

Geef een reactie

Het e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *