Backend

Co nowego w Javie? Przegląd zmian, które przyniosło JDK 20

JDK 20

21 marca 2023 roku światło dzienne ujrzał JDK 20, czyli następna wersja Java typu short-term release (lub inaczej non-LTS release). Oznacza to, że już tylko 6 miesięcy dzieli nas od kolejnego LTS-a, czyli JDK 21. W związku z tym czas na przegląd nowości, które wprowadzono ostatnio do języka Java.

Nowości te już niedługo staną się standardowymi komponentami tego języka i będą towarzyszyć nam codziennie po migracji do wersji JDK 21 LTS. W tym artykule skupiłem się na dwóch grupach wprowadzonych ostatnio rozwiązań. Pierwsza to mechanizmy wykorzystujące koncepcję Pattern Matching. Druga grupa to mechanizmy dotyczące zarządzania wątkami. W tych obszarach pojawiło się kilka zmian, które zwiększają wygodę używania języka Java oraz poprawiają wydajność aplikacji.

Nie są to wszystkie nowości, które dodano w ostatnim czasie do języka Java. Jeżeli chcesz uzupełnić wiedzę z tego artykułu o kolejne nowe elementy, które pojawiły się do wersji JDK 20, zapraszam na mój webinar w poniedziałek 24.04.2023 o godzinie 20. Omówię na nim wszystkie nowości, które wprowadzono do języka Java w ostatnim czasie i pokażę bardzo dużo praktycznych przykładów ich użycia (zapisy na webinar tutaj).

JDK 20. Zasada działania mechanizmu Pattern Matching

Przegląd nowości zaczynamy od przypomnienia, czym jest mechanizm Pattern Matching. Na tej właściwości opiera się wiele nowych elementów, które od kilku wersji Java są wprowadzane do składni języka. Niektóre z nich osiągnęły już status rozwiązań stabilnych, natomiast inne ciągle są w fazie preview lub incubator. Elastyczność i wygoda, które dają nowe składniki języka Java powodują, że kwestią czasu jest, kiedy mechanizmy czerpiące z koncepcji Pattern Matching, na stałe zagoszczą wśród elementów języka Java w wersji stabilnej. 

Czym właściwie jest Pattern Matching? W celu zrozumienia tego mechanizmu musimy przypomnieć sobie działanie kilku klas, które wykorzystujemy do pracy z wyrażeniami regularnymi. Rozwiązania te razem ze składnią wyrażeń regularnych stanowią pewną formę Pattern Matching. Polega ona na analizowaniu ciągu znaków pod kątem występowania w nim oczekiwanych wzorców. W ten sposób realizowana jest koncepcja Pattern Matching, czyli wyszukiwanie konkretnego wyrażenia lub wyrażeń w analizowanym tekście. Przeanalizujmy fragment kodu, który znajdziesz poniżej:

String text = "Lorem ipsum dolor SIT AMET, consectetur ...";
String regex = "[A-Z]+";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
   System.out.println(matcher.group());
   System.out.println(matcher.start() + " " + matcher.end());
}

Obiekty klas Pattern oraz Matcher realizują funkcjonalność, która w napisie wskazywanym przez referencję text znajduje wszystkie dopasowania wzorca, określonego przez wyrażenie regularne spod referencji regex. Wprowadźmy kilka pojęć, które pojawiły się w kodzie. Przy omawianiu nowych elementów, opartych o Pattern Matching, będę nawiązywał do tych definicji, co pozwoli nam jeszcze lepiej zauważyć powiązania z Pattern Matching. I tak przeglądany ciąg znaków wskazywany przez referencję text to matched target. Wzorzec którego szukamy, zapisany w tym przypadku z użyciem wyrażenia regularnego to pattern. Mamy jeszcze wyniki wyszukiwania wzorca w analizowanym napisie, które określamy jako result.

Pattern Matching for instanceof

Pierwszym omawianym mechanizmem, który wdraża koncepcję Pattern Matching jest Pattern Matching for instanceof (wprowadzony w JDK 14). Działanie tego elementu języka Java przeanalizujemy na przykładzie metody, której kod umieściłem poniżej:

public void printIfStr(Object o) {
   if (o instanceof String s) {
       System.out.println("Text: " + s.toUpperCase());
   }
}

Operator instanceof sprawdza typ referencji o i jeżeli jest to String, wtedy wykonuje rzutowanie obiektu spod referencji o na obiekt klasy String, na który w przypadku udanej konwersji wskazuje referencja s. W tej sytuacji matched target to obiekt wskazywany przez referencję o. Typ String to pattern, ponieważ oczekujemy, że obiekt pod referencją o będzie właśnie takiego typu. Result to obiekt pod referencją s, który będzie przez nią wskazywany w wyniku poprawnego rzutowania. Wprowadzając zdefiniowane wcześniej pojęcia, dokładnie widzimy, jak w omawianym fragmencie kodu został wdrożony mechanizm Pattern Matching. W Pattern Matching for instanceof referencja s nazywana jest pattern variable, natomiast całe wyrażenie String s nazywane jest type pattern. Dzięki Pattern Matching for instanceof nie musisz więcej pisać takiego kodu:

