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, iż 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. o ile 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).
Zasada działania mechanizmu Pattern Matching
Przegląd nowości zaczynamy od przypomnienia, czym jest mechanizm Pattern Matching. Na tej adekwatnoś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ą, iż 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 adekwatnie 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 o ile 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, iż 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 realizowane są 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ć adekwatnoś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, o ile 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 o ile 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 }Wszystko, o czym za mało mówi się w branży IT.
Prosto na Twoją skrzynkę.
Pattern Matching for Switch Expressions and Statements
Kolejny mechanizm, który wykorzystuje Pattern Matching to Pattern Matching for Switch Expressions and Statements. Mechanizm w tej chwili 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, iż 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 -> "..."; };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 w tej chwili 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. o ile przyjmiemy, iż 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, iż 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. o ile 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<T> {}oraz dwa rekordy, które implementują ten interfejs:
record Tuple<T> (T t1, T t2) implements Multi<T> {} record Triple<T> (T t1, T t2, T t3) implements Multi<T> {}Kiedy utworzymy obiekt wskazywany przez rekord typu Multi z konkretnie sprecyzowanym typem T:
Multi<String> 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<Point> 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. o ile 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.
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, iż są to nowe byty, może okazać się, iż 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.