Multithreading w Javie – podstawy

TL;DR: Multithreading w Javie pozwala wykonywać kilka operacji równocześnie. Używamy klasy Thread lub interfejsu Runnable do tworzenia wątków. To podstawa efektywnych aplikacji Java, ale wymaga ostrożności z synchronizacją.

Jeśli chcesz, żeby Twoja aplikacja Java robiła kilka rzeczy jednocześnie – na przykład pobierała dane z internetu podczas gdy użytkownik wciąż może klikać w interfejs – potrzebujesz multithreadingu. To jedna z najważniejszych umiejętności każdego Java developera.

Dlaczego to ważne?

Współczesne aplikacje muszą być responsywne. Użytkownicy nie chcą czekać aż jedna operacja się skończy, żeby móc kliknąć przycisk. Multithreading pozwala aplikacji „robić kilka rzeczy na raz” – interfejs pozostaje żywy podczas gdy w tle wykonują się czasochłonne operacje jak dostęp do bazy danych czy wywołania API.

Co się nauczysz:

  • Czym są wątki (threads) i jak działają w Javie
  • Jak tworzyć wątki używając klasy Thread i interfejsu Runnable
  • Podstawowe metody zarządzania wątkami
  • Jakie problemy może powodować współdzielenie danych między wątkami
  • Kiedy używać multithreadingu w praktycznych aplikacjach

Wymagania wstępne:

Poziom: Podstawy programowania Java
Potrzebujesz: Znajomość klas, obiektów, interfejsów w Javie
Środowisko: Java 8+ (przykłady działają w każdej wersji Java)

Czym jest wątek (thread)?

Wątek to jak jeden pracownik w restauracji. Aplikacja single-threaded to restauracja z jednym kelnerem – musi obsłużyć jedno zamówienie za drugim. Aplikacja multi-threaded to restauracja z kilkoma kelnerami – mogą obsługiwać wielu klientów równocześnie.

Wątek (thread) to niezależna ścieżka wykonania w programie. Każdy program Java ma co najmniej jeden wątek – main thread, w którym wykonuje się metoda main(). Możemy tworzyć dodatkowe wątki, które będą działać równolegle.

Thread – lekki proces w ramach aplikacji Java, który może wykonywać kod niezależnie od innych wątków

Tworzenie wątków – sposób pierwszy: klasa Thread

Najprostszy sposób na stworzenie wątku to rozszerzenie klasy Thread:

public class MyThread extends Thread {
    private String threadName;
    
    public MyThread(String name) {
        this.threadName = name;
    }
    
    @Override
    public void run() {
        // Ten kod będzie wykonywany w osobnym wątku
        for (int i = 1; i <= 5; i++) {
            System.out.println(threadName + " - krok " + i);
            
            try {
                // Pauza 1 sekunda
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(threadName + " został przerwany");
            }
        }
        System.out.println(threadName + " zakończony!");
    }
}

Jak używać tego wątku:

public class ThreadExample {
    public static void main(String[] args) {
        // Tworzymy dwa wątki
        MyThread thread1 = new MyThread("Wątek-1");
        MyThread thread2 = new MyThread("Wątek-2");
        
        // Uruchamiamy je
        thread1.start();  // Ważne: start(), nie run()!
        thread2.start();
        
        System.out.println("Main thread kończy pracę");
    }
}
Wynik będzie pokazywał, że oba wątki działają równocześnie - ich komunikaty będą się przeplatać, bo każdy wykonuje się niezależnie

Tworzenie wątków - sposób drugi: interfejs Runnable

Lepszym podejściem jest implementacja interfejsu Runnable. Dlaczego? Bo Java ma tylko dziedziczenie pojedyncze, więc jeśli nasza klasa już dziedziczy po innej klasie, nie możemy rozszerzyć Thread.

public class MyTask implements Runnable {
    private String taskName;
    
