Introduction
Dans
la partie I , nous avons examiné comment les threads sont créés. Rappelons-nous encore une fois.
Un thread est représenté par la classe Thread, dont
run()
la méthode est appelée. Utilisons donc le
compilateur Java en ligne Tutorialspoint et exécutons le code suivant :
public class HelloWorld {
public static void main(String[] args) {
Runnable task = () -> {
System.out.println("Hello World");
};
new Thread(task).start();
}
}
Est-ce la seule option pour démarrer une tâche sur un thread ?
java.util.concurrent.Callable
Il s'avère que
java.lang.Runnable a un frère appelé
java.util.concurrent.Callable qui est venu au monde en Java 1.5. Quelles sont les différences? Si vous regardez attentivement le Javadoc de cette interface, nous voyons que, contrairement à
Runnable
, la nouvelle interface déclare une
call()
méthode qui renvoie un résultat. En outre, il lève une exception par défaut. Autrement dit, cela nous évite d'avoir à
try-catch
bloquer les exceptions vérifiées. Pas mal, non ? Maintenant, nous avons une nouvelle tâche au lieu de
Runnable
:
Callable task = () -> {
return "Hello, World!";
};
Mais qu'est-ce qu'on en fait ? Pourquoi avons-nous besoin d'une tâche exécutée sur un thread qui renvoie un résultat ? Évidemment, pour toute action effectuée à l'avenir, nous nous attendons à recevoir le résultat de ces actions à l'avenir. Et nous avons une interface avec un nom correspondant :
java.util.concurrent.Future
java.util.concurrent.Future
L' interface
java.util.concurrent.Future définit une API pour travailler avec des tâches dont nous prévoyons de recevoir les résultats dans le futur : des méthodes pour obtenir un résultat et des méthodes pour vérifier l'état. En ce qui concerne
Future
, nous nous intéressons à son implémentation dans la classe
java.util.concurrent.FutureTask . C'est la "Tâche" qui sera exécutée dans
Future
. Ce qui rend cette implémentation encore plus intéressante, c'est qu'elle implémente également Runnable. Vous pouvez considérer cela comme une sorte d'adaptateur entre l'ancien modèle de travail avec des tâches sur les threads et le nouveau modèle (nouveau dans le sens où il est apparu dans Java 1.5). Voici un exemple:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class HelloWorld {
public static void main(String[] args) throws Exception {
Callable task = () -> {
return "Hello, World!";
};
FutureTask<String> future = new FutureTask<>(task);
new Thread(future).start();
System.out.println(future.get());
}
}
Comme vous pouvez le voir dans l'exemple, nous utilisons la
get
méthode pour obtenir le résultat de la tâche.
Note:lorsque vous obtenez le résultat en utilisant la
get()
méthode, l'exécution devient synchrone ! Selon vous, quel mécanisme sera utilisé ici ? Certes, il n'y a pas de bloc de synchronisation. C'est pourquoi nous ne verrons pas
WAITING dans JVisualVM comme un
monitor
ou
wait
, mais comme la
park()
méthode familière (parce que le
LockSupport
mécanisme est utilisé).
Interfaces fonctionnelles
Ensuite, nous parlerons des classes de Java 1.8, nous ferions donc bien de fournir une brève introduction. Regardez le code suivant :
Supplier<String> supplier = new Supplier<String>() {
@Override
public String get() {
return "String";
}
};
Consumer<String> consumer = new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
};
Function<String, Integer> converter = new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return Integer.valueOf(s);
}
};
Beaucoup, beaucoup de code supplémentaire, n'est-ce pas ? Chacune des classes déclarées exécute une fonction, mais nous utilisons un tas de code de support supplémentaire pour la définir. Et c'est ainsi que pensaient les développeurs Java. En conséquence, ils ont introduit un ensemble "d'interfaces fonctionnelles" (
@FunctionalInterface
) et ont décidé que Java lui-même ferait désormais la "réflexion", ne nous laissant que les choses importantes à nous soucier :
Supplier<String> supplier = () -> "String";
Consumer<String> consumer = s -> System.out.println(s);
Function<String, Integer> converter = s -> Integer.valueOf(s);
Un
Supplier
ravitaillement. Il n'a pas de paramètres, mais il renvoie quelque chose. C'est ainsi qu'il fournit les choses. A
Consumer
consomme. Il prend quelque chose comme entrée (un argument) et en fait quelque chose. L'argument est ce qu'il consomme. Ensuite, nous avons également
Function
. Il prend des entrées (arguments), fait quelque chose et renvoie quelque chose. Vous pouvez voir que nous utilisons activement des génériques. Si vous n'êtes pas sûr, vous pouvez obtenir un rappel en lisant "
Les génériques en Java : comment utiliser les crochets en pratique ".
ComplétableFutur
Le temps a passé et une nouvelle classe appelée
CompletableFuture
est apparue dans Java 1.8. Il implémente l'
Future
interface, c'est-à-dire que nos tâches seront terminées dans le futur, et nous pouvons appeler
get()
pour obtenir le résultat. Mais il implémente également l'
CompletionStage
interface. Le nom dit tout : il s'agit d'une certaine étape d'un ensemble de calculs. Une brève introduction au sujet peut être trouvée dans la revue ici : Introduction à CompletionStage et CompletableFuture. Allons droit au but. Examinons la liste des méthodes statiques disponibles qui nous aideront à démarrer :
voici les options pour les utiliser :
import java.util.concurrent.CompletableFuture;
public class App {
public static void main(String[] args) throws Exception {
CompletableFuture<String> completed;
completed = CompletableFuture.completedFuture("Just a value");
CompletableFuture<Void> voidCompletableFuture;
voidCompletableFuture = CompletableFuture.runAsync(() -> {
System.out.println("run " + Thread.currentThread().getName());
});
CompletableFuture<String> supplier;
supplier = CompletableFuture.supplyAsync(() -> {
System.out.println("supply " + Thread.currentThread().getName());
return "Value";
});
}
}
Si nous exécutons ce code, nous verrons que la création d'un
CompletableFuture
implique également le lancement d'un pipeline complet. Par conséquent, avec une certaine similitude avec la SteamAPI de Java8, c'est là que l'on trouve la différence entre ces approches. Par exemple:
List<String> array = Arrays.asList("one", "two");
Stream<String> stringStream = array.stream().map(value -> {
System.out.println("Executed");
return value.toUpperCase();
});
Ceci est un exemple de l'API Stream de Java 8. Si vous exécutez ce code, vous verrez que "Executed" ne sera pas affiché. En d'autres termes, lorsqu'un flux est créé en Java, le flux ne démarre pas immédiatement. Au lieu de cela, il attend que quelqu'un en veuille une valeur. Mais
CompletableFuture
commence à exécuter le pipeline immédiatement, sans attendre que quelqu'un lui demande une valeur. Je pense que c'est important de comprendre. Donc, nous avons un
CompletableFuture
. Comment pouvons-nous créer un pipeline (ou une chaîne) et quels mécanismes avons-nous ? Rappelez-vous ces interfaces fonctionnelles dont nous avons parlé plus tôt.
- Nous avons a
Function
qui prend un A et renvoie un B. Il a une seule méthode : apply()
.
- Nous avons un
Consumer
qui prend un A et ne renvoie rien (Void). Il a une seule méthode : accept()
.
- Nous avons
Runnable
, qui s'exécute sur le thread, ne prend rien et ne renvoie rien. Il a une seule méthode : run()
.
La prochaine chose à retenir est qu'il
CompletableFuture
utilise
Runnable
,
Consumers
et
Functions
dans son travail. En conséquence, vous pouvez toujours savoir que vous pouvez faire ce qui suit avec
CompletableFuture
:
public static void main(String[] args) throws Exception {
AtomicLong longValue = new AtomicLong(0);
Runnable task = () -> longValue.set(new Date().getTime());
Function<Long, Date> dateConverter = (longvalue) -> new Date(longvalue);
Consumer<Date> printer = date -> {
System.out.println(date);
System.out.flush();
};
CompletableFuture.runAsync(task)
.thenApply((v) -> longValue.get())
.thenApply(dateConverter)
.thenAccept(printer);
}
Les méthodes
thenRun()
,
thenApply()
et
thenAccept()
ont des versions "Async". Cela signifie que ces étapes seront réalisées sur un thread différent. Ce fil sera tiré d'un pool spécial - nous ne saurons donc pas à l'avance s'il s'agira d'un nouveau ou d'un ancien fil. Tout dépend de l'intensité de calcul des tâches. En plus de ces méthodes, il existe trois autres possibilités intéressantes. Pour plus de clarté, imaginons que nous ayons un certain service qui reçoit une sorte de message de quelque part — et cela prend du temps :
public static class NewsService {
public static String getMessage() {
try {
Thread.currentThread().sleep(3000);
return "Message";
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
}
Maintenant, jetons un coup d'œil aux autres capacités
CompletableFuture
fournies. On peut combiner le résultat d'un
CompletableFuture
avec le résultat d'un autre
CompletableFuture
:
Supplier newsSupplier = () -> NewsService.getMessage();
CompletableFuture<String> reader = CompletableFuture.supplyAsync(newsSupplier);
CompletableFuture.completedFuture("!!")
.thenCombine(reader, (a, b) -> b + a)
.thenAccept(result -> System.out.println(result))
.get();
Notez que les threads sont des threads démons par défaut, donc pour plus de clarté, nous utilisons
get()
pour attendre le résultat. Non seulement pouvons-nous combiner
CompletableFutures
, nous pouvons également retourner un
CompletableFuture
:
CompletableFuture.completedFuture(2L)
.thenCompose((val) -> CompletableFuture.completedFuture(val + 2))
.thenAccept(result -> System.out.println(result));
Ici, je tiens à souligner que la
CompletableFuture.completedFuture()
méthode a été utilisée par souci de brièveté. Cette méthode ne crée pas de nouveau thread, donc le reste du pipeline sera exécuté sur le même thread où
completedFuture
il a été appelé. Il existe aussi une
thenAcceptBoth()
méthode. C'est très similaire à
accept()
, mais si
thenAccept()
accepte a
Consumer
,
thenAcceptBoth()
accepte un autre
CompletableStage
+
BiConsumer
en entrée, c'est-à-dire a
consumer
qui prend 2 sources au lieu d'une. Il y a une autre capacité intéressante offerte par les méthodes dont le nom inclut le mot "Soit" :
Ces méthodes acceptent une alternative
CompletableStage
et sont exécutées sur celle
CompletableStage
qui doit être exécutée en premier. Enfin, je veux terminer cette revue avec une autre fonctionnalité intéressante de
CompletableFuture
: la gestion des erreurs.
CompletableFuture.completedFuture(2L)
.thenApply((a) -> {
throw new IllegalStateException("error");
}).thenApply((a) -> 3L)
.thenAccept(val -> System.out.println(val));
Ce code ne fera rien, car il y aura une exception et rien d'autre ne se passera. Mais en décommentant la déclaration "exceptionnellement", nous définissons le comportement attendu. En parlant de
CompletableFuture
, je vous recommande également de regarder la vidéo suivante :
À mon humble avis, ce sont parmi les vidéos les plus explicatives sur Internet. Ils devraient expliquer clairement comment tout cela fonctionne, quelle boîte à outils nous avons à disposition et pourquoi tout cela est nécessaire.
Conclusion
Heureusement, il est maintenant clair comment vous pouvez utiliser les threads pour obtenir des calculs une fois qu'ils sont terminés. Matériels supplémentaires:
Mieux ensemble : Java et la classe Thread. Partie I — Threads d'exécution Mieux ensemble : Java et la classe Thread. Partie II — Synchronisation Mieux ensemble : Java et la classe Thread. Partie III — Interaction Mieux ensemble : Java et la classe Thread. Partie V — Executor, ThreadPool, Fork/Join Mieux ensemble : Java et la classe Thread. Partie VI — Tirez !