쓰레드
프로세스와 쓰레드
- 프로세스
- OS로부터 실행에 필요한 자원(메모리)을 할당받아 실행중인 프로그램
- 쓰레드
- 프로세스 내에서 실제로 작업을 수행하는 주체
프로세스와 쓰레드는 1:N의 형태이다.
쓰레드 구현 & 사용
1. Thread 클래스 상속
1
2
3
4
5
6
7
class MyThread extends Thread {
@Override
public void run() { ... }
}
MyThread t = new MyThread();
t.start();
2. Runnable 인터페이스 구현
1
2
3
4
5
6
7
8
class MyRunnable implements Runnable {
@Override
public void run() { ... }
}
Runnable r = new MyRunnable(); // 다형성
Thread t = new Thread(r);
t.start();
쓰레드를 구현할 때 Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 주로 Runnable 인터페이스를 구현해서 쓰레드를 구현하는 방식을 많이 사용한다.
한번 start 메서드를 실행한 쓰레드는 start 메서드를 다시 실행할 수 없다. start 메서드를 두 번 이상 호출하면
IllegalThreadStateException
이 발생한다.
start() 와 run()
main() 메서드에서 run()
메서드를 호출하면 main() 메서드가 쌓여있는 스택에 run() 메서드가 추가되지만, start()
메서드를 호출하면 쓰레드를 위한 새로운 스택이 생성되고 그 스택에 run() 메서드가 추가되는 차이가 있다.
쓰레드의 priority
쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있다.
우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라지므로 쓰레드가 처리하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.
우선순위를 지정하지 않으면 자동적으로 쓰레드를 실행한 쓰레드의 우선순위를 따른다.
int getPriority()
- 쓰레드의 우선순위 조회
void setPriority(int newPriority)
- 쓰레드의 우선순위 설정
사실 싱글코어가 아니라면 쓰레드에 우선순위를 다르게 두어도 큰 차이가 없다.
쓰레드 그룹
쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로 자바에서는 쓰레드 그룹을 다루기 위해 ThreadGroup
이라는 클래스를 제공한다.
쓰레드 그룹은 보안상의 이유로 도입된 개념으로 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.
모든 쓰레드는 반드시 하나의 쓰레드 그룹에 포함되어 있어야 하기 때문에 쓰레드 그룹을 지정하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 그룹에 속하게 된다.
자바 프로그램이 실행되면 JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM 운영에 필요한 쓰레드들을 생성해서 그룹에 포함시킨다.
데몬 쓰레드
데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 그래서 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 자동으로 종료된다.
데몬 쓰레드의 대표적인 예로 가비지 컬렉터
가 있다.
데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 구현한다. 다만, 실행하기 전에 setDaemon(true)
를 호출해야 한다. 또한, 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package org.example.thread;
class AutoSaveRunnable implements Runnable {
@Override
public void run() {
while(true) {
if (ThreadExample.autoSave) {
System.out.println("autoSave");
ThreadExample.autoSave = false;
}
}
}
}
public class ThreadExample {
public static volatile boolean autoSave = false; // 1
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new AutoSaveRunnable(), "AutoSaveThread");
t.setDaemon(true);
t.start();
for(int i = 0; i < 30; i++) {
System.out.println("i = " + i);
Thread.sleep(1000);
if (i % 5 == 0) {
autoSave = true;
}
}
}
}
위 코드에서 // 1
을 보면 volatile
키워드가 사용되었는데 이 키워드를 사용하지 않으면 메모리 가시성 문제
때문에 데몬 쓰레드에서 autoSave 문자가 출력되지 않는다.
쓰레드의 실행제어
쓰레드의 실행제어와 관련된 메서드
메서드 | 설명 |
---|---|
sleep() | 지정된 시간(ms)동안 쓰레드를 일시정지시킨다. 지정된 시간이 지나면 실행대기상태가 된다. |
join() | 지정된 시간(ms)동안 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다. |
interrupt() | 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. |
stop() | 쓰레드를 즉시 종료시킨다. |
suspend() | 쓰레드를 일시정지상태로 만든다. resume()을 호출하면 실행대기상태가 된다. |
resume() | suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다. |
yield() | 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다. |
쓰레드의 상태
상태 | 설명 |
---|---|
NEW | 쓰레드가 생성되고 아직 start() 메서드가 호출되지 않은 상태 |
RUNNABLE | 실행 중 또는 실행 가능한 상태 |
BLOCKED | 동기화블럭에 의해서 일시정지된 상태 |
WAITING , TIMED_WAITING | 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지상태. TIMED_WAITING 은 시간이 지정된 일시정지상태를 의미. |
TERMINATED | 쓰레드의 작업이 종료된 상태 |
실험
sleep()
- A 쓰레드에서 B 쓰레드 start() 호출
- B 쓰레드에서 sleep() 호출 (B: TIMED_WAITING)
- A 쓰레드에서 B 쓰레드 interrupt() 호출
- sleep()에 지정된 시간에 관계없이 즉시 B 쓰레드에 InterruptedException 발생
join()
- A 쓰레드에서 B 쓰레드 start() 호출
- A 쓰레드에서 B 쓰레드 join() 호출 (A: WAITING)
- B 쓰레드에서 interrupt() 호출
- 즉시 B 쓰레드가 종료되고 A 쓰레드 다시 실행 (A: RUNNABLE)
이 때, A 쓰레드를 호출한 main 쓰레드가 A 쓰레드 interrupt()를 호출하면 A 쓰레드에 InterruptedException 발생하여 B 쓰레드가 작업 중이어도 A 쓰레드가 동작한다.
정리
- Thread를 상속받은 객체 내에서 interrupt()를 호출하면 자체적으로 InterruptedException을 발생시킬 수 있다.
- InterruptedException이 발생하면 쓰레드는 RUNNABLE 상태가 되고 isInterrupted()의 반환값이 false가 된다.
- join()을 호출하여 WAITING 상태인 쓰레드에 InterruptedException을 발생시키려면 해당 쓰레드가 아닌 다른 쓰레드에서 interrupt()를 호출해야 한다.
쓰레드의 동기화
- 멀티쓰레드 프로세스의 경우 여러 쓰레드가 프로세스의 공유 자원을 이용해서 작업을 할 경우 의도했던 것과는 다른 결과를 얻을 수 있다.이를 방지하기 위해 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드의 접근을 막는
임계영역
과락
이라는 개념이 생겼다. - 임계영역에서 wait()를 호출하면 현재 lock을 얻은 쓰레드는 lock을 반납하고 waiting pool에서 대기한다.
- notify()를 호출하면 waiting pool에서 임의의 쓰레드를 다시 실행한다.
- notify()를 지속적으로 호출해도 운이 나빠 특정 쓰레드는 실행되지 않고 계속 대기할 수도 있다. 이를
기아 현상
라고 한다. - notifyAll()을 호출하면 waiting pool의 모든 쓰레드에게 신호를 보내지만 그 중 하나의 쓰레드만 다시 실행된다. 이 때, lock을 얻기 위해 여러 쓰레드가 경쟁하는데 이를
경쟁 상태(race condition)
라고 한다. - waiting pool은 객체마다 존재하여 wait(), notify(), notifyAll() 메서드는 Object 클래스에 정의되어 있다.
- synchronized 키워드를 이용하는 대신 Lock 클래스를 이용해 수동으로 lock을 잠그고 해제할 수 있다.
- 공유 자원을 이용하는 작업을 수행하기 전 lock을 잠그고 작업 내용을 try-catch-finally로 감싼 뒤 finally 블럭에서 lock을 해제하는 식으로 코드를 작성하면 된다.
- Condition 클래스를 이용하면 공유 자원 객체의 waiting pool이 아닌 Condition 객체의 waiting pool에 대기하도록 하여 쓰레드를 구분할 수 있다. 이렇게 하면 원하는 종류의 쓰레드가 notify() 신호를 받을 수 있도록 할 수 있다. 이로 인해 기아 현상과 경쟁 상태를 어느정도 개선할 수 있지만 여전히 특정 쓰레드를 선택할 수는 없어서 같은 종류의 쓰레드간의 기아 현상과 경쟁 상태가 발생할 수 있다.