public void printIfStr(Object o) {
   if (o instanceof String) {
       String s = (String)o;
       System.out.println("Text: " + s.toUpperCase());
   }
}

Teraz sprawdzanie typu i operacja rzutowania odbywają się w jednej instrukcji. Pattern variable, czyli w naszym przypadku referencję s, możesz stosować w instrukcjach i wyrażeniach, które znajdują się w ciele metody, gdzie umieściliśmy instrukcję warunkową if. Przykładowo zaraz po rzutowaniu na obiekt klasy String, z poziomu referencji s jeszcze w ramach tego samego wyrażenia, możemy sprawdzać właściwości napisu wynikowego i uzależniać od tego wykonanie ciała instrukcji warunkowej if. W poniższym kodzie ciało instrukcji if wykona się tylko wtedy, jeżeli napis wskazywany przez referencję s, będzie zaczynał się od litery A.

public void printUpperIfStr(Object o) {
   if (o instanceof String s && s.startsWith("A")) {
       System.out.println("Text: " + s.toUpperCase());
   }
}

W kolejnym przykładzie zobaczysz, w jaki sposób wykorzystać pattern variable poza blokiem instrukcji if, ale jeszcze w ciele metody printUpperIfStr:

public static void printUpperIfStr(Object o) {
   if (!(o instanceof String s)) {
       return;
   }
   System.out.println(s.toUpperCase());
}

Kompilator Java dobrze radzi sobie z analizowaniem kodu, w którym wykorzystywany jest Pattern Matching for instanceof, dlatego jeżeli rzutowanie do wskazanego typu okaże się niemożliwe, wystąpi błąd kompilacji.

Integer counter = 10;

// Error: Inconvertible types; cannot cast 'java.lang.Integer' to 'java.lang.String'
if (counter instanceof String s) {}

Pattern Matching for instanceof skraca kod i zwiększa jego przejrzystość. Rozważania na ten temat zakończmy przykładem klasy Person, gdzie wykorzystano poznany przez nas element języka Java do uproszczenia ciała metody equals. Dla zwiększenia czytelności kodu pominąłem niektóre składniki klasy Person, przykładowo metodę hashCode, którą zawsze należy implementować razem z metodą equals.

public class Person {
   private final String name;
   private final int age;

   public Person(String name, int age) {
       this.name = name;
       this.age = age;
   }

   @Override
   public boolean equals(Object obj) {
       return obj instanceof Person p &&
               name.equals(p.name) &&
               age == p.age;
   }

   // Pomijam implementację hashCode
}

Pattern Matching for Switch Expressions and Statements

Kolejny mechanizm, który wykorzystuje Pattern Matching to Pattern Matching for Switch Expressions and Statements. Mechanizm obecnie jest w fazie preview. W tym przypadku dopasowanie matched target do jednego z podanych wzorców (pattern) odbywa się z wykorzystaniem instrukcji switch lub switch expressions. Przeanalizujmy poniższy kod:

Object o = "KM";
String result = switch (o) {
   case Integer a -> "int %d".formatted(a);
   case Double b -> "double %f".formatted(b);
   case String c -> c.toLowerCase();
   case Object ob -> "...";
};

Matched target w tym przypadku to selector expression instrukcji switch, czyli referencja o, podana w nawiasach przy słowie kluczowym switch. Każdy case określa kolejny pattern, do którego będzie dopasowany obiekt spod referencji o. W zależności od tego, jakiego typu obiekt wskazywany jest przez referencję o, wykonają się instrukcje przyporządkowane konkretnemu case. Mechanizm Pattern Matching for Switch nie tylko zwiększa przejrzystość kodu, ale również wydajność aplikacji. Instrukcja switch expressions w tej konfiguracji wykonuje się ze złożonością czasową O(1), podczas gdy podobne sprawdzanie z wykorzystaniem bloku instrukcji if – else if – else zwiększyłoby złożoność czasową do O(n). 

Pattern Matching for Switch często współpracuje z innym nowym elementem języka Java, czyli Guarded Patterns. Połączenie tych dwóch rozwiązań pozwala na jeszcze bardziej precyzyjne definiowanie warunków, przyporządkowanych do konkretnego case-a. Tym bardziej, że teraz warunki, które umieszczamy w case możemy zapisywać z wykorzystaniem nowego słowa kluczowego when, co jeszcze bardziej upraszcza kod i zwiększa jego czytelność. Implementowane w ten sposób warunki umieszczane po case nazywamy guarded case label. Poniżej pokazano kod implementujący to podejście:

Object o = "KM";
String result = switch (o) {
   case Integer a -> "int %d".formatted(a);
   case String c when c.length() < 4 -> c.toLowerCase();
   case Object ob -> "...";
};

JDK 20. Record Pattern

