Liigu peamise sisu juurde

Lõimed

Sissejuhatus

Lõim (thread) on Java programmis iseseisev täitmisüksus. Igal programmil on juba vähemalt üks lõim - pealõim, mis käivitab main-meetodi -, kuid lisalõimi saab luua, et teha tööd sellega samaaegselt. Java pakub kahte viisi, kuidas määrata, mida lõim tegema peab, ning mõningaid meetodeid selle juhtimiseks: millal lõim käivitub ja kuidas oodata selle töö lõppemist.

Thread klassist pärimine

Kõige otsesem viis lõime loomiseks on laiendada Thread klassi ja üle kirjutada selle run meetod. Kui kutsuda sellise klassi eksemplaril välja start(), käivitatakse uus lõim, mis täidab run-meetodit paralleelselt kutsuva lõimega:

public class Worker extends Thread {
@Override
public void run() {
System.out.println("Working on thread: " + Thread.currentThread().getName());
}
}
Worker w = new Worker();
w.start(); // launches a new thread

Tehniliselt start() tagastab tulemuse koheselt. Mida see tähendab on see, et lõim pannakse ajastusse ning JVM käivitab run()-meetodi iseseisvalt. Kutsuv lõim jätkab samal ajal järgmise käsu täitmist.

Runnable liidese teostamine

Teine viis oleks teostada Runnable liidest. Runnable sisaldab ühte meetodit - run - ja kirjeldab teostavat tööd, mitte lõime ennast. Sama Runnable-i saab anda üle Thread-ile, ExecutorService-ile või mõnele muule raamistikule, mis seda kasutaks.

public class Task implements Runnable {
@Override
public void run() {
System.out.println("Working on thread: " + Thread.currentThread().getName());
}
}
Thread t = new Thread(new Task());
t.start();

Kuna Runnable on funktsionaalne liides, saab seda väljendada ka lambda-avaldisena:

Thread t = new Thread(() -> System.out.println("Hello from " + Thread.currentThread().getName()));
t.start();

Kumba eelistada

Praktikas eelistatakse teostada Runnable liidest üle Thread klassi pärimise kahel põhjusel:

Ühekordne pärimine. Klass saab laiendada ainult ühte teist klassi. Kui klass juba pärib mõnest domeenipõhiklassist, ei saa ta samal ajal laiendada Thread-i, kuid Runnable-i saab alati implementeerida.

Vastutuse lahusus. Thread-i laiendamine seob töö sisu sellega, kuidas seda käivitatakse. Runnable-i rakendamine hoiab töö loogika eraldi täitmismehhanismist, mistõttu saab sama ülesannet hiljem käivitada näiteks lõimepuulis, ajastajas või mõnes muus raamistikus ilma koodi muutmata.

Runnable task = () -> doWork();         // define the task once

new Thread(task).start(); // run on a fresh thread
executor.submit(task); // run on a thread pool
scheduler.schedule(task, 5, SECONDS); // run after a delay

start() vs run()

run()-meetodi otsene väljakutse ei käivita uut lõime. See täidab meetodi sünkroonselt samas lõimes, täpselt nagu tavaline meetodikutse:

Thread t = new Thread(() -> System.out.println("Hello"));

t.run(); // prints "Hello" on the current thread - no new thread is created
t.start(); // schedules a new thread that will print "Hello"

See on üks levinumaid vigu Java konkurrentsuses. Kompilaator ei hoiata selle eest - mõlemad kutsed on korrektsed -, kuid tulemusena on programm järjestikuline, mitte konkurrentne.

Lõime töö lõpetamise ootamine: join()

thread.join() peatab kutsuva lõime töö seni, kuni sihtlõim on oma run-meetodi lõpetanud. Ilma join()-ita ei ole garantiid, et töö on valmis hetkeks, kui tulemust kasutatakse:

int[] result = new int[1];

Thread t = new Thread(() -> result[0] = expensiveCalculation());
t.start();

System.out.println(result[0]); // could result in an error or incorrect value - t might have not completed work yet

t.join(); // wait for t to finish

System.out.println(result[0]); // safe - t has completed

join() viskab InterruptedException-i, seega tuleb see kas deklareerida või kinni püüda. join()-ile saab anda ka ajapiirangu - näiteks t.join(1000) ootab maksimaalselt ühe sekundi. Kui lõim selle aja jooksul oma tööd ei lõpeta, naaseb join() ikkagi ja programmi täitmine jätkub.

Lõime olekud ja elutsükkel

Lõim liigub oma elutsükli jooksul läbi mitme selgelt määratletud oleku:

Lõime hetkeseisu saab kontrollida meetodiga thread.getState(), mis tagastab Thread.State tüüpi väärtuse. See on peamiselt kasulik silumisel - toodangus on lõime oleku küsimine harva vajalik.

Thread.sleep

Thread.sleep(millis) peatab parajasti töötava lõime vähemalt etteantud millisekunditeks. See ei vabasta lõime poolt hoitavaid lukke ning seda saab katkestada:

Thread t = new Thread(() -> {
try {
Thread.sleep(1000);
System.out.println("Awake");
} catch (InterruptedException e) {
System.out.println("Interrupted while sleeping");
}
});
t.start();

sleep on kasulik aeglase töö simuleerimiseks, perioodiliseks kontrollimiseks ning vea järel ajutiseks taandumiseks. Lõimede tegelikuks koordineerimiseks kasutatakse seda harva - selleks on mõeldud meetodid nagu join, wait, notify ning kõrgema taseme tööriistad.

Deemonlõimed

Lõimed jagunevad kahte tüüpi: kasutajalõimed (user threads) ja deemonlõimed (daemon threads). JVM lõpetab töö siis, kui viimane kasutajalõim lõpetab oma töö, sõltumata sellest, mitu deemonlõime veel töötab. Deemonlõimed sobivad taustatöödeks - näiteks vahemälu puhastamiseks või monitoorimiseks -, mille lõppemisel peaks programm sulgema iseennast:

Thread t = new Thread(() -> { /* background work */ });
t.setDaemon(true); // must be set before start()
t.start();

Vaikimisi pärivad lõimed oma staatuse lõimelt, mis selle lõi. Pealõim (main) on kasutajalõim, seega on main-ist loodud lõimed samuti kasutajalõimed, kui neid eraldi deemonlõimedeks ei märgita.

Lõimede nimetamine

Lõimedel on nimi, mis vaikimisi on kujul Thread-0, Thread-1 jne. Silumise ja analüüsi jaoks on kasulik anda lõimedele kirjeldavad nimed - lõimenimed ilmuvad veateadetes (stack trace), tööriistades ning meetodi Thread.currentThread().getName() väljundis:

Thread t = new Thread(task, "image-loader-1");
t.start();

Sisukas nimi võib säästa palju aega näiteks tupiku (deadlock) või aeglase testi põhjuste uurimisel.

Lõimede puudujäägid

Kaks lõime võivad käivitada sama programmi erinevaid osi samaaegselt, kuid ilma koordineerimiseta ei saa nad seda teha turvaliselt. Kui mõlemad lõimed loevad ja kirjutavad sama välja, sõltub tulemus operatsioonide täpsest ajastusest - seda tüüpi vigu tuntakse kui tõrkeolukordadena (race conditions).

Järgmine peatükk käsitleb, mis läheb valesti jagatud muudetava olekuga ning kuidas synchronized märksõna aitab neid probleeme vältida.