[Thread] 02. Thread의 상태와 제어
- -
망아지 같은 스레드를 잘 사용하기 위해서는 그들의 상태를 잘 이해하고 제어해야 한다.
스레드의 우선 순위와 상태
스레드의 우선순위
멀티스레드 환경에서 프로그램의 효율성과 안정성을 보장하기 위해서는 스레드의 우선순위와 상태 제어가 필수적이다.
스레드의 우선순위는 스레드가 CPU 시간을 얼마나 많이 할당받을 수 있을지를 결정하는 역할을 수행한다. 우선순위가 높은 스레드는 우선 순위가 낮은 스레드보다 더 자주 실행될 "가능성"이 크다. 다음과 같은 상황을 고민해보자.
- 메신저를 쓰면서 파일 전송이 좀 느릴 수는 있지만 채팅이 전달 안되는것은 참기 어렵다.
- 사용자 UI를 담당하는 스레드는 빠른 응답성을 위해 높은 우선순위를 부여한다.
- G.C를 담당하는 스레드는 평소에는 낮은 우선순위를 가지다가 메모리 부족 등 급박한 상황에서는 우선순위를 높여준다.
우선순위의 제어
스레드 우선순위는 1~10까지의 정수로 지정할 수 있으며 Thread 클래스에는 기본 상수와 set / get을 이용할 수 있다. 별도로 우선순위를 지정하지 않았을 때 기본 우선순위는 5이다.
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;
public class PriorityTest {
static class MessengerThread extends Thread {
public void run() {
for (int i = 0; i < 30; i++) {
System.out.print(this.getPriority());
}
}
}
public static void main(String[] args) {
Thread t1 = new MessengerThread();
Thread t2 = new MessengerThread();
t1.setPriority(Thread.MIN_PRIORITY);
t2.setPriority(9);
t1.start();
t2.start();
System.out.println(Thread.currentThread().getPriority());
}
}
// 실행 결과 예
5
999999999999999999999999999999111111111111111111111111111111
5
111119911111119999999999999999999991111111111111111119999999
우선순위를 사용하면서 주의 사항은 우선순위가 높다고 해서 그 스레드만 실행되지는 않는다는 것이다. 단지 그 스레드가 실행될 확률이 높아지는 것 뿐이다. 즉 우선순위 만으로는 스레드를 100% 제어할 수는 없다.
스레드의 상태
스레드는 총 6가지의 상태를 갖는데 이 값들은 Thread.State의 enum 상수들로 정의 되어 있다.
enum 상수 | 설명 |
NEW | 스레드 객체가 생성된 후 아직 start()가 호출되지 않은 상태 |
RUNNABLE | JVM 선택에 의해 실행 가능한 상태 |
BLOCKED | 사용하려는 객체의 모니터 락이 풀릴 때까지 기다리는 상태 |
WAITING | sleep(), wait(), join() 등에 의해 정해진 시간 없이 대기중인 상태 |
TIMED_WAITING | sleep(), wait(), join() 등에 의해 정해진 시간 동안 대기중인 상태 |
TERMINATE | run() 메서드의 종료로 소멸된 상태 |
- new 키워드를 이용해서 스레드를 생성하면 NEW의 상태이다.
- start() 메서드가 호출되면 비로서 스레드는 실행 가능한 상태 즉 RUNNABLE이 된다. 가능한것이지 실행은 아니다. RUNNABLE 스레드들은 큐형태로 관리된다.
- RUNNABLE 상태의 스레드들은 OS의 스레드 스케줄러에 의해 선택되면 run()이 동작한다. 비로서 RUNNING 상태가 된다.
- run() 메서드가 종료되면 스레드는 TERMINATE 상태가 되며 한번 종료한 스레드는 다시 동작할 수 없다.
- 동작하던 스레드는 sleep(), wait(), join(), I/O 블로킹 등의 상태에 따라 WAITING 또는 TIMED_WAITING 상태가 될 수 있다. 이 스레드들은 대기 풀(waiting pool)에서 관리된다.
- 대기 풀에 있던 스레드들은 대기 시간이 종료되거나 notify(), interrupt(), I/O 종료등의 상황에 다시 RUNNABLE로 이동해서 선택을 기다린다.
- RUNNING 상태에서 yield()를 호출하면 실행권을 양보하는데 이때는 대기 풀로 이동하지 않고 바로 RUNNABLE 상태로 이동한다.
그럼 각각의 상태를 제어해보자.
스레드의 상태 제어
sleep()
sleep()은 지정된 시간동안 이 메서드를 호출한 스레드는 대기 풀에서 대기하게 되고 시간이 지나면 runnable queue로 이동한다.
메서드 명 | 선언부와 설명 |
sleep() | public static native void sleep(long millis) throws InterruptedException |
millis 동안 동작중인 스레드를 대기풀에서 대기하게 한다. millis가 지나면 자동으로 동작준비 상태로 이동 | |
public static void sleep(long millis, int nanos) throws InterruptedException | |
스레드를 millis + nanos 까지 대기 풀에서 대기하게 한다. 좀 더 세밀한 조절이 가능 |
public class SleepTest1 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.start();
}
static class Timer extends Thread {
public void run() {
for (int i = 0; i < 3; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("째깍: " + LocalTime.now());
}
}
}
}
// 출력 예시
째깍: 07:24:33.674373
째깍: 07:24:34.683478
째깍: 07:24:35.684243
이제 1초 정도의 간격으로 째깍 째깍 출력되는 것을 확인 할 수 있다. 왜 1초가 아니고 조금씩 넘어갈까?
join()
join()은 다른 스레드(원인스레드)의 작업이 종료될 때까지 대기 풀에서 대기하게 만든다. 다음의 경우 팔과 몸통의 작성이 끝난 후에 조립 스레드가 동작해야 한다. 이를 위해 조립 스레드는 팔작성.join(), 몸통작성.join()을 호출하고 각 스레드의 종료를 대기 풀에서 기다린다.
원인스레드가 종료되면 대기 풀에서 RUNNABLE이 된다.
메서드 명 | 선언부와 설명 |
join() | public final void join() throws InterruptedException |
스레드가 소멸될 때까지 대기한다. | |
public final synchronized void join(long millis) throws InterruptedException | |
스레드가 소멸되는지 millis간격으로 체크하고 소멸될 때까지 대기한다. | |
public final synchronized void join(long millis, int nanos) throws InterruptedException | |
nanos까지 동원해서 좀 더 세밀하게 스레드 상태를 체크한다. |
public class JoinTest {
static class GuguThread extends Thread {
private int dan;
private int [] sharedResource;
public GuguThread(int dan,int [] sharedResource) {
this.dan = dan;
this.sharedResource = sharedResource;
}
@Override
public void run() {
for (int i = 1; i < 10; i++) {
sharedResource[dan] += i * dan;
}
System.out.print(dan + "단 완료\t");
}
}
public static void main(String[] args) {
List<GuguThread> gugus = new ArrayList<>();
int [] result = new int[10];
for (int i = 1; i < 10; i++) {
GuguThread gugu = new GuguThread(i, result);
gugus.add(gugu);
gugu.start();
}
for (GuguThread gugu : gugus) {
try {
gugu.join(); // gugu가 끝날 때까지 main thread는 대기!
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.printf("\n 배열: %s, 총 합은: %d\n" , Arrays.toString(result),
Arrays.stream(result).sum());
}
}
interrupt()
interrupt()는 join(), sleep()에 의해 대기 풀에서 대기중인 스레드를 깨워서 즉시 runnable로 이동시킨다. 이를 위해 대기 스레드들에게 InterruptException을 발생시킨다.
메서드 명 | 선언부와 설명 |
interrupt() | public void interrupt() |
대기 중인 스레드를 강제로 깨워서 동작준비 상태로 바로 이동하게 한다. |
public class InterruptTest {
static class MinuteAlarmThread extends Thread {
public void run() {
try {
Thread.sleep(1000 * 10); // 10초 수면 계획중!
System.out.println("시간이 되었습니다.");
} catch (InterruptedException e) {
System.out.println("깜짝이야.");
}
}
}
public static void main(String[] args) {
Thread timeChecker = new MinuteAlarmThread();
timeChecker.start();
try {
Thread.sleep(1000); // 1초만 잘까~~
} catch (InterruptedException e) {
e.printStackTrace();
}
timeChecker.interrupt(); // 일어나!!
System.out.println("main is over!");
}
}
timeChecker.interrupt()가 있을 때와 없을 때의 동작을 잘 비교해보자.
yield()
yield()는 동작하고 있다가 다른 스레드에게 실행권을 양보한다. yield는 착한 녀석(양보 ㅎ)이니까 대기 풀로 이동하지 않고 즉시 runnable로 이동한다.
메서드 명 | 선언부와 설명 |
yield() | public static native void yield() |
스레드 스케줄러에게 이 스레드가 실행을 양보할 수 있음을 알려준다. |
public class YieldTest {
static class YieldThread extends Thread {
String symbol;
public YieldThread(String symbol) {
this.symbol = symbol;
}
public void run() {
for (int i = 0; i < 60; i++) {
if (i % 2 == 0) {
System.out.print(symbol);
} else {
Thread.yield();
}
}
}
}
public static void main(String[] args) {
new YieldThread("_").start();
new YieldThread("^").start();
}
}
yield가 있을 때와 없을 때의 출력 결과를 비교해보면 쉽게 그 동작을 확인할 수 있다.
스레드의 종료
스레드는 run() 메서드가 return 되면 자동으로 종료된다. 그럼 임의로 스레드를 종료시키려면 어떻게 해야할까? 스레드의 stop()이라는 메서드는 즉시 해당 스레드를 종료시킨다. 하지만 이 스레드가 어떤 작업을 하다가 갑자기 사망해버리면 불완전한 상태로 작업이 종료되버릴 수 있기 때문에 deprecated 되었다.
대신 종료를 파악하는 flag 값을 이용해서 자연스럽게 종료할 수 있게 해야 한다.
public class StopTest {
static class GuguThread extends Thread {
boolean flag = true;
public void run() {
for (int j = 1; j < 10; j++) {
if (flag) {
List.of(5, "*", j, "=", 5 * j).forEach(i -> {
try {
Thread.sleep(39);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(i);
});
System.out.println();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
GuguThread t = new GuguThread();
t.start();
Thread.sleep(1234);
if (Math.random() > 0.5) {
System.out.println("stop");
t.stop();
} else {
System.out.println("false");
t.flag = false;
}
System.out.println("main over");
}
}
또한 한번 종료된 스레드는 다시 start()될 수 없다. 동일한 스레드가 필요하다면 새로운 스레드 객체를 만들고 start() 해줘야 한다.
데몬 스레드
데몬 스레드란 일반 스레드의 작업을 돕는 보조적인 스레드다. 앞서 이야기 했듯이 일반 스레드는 main 스레드와 별개로 동작한다. 따라서 main의 종료 여부와 무관하게 계속 동작한다. 반면 데몬 스레드는 일반 스레드들이 모두 종료되면 자동적으로 종료된다. 즉 안정적으로 종료할 수 없다는 점도 유의 해야 한다.
데몬 스레드를 만들기 위해서는 setDaemon(boolean) 메서드를 활용한다.
public class DaemonThreadTest {
static class SaveDaemon extends Thread {
public SaveDaemon() {
this.setDaemon(true); // 데몬 스레드로 만듬
}
public void run() {
while (true) {
try {
Thread.sleep(1000 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("자동 저장함");
}
}
}
public static void main(String args[]) {
Thread daemon = new SaveDaemon();
daemon.start();
for (int i = 0; i < 20; i++) {
try {
Thread.sleep(1000);
System.out.println("작업 중... " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("main over");
}
}
'자바(SE)' 카테고리의 다른 글
[Thread] 04. 멀티 스레드와 Collection (0) | 2024.07.06 |
---|---|
[Thread] 03.데이터의 공유와 동기화 (0) | 2024.07.05 |
[Thread] 01. Multi Thread 개념 (0) | 2024.07.03 |
[java]try~with~resource의 close 호출 시점 (0) | 2024.03.14 |
S.O.L.I.D. (0) | 2024.01.09 |
소중한 공감 감사합니다