Rekord to typ wprowadzony w JDK 14. Pozwala na proste i szybkie definiowanie struktury konkretnego bytu oraz automatyczne generowanie ważnych metod. Na rzecz rekordu możemy tworzyć obiekty, którymi możemy zarządzać bez możliwości ich mutowania. Dla rekordów również wdrożono koncepcję Pattern Matching, co doprowadziło do powstania mechanizmu Record Pattern, który obecnie znajduje się w fazie preview. Rozpatrzmy przykładowo record Address:

public record Address(String city, String street, int number) {}

Z rekordami możemy stosować operator instanceof i sprawdzać, czy konkretna referencja wskazuje na obiekt typu Address. Dodatkowo wobec obiektu, który uzyskamy po rzutowaniu, możemy zastosować mechanizm record deconstruction, co pozwala bardzo wygodnie wydobyć z obiektu wartości poszczególnych pól składowych. Zobacz kod z zastosowaniem tego podejścia:

Object a = new Address("C", "S", 1);
if (a instanceof Address(String c, String s, int n)) {
   System.out.println(c + " " + s + " " + n);
}

W tym przypadku pattern to Address(String c, String s, int n). Postać Record Pattern zawsze odnosi się do postaci konstruktora kanonicznego rekordu, na którym działa ten mechanizm. W przypadku rekordu Address generowany jest konstruktor kanoniczny trójargumentowy, który przyjmuje argumenty city, street oraz number. To zawsze w przypadku rekordu Address wymusi podanie w sekcji dekonstrukcji rekordu trzech elementów, które pozwolą przechwycić wartości i obiekty spod city, street oraz number. W analizowanym kodzie uda się to zrobić dzięki zmiennym c, s oraz n. Gdyby do rekordu Address dodano konstruktor z dwoma parametrami:

public record Address(String city, String street, int number) {
   public Address(String city, String street) {
       this(city, street, 1);
   }
}

nic to nie zmienia. Mechanizm Record Pattern nie poradzi sobie z dopasowaniem struktury obiektu rekordu Address do proponowanej postaci record deconstruction, którą przedstawia kod poniżej:

Object a = new Address("C", "S", 1);
if (a instanceof Address(String c, String s) address) {
   System.out.println(c + " " + s + " " + n);
   System.out.println(address);
}

Kompilator zgłosi błąd niepoprawnej postaci kodu realizującego dekonstrukcję. Record Pattern potrafi rozpoznać typ dopasowanego obiektu i wspiera wykorzystywanie var. Dlatego możesz napisać jeszcze inną postać bloku dekonstrukcji rekordu:

Object a = new Address("C", "S", 1);
if (a instanceof Address(var c, var s, var n) address) {
   System.out.println(c + " " + s + " " + n);
   System.out.println(address);
}

Mechanizm Record Pattern współpracuje z mechanizmem Pattern Matching for Switch Expressions and Statements. Daje to niezwykle elastyczną strukturę w kodzie, która najpierw odpowiednio dopasuje typ sprawdzanego obiektu, a następnie zastosuje wobec niego dekonstrukcję i wydobędzie z niego poszczególne elementy. Jeżeli przyjmiemy, że zdefiniowano rekord, taki jak poniżej:

record Container(Object data) {}

możemy zastosować go w pracy z Pattern Matching for Switch, tak jak pokazuje to kod poniżej:

Container c = new Container("KM");
var res = switch (c) {
   case Container(String s) -> "string: %s".formatted(s);
   case Container(Integer i) -> "int: %d".formatted(i);
   case Container(Object ob) -> "...";
};
System.out.println(res);

Kolejna struktura związana z Record Pattern to Nested Record Pattern i możliwość dekonstruowania rekordów zagnieżdżonych w innych rekordach. Dla przykładowego rekordu:

record Contact(String name, String surname, Address address) {}

gdzie Address to rekord o strukturze, którą przedstawiłem wcześniej, możemy zastosować Record Pattern, tak jak pokazano poniżej:

Object contact = new Contact(
       "ADAM", "NOWAK", new Address("C", "S", 1));


if (contact instanceof Contact(
       var name,
       var surname,
       Address (var city, var street, var number))) {
   System.out.println(name);
   System.out.println(surname);
   System.out.println(city);
   System.out.println(street);
   System.out.println(number);
}

Record Pattern daje nam ogromne możliwości i sprawia, że odnoszenie się do poszczególnych elementów obiektów rekordu jest bardzo łatwe i elastyczne. Mechanizm posiada jednak pewne ograniczenie. Record Pattern nie wspiera boxing oraz unboxing. Jeżeli utworzysz rekord, tak jak w poniższym kodzie:

record Point(Double x, Double y) {}

czyli określisz typ jego pól przykładowo jako Double, nie możesz później podczas stosowania mechanizmu Record Pattern odwoływać się do tych pól poprzez zmienne typu double. Dostaniesz wtedy błąd kompilacji:

Object p = new Point(12.2, 23.4);
if (p instanceof Point(double x, double y)) {
}

W JDK 20 ulepszono rozpoznawanie typów przez mechanizm Record Pattern, podczas gdy pracuje z typami generycznymi. Rozpatrzmy przykładowo interfejs:

