Java
Semantyka Javy 21 LTS: wyjątki, kontrakt equals/hashCode, rekordy, sealed classes, generyki, Stream API i wątki wirtualne.
Java
Semantyka Javy 21 LTS: wyjątki, kontrakt equals/hashCode, rekordy, sealed classes, generyki, Stream API i wątki wirtualne.
Wyjątki: checked vs unchecked i try-with-resources
na egzaminieThrowable dzieli się na Error (błędy poziomu JVM, nie do przechwytywania w normalnym kodzie) i Exception. Exception dzieli się dalej: podklasy RuntimeException to wyjątki niekontrolowane (unchecked), reszta to wyjątki kontrolowane (checked), wymuszane przez kompilator.
Throwable → Error | Exception. Exception → RuntimeException (unchecked) | pozostałe klasy (checked, np. IOException, SQLException).
Checked exception: Wyjątek niebędący podklasą RuntimeException; kompilator wymaga jego obsłużenia (catch) lub zadeklarowania (throws) w sygnaturze metody.
Unchecked exception: Podklasa RuntimeException (lub Error); kompilator nie wymaga catch ani throws, np. NullPointerException, IllegalArgumentException, IllegalStateException.
// Checked: wywołujący MUSI obsłużyć lub zadeklarować
void readConfig(String path) throws IOException {
Files.readString(Path.of(path));
}
// Unchecked: wywołujący MOŻE obsłużyć, kompilator nie wymusza
int divide(int a, int b) {
if (b == 0) throw new IllegalArgumentException("Divisor cannot be zero");
return a / b;
} Łapanie wyjątku i ignorowanie go pustym catch(Exception e) {} ukrywa błąd i utrudnia debugowanie: loguj albo propaguj dalej, nigdy nie połykaj w ciszy.
- Unchecked dla błędów programistycznych (złe argumenty, zły stan): klient i tak nie mógł się przed nimi zabezpieczyć w runtime.
- Checked tylko gdy wywołujący realnie może i powinien się odzyskać (np. brak pliku, retry po timeout sieciowym).
- Nadużywanie checked exceptions w warstwie domenowej prowadzi do zaśmieconych sygnatur i przechwytywania na wszelki wypadek.
static void readFirstLine(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
System.out.println(reader.readLine());
}
} Przy wielu zasobach w jednym try(...) zamykane są w kolejności odwrotnej do deklaracji: ostatni zadeklarowany zamyka się pierwszy.
Jeśli wyjątek wystąpi zarówno w bloku try, jak i w close(), wyjątek z try jest wyjątkiem głównym. Wyjątek z close() trafia do niego jako suppressed (Throwable.getSuppressed()).
Checked to kompilator pilnujący jak nauczyciel; unchecked to błąd programisty, którego kompilator nie wyłapie za ciebie.
equals(), hashCode() i pułapki autoboxingu
na egzaminieObject.equals() domyślnie porównuje referencje (==), a Object.hashCode() zwraca wartość zależną od JVM (zwykle powiązaną z adresem w pamięci). Nadpisanie jednej metody bez drugiej to najczęstszy sposób na złamanie kolekcji haszujących.
- Zwrotność: x.equals(x) zawsze true
- Symetria: x.equals(y) wtedy i tylko wtedy, gdy y.equals(x)
- Przechodniość: x.equals(y) i y.equals(z) implikuje x.equals(z)
- Spójność: powtarzane wywołania dają ten sam wynik, jeśli pola się nie zmieniły
- x.equals(null) zawsze zwraca false
Obiekty równe wg equals() MUSZĄ mieć ten sam hashCode(). Obiekty nierówne MOGĄ mieć ten sam hashCode() (kolizja dozwolona, ale pogarsza wydajność HashMap).
final class Point {
private final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
} Nadpisanie equals() bez hashCode() sprawia, że obiekt znika z HashSet/HashMap: dwa logicznie równe obiekty trafiają do różnych kubełków, bo mają różny hashCode().
Autoboxing: Automatyczna konwersja typu prymitywnego na jego wrapper (np. int na Integer) i odwrotnie (unboxing), wykonywana niejawnie przez kompilator.
Integer.valueOf(int) cache'uje instancje dla wartości od -128 do 127 (JLS 5.1.7); autoboxing w tym zakresie zwraca tę samą instancję, poza nim tworzy nowy obiekt.
Integer a = 127, b = 127;
System.out.println(a == b); // true, cache -128..127
Integer c = 128, d = 128;
System.out.println(c == d); // false, poza cache, różne obiekty
System.out.println(c.equals(d)); // true, poprawne porównanie wartości Kod testowany na małych liczbach (np. w pętli 0..10) może działać z ==, bo trafia w cache. W produkcji, na większych wartościach, ten sam kod daje ciche, losowe błędy logiczne.
Zawsze .equals() do porównania wartości, == tylko do celowego porównania referencji.
Integer x = null; int y = x; rzuca NullPointerException w momencie unboxingu, nie w momencie przypisania null.
equals mówi CO jest równe, hashCode mówi GDZIE tego szukać w tablicy haszującej: muszą się zgadzać.
String: niezmienność i string pool
na egzaminieString w Javie jest niezmienny (immutable): raz utworzonego obiektu String nie da się zmodyfikować. Każda operacja pozornie modyfikująca (np. concat, replace, toUpperCase) zwraca nowy obiekt.
- Bezpieczeństwo wątkowe bez synchronizacji: niezmienny obiekt można bezpiecznie współdzielić między wątkami
- Bezpieczne cache'owanie hashCode() (pole prywatne liczone raz, potem zwracane bez przeliczania)
- Bezpieczeństwo w classloaderach, ścieżkach plików, połączeniach sieciowych: wartość nie może się zmienić pod spodem po walidacji
- Współdzielenie literałów w string pool bez ryzyka, że jeden fragment kodu zmodyfikuje string używany gdzie indziej
String pool: Pula literałów String w pamięci JVM (część sterty od Javy 7), w której kompilator automatycznie interniuje literały. Identyczne literały wskazują na ten sam obiekt.
String a = "Tenzan";
String b = "Tenzan";
System.out.println(a == b); // true, ten sam obiekt z string pool
String c = new String("Tenzan");
System.out.println(a == c); // false, nowy obiekt na stercie
System.out.println(a == c.intern()); // true, intern() zwraca referencję z puli String c = new String("Tenzan") celowo omija pulę i tworzy nowy obiekt na stercie; porównywanie go przez == z literałem da false, mimo identycznej wartości. To najczęstsza pułapka na rozmowach rekrutacyjnych.
String.intern() zwraca referencję z puli dla danej wartości, dodając ją do puli, jeśli jeszcze tam nie ma. Po intern() porównanie == z literałem o tej samej treści zwraca true.
Konkatenacja stringów operatorem + wewnątrz pętli tworzy nowy obiekt String przy każdej iteracji (bo String jest niezmienny). Kompilator optymalizuje pojedyncze wyrażenie +, ale nie serię dołączeń w pętli.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i).append(',');
}
String result = sb.toString(); Od Javy 9 (JEP 254) String przechowuje dane w byte[] z kodowaniem Latin-1, gdy to możliwe, zamiast char[] UTF-16. Oszczędza to zwykle około połowę pamięci dla typowych tekstów ASCII/Latin.
String się nie zmienia, tylko referencja wskazuje gdzie indziej: +, replace, substring zawsze zwracają nowy obiekt.
Rekordy (records): nowoczesny nośnik danych
na egzaminieRekord (Java 16+, JEP 395) to zwięzła, niezmienna klasa danych. Jedna deklaracja generuje konstruktor kanoniczny, prywatne pola final, publiczne akcesory, equals(), hashCode() i toString(), eliminując boilerplate ręcznie pisanych DTO.
public record Employee(String name, String email, double salary) {}
Employee e = new Employee("Anna Kowalska", "anna@firma.pl", 9500.0);
System.out.println(e.name()); // "Anna Kowalska", brak prefiksu "get"
System.out.println(e); // Employee[name=Anna Kowalska, email=anna@firma.pl, salary=9500.0] Konstruktor kanoniczny (parametry = komponenty rekordu). Prywatne pola final o tych samych nazwach. Publiczne metody akcesorów o nazwie pola bez prefiksu get (np. name(), nie getName()). Metody equals()/hashCode() porównujące wszystkie komponenty. Metoda toString() w formacie NazwaRekordu[pole=wartość, ...].
Compact constructor: Konstruktor bez jawnej listy parametrów (używa niejawnie parametrów o nazwach komponentów), pozwala walidować lub normalizować dane wejściowe przed niejawnym przypisaniem do pól.
public record Range(int min, int max) {
public Range {
if (min > max) {
throw new IllegalArgumentException("min > max");
}
// brak this.min = min: przypisanie pól jest niejawne
}
} W konstruktorze kompaktowym nie wolno pisać this.min = min; jawnie: przypisanie do pól jest niejawne i następuje automatycznie po jego zakończeniu. Wolno tylko modyfikować same parametry (np. min = Math.abs(min);).
- Rekord jest niejawnie final: nie można go rozszerzyć
- Rekord nie może extends innej klasy (niejawnie dziedziczy po java.lang.Record), ale może implements dowolną liczbę interfejsów
- Nie można deklarować dodatkowych pól instancyjnych poza komponentami rekordu (statyczne pola/metody są dozwolone)
- Można nadpisać wygenerowane equals()/hashCode()/toString() lub dodać własne metody i konstruktory pomocnicze
Rekordy nie nadają się do encji z tożsamością i mutowalnym stanem (np. encje JPA z wymaganym bezargumentowym konstruktorem i setterami). To narzędzie do przenoszenia niemutowalnych danych, nie do modelowania stanu zmieniającego się w czasie.
Od Javy 21 rekordy można dekonstruować we wzorcach (record patterns) w instanceof i switch, wyciągając komponenty bezpośrednio bez wywoływania akcesorów.
Rekord to dane plus tożsamość wartości (equals po zawartości), klasa to zachowanie plus tożsamość referencyjna.
Hierarchie typów: interfejsy, klasy abstrakcyjne, sealed classes i pattern matching
na egzaminieInterfejsy i klasy abstrakcyjne to dwa narzędzia do modelowania hierarchii typów. Sealed classes/interfaces (Java 17+) dodają kontrolę nad tym, kto może rozszerzać typ. Pattern matching dla switch (Java 21) pozwala wyczerpująco skonsumować taki typ bez branchu default.
Interfejs
- Wielodziedziczenie: klasa może implementować wiele interfejsów
- Metody: abstract, default (Java 8+), static, private (Java 9+); brak stanu instancyjnego poza stałymi
- Kontrakt zdolności: co obiekt POTRAFI zrobić
Klasa abstrakcyjna
- Jednodziedziczenie: tylko jedna klasa bazowa
- Może mieć konstruktory, pola instancyjne, metody z dowolną widocznością (private/protected/public)
- Hierarchia 'jest': wspólna implementacja i stan dla podklas
public interface PaymentProcessor {
void charge(double amount);
default void chargeWithLog(double amount) {
log(amount);
charge(amount);
}
private void log(double amount) {
System.out.printf("Charging %.2f%n", amount);
}
} sealed: Modyfikator (Java 17+) ograniczający zbiór klas/interfejsów mogących bezpośrednio rozszerzać dany typ do jawnie wymienionych w klauzuli permits.
public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {} Każda klasa wymieniona w permits musi być zadeklarowana jako final, sealed (dalej ogranicza) albo non-sealed (jawnie otwiera hierarchię z powrotem). Brak jednego z tych słów to błąd kompilacji.
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> t.base() * t.height() / 2;
};
} record Point(int x, int y) {}
static String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == y -> "Punkt na przekątnej: " + x;
case Point(int x, int y) -> "Punkt (" + x + ", " + y + ")";
default -> "Nieznany obiekt";
};
} Przed sealed classes jedyną ochroną przed nieobsłużonym podtypem w switch/if-else był ręcznie pisany branch default rzucający wyjątek w runtime. Sealed plus pattern matching przenosi ten błąd z runtime na czas kompilacji.
- Interfejs: gdy potrzebujesz kontraktu implementowanego przez niepowiązane typy (np. Comparable, Runnable)
- Klasa abstrakcyjna: gdy podklasy dzielą realną implementację i stan
- Sealed: gdy znasz z góry pełny, zamknięty zbiór wariantów (np. reprezentacja AST, wynik operacji: Success/Failure)
sealed mówi 'to są WSZYSTKIE możliwe podtypy', switch z pattern matching mówi 'obsłużyłem je WSZYSTKIE, kompilator to potwierdza'.
Generyki, type erasure i var
na egzaminieGeneryki dają bezpieczeństwo typów w czasie kompilacji bez rzutowań. JVM ich jednak nie widzi w runtime: mechanizm type erasure usuwa parametry typu z bytecode'u, zostawiając tylko ich ograniczenia (bounds) albo Object.
List<String> i List<Integer> mają dokładnie tę samą klasę w runtime: List.class. Nie da się zrobić instanceof List<String>, tylko instanceof List<?> (wildcard nieograniczony).
static <T extends Comparable<T>> T max(List<T> list) {
return list.stream()
.max(Comparator.naturalOrder())
.orElseThrow(NoSuchElementException::new);
} PECS (Producer Extends, Consumer Super): Reguła doboru wildcardów. Użyj ? extends T dla źródła, z którego tylko czytasz (producenta). Użyj ? super T dla celu, do którego tylko zapisujesz (konsumenta).
static void copy(List<? extends Number> source, List<? super Number> destination) {
for (Number n : source) {
destination.add(n);
}
} new T[10] nie kompiluje się przy generycznym T: erasure nie zna rzeczywistego typu elementu w runtime, więc JVM nie potrafi utworzyć poprawnie otypowanej tablicy. Obejście: Array.newInstance(...) z jawnym Class<T> i rzutowaniem, albo użycie List<T> zamiast tablicy.
Kompilator generuje niewidoczne metody mostkujące (bridge methods), gdy nadpisanie metody generycznej po erasure miałoby inną sygnaturę niż w typie bazowym. Dzięki temu polimorfizm działa mimo erasure.
var: Lokalne wnioskowanie typu (Java 10+, JEP 286): kompilator wyprowadza konkretny typ statyczny zmiennej lokalnej z wyrażenia inicjalizującego. To nie jest typowanie dynamiczne; typ jest ustalany raz, w czasie kompilacji.
var users = new ArrayList<String>(); // typ: ArrayList<String>
for (var user : users) { // typ: String
System.out.println(user.toUpperCase());
}
try (var conn = dataSource.getConnection()) {
// typ: Connection
} var wymaga inicjalizatora (var x; nie kompiluje się). Słowo var nie działa dla pól klasy ani zwykłych parametrów metod (dozwolone od Javy 11 tylko dla parametrów lambdy).
Nie nadużywaj var tam, gdzie ukrywa typ niejasny z kontekstu (np. var result = process();).
Generyki plus var to dwa różne poziomy: generyki to bezpieczeństwo typu w API, var to wygoda zapisu bez zmiany rzeczywistego typu.
Stream API: leniwość, operacje terminalne, kolektory
na egzaminieStream API opisuje przetwarzanie danych deklaratywnie: źródło, operacje pośrednie (intermediate), operacja terminalna (terminal). Operacje pośrednie są leniwe: nic się nie wykonuje, dopóki nie pojawi się operacja terminalna.
map(), filter(), sorted(), distinct(), peek() budują tylko opis pipeline'u. Faktyczne przejście po elementach następuje dopiero przy wywołaniu operacji terminalnej, i to element po elemencie (nie faza po fazie).
List<String> names = List.of("Ala", "Bartek", "Celina");
names.stream()
.filter(n -> {
System.out.println("filter: " + n);
return n.length() > 3;
})
.map(String::toUpperCase);
// brak wypisania na konsolę - bez operacji terminalnej pipeline się nie wykonuje Operacja terminalna: Operacja kończąca pipeline i faktycznie konsumująca strumień, zwracająca wynik lub efekt uboczny (collect, forEach, reduce, count, anyMatch, findFirst...). Po jej wywołaniu strumień jest zamknięty.
Ponowne użycie tego samego obiektu Stream po wywołaniu operacji terminalnej rzuca IllegalStateException ('stream has already been operated upon or closed'). Strumień to jednorazowy potok, nie kolekcja wielokrotnego użytku.
Map<Integer, List<String>> byLength = names.stream()
.collect(Collectors.groupingBy(String::length)); - toList() / toSet() / toUnmodifiableList(): zbiór wynikowy
- toMap(keyFn, valueFn): mapa klucz-wartość
- joining(delimiter): łączenie Stringów
- groupingBy(classifier): grupowanie po kluczu
- partitioningBy(predicate): podział na true/false
- counting(), summarizingInt(): agregacje liczbowe
Operacje takie jak limit(), findFirst(), anyMatch() mogą zakończyć przetwarzanie wcześniej, nie odwiedzając całego źródła; dzięki temu da się bezpiecznie operować na nieskończonych strumieniach (Stream.iterate, Stream.generate).
List<Integer> firstFive = Stream.iterate(1, n -> n + 1)
.limit(5)
.toList(); // [1, 2, 3, 4, 5], JDK 16+ Stream.toList() zwraca niemodyfikowalną listę podobną w duchu do collect(Collectors.toUnmodifiableList()). Obie metody nie są jednak tożsame: toList() dopuszcza elementy null, natomiast toUnmodifiableList() rzuca NullPointerException dla null. JDK nie gwarantuje konkretnego typu implementacji ani identyczności zachowania.
Operacje pośrednie tylko planują; operacja terminalna naciska 'start'.
Wątki wirtualne (Project Loom) vs platformowe
na egzaminieWątki wirtualne (Java 21, JEP 444) to lekkie wątki zarządzane przez JVM, mapowane wiele-do-kilku na wątki platformowe (wątki nośne, carrier threads). Celem jest tania współbieżność dla kodu blokującego na I/O, bez pisania kodu asynchronicznego.
Wątek platformowy
- Odwzorowanie 1:1 na wątek systemu operacyjnego
- Kosztowny: stos rzędu megabajtów, tworzenie i przełączanie kontekstu drogie
- Praktyczny limit: tysiące wątków na proces
- Dobry do obliczeń intensywnie korzystających z CPU
Wątek wirtualny
- Zarządzany przez JVM, tymczasowo montowany na wątku nośnym
- Tani: lekki stos rosnący na stercie, tworzenie niemal darmowe
- Praktyczny limit: setki tysięcy lub miliony instancji
- Dobry do kodu blokującego na I/O (sieć, baza danych, pliki)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return null;
});
}
} // executor.close() czeka na zakończenie wszystkich zadań Gdy wątek wirtualny blokuje się na operacji I/O, JVM odczepia go od wątku nośnego, zwalniając ten wątek nośny do obsługi innych wątków wirtualnych. Po zakończeniu operacji wątek wirtualny wraca na dowolny wolny wątek nośny.
Pinning: wątek wirtualny wykonujący blokującą operację wewnątrz bloku synchronized (albo natywnej metody JNI) NIE odczepia się od wątku nośnego, tylko go blokuje (przypina). Przy dużej liczbie wątków wirtualnych to zjada pulę wątków nośnych i niweczy korzyść skalowania.
private final ReentrantLock lock = new ReentrantLock();
void transfer() {
lock.lock();
try {
// blokująca operacja I/O nie przypina wątku nośnego
} finally {
lock.unlock();
}
} Flaga -Djdk.tracePinnedThreads=full (albo short) loguje miejsca w kodzie, gdzie wątek wirtualny zostaje przypięty do wątku nośnego podczas blokowania.
Wątki wirtualne nie przyspieszają obliczeń intensywnie korzystających z CPU (nie ma ich więcej niż rdzeni procesora, które faktycznie mogą coś liczyć). Do równoległych obliczeń nadal właściwy jest ForkJoinPool albo równoległe strumienie, nie wątki wirtualne.
- Twórz jeden wątek wirtualny na zadanie (task), nie pul ich jak platformowych: są tanie, pooling tylko dodaje złożoność bez korzyści
- Unikaj synchronized w ścieżkach z blokującym I/O na wątkach wirtualnych, użyj java.util.concurrent.locks
- Nie trzymaj dużych ThreadLocal na wątkach wirtualnych masowo: koszt pamięci mnoży się przez liczbę instancji
Platformowy wątek to drogi pracownik na etacie; wirtualny to tani podwykonawca na zlecenie, którego JVM zatrudnia i zwalnia w mikrosekundach.
Sprawdź się
20 z 24 pytań, losowo, z wyjaśnieniem po każdym. Wynik zostaje na tym urządzeniu.
Rozpocznij quiz