    public MyTask(String name) {
        this.taskName = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(taskName + " - wykonuję krok " + i);
            
            try {
                Thread.sleep(800);
            } catch (InterruptedException e) {
                System.out.println(taskName + " przerwane");
                return;
            }
        }
        System.out.println(taskName + " zakończone!");
    }
}

Używanie z Runnable:

public class RunnableExample {
    public static void main(String[] args) {
        // Tworzymy zadania
        MyTask task1 = new MyTask("Zadanie-A");
        MyTask task2 = new MyTask("Zadanie-B");
        
        // Tworzymy wątki przekazując zadania
        Thread thread1 = new Thread(task1);
        Thread thread2 = new Thread(task2);
        
        // Uruchamiamy
        thread1.start();
        thread2.start();
        
        System.out.println("Zadania zostały uruchomione");
    }
}
Pro tip: Preferuj Runnable nad rozszerzaniem Thread. To lepiej separuje logikę zadania od mechanizmu wątku.

Podstawowe metody zarządzania wątkami

Metody klasy Thread

MetodaOpisPrzykład użycia
start()Uruchamia wątekZawsze używaj zamiast run()
sleep(ms)Wstrzymuje wątek na określony czasSymulacja długiej operacji
join()Czeka aż wątek się zakończySynchronizacja zakończenia
isAlive()Sprawdza czy wątek jeszcze działaMonitorowanie stanu
interrupt()Przerywa wątekGraceful shutdown

Praktyczny przykład z join()

public class JoinExample {
    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            System.out.println("Pracownik rozpoczyna pracę...");
            try {
                Thread.sleep(3000); // 3 sekundy pracy
            } catch (InterruptedException e) {
                System.out.println("Praca przerwana");
            }
            System.out.println("Pracownik kończy pracę");
        });
        
        worker.start();
        
        try {
            // Czekamy aż worker skończy
            worker.join();
        } catch (InterruptedException e) {
            System.out.println("Oczekiwanie przerwane");
        }
        
        System.out.println("Wszyscy pracownicy skończyli - zamykamy biuro");
    }
}

Problem współdzielenia danych

Uwaga: Gdy kilka wątków ma dostęp do tych samych danych, mogą wystąpić nieprzewidywalne błędy!

Zobaczmy problem na przykładzie:

public class CounterProblem {
    private static int counter = 0;
    
    public static void main(String[] args) throws InterruptedException {
        // Dwa wątki będą zwiększać ten sam licznik
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++; // Niebezpieczne!
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++; // Niebezpieczne!
            }
        });
        
        thread1.start();
        thread2.start();
        
        // Czekamy aż oba się skończą
        thread1.join();
        thread2.join();
        
        System.out.println("Spodziewany wynik: 2000");
        System.out.println("Rzeczywisty wynik: " + counter);
        // Często będzie mniej niż 2000!
    }
}
Pułapka: Operacja counter++ wygląda na atomową, ale w rzeczywistości to trzy kroki: wczytaj wartość, zwiększ ją, zapisz z powrotem. Wątki mogą się "przecinać" między tymi krokami.

Podstawowe rozwiązanie - synchronized

Najprostsze rozwiązanie to słowo kluczowe synchronized:

public class SafeCounter {
    private static int counter = 0;
    private static final Object lock = new Object();
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {
                    counter++; // Teraz bezpieczne
                }
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {
                    counter++; // Teraz bezpieczne
                }
            }
        });
        
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        
        System.out.println("Wynik: " + counter); // Zawsze 2000
    }
}
Synchronized - mechanizm który pozwala tylko jednemu wątkowi na raz wykonać określony fragment kodu

Kiedy używać multithreadingu?

Dobre przypadki użycia:

Aplikacje GUI: Interfejs użytkownika w osobnym wątku, długie operacje w tle
Serwery: Każde połączenie klienta obsługiwane w osobnym wątku
Przetwarzanie równoległe: Dzielenie dużego zadania na mniejsze części
I/O operacje: Podczas gdy jeden wątek czeka na dane z dysku, inne mogą pracować

Praktyczny przykład: pobieranie danych