interface Multi {}

oraz dwa rekordy, które implementują ten interfejs:

record Tuple (T t1, T t2) implements Multi {}

record Triple (T t1, T t2, T t3) implements Multi {}

Kiedy utworzymy obiekt wskazywany przez rekord typu Multi z konkretnie sprecyzowanym typem T:

Multi multi = new Triple<>("A", "B", "C");

mechanizm Record Pattern samodzielnie wywnioskuje, co zostało podstawione pod typ T i nie musimy już tego nigdzie precyzować. W kodzie poniżej od początku kompilator zna typ referencji t1, t2 oraz t3, którym jest String.

if (multi instanceof Tuple(var t1, var t2)) {
   System.out.println(t1.toLowerCase());
   System.out.println(t2.toUpperCase());
} else if (multi instanceof Triple(var t1, var t2, var t3)) {
   System.out.println(t1.strip());
   System.out.println(t2.stripLeading());
   System.out.println(t3.stripTrailing());
}

Podobne zachowanie uzyskamy podczas pracy z instrukcją switch:

switch (multi) {
   case Tuple(var t1, var t2) -> {
       System.out.println(t1.toLowerCase());
       System.out.println(t2.toUpperCase());
   }
   case Triple(var t1, var t2, var t3) -> {
       System.out.println(t1.strip());
       System.out.println(t2.stripLeading());
       System.out.println(t3.stripTrailing());
   }
   default -> {
       // ...
   }
}

Pattern Matching for Enhanced For Statement

Przegląd mechanizmów, w których wykorzystuje się Pattern Matching kończymy na  nowej możliwości języka Java, która weszła od wersji JDK 20. Dzięki niej mechanizm Record Pattern można stosować podczas iterowania po kolekcji lub tablicy obiektów rekordu. Prezentuje to przykład poniżej:

List points = List.of(
       new Point(12.2, 32.1),
       new Point(22.2, 12.1)
);
for (Point(Double x, Double y) : points) {
   System.out.println("x = " + x + " y = " + y);
}

W tym przypadku występuje jednak kilka ograniczeń. Elementy przeglądanej kolekcji nie mogą być null oraz kiedy jeden element nie pasuje do określonego wzorca rzucany jest wyjątek MatchException. Mimo to jest to kolejny mechanizm, który zachęca nas do używania rekordów w aplikacjach Java ze względu na duże możliwości i prostotę zarządzania strukturą obiektów tego rodzaju. 

Jak widzisz koncepcja Pattern Matching na dobre zakorzeniła się w różnych elementach języka Java. Dzięki niej do Java wprowadza się nowsze konstrukcje programistyczne, znane z innych języków programowania. Jeżeli chcesz dowiedzieć się jeszcze więcej o mechanizmie Pattern Matching wejdź na stronę projektu Amber openjdk.org/projects/amber/ , który skupia się na rozwijaniu tych elementów języka Java, które wykorzystują między innymi podejście Pattern Matching.

JDK 20. Live Threads

Teraz skupimy się na ciekawych mechanizmach, które są rozwijane w ramach projektu Loom wiki.openjdk.org/display/loom. Chodzi o Virtual Threads (faza preview) oraz Structured Concurrency (faza incubator) wprowadzone w JDK 19. Z racji tego, że są to nowe byty, może okazać się, że kiedy będą wprowadzane do języka Java w wersji LTS, ich zachowanie może różnić się nieco od tego, które opisuję poniżej.

JVM jest środowiskiem wielowątkowym. W Java mamy specjalny typ java.lang.Thread, który pozwala tworzyć wątki w ramach aplikacji Java. Każdy wątek utworzony w ten sposób jest wątkiem systemowym, czyli inaczej Platform Thread. Z wątkami tego typu wiąże się kilka problemów. Za każdym razem kiedy wątek tego rodzaju jest tworzony, system operacyjny musi zaalokować bardzo dużą ilość pamięci na stosie, żeby przechować zasoby wątku. Później podczas pracy wątku, ten ogromny obszar pamięci liczony niekiedy w megabajtach, musi być zarządzany. Wiąże się z tym szereg operacji, które są kosztowne zarówno pod kątem pamięci, jak również czasu. Duża ilość pamięci zajmowana przez pojedynczy wątek systemowy nakłada dodatkowo ograniczenia związane z liczbą możliwych do utworzenia wątków. Przy przekroczeniu tej ilości otrzymamy błąd OutOfMemoryError.

W przeszłości próbowano w Java rozwiązać ten problem poprzez wdrażanie różnych koncepcji. Jednak dopiero w ostatnim czasie wprowadzono mechanizm z projektu Loom o nazwie Virtual Threads. Wszystko wskazuje na to, że wreszcie doczekaliśmy się rozwiązania, które dobrze poradzi sobie z problemami wątków Java, a wszystko to ubrane będzie w przyjemną składnię tego języka. Virtual Threads to alternatywne podejście, w którym pamięć związana z tworzonym wątkiem przechowywana jest na stercie. Tworzony w takiej sytuacji wątek nie zajmuje od razu dużej ilości pamięci, tylko na początku dostaje pewną niezbędną do egzystencji ilość miejsca na stercie. Później w trakcie jego działania i potrzeby przechowania nowych zasobów, ten obszar jest powiększany. Tworzenie takiego wątku jest bardzo proste i prezentuje to kod poniżej:

Thread t = Thread.ofVirtual()
       .name("Thread 1")
       .start(() -> System.out.println("HELLO"));

Java dostarcza również nową implementację interfejsu ExecutorService o nazwie ThreadPerTaskExecutor. Dzięki niej możemy tworzyć Virtual Thread, za każdym razem, kiedy chcemy uruchomić task, wywołując metodę submit. Takie zachowanie uzyskamy, wywołując metodę Executors.newVirtualThreadPerTaskExecutor. Przykład kodu z wykorzystaniem tej metody umieściłem poniżej:

try (var executor
            = Executors.newVirtualThreadPerTaskExecutor()) {
   executor.submit(()
           -> System.out.println("TASK"));

Istnieje również możliwość skorzystania z klasy ThreadFactory, która pozwoli między innymi skonfigurować dynamicznie nadawane nazwy tworzonych wątków wirtualnych. Ułatwia to później ich identyfikację oraz analizę działania. Zobaczysz to w kodzie poniżej. W ramach tworzenia obiektu typu ThreadFactory wykorzystuje się metodę name, której w tym przypadku jako pierwszy argument podałem “thread-”. Oznacza to, że nazwa każdego tworzonego w ten sposób wątku będzie zaczynać się od “thread-”. Drugim argumentem jest wartość początkowa licznika, który za każdym razem kiedy zostanie przydzielona nazwa do nowego wątku, zwiększy się o 1, a jego wartość będzie doklejana na koniec nazwy kolejnego tworzonego wątku. Kiedy chcesz skorzystać z obiektu typu ThreadFactory musisz zastosować metodę Executors.newThreadPerTaskExecutor.

ThreadFactory threadFactory = Thread.ofVirtual()
       .name("thread-", 0)
  .factory();


try (var executor
          = Executors.newThreadPerTaskExecutor(threadFactory)) {
   executor.submit(()
           -> System.out.println("Action !"));
}

Jak dokładnie działają Virtual Threads? JVM zarządza pulą wątków systemowych, tworzoną w ramach dedykowanej implementacji ForkJoinPool. Na początku ilość Platform Threads wynosi tyle, ile liczba CPU, a potem może być zwiększana, ale nie może przekroczyć wartości 256. Dla każdego utworzonego Virtual Thread JVM planuje jego wykonanie z wykorzystaniem Platform Thread. Virtual threads, które są gotowe do działania umieszczane są w kolejce FIFO i planowane z wykorzystaniem domyślnego scheduler-a zdefiniowanego w klasie VirtualThread. W razie potrzeby możesz zastąpić domyślny scheduler inną implementacją.

Kiedy przychodzi pora na wykonanie wątku Virtual Thread w ramach przydzielonego mu Platform Thread, na początek następuje kopiowanie pamięci przydzielonej dla Virtual Thread ze sterty na stos związany z konkretnym Platform Thread. W tej sytuacji Platform Thread staje się wątkiem odpowiedzialnym za wykonanie przydzielonego mu Virtual Thread i wtedy nazywamy go Carrier Thread (wątek który przetwarza Virtual Thread). W trakcie wykonywania Virtual Thread może zdarzyć się tak, że rozpocznie się operacja, która blokuje jego działanie. Wtedy Carrier Thread jest zwalniany, ponieważ nie ma póki co żadnych operacji do wykonania na rzecz zawieszonego Virtual Thread.

Dodatkowo zawartość stosu zwolnionego Carrier Thread jest kopiowana do obszaru na stercie powiązanego z zablokowanym Virtual Thread. To całkowicie zwalnia Carrier Thread, który staje się ponownie Platform Thread i może teraz realizować operacje innego Virtual Thread, który jest gotowy do działania. Kiedy zablokowany Virtual Thread kończy operację, która spowodowała wstrzymanie jego działania, jest ponownie planowany do wykonania i wkrótce zostanie uruchomiony albo przez ten sam Platform Thread, który pracował z nim wcześniej, albo przez inny który akurat jest wolny i może przyjąć nowe zadanie do wykonania.

Poniżej zamieszczam kod prezentujący opisane zachowanie. Przy uruchomieniu aplikacji będziemy mieć tyle Platform Threads, ile CPU. Dlatego zleciłem wykonanie Virtual Threads w liczbie o jeden więcej niż ilość Platform Threads. To pozwoli zaobserwować, że przynajmniej jeden Platform Thread zostanie wykonany więcej niż jeden raz. Ilość dostępnych Platform Threads pozyskasz za pomocą metody Runtime.getRuntime().availableProcessors(). W kodzie wykorzystałem dodatkowo pomocniczą metodę sleep, żeby kod wewnątrz metody submit był przejrzysty i nie wymagał obsługi wyjątków.

static void sleep(int seconds) {
   try {
       TimeUnit.SECONDS.sleep(seconds);
   } catch (Exception e) {
       System.out.println(e.getMessage());
   }
}
ThreadFactory threadFactory = Thread
       .ofVirtual()
       .name("task-", 0)
       .factory();

int cpus = Runtime.getRuntime().availableProcessors();

try (var executor
          = Executors.newThreadPerTaskExecutor(threadFactory)) {
   IntStream
           .range(0, cpus + 1)
           .forEach(i -> executor.submit(() -> {
                   System.out.println(Thread.currentThread());
                   sleep(1);
           }));
}

Przykładowy wynik uruchomienia powyższego kodu może wyglądać następująco:

VirtualThread[#23,thread-0]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#25,thread-1]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#26,thread-2]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#28,thread-4]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#30,thread-6]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#31,thread-7]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#32,thread-8]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#27,thread-3]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#29,thread-5]/runnable@ForkJoinPool-1-worker-3

Możemy zauważyć, że kolejne Virtual Threads o nazwach thread-1, thread-2, …, thread-8 są wykonywane przez Carrier Threads o nazwach kończących się na worker-1, worker-2, worker-3, worker-4.

Istnieją sytuacje, kiedy Virtual Thread, który jest zablokowany nie zostanie zwolniony przez Carrier Thread. Dzieje się tak, kiedy kod blokujący jest wykonywany wewnątrz bloku lub metody synchronicznej lub kiedy wywoływana jest funkcja natywna. W takiej sytuacji Carrier Thread musi poczekać aż Virtual Thread się skończy. W opisanym stanie oczekiwania Carrier Thread jest nazywany Pinned Thread. Należy unikać takich sytuacji (przykładowo zamiast synchronized stosować Lock API). Kiedy Platform Thread będzie zablokowany, JVM zaangażuje do wykonania Virtual Thread inny Platform Thread, jednak taka sytuacja zmniejsza elastyczność naszej aplikacji i powoduje generowanie nieoptymalnej ilości wątków systemowych.

Structured Concurrency

Model pracy wątków w oparciu o mechanizm Structured Concurrency jest podobny do koncepcji bloków kodu w programowaniu strukturalnym. Kiedy w języku takim jak Java wywołujesz metodę o przykładowej nazwie m2 wewnątrz innej wywołanej metody o przykładowej nazwie m1, wtedy metoda m2 musi zakończyć swoje działanie zanim zakończy się metoda m1. Jest to bardzo prosta koncepcja, która towarzyszy nam w zasadzie od początku nauki programowania. Mechanizm Structured Concurrency zakłada, że podobne zachowanie wdrażamy dla virtual threads, które uruchamiamy w naszym programie. Kiedy wewnątrz wątku v1 uruchamiany jest wątek v2, wątek v2 nie może działać dłużej niż wątek v1.

Kiedy w ramach uruchomionego wątku v1 rusza wątek v2 działają one niezależnie od siebie, ale kiedy wątek v1 będzie się kończył mamy kilka możliwych scenariuszy. Wątek v1 kończy działanie, ponieważ wątek v2 również wcześniej zakończył wykonywanie zdefiniowanych w nim operacji. Inny scenariusz ma miejsce, kiedy wątek v2 wciąż jeszcze działa. Wtedy wątek v1 musi poczekać na jego zakończenie i dopiero kiedy wątek v2 skończy wykonywanie swoich czynności, wątek v1 również przestanie działać. Możesz jeszcze zlecić zatrzymanie wątku nadrzędnego v1, wtedy zatrzymujesz również wątek v2 i nie musisz się martwić o to, że po zatrzymaniu wątku v1, mogą jeszcze w pamięci zostać działające inne wątki z nim związane. To ułatwia zarządzanie wątkami i sprawia, że kod jest łatwy i przejrzysty.

Implementacja mechanizmu Structured Concurrency w Java wymaga zastosowania typu StructuredTaskScope, który używamy wewnątrz bloku try-with-resources. Tworzony jest wtedy specjalny obiekt, który w kodzie poniżej wskazywany jest przez referencję scope. Właśnie ten obiekt zarządza Virtual Threads w opisany wcześniej sposób. Możemy zdefiniować różne rodzaje zakresów do zarządzania Virtual Threads. W pierwszym przykładzie zastosowano typ ShutdownOnFailure. Generuje on scope, który zamyka każdy uruchomiony w tym scope Virtual Thread, który w trakcie swojego działania rzuci wyjątek. Działanie tak zdefiniowanego zakresu kończy się, kiedy wszystkie utworzone w nim Virtual Threads zakończą się prawidłowo.

Kiedy jeden z Virtual Threads rzuci wyjątek, wtedy jego przechwycenie przez sekcję try-with-resources, w której zdefiniowano zakres, jest możliwe tylko wtedy, kiedy na rzecz referencji scope wywołamy metodę throwIfFailed. To wywołanie musi wystąpić po wywołaniu na rzecz scope metody join. 

static String getMessage1() {
   return "MESSAGE 1";
}
static String getMessage2() {
   return "MESSAGE 2";
}
static String runScope() {
   try (var scope
                = new StructuredTaskScope.ShutdownOnFailure()) {
       var msg1 = scope.fork(App::getMessage1);
       var msg2 = scope.fork(App::getMessage2);

       scope.join();
       scope.throwIfFailed();

       return msg1.resultNow() + " " + msg2.resultNow();
   } catch (InterruptedException | ExecutionException e) {
       throw new IllegalStateException(e);
   }
}

public static void main(String[] args) {
   System.out.println(runScope());
}

Inny rodzaj zakresu opiera się na konstruktorze ShutdownOnSuccess. W tej konfiguracji praca zakresu kończy się w momencie, kiedy pierwszy Virtual Thread zdefiniowany w tym zakresie kończy się i zwraca wartość, która jednocześnie staje się wartością zwracaną przez cały zakres.

static String getMessage1() {
   return "MESSAGE 1";
}


static String getMessage2() {
   return "MESSAGE 2";
}
static String runScope() {
   try (var scope
        = new StructuredTaskScope.ShutdownOnSuccess())    {
       scope.fork(App::getMessage1);
       scope.fork(App::getMessage2);


       scope.join();


       return scope.result();
   } catch (InterruptedException | ExecutionException e) {
       throw new IllegalStateException(e);
   }
}


public static void main(String[] args) {
   System.out.println(runScope());
}

Scoped Values

Scoped Values to kolejna ciekawa właściwość, która pojawiła się w JDK 20. Mechanizm rozwijany jest w ramach projektu Loom (razem z Virtual Threads oraz Structured Concurrency) i obecnie znajduje się w fazie incubator. Scoped Values umożliwiają łatwe współdzielenie niezmiennych danych w ramach jednego wątku oraz pomiędzy wątkami. Sposób ich działania przypomina obiekty ThreadLocal, które są obecne w Java prawie od samego początku. Scoped Values zostały zaprojektowane pod kątem pracy z Virtual Threads oraz Structured Concurrency, w związku z czym powinny być pierwszym wyborem programisty w najnowszych wersjach Java.

Mają one kilka zalet w stosunku do starszego podejścia. Scoped Values tworzone są tylko na czas życia wątku, w którym jest osadzono. Kiedy wątek kończy pracę, również obiekty typu ScopedValue są oznaczone do usunięcia, co gwarantuje brak wycieków pamięci. Poza tym obiekty ScopedValue są niezmienne i łatwiej zarządzać nimi, kiedy chcemy udostępniać je dla wielu wątków jednocześnie. Najczęściej Scoped Values definiowane są jako pola public static, dzięki czemu można się do nich łatwo odwoływać. Dzięki temu nie musimy przekazywać konkretnego obiektu jako argument wielu metod, co prowadzi do uproszczenia kodu. Poniżej prezentuję prosty przykład, który pokazuje, jak może wyglądać kod, w którym chcemy  w kilku miejscach odwołać się do konkretnego obiektu.

public record Product(Long id, String name) 
{}
public record User (Long id, String username, boolean isAdmin) {}
public interface ProductRepository {
   List findAll(User user);
}
public class ProductRepositoryImpl
       implements ProductRepository {


   private final List products = List.of(
           new Product(1L, "PR A"),
           new Product(2L, "PR B")
   );


   @Override
   public List findAll(User user) {
       return user.isAdmin() ? products : List.of();
   }
}
public class ProductServiceImpl {
   private final ProductRepository productRepository;


   public ProductServiceImpl(
           ProductRepository productRepository) {
       this.productRepository = productRepository;
   }


   public void findAllIfAdmin(User user) {
       productRepository
               .findAll(user)
               .forEach(System.out::println);
   }
}
public static void main(String[] args) {
   var productRepository
           = new ProductRepositoryImpl();
   var productService
           = new ProductServiceImpl(productRepository);
   productService
           .findAllIfAdmin(new User(1L, "a", true));
}

Jeżeli chcemy z poziomu obiektu pod referencją productService pobrać przykładową listę obiektów rekordu Product tylko wtedy, kiedy użytkownik ma odpowiednie uprawnienia (pole isAdmin obiektu User posiada wartość true), wtedy istnieje konieczność przekazania obiektu rekordu User jako argument do kilku metod. Najpierw obiekt reprezentujący użytkownika przekazujemy jako argument do metody findAllIfAdmin wywołanej na rzecz productService. Następnie w ramach wywołania metody findAllIfAdmin, przekazujemy ten sam obiekt jako argument kolejnej metody, tym razem findAll wywołanej z poziomu obiektu wskazywanego przez referencję productRepository.

W przypadku bardziej złożonej logiki, prawdopodobnie należałoby przekazywać obiekt rekordu User w jeszcze inne miejsca, jeżeli zależałoby nam na wykonywaniu różnych operacji w zależności od uprawnień użytkownika. To oczywiście wpływa na pogorszenie jakości kodu i jest niewygodne dla programisty. Właśnie w takiej sytuacji możemy zastosować Scoped Value. Poniżej umieściłem kod, który wykorzystując Scoped Value, eliminuje niedogodności z poprzedniego przykładu.

public record Product(Long id, String name) {}
public record User (Long id, String username, boolean isAdmin) {}
public interface ProductRepository {
   List findAll();
}
public class ProductRepositoryImpl
       implements ProductRepository {


   private final List products = List.of(
           new Product(1L, "PR A"),
           new Product(2L, "PR B")
   );


   @Override
   public List findAll() {
       return ProductServiceImpl.USER.get().isAdmin() ?
               products : List.of();
   }
}
public class ProductServiceImpl {
   public static final ScopedValue USER
           = ScopedValue.newInstance();
   private final ProductRepository productRepository;


   public ProductServiceImpl(
           ProductRepository productRepository) {
       this.productRepository = productRepository;
   }


   public void findAllIfAdmin(User user) throws Exception {
       ScopedValue.where(USER, user, () ->
               productRepository
                       .findAll()
                       .forEach(System.out::println)
       );
   }
}
public static void main(String[] args) {
   try {
       var productRepository
               = new ProductRepositoryImpl();
       var productService
               = new ProductServiceImpl(productRepository);
       productService
               .findAllIfAdmin(
                       new User(1L, "a", true));
   } catch (Exception e) {
       e.printStackTrace();
   }
}

W klasie ProductServiceImpl umieszczono publiczne statyczne pole składowe USER, które zawiera dane użytkownika. 

public static final ScopedValue USER
           = ScopedValue.newInstance();

W taki sposób implementuje się obiekt ScopedValue gotowy do przechowania informacji, które za chwilę będą dostępne wszędzie w metodach wywoływanych w ramach konkretnego wątku. W metodzie findAllIfAdmin znajduje się wywołanie metody where:

ScopedValue.where(USER, user, () ->
               productRepository
                       .findAll()
                       .forEach(System.out::println)
       );

Jako pierwszy argument metoda where przyjmuje referencję ScopedValue USER, która będzie ustawiona na obiekt, podany jako drugi argument. W tym przypadku jest to obiekt rekordu User wskazywany przez referencję user. Trzecim argumentem metody where jest implementacja interfejsu Runnable lub Callable. W ten sposób określamy instancję wątku, w ramach którego będzie dostępny przygotowany wcześniej obiekt typu ScopedValue wskazywany przez referencję USER. Dzięki temu metoda findAll wywoływana na rzecz productRepository nie musi dłużej jako argument przyjmować obiektu rekordu User. Teraz można odwołać się do niego bezpośrednio z wnętrza ciała tej metody, wykorzystując Scoped Value:

@Override
public List findAll() {
   return ProductServiceImpl.USER.get().isAdmin() ?
           products : List.of();
}

Wystarczy na rzecz obiektu USER wywołać metodę get(). Istnieje również możliwość skorzystania z takich metod jak orElse lub orElseThrow. Dostępnych jest również kilka innych metod do zarządzania obiektem typu User w ramach Scoped Value. Na webinarze pokażę kolejne praktyczne przykłady, z których dowiesz się, w jaki sposób prawidłowo zmieniać wskazanie referencji ScopedValue oraz jak wykorzystać mechanizm Scoped Values podczas pracy z Virtual Threads oraz Structured Concurrency.

JDK 20. Podsumowanie

Za nami omówienie kilku nowości, które w ostatnim czasie pojawiły się w Java. Opisane mechanizmy JDK 20 wnoszą wiele ciekawych rozwiązań, zwiększają jakość kodu i pokazują, że twórcy języka Java dokładają wszelkich starań, żeby język był przyjemny w używaniu i podążał za nowymi koncepcjami w świecie programowania.

Pamiętaj, że na webinarze w poniedziałek 24.04.2023 o godzinie 20 rozwinę temat nowości w języku Java. Dokładnie omówię ich przeznaczenie oraz pokażę praktyczne przykłady użycia.

Jeżeli chcesz solidnie i skutecznie nauczyć się języka Java od podstaw po zaawansowane aplikacje komercyjne, zapraszam Cię na Platformę Online KM Programs. Znajdziesz na niej wiele godzin profesjonalnych nagrań w ramach zamieszczonych tam kursów: km-programs.pl/e-learning/.

Gotowy na ciekawą przygodę z programowaniem w języku Java? Zapraszam!

Zdjęcie główne artykułu pochodzi z unsplash.com.

Twórca KM Programs

Programowaniem i nauczaniem programowania zajmuje się już od kilkunastu lat. Znajduje w tym ogromną pasję, dzięki której cały czas rozwija się i czerpie z tego bardzo dużą przyjemność

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/co-nowego-w-javie-jdk-20" order_type="social" width="100%" count_of_comments="8" ]