Wielowątkowość w Java – tworzenie wątków

tworzenie wątków w java

To już drugi artykuł z serii o wielowątkowości w Java. Po wstępie teoretycznym z ostatniego artykułu, przejdziemy do praktyki. Z tego artykułu dowiesz się, jak w tworzyć wątki w programach napisanych w języku Java i poznasz mechanizmy wbudowane w Javę, które realizują to zadanie. Przykłady będą oparte o JDK 17.

Reprezentacja wątków w Javie

Java Development Kit posiada wbudowaną klasę java.lang.Thread, która reprezentuje wątek. Instancja tej klasy przechowuje kod (zadanie) do wykonania w innym wątku. W tej klasie znajduje się kilka przydatnych metod, które służą do sprawdzania statusu wątku i podstawowe operacje do zarządzania nim.

Większość operacji w tej klasie jest delegowana do metod natywnych. Metody natywne to metody, które są zaimplementowane w innym kompilowanym języku i znajdują się w bibliotece natywnej. Klasa Thread używa takich metod, ponieważ wątki są mechanizmem systemowym. To znaczy, że ich implementacja będzie różna dla różnych systemów operacyjnych. Zarządzanie wątkami odbywa się na niższym poziomie niż kod programu uruchamiany w maszynie wirtualnej Javy (JVM).

Na szczęście, na co dzień nie odczujesz tego, że są takie specjalne metody. Wątki w Javie po prostu działają. W rozwiązaniach, które zobaczysz w moich artykułach, nie wykroczymy poza kod napisany w języku Java.

Tworzenie wątków

Aby stworzyć wątek w języku Java, należy stworzyć instancję klasy Thread, a następnie wywołać metodę start() (uwaga: nie pomyl z metodą run()! Metoda run() uruchomi kod w głównym wątku).

Konstruktor klasy Thread przyjmuje obiekt typu Runnable – klasa implementująca ten interfejs przechowuje kod, który ma być uruchomiony w nowym wątku.

class SimpleExampleWithRunnable {
    public static void main(String[] args)  {
        Thread thread = new Thread(new AsyncRunnable());
        thread.start();

        System.out.println("Thread name: " + thread.getName());
        System.out.println("Is Alive: " + thread.isAlive());
    }
}

class AsyncRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("Hello World (asynchronously)!");
    }
}

W taki sposób możesz stworzyć nowy wątek (oczywiście to nie jest jedyna droga). Kod z klasy AsyncRunnable wykona się w oddzielnym wątku i wyświetli „Hello World (asynchronously)!”. Pomimo, że metodę start() wykonujemy przed wyświetleniem dwóch ostatnich komunikatów, to nie ma gwarancji, że „Hello World” zostanie wyświetlone pierwsze. Ze względu na niedeterministyczną naturę wątków, możesz zaobserwować różne wyniki powyższego programu.

Najczęściej zobaczysz, że wynikiem programu będzie wyświetlenie następującego tekstu:

Hello World (asynchronously)!
Thread name: Thread-0
Is Alive: false

Może się zdarzyć, że „Hello World” zostanie wyświetlone w innej linijce, albo zobaczymy „Is Alive: true”. Wszystko zależy od tego, kiedy system przydzieli czas CPU dla tego wątku (czas CPU to miara przydziału procesora na zadanie).

Tworzenie wątku przez wyrażenie lambda

Warto zauważyć, że Runnable to interfejs posiadający jedną metodę bez implementacji (tzw. interfejs funkcyjny, functional interface). W Javie 8 i nowszych taki interfejs można zastąpić wyrażeniem lambda.

Wyrażenie lambda pozwala na pomięcie całej formułki deklaracji klasy i tworzenia instancji obiektu tej klasy. Dzięki temu możesz o wiele krócej zapisać to, co ma robić nowy wątek.

Odpowiednikiem powyższego kodu z użyciem wyrażenia lambda zamiast jawnej implementacji Runnable jest:

class SimpleExampleWithRunnable {
    public static void main(String[] args)  {
        Thread thread = new Thread(() -> {
            System.out.println("Hello World (asynchronously)!");
        });
        thread.start();

        System.out.println("Thread name: " + thread.getName());
        System.out.println("Is Alive: " + thread.isAlive());
    }
}

Tworzenie wątku przez rozszerzanie klasy Thread

Klasa Thread rozszerza interfejs Runnable. Oznacza to, że posiada ona metodę run(), w której zdefiniowane jest, co ma robić nowy wątek. Domyślnie wątek wykonuje zadanie przekazane w konstruktorze, co widzisz w poprzednich przykładach.

Możemy wykorzystać konstrukcję klasy Thread do stworzenia swojej własnej klasy Thread z ustalonym zadaniem. Poniższy przykład działa tak jak dwa poprzednie:

class SimpleExampleWithThreadSubclass {
    public static void main(String[] args) {
        Thread thread = new CustomThread();
        thread.start();

        System.out.println("Thread name: " + thread.getName());
        System.out.println("Is Alive: " + thread.isAlive());
    }
}

class CustomThread extends Thread {
    @Override
    public void run() {
        System.out.println("Hello World (asynchronously)!");
    }

}

W tym przykładzie widać dobrze, dlaczego należy uważać, aby nie pomylić metod run() i start().
Metoda run() wykonuje zadanie od razu i nie posiada mechanizmów tworzących wątek i przekazujących to zadanie do nowego wątku. Niestety ze względu na nazewnictwo, łatwo je ze sobą pomylić (jedno i drugie sugeruje uruchomienie zadania).

Widoczność wątków w systemie

Gdy wywołasz metodę start(), to maszyna wirtualna Javy tworzy nowy wątek używając mechanizmów systemowych. Oznacza to też, że system operacyjny widzi uruchomione przez JVM wątki i może dokonać ich inspekcji. Taka inspekcja może polegać na sprawdzeniu przydzielonych zasobów dla wątku, sprawdzeniu jakie pliki zostały otwarte przez wątek, czy jakich bibliotek systemowych używa dany wątek.

Systemy operacyjne posiadają narzędzia do inspekcji uruchomionych procesów (np. Menedżer zadań w systemie Windows, Monitor aktywności w systemie macOS czy top w systemach uniksopodobnych). W tych narzędziach zazwyczaj nie zobaczymy wyróżnionych poszczególnych wątków, ale to nie znaczy, że te wątki są niewidoczne dla systemu.

Wątki można nazywać i wykorzystam ten mechanizm, aby pokazać, że system widzi stworzone w Javie wątki. Do stworzenia wątku z własną nazwą użyje drugiego konstruktora klasy Thread, który oprócz instancji Runnable przyjmuje String z nazwą wątku.

public class ThreadWithNameExample {
    public static void main(String[] args) {
        Thread sampleThread = new Thread(ThreadWithNameExample::doNotStop, "Our sample thread");
        sampleThread.start();
    }

    private static void doNotStop() {
        while(true) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println("Interrupted " + Thread.currentThread().getName());
            }
        }
    }
}

Ten program uruchomi nowy wątek o nazwie „Our sample thread”, który będzie „spał” w nieskończonej pętli. Ta pętla ma za zadanie przedłużenie życia wątku, aby łatwiej było dokonać inspekcji.

Uwaga: Jeśli uruchomiłeś ten program u siebie, to pamiętaj, że działa on w nieskończoność (należy wymusić zakończenie procesu, aby go wyłączyć).

Używam systemu macOS 13.2.1, więc pokażę przykład z użyciem narzędzia Monitor aktywności. Jak widać na poniższym zrzucie ekranu istnieje proces java, który wykonuje mój program. W oknie informacji o procesie jest przycisk „Próbkuj”, który pozwala na zebranie szczegółowych informacji o tym, co robi w tej chwili dany proces.

Monitor aktywności - inspekcja
Program Monitor aktywności z oknem informacji o procesie java

Po kliknięciu przycisku „Próbkuj” pojawia się szczegółowy raport, w którym odnalazłem wątek nazwany „Our sample thread”. Pod tym wątkiem znajduje się cały stos wywołań z chwili poboru próbki, a także biblioteki, które są używane do aktualnie wykonywanego przez wątek zadania.

Tworzenie wątków - monitor aktywności inspekcja wątku
Próbka procesu java – zrzut aktywności programu w danej chwili

Nazywanie wątków ułatwia analizę błędów. Jeśli napisałeś program, który jest uruchamiany u kogoś innego (np. u Twojego klienta), to w przypadku błędu możesz go poprosić o taką próbkę. Wątki z czytelnymi nazwami na pewno ułatwią analizę sytuacji.

Niedeterministyczna natura wątków

Uruchamiając powyższe przykłady nie zawsze możesz zaobserwować niedeterministyczną naturę wątków, o której wspominam od początku cyklu artykułów. Główną przyczyną jest to, że uruchamiamy tylko jeden dodatkowy wątek i wstawiamy całą linijkę tekstu naraz.

O wiele łatwiej zauważyć wspomnianą naturę wątków w poniższym przykładzie, gdzie uruchamiane są dwa wątki wyświetlające tekst.

public class SimpleExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() ->
                System.out.print("Hello")
        );
        Thread thread2 = new Thread(() ->
                System.out.print(" world?")
        );
        thread1.start();
        thread2.start();
        
        System.out.println("I hope it works");
        System.out.println("Is thread1 alive? " + thread1.isAlive());
        System.out.println("Is thread2 alive? " + thread2.isAlive());
    }
}

Jeśli uruchomisz ten program kilkukrotnie, to będziesz w stanie zaobserwować, że prawie za każdym razem rezultat będzie inny.

Przykładowy rezultat trzech kolejnych uruchomień:

Uruchomienie nr 1:

Hello world?I hope it works
Is thread1 alive? false
Is thread2 alive? false


Uruchomienie nr 2:

HelloI hope it works
 world?Is thread1 alive? false
Is thread2 alive? false


Uruchomienie nr 3:

I hope it works
 world?HelloIs thread1 alive? true
Is thread2 alive? false

Oczekiwanie na wątek

Powyższy problem z kolejnością wyświetleń możesz naprawić używając metody join(), która służy do zatrzymania jednego wątku do momentu zakończenie wskazanego wątku. Wątkiem czekającym będzie wątek, który wywoła metodę join() (np. wątek główny, który uruchomił metodę main).

Zmodyfikuję powyższy przykład dodając join() po każdym start(), aby mieć pewność, że tekst wyświetli się w określonej kolejności.

public class SimpleExample {
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() ->
                System.out.print("Hello")
        );
        Thread thread2 = new Thread(() ->
                System.out.print(" world?")
        );
        thread1.start();
        thread1.join();
        thread2.start();
        thread2.join();
        
        System.out.println("I hope it works");
        System.out.println("Is thread1 alive? " + thread1.isAlive());
        System.out.println("Is thread2 alive? " + thread2.isAlive());
    }
}

Uruchomienie tego programu daje poniższy rezultat – tekst jest teraz zawsze wyświetlany tak, aby był czytelny:

Hello world?I hope it works
Is thread1 alive? false
Is thread2 alive? false

Zauważ, że naprawiając ten problem w taki sposób, sprawiasz, że tak naprawdę wszystko wykonuje się po kolei. Program tworzy wątki, ale te wątki nie wykonują nic w międzyczasie. Program i tak czeka, aż każdy wątek po kolei skończy swoją pracę.

W tym przypadku zastosowanie wielowątkowości nie miało sensu, ponieważ wprowadza tylko dodatkowe skomplikowanie programu. Dodatkowe wątki nie dają żadnego zysku, bo i tak uruchamiasz je po kolei.

Obsługa błędów w wątkach

Może się zdarzyć sytuacja w której wątek zakończy pracę w wyniku niewychwyconego wyjątku. Możesz się przed tym zabezpieczyć podpinając do wątku własny UncaughtExceptionHandler, który obsłuży taki wyjątek.

W poniższym przykładzie główny wątek programu zawsze wykonuje się tak samo.

public class ExceptionHandler {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            throw new IllegalStateException("oops");
        });
        thread.start();
        thread.join();
        
        System.out.printf("[%s] Hello World!%n", Thread.currentThread().getName());
    }
}

Efektem wykonania tego programu jest wyświetlenie poniższego komunikatu:

Exception in thread "Thread-0" java.lang.IllegalStateException: oops
	at org.example.threads.ExceptionHandler.lambda$main$0(ExceptionHandler.java:6)
	at java.base/java.lang.Thread.run(Thread.java:833)
[main] Hello World!

Jeśli chciałbyś dodać własny komunikat w przypadku błędu wątku i przekazać informację o danym błędzie do głównego wątku, to możesz stworzyć własny UncaughtExceptionHandler, który to obsłuży.

W klasie obsługującej błędy (twojej własnej implementacji UncaughtExceptionHandler) powinieneś zaimplementować metodę void uncaughtException(Thread thread, Throwable exception). Ta metoda zostanie wywołana zamiast standardowego mechanizmu Javy, który obsługuje niewychwycone wyjątki.

public class ExceptionHandler {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            throw new IllegalStateException("oops");
        });
        boolean[] didFail = new boolean[] {false};

        thread.setUncaughtExceptionHandler(new CustomExceptionHandler(didFail));
        thread.start();
        thread.join();

        System.out.printf("[%s] Hello World! Did our thread fail? %s%n", Thread.currentThread().getName(), didFail[0]);
    }
}

class CustomExceptionHandler implements Thread.UncaughtExceptionHandler {
    private final boolean[] didFail;

    CustomExceptionHandler(boolean[] didFail) {
        this.didFail = didFail;
    }

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.printf("[%s] Thread %s threw an exception (message: %s), don't bother about it.%n",
                Thread.currentThread().getName(), t.getName(), e.getMessage());
        didFail[0] = true;
    }
}

Klasa CustomExceptionHandler w tablicy didFail zapisuje true, jeśli wystąpił wyjątek w wątku. Dzięki temu w głównym wątku możemy odczytać informację o tym, co się stało z drugim wątkiem. Oprócz tego klasa wyświetla własny komunikat zamiast Exception in thread …”.

Efektem wykonania powyższego programu jest poniższy komunikat:

[Thread-0] Thread Thread-0 threw an exception (message: oops), don't bother about it.
[main] Hello World! Did our thread fail? True

Spróbuj przetestować ten kod z zakomentowaną linijką nr 4 i zobacz co się stanie.

Uwaga o rezultatach

Przykłady z tego artykułu uruchomiłem na MacBooku Pro M2 16GB (Mac14,7) (Java 17 w implementacji Eclipse Temurin 17.0.4). W zależności od systemu, sprzętu, wolnych zasobów i JRE efekt może być różny. Rezultaty w tym artykule obrazują jak działają wątki, ale nie ma gwarancji dokładnie takiego zachowania.

Co dalej?

W kolejnych artykułach z cyklu opowiem o tym, jakie są stany wątków w Javie i jak wykorzystać wielowątkowość w praktyce. W przyszłych artykułach pokażę jak wątki mogą na siebie wpływać i jakie są mechanizmy do ograniczanie negatywnych efektów takich sytuacji.

Następny artykuł: Wielowątkowość w Java – stany wątków

Autor
Cezary Regec
Cezary Regec
Software Engineer w FINANTEQ
Chcesz z nami pracować?