public class DataFetcher implements Runnable {
    private String dataSource;
    
    public DataFetcher(String source) {
        this.dataSource = source;
    }
    
    @Override
    public void run() {
        System.out.println("Rozpoczynam pobieranie z: " + dataSource);
        
        try {
            // Symulacja pobierania danych (np. z API)
            Thread.sleep(2000 + (int)(Math.random() * 1000));
        } catch (InterruptedException e) {
            System.out.println("Pobieranie przerwane: " + dataSource);
            return;
        }
        
        System.out.println("✓ Dane pobrane z: " + dataSource);
    }
    
    public static void main(String[] args) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        
        // Pobieramy dane z trzech źródeł równocześnie
        Thread api1 = new Thread(new DataFetcher("API-1"));
        Thread api2 = new Thread(new DataFetcher("API-2"));
        Thread api3 = new Thread(new DataFetcher("API-3"));
        
        api1.start();
        api2.start();
        api3.start();
        
        // Czekamy na wszystkie
        api1.join();
        api2.join();
        api3.join();
        
        long endTime = System.currentTimeMillis();
        System.out.println("Wszystkie dane pobrane w: " + (endTime - startTime) + "ms");
        System.out.println("Sekwencyjnie zajęłoby to ~6-9 sekund!");
    }
}
Typowy błąd: Tworzenie zbyt wielu wątków. Każdy wątek zajmuje pamięć (~1MB na stos). W praktycznych aplikacjach używa się thread pools (np. ExecutorService).
Jaka jest różnica między start() a run()?

start() tworzy nowy wątek i wywołuje run() w tym wątku. Bezpośrednie wywołanie run() wykonuje kod w bieżącym wątku - bez multithreadingu!

Ile wątków mogę stworzyć?

Teoretycznie tysiące, ale praktycznie każdy wątek zajmuje ~1MB pamięci. Większość aplikacji używa od kilku do kilkudziesięciu wątków. Dla większej liczby zadań używa się thread pools.

Czy multithreading zawsze przyspiesza aplikację?

Nie! Na komputerze z jednym rdzeniem wątki będą się przełączać, co może być wolniejsze. Przyspieszenie jest widoczne przy operacjach I/O lub na maszynach wielordzeniowych.

Co to jest race condition?

To sytuacja gdy wynik programu zależy od kolejności wykonania wątków. Na przykład dwa wątki modyfikują tę samą zmienną - wynik może być nieprzewidywalny. Rozwiązanie: synchronizacja.

Jak debugować problemy z wątkami?

Problemy z multithreadingiem są trudne do debugowania bo mogą występować sporadycznie. Używaj logowania z nazwami wątków, narzędzi jak jstack, i testuj pod obciążeniem.

Co to jest deadlock?

Sytuacja gdy dwa wątki czekają na siebie nawzajem i żaden nie może kontynuować. Na przykład: wątek A czeka na zasób B, wątek B czeka na zasób A. Program "zamarza".

Czy powinienem używać Thread czy Runnable?

Preferuj Runnable. Jest bardziej elastyczne (możesz dziedziczyć po innej klasie) i lepiej separuje logikę od mechanizmu wątku. Thread używaj tylko gdy naprawdę potrzebujesz rozszerzyć jego funkcjonalność.

🚀 Zadanie dla Ciebie

Stwórz program który symuluje pobieranie 5 plików z internetu. Każdy "download" ma trwać losowo od 1 do 3 sekund (użyj Thread.sleep()). Porównaj czas wykonania gdy robisz to sekwencyjnie vs. równolegle. Dodaj licznik który pokazuje ile downloadów się już skończyło - pamiętaj o synchronizacji!

Następne kroki:

Teraz gdy znasz podstawy multithreadingu, warto zgłębić bardziej zaawansowane tematy:

Przydatne zasoby:

Jakie są Twoje doświadczenia z multithreadingiem w Javie? Z jakimi problemami się spotkałeś? Podziel się w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry