쌩로그

[JAVA] 김영한의 실전 자바 고급 1편 - Se04. 스레드 제어와 생명 주기1 본문

Language/JAVA

[JAVA] 김영한의 실전 자바 고급 1편 - Se04. 스레드 제어와 생명 주기1

.쌩수. 2025. 3. 7. 15:45
반응형

목차

  1. 포스팅 개요
  2. 본론
     2-1. 스레드 기본 정보
     2-2. 스레드 생명 주기 - 설명
     2-3. 스레드 생명 주기 - 코드
     2-4. 체크 예외 재정의
     2-5. join - 시작
     2-6. join - 필요한 상황
     2-7. join - sleep 사용
     2-8. join - join 사용
     2-9. join - 특정 시간 만큼만 대기
  3. 요약

1. 포스팅 개요

해당 포스팅은 김영한의 실전 자바 고급 1편 Section 4의 스레드 제어와 생명 주기1 에 대한 학습 내용이다.

학습 레포 URL : https://github.com/SsangSoo/inflearn-holyeye-java-adv1 (해당 레포는 완강시 public으로 전환 예정이다.)

2. 본론

2-1. 스레드 기본 정보

Thread 클래스는 스레드를 생성하고 관리하는 기능을 제공한다.
Thread 클래스가 제공하는 정보들을 확인해보자.
하나는 기본으로 제공되는 main 스레드의 정보를, 하나는 직접 만든 myThread 스레드의 정보를 출력해보자.

import thread.start.HelloRunnable;  

import static util.MyLogger.log;  

public class ThreadInfoMain {  

    public static void main(String[] args) {  
        //main 스레드  
        Thread mainThread = Thread.currentThread();  
        log("mainThread = " + mainThread);  
        log("mainThread.threadId() = " + mainThread.threadId());  
        log("mainThread.getName() = " + mainThread.getName());  
        log("mainThread.getPriority() = " + mainThread.getPriority());  
        log("mainThread.getThreadGroup() = " + mainThread.getThreadGroup());  
        log("mainThread.getState() = " + mainThread.getState());  


        // myThread 스레드  
        Thread myThread = new Thread(new HelloRunnable(), "myThread");  
        log("myThread = " + myThread);  
        log("myThread.threadId() = " + myThread.threadId());  
        log("myThread.getName() = " + myThread.getName());  
        log("myThread.getPriority() = " + myThread.getPriority());  
        log("myThread.getThreadGroup() = " + myThread.getThreadGroup());  
        log("myThread.getState() = " + myThread.getState());  
    }  
}

// 실행 결과
10:00:39.766 [     main] mainThread = Thread[#1,main,5,main]
10:00:39.774 [     main] mainThread.threadId() = 1
10:00:39.775 [     main] mainThread.getName() = main
10:00:39.779 [     main] mainThread.getPriority() = 5
10:00:39.779 [     main] mainThread.getThreadGroup() = java.lang.ThreadGroup[name=main,maxpri=10]
10:00:39.780 [     main] mainThread.getState() = RUNNABLE
10:00:39.784 [     main] myThread = Thread[#22,myThread,5,main]
10:00:39.784 [     main] myThread.threadId() = 22
10:00:39.785 [     main] myThread.getName() = myThread
10:00:39.785 [     main] myThread.getPriority() = 5
10:00:39.786 [     main] myThread.getThreadGroup() = java.lang.ThreadGroup[name=main,maxpri=10]
10:00:39.787 [     main] myThread.getState() = NEW

1. 스레드 생성
스레드를 생성할 때는 실행할 Runnable 인터페이스의 구현체와, 스레드의 이름을 전달할 수 있다.

Thread myThread = new Thread(new HelloRunnable(), "myThread"); 
  • Runnable 인터페이스
    • 실행할 작업을 포함하는 인터페이스이다.
    • HelloRunnable 클래스는 Runnable 인터페이스를 구현한 클래스이다.
  • 스레드 이름
    • "myThread" 라는 이름으로 스레드를 생성한다.
      • 이 이름은 디버깅이나 로깅 목적으로 유용하다.
      • 참고로 이름을 생략하면 Thread-0 , Thread-1 과 같은 임의의 이름이 생성된다.

2. 스레드 객체 정보

log("mainThread = " + mainThread);
  • myThread 객체를 문자열로 변환하여 출력한다.
  • Thread 클래스의 toString() 메서드는 스레드 ID, 스레드 이름, 우선순위, 스레드 그룹을 포함하는 문자열을 반환한다.
  • Thread[#21,myThread,5,main]

3. 스레드 ID

log("mainThread.threadId() = " + mainThread.threadId());
  • threadId():
    • 스레드의 고유 식별자를 반환하는 메서드이다.
      • 이 ID는 JVM 내에서 각 스레드에 대해 유일하다.
    • ID는 스레드가 생성될 때 할당되며, 직접 지정할 수 없다.

4. 스레드 이름

log("mainThread.getName() = " + mainThread.getName());
  • getName()
    • 스레드의 이름을 반환하는 메서드이다.
    • 생성자에서 "myThread" 라는 이름을 지정했기 때문에, 이 값이 반환된다.
    • 참고로 스레드 ID는 중복되지 않지만, 스레드 이름은 중복될 수 있다.

5. 스레드 우선순위

log("mainThread.getPriority() = " + mainThread.getPriority());
  • getPriority()
    • 스레드의 우선순위를 반환하는 메서드이다.
      • 우선순위는 1 (가장 낮음)에서 10 (가장 높음)까지의 값으로 설정할 수 있으며, 기본값은 5이다.
    • setPriority() 메서드를 사용해서 우선순위를 변경할 수 있다.
    • 우선순위는 스레드 스케줄러가 어떤 스레드를 우선 실행할지 결정하는 데 사용된다.
    • 하지만 실제 실행 순서는 JVM 구현과 운영체제에 따라 달라질 수 있다.

6. 스레드 그룹

log("mainThread.getThreadGroup() = " + mainThread.getThreadGroup());
  • getThreadGroup()
    • 스레드가 속한 스레드 그룹을 반환하는 메서드이다.
    • 스레드 그룹은 스레드를 그룹화하여 관리할 수 있는 기능을 제공한다.
    • 기본적으로 모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속하게 된다.
    • 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업(예: 일괄 종료, 우선순위 설정 등)을 수행할 수 있다.
  • 부모 스레드(Parent Thread)
    • 새로운 스레드를 생성하는 스레드를 의미한다.
    • 스레드는 기본적으로 다른 스레드에 의해 생성된다.
    • 이러한 생성 관계에서 새로 생성된 스레드는 생성한 스레드를 부모로 간주한다.
    • 예를 들어 myThreadmain 스레드에 의해 생성되었으므로 main 스레드가 부모 스레드이다.
    • main 스레드는 기본으로 제공되는 main 스레드 그룹에 소속되어 있다.
      • 따라서 myThread 도 부모 스레드인 main 스레드의 그룹인 main 스레드 그룹에 소속된다.
    • 참고: 스레드 그룹 기능은 직접적으로 잘 사용하지는 않기 때문에, 이런 것이 있구나 정도만 알고 넘어가면 된다.

7. 스레드 상태

log("mainThread.getState() = " + mainThread.getState());
  • getState()
    • 스레드의 현재 상태를 반환하는 메서드이다.
    • 반환되는 값은 Thread.State 열거형에 정의된 상수 중 하나이다.
    • 주요 상태는 다음과 같다.
      • NEW: 스레드가 아직 시작되지 않은 상태이다.
      • RUNNABLE: 스레드가 실행 중이거나 실행될 준비가 된 상태이다.
      • BLOCKED: 스레드가 동기화 락을 기다리는 상태이다.
      • WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태이다.
      • TIMED_WAITING: 일정 시간 동안 기다리는 상태이다.
      • TERMINATED: 스레드가 실행을 마친 상태이다.

출력 결과를 보면 main 스레드는 실행 중이기 때문에 RUNNABLE 상태이다.
myThread 는 생성하고 아직 시작하지 않았기 때문에, NEW 상태이다.

10:00:39.780 [     main] mainThread.getState() = RUNNABLE
...
10:00:39.787 [     main] myThread.getState() = NEW

2-2. 스레드 생명 주기 - 설명

스레드는 생성하고 시작하고, 종료되는 생명주기를 가진다.
스레드의 생명 주기에 대해 자세히 알아보자.

스레드의 상태

  • New (새로운 상태): 스레드가 생성되었으나 아직 시작되지 않은 상태.
  • Runnable (실행 가능 상태): 스레드가 실행 중이거나 실행될 준비가 된 상태.
  • 일시 중지 상태들 (Suspended States) : 이 상태의 쓰레드들은 CPU의 스케줄러에 들어가지 않는다.
    • Blocked (차단 상태): 스레드가 동기화 락을 기다리는 상태.
    • Waiting (대기 상태): 스레드가 무기한으로 다른 스레드의 작업을 기다리는 상태.
    • Timed Waiting (시간 제한 대기 상태): 스레드가 일정 시간 동안 다른 스레드의 작업을 기다리는 상태.
  • Terminated (종료 상태): 스레드의 실행이 완료된 상태.

참고
자바에서 스레드의 일시 중지 상태들(Suspended States)이라는 상태는 없다.
스레드가 기다리는 상태들을 묶어서 쉽게 설명하기 위해 사용한 용어이다.


자바 스레드(Thread)의 생명 주기는 여러 상태(state)로 나뉘어지며, 각 상태는 스레드가 실행되고 종료되기까지의 과정을 나타낸다.
자바 스레드의 생명 주기를 자세히 알아보자.

1. New (새로운 상태)
- 스레드가 생성되고 아직 시작되지 않은 상태이다.
- 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태이다.
- 예: Thread thread = new Thread(runnable);

2. Runnable (실행 가능 상태)

  • 스레드가 실행될 준비가 된 상태이다.
    • 이 상태에서 스레드는 실제로 CPU에서 실행될 수 있다.
  • start() 메서드가 호출되면 스레드는 이 상태로 들어간다.
  • 예: thread.start();
  • 이 상태는 스레드가 실행될 준비가 되어 있음을 나타내며, 실제로 CPU에서 실행될 수 있는 상태이다.
    • 그러나 Runnable 상태에 있는 모든 스레드가 동시에 실행되는 것은 아니다.
    • 운영체제의 스케줄러가 각 스레드에 CPU 시간을 할당하여 실행하기 때문에, Runnable 상태에 있는 스레드는 스케줄러의 실행 대기열에 포함되어 있다가 차례로 CPU에서 실행된다.
  • 참고로 운영체제 스케줄러의 실행 대기열에 있든, CPU에서 실제 실행되고 있든 모두 RUNNABLE 상태이다. 자바에서 둘을 구분해서 확인할 수는 없다.
  • 보통 실행 상태라고 부른다.

3. Blocked (차단 상태)

  • 스레드가 다른 스레드에 의해 동기화 락을 얻기 위해 기다리는 상태이다.
  • 예를 들어, synchronized 블록에 진입하기 위해 락을 얻어야 하는 경우 이 상태에 들어간다.
  • 예: synchronized (lock) { ... } 코드 블록에 진입하려고 할 때, 다른 스레드가 이미 lock 의 락을 가지고 있는 경우.
  • 지금은 이런 상태가 있다 정도만 알아두자. 이 부분은 뒤에서 자세히 다룬다.

4. Waiting (대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태이다.
  • wait() , join() 메서드가 호출될 때 이 상태가 된다.
  • 스레드는 다른 스레드가 notify() 또는 notifyAll() 메서드를 호출하거나, join() 이 완료될 때까지 기다린다.
  • 예: object.wait();
  • 지금은 이런 상태가 있다 정도만 알아두자. 이 부분은 뒤에서 자세히 다룬다.

5. Timed Waiting (시간 제한 대기 상태)

  • 스레드가 특정 시간 동안 다른 스레드의 작업이 완료되기를 기다리는 상태이다.
  • sleep(long millis) , wait(long timeout) , join(long millis) 메서드가 호출될 때 이 상태가 된다.
  • 주어진 시간이 경과하거나 다른 스레드가 해당 스레드를 깨우면 이 상태에서 벗어난다.
  • 예: Thread.sleep(1000);
  • 지금은 이런 상태가 있다 정도만 알아두자. 이 부분은 뒤에서 자세히 다룬다.

6. Terminated (종료 상태)

  • 스레드의 실행이 완료된 상태이다.
  • 스레드가 정상적으로 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 들어간다.
  • 스레드는 한 번 종료되면 다시 시작할 수 없다.
    • 새로 만들어서 시작(start)해야 한다.

자바 스레드의 상태 전이 과정

  1. New → Runnable: start() 메서드를 호출하면 스레드가 Runnable 상태로 전이된다.
  2. Runnable → Blocked/Waiting/Timed Waiting: 스레드가 락을 얻지 못하거나, wait() 또는 sleep() 메서드를 호출할 때 해당 상태로 전이된다.
  3. Blocked/Waiting/Timed Waiting → Runnable: 스레드가 락을 얻거나, 기다림이 완료되면 다시 Runnable 상태로 돌아간다.
  4. Runnable → Terminated: 스레드의 run() 메서드가 완료되면 스레드는 Terminated 상태가 된다.

2-3. 스레드 생명 주기 - 코드

스레드 생명 주기를 코드로 확인해보자.
스레드가 생성, 실행, 대기 및 종료 상태로 변할 때마다 해당 상태를 로그로 출력한다.
이를 통해 스레드의 생명주기를 이해해보자.

import static util.MyLogger.log;  

public class ThreadStateMain {  

    public static void main(String[] args) throws InterruptedException {  
        Thread thread = new Thread(new MyRunnable(), "myThread");  
        log("myThread.state1 = " + thread.getState());  
        log("myThread.start()");  
        thread.start();  
        Thread.sleep(1000); // 1초 후 찍지않으면 너무 빠르게 진행되어 TIMED_WAITING을 보지 못 할수도 있다.  
        log("myThread.state3 = " + thread.getState()); // TIMED_WAITING  
        Thread.sleep(4000);  
        log("myThread.state5 = " + thread.getState()); // TERMINATED  
        log("end");  
    }  

    static class MyRunnable implements Runnable {  

        @Override  
        public void run() {  
            try {  
                log("start");  
                log("myThread.state2 = " + Thread.currentThread().getState()); // RUNNABLE  
                log("sleep() start");  
                Thread.sleep(3000); // myThread  
//                log("myThread.state2 = " + Thread.currentThread().getState()); // sleep 상태에선 찍을 수 없음.. 깨어난 이후에는 Runnable 상태임  
                log("sleep() end");  
                log("myThread.state4 = " + Thread.currentThread().getState()); // RUNNABLE  
                log("end");  
            } catch (InterruptedException e) {  
                throw new RuntimeException(e);  
            }  
        }  
    }  
}
// 실행 결과
13:56:37.924 [     main] myThread.state1 = NEW
13:56:37.926 [     main] myThread.start()
13:56:37.927 [ myThread] start
13:56:37.927 [ myThread] myThread.state2 = RUNNABLE
13:56:37.927 [ myThread] sleep() start
13:56:38.930 [     main] myThread.state3 = TIMED_WAITING
13:56:40.941 [ myThread] sleep() end
13:56:40.941 [ myThread] myThread.state4 = RUNNABLE
13:56:40.941 [ myThread] end
13:56:42.933 [     main] myThread.state5 = TERMINATED
13:56:42.934 [     main] end
  • Thread.currentThread() 를 호출하면 해당 코드를 실행하는 스레드 객체를 조회할 수 있다.
  • Thread.sleep() : 해당 코드를 호출한 스레드는 TIMED_WAITING 상태가 되면서 특정 시간 만큼 대기한다.
    • 시간은 밀리초(ms) 단위이다. 1밀리초 = 1/1000 초, 1000밀리초 = 1초이다.
  • Thread.sleep()InterruptedException 이라는 체크 예외를 던진다.
    • 따라서 체크 예외를 잡아서 처리하거나 던져야 한다.
      • run() 메서드 안에서는 체크 예외를 반드시 잡아야 한다.
      • 이 부분은 바로 뒤에서 설명한다.
  • InterruptedException 은 인터럽트가 걸릴 때 발생하는데, 인터럽트는 뒤에서 알아본다.
    • 지금은 체크 예외가 발생한다 정도만 이해하면 충분하다.

실행 상태 그림

  • main 스레드의 상태는 생략했다. 여기서는 myThread 스레드의 상태에 집중하자
  • state1 = NEW
    • main 스레드를 통해 myThread 객체를 생성한다.
    • 스레드 객체만 생성하고 아직 start() 를 호출하지 않았기 때문에 NEW 상태이다.
  • state2 = RUNNABLE
    • myThread.start() 를 호출해서 myThread 를 실행 상태로 만든다.
    • 따라서 RUNNABLE 상태가 된다.
    • 참고로 실행 상태가 너무 빨리 지나가기 때문에 main 스레드에서 myThread 의 상태를 확인하기는 어렵다.
    • 대신에 자기 자신인 myThread 에서 실행 중인 자신의 상태를 확인했다.
  • state3 = TIMED_WAITING
    • Thread.sleep(3000) : 해당 코드를 호출한 스레드는 3000ms (3초)간 대기한다.
    • myThread 가 해당 코드를 호출했으므로 3초간 대기하면서 TIMED_WAITING 상태로 변한다.
    • 참고로 이때 main 스레드가 myThreadTIMED_WAITING 상태를 확인하기 위해 1초간 대기하고 상태를 확인했다.
  • state4 = RUNNABLE
    • myThread 는 3초의 시간 대기 후 TIMED_WAITING 상태에서 빠져나와 다시 실행될 수 있는 RUNNABLE 상태로 바뀐다.
  • state5 = TERMINATED
    • myThreadrun() 메서드를 실행 종료하고 나면 TERMINATED 상태가 된다.
    • myThread 입장에서 run() 이 스택에 남은 마지막 메서드인데, run() 까지 실행되고 나면 스택이 완전히 비워진다.
    • 이렇게 스택이 비워지면 해당 스택을 사용하는 스레드도 종료된다.

실행 상태 그림 - main 스레드 포함

  • main 스레드의 상태까지 포함한 전체 그림이다. 참고로만 봐두자

2-4. 체크 예외 재정의

Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 체크 예외를 밖으로 던질 수 없는 이유를 알아보자.

Runnable 인터페이스는 다음과 같이 정의되어 있다.

public interface Runnable {
    void run();
}

자바에서 메서드를 재정의 할 때, 재정의 메서드가 지켜야 할 예외와 관련된 규칙이 있다.

  • 체크 예외
    • 부모 메서드가 체크 예외를 던지지 않는 경우, 재정의된 자식 메서드도 체크 예외를 던질 수 없다.
    • 자식 메서드는 부모 메서드가 던질 수 있는 체크 예외의 하위 타입만 던질 수 있다.
  • 언체크(런타임) 예외
    • 예외 처리를 강제하지 않으므로 상관없이 던질 수 있다.

Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않는다.
따라서 Runnable 인터페이스의 run() 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없다.
다음 코드를 실행하면 컴파일 오류가 발생한다.

public class CheckedExceptionMain {  

    public static void main(String[] args) throws Exception {  
        throw new Exception();  
    }  


    static class CheckedRunnable implements Runnable {  

        @Override  
        public void run() /*throws Exception*/ {  // 주석 풀면 예외 발생
            /*throw new Exception();*/  // 주석 풀면 예외 발생  
        }  
    }  
}
  • main() 은 체크 예외를 밖으로 던질 수 있다.
  • run() 은 체크 예외를 밖으로 던질 수 없다.

예를 들어 다음 코드의 InterruptedException 도 체크 예외이므로 던질 수 없다.
컴파일 오류가 발생한다.

static class MyRunnable implements Runnable {  
    public void run() throws InterruptedException {
        Thread.sleep(3000);
    }
}

자바는 왜 이런 제약을 두는 것일까?

부모 클래스의 메서드를 호출하는 클라이언트 코드는 부모 메서드가 던지는 특정 예외만을 처리하도록 작성된다.
자식 클래스가 더 넓은 범위의 예외를 던지면 해당 코드는 모든 예외를 제대로 처리하지 못할 수 있다.
이는 예외 처리의 일관성을 해치고, 예상하지 못한 런타임 오류를 초래할 수 있다.

다음 예를 보자. 실제 작동하는 코드는 아니다

class Parent {
    void method() throws InterruptedException {
        // ...
    }
}


class Child extends Parent {
    @Override
    void method() throws Exception {
        // ...
    }

}

public class Test {
    public static void main(String[] args) {
        Parent p = new Child();
        try {
            p.method();    
        } catch (InterruptedException e) {
            // InterruptedException 처리
        }
    }
}
  • 자바 컴파일러Parent pmethod() 를 호출한 것으로 인지한다.
  • Parent pInterruptedException 를 반환하는데, 그 자식이 전혀 다른 예외를 반환한다면 클라이언트는 해당 예외를 잡을 수 없다.
  • 이것은 확실하게 모든 예외를 체크하는 체크 예외의 규약에 맞지 않는다.
  • 따라서 자바에서 체크 예외의 메서드 재정의는 다음과 같은 규칙을 가진다.

체크 예외 재정의 규칙

  • 자식 클래스에 재정의된 메서드는 부모 메서드가 던질 수 있는 체크 예외의 하위 타입만을 던질 수 있다.
  • 원래 메서드가 체크 예외를 던지지 않는 경우, 재정의된 메서드도 체크 예외를 던질 수 없다.

안전한 예외 처리

체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try-catch 블록 내에서 처리하게 된다.
이는 예외 발생시 예외가 적절히 처리되지 않아서 프로그램이 비정상 종료되는 상황을 방지할 수 있다.

특히 멀티스레딩 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다.

하지만 이전에 자바 예외 처리 강의에서 설명했듯이, 체크 예외를 강제하는 이런 부분들은 자바 초창기 기조이고,
최근에는 체크 예외보다는 언체크(런타임) 예외를 선호한다.

Sleep 유틸리티

Thread.sleep() 을 자주 사용할 예정인데, 이 코드는 InterruptedException 체크 예외를 발생시킨다.
학습용 예제의 run() 메서드 안에서 다음과 같이 try ~ catch 를 계속 사용하는 것은 상당히 번거롭다.

호출 코드 예시 - 기존

void run() {
    try {  
        Thread.sleep(millis);  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }
}

다음과 같이 체크 예외를 런타임 예외로 변경하는 간단한 유틸리티를 만들어 사용하자.

ThreadUtils.sleep()

import static util.MyLogger.log;  

public abstract class ThreadUtils {  

    public static void sleep(long millis) {  
        try {  
            Thread.sleep(millis);  
        } catch (InterruptedException e) {  
            log("인터럽트 발생, " + e.getMessage());  
            throw new RuntimeException(e);  
        }  
    }  
}

호출 코드 예시 - 유틸리티 사용

import static util.ThreadUtils.*;  

public void run() {  
    sleep(1000);  
}  

2-5. join - 시작

앞서 Thread.sleep() 메서드를 통해 TIMED_WAITING 상태를 알아보았다.
이번에는 join() 메서드를 통해 WAITING (대기 상태)가 무엇이고 왜 필요한지 알아보자.

Waiting (대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태이다.

먼저 스레드로 특정 작업을 수행하는 간단한 예제를 하나 만들어보자.

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class JoinMainV0 {  
    public static void main(String[] args) {  
        log("Start");  
        Thread thread1 = new Thread(new Job(), "thread-1");  
        Thread thread2 = new Thread(new Job(), "thread-2");  

        thread1.start();  
        thread2.start();  
        log("End");  
    }  


    static class Job implements Runnable {  

        @Override  
        public void run() {  
            log("작업 시작");  
            sleep(2000);  
            log("작업 완료");  
        }  
    }  
}

// 실행 결과
14:40:43.671 [     main] Start
14:40:43.676 [     main] End
14:40:43.676 [ thread-1] 작업 시작
14:40:43.676 [ thread-2] 작업 시작
14:40:45.693 [ thread-1] 작업 완료
14:40:45.693 [ thread-2] 작업 완료
  • 참고 : 스레드의 실행 순서는 보장되지 않기 때문에, 실행 결과는 약간 다를 수 있다.
  • 그림에서는 생략했지만, thread-2main 스레드가 생성하고 start() 를 호출해서 실행한다.

thread-1 , thread-2 는 각각 특정 작업을 수행한다.
작업 수행에 약 2초 정도가 걸린다고 가정하기 위해 sleep() 을 사용해서 2초간 대기한다.
(그림에서는 RUNNABLE 로 표현했지만, 실제로는 TIMED_WAITING 상태이다.)

sleep() 메서드는 Thread.sleep() 대신에 앞서 만든 유틸리티를 import static 으로 사용한다.

import static util.ThreadUtils.sleep;

실행 결과를 보면 main 스레드가 먼저 종료되고, 그 다음에 thread-1 , thread-2 가 종료된다.

14:40:43.671 [     main] Start
14:40:43.676 [     main] End
14:40:43.676 [ thread-1] 작업 시작
14:40:43.676 [ thread-2] 작업 시작
14:40:45.693 [ thread-1] 작업 완료
14:40:45.693 [ thread-2] 작업 완료

main 스레드는 thread-1 , thread-2실행하고 바로 자신의 다음 코드를 실행한다.
여기서 핵심은 main 스레드가 thread-1 , thread-2 가 끝날 때까지 기다리지 않는다는 점이다.
main 스레드는 단지 start() 를 호출해서 다른 스레드를 실행만 하고 바로 자신의 다음 코드를 실행한다.

그런데 만약 thread-1 , thread-2 가 종료된 다음에 main 스레드를 가장 마지막에 종료하려면 어떻게 해야할까?
예를 들어서 main 스레드가 thread-1 , thread-2 에 각각 어떤 작업을 지시하고, 그 결과를 받아서 처리하고 싶다면 어떻게 해야할까?

이런 상황이 왜 필요한지 이해를 돕기위해 간단한 예제를 만들어보자.

2-6. join - 필요한 상황

1~100까지 더하는 간단한 코드이다.

int sum = 0;
for(int i = 1; i <= 100; i++) {
    sum += i;
}

이 코드는 스레드를 하나만 사용하기 때문에 CPU 코어도 하나만 사용할 수 있다.
CPU 코어를 더 효율적으로 사용하려면 여러 스레드로 나누어 계산하면 된다.
1 ~ 100 까지 더한 결과는 5050 이다.
이 연산은 다음과 같이 둘로 나눌 수 있다.

  • 1 ~ 50 까지 더하기 = 1275
  • 51 ~ 100 까지 더하기 = 3775

두 계산 결과를 합하면 5050 이 나온다

main 스레드가 1 ~ 100 으로 더하는 작업을 thread-1 , thread-2 에 각각 작업을 나누어 지시하면 CPU 코어를 더 효율적으로 활용할 수 있다.
CPU 코어가 2개라면 이론적으로 연산 속도가 2배 빨라진다.

  • thread-1 : 1 ~ 50 까지 더하기
  • thread-2 : 51 ~ 100 까지 더하기
  • main : 두 스레드의 계산 결과를 받아서 합치기(이건 간단한 연산 한 번이니 속도 계산에서 제외하자)

이제 코드를 작성하자.

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class JoinMainV1 {  
    public static void main(String[] args) {  
        log("Start");  
        SumTask task1 = new SumTask(1, 50);  
        SumTask task2 = new SumTask(51, 100);  
        Thread thread1 = new Thread(task1, "thread-1");  
        Thread thread2 = new Thread(task2, "thread-2");  

        thread1.start();  
        thread2.start();  

        log("task1.result = " + task1.result);  
        log("task2.result = " + task2.result);  

        int sumAll = task1.result + task2.result;  
        log("task1 + task2 = " + sumAll);  
        log("End");  
    }  

    static class SumTask implements Runnable {  

        int startValue;  
        int endValue;  
        int result;  

        public SumTask(int startValue, int endValue) {  
            this.startValue = startValue;  
            this.endValue = endValue;  
        }  

        @Override  
        public void run() {  
            log("작업 시작");  
            sleep(2000);  
            int sum = 0;  
            for(int i = startValue; i <= endValue; i++) {  
                sum += i;  
            }  
            result = sum;  
            log("작업 완료 result = " + result);  
        }  
    }  
}

// 실행 결과
14:49:46.439 [     main] Start
14:49:46.443 [ thread-1] 작업 시작
14:49:46.443 [ thread-2] 작업 시작
14:49:46.450 [     main] task1.result = 0
14:49:46.451 [     main] task2.result = 0
14:49:46.452 [     main] task1 + task2 = 0
14:49:46.452 [     main] End
14:49:48.454 [ thread-1] 작업 완료 result = 1275
14:49:48.454 [ thread-2] 작업 완료 result = 3775

SumTask 는 계산의 시작값( startValue )과 계산의 마지막 값( endValue )을 가진다.
그리고 계산이 끝나면 그 결과를 result 필드에 담아둔다.
main 스레드는 thread-1 , thread-2 를 만들고 다음과 같이 작업을 할당한다.

  • thread-1 : task1 - 1 ~ 50 까지 더하기
  • thread-2 : taks2 - 51 ~ 100 까지 더하기

thread-1task1 인스턴스의 run() 을 실행하고, thread-2task2 인스턴스의 run() 을 실행한다.
각각의 스레드는 계산 결과를 result 멤버 변수에 보관한다.

run() 에서 수행하는 계산이 2초 정도는 걸리는 복잡한 계산이라고 가정하자.
그래서 sleep(2000) 으로 설정했다.
여기서는 약 2초 후에 계산이 완료되고 result 에 결과가 담긴다.

main 스레드는 thread1 , thread2 에 작업을 지시한 다음에 작업의 결과인 task1.result , task2.result 를 얻어서 사용한다.

그런데 실행 결과를 보면 기대와 다르게 task1.result , task2.result 모두 0으로 나온다.
그리고 task1 + task2 의 결과도 0 으로 나온다.

계산이 전혀 진행되지 않았다.
이 부분을 자세히 분석해보자.

실행 결과 분석

main 스레드는 thread-1 , thread2 에 작업을 지시하고, thread-1 , thread2계산을 완료하기도 전에 먼저 계산 결과를 조회했다.
참고로 thread-1 , thread-2 가 계산을 완료하는데는 2초 정도의 시간이 걸린다.
따라서 결과가 task1 + task2 = 0 으로 출력된다.

이 부분을 메모리 구조로 좀 더 자세히 살펴보자

  • 프로그램이 처음 시작되면 main 스레드는 thread-1 , thread-2 를 생성하고 start() 로 실행한다.
  • thread-1 , thread-2 는 각각 자신에게 전달된 SumTask 인스턴스의 run() 메서드를 스택에 올리고 실행 한다.
    • thread-1x001 인스턴스의 run() 메서드를 실행한다.
    • thread-2x002 인스턴스의 run() 메서드를 실행한다.
  • main 스레드는 두 스레드를 시작한 다음에 바로 task1.result , task2.result 를 통해 인스턴스에 있는 결과 값을 조회한다.
    • 참고로 main 스레드가 실행한 start() 메서드는 스레드의 실행이 끝날 때 까지 기다리지 않는다!
    • 다른 스레드를 실행만 해두고, 자신의 다음 코드를 실행할 뿐이다!
  • thread-1 , thread-2 가 계산을 완료해서, result 에 연산 결과를 담을 때 까지는 약 2초 정도의 시간이 걸린다.
    • main 스레드는 계산이 끝나기 전에 result 의 결과를 조회한 것이다.
    • 따라서 0 값이 출력된다.
  • 2초가 지난 이후에 thread-1 , thread-2 는 계산을 완료한다.
  • 이때 main 스레드는 이미 자신의 코드를 모두 실행하고 종료된 상태이다.
  • task1 인스턴스의 result 에는 1275 가 담겨있고, task2 인스턴스의 result 에는 3775 가 담겨있다.

여기서 문제의 핵심은 main 스레드가 thread-1 , thread-2 의 계산이 끝날 때 까지 기다려야 한다는 점이다.
그럼 어떻게 해야 main 스레드가 기다릴 수 있을까?

참고 - this의 비밀

어떤 메서드를 호출하는 것은, 정확히는 특정 스레드가 어떤 메서드를 호출하는 것이다.
스레드는 메서드의 호출을 관리하기 위해 메서드 단위로 스택 프레임을 만들고 해당 스택 프레임을 스택 위에 쌓아 올린다.

이 때 인스턴스의 메서드를 호출하면, 어떤 인스턴스의 메서드를 호출했는지 기억하기 위해, 해당 인스턴스의 참조 값을 스택 프레임 내부에 저장해둔다.
이것이 바로 우리가 자주 사용하던 this 이다.

특정 메서드 안에서 this 를 호출하면 바로 스택프레임 안에 있는 this 값을 불러서 사용하게 된다.
그림을 보면 스택 프레임 안에 있는 this 를 확인할 수 있다.
이렇게 this 가 있기 때문에 thread-1 , thread-2 는 자신의 인스턴스를 구분해서 사용할 수 있다.
예를 들어서 필드에 접근할 때 this 를 생략하면 자동으로 this 를 참고해서 필드에 접근한다.
정리하면 this 는 호출된 인스턴스 메서드가 소속된 객체를 가리키는 참조이며, 이것이 스택 프레임 내부에 저장되어 있다.

2-7. join - sleep 사용

특정 스레드를 기다리게 하는 가장 간단한 방법은 sleep() 을 사용하는 것이다.

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class JoinMainV2 {  
    public static void main(String[] args) {  
        log("Start");  
        SumTask task1 = new SumTask(1, 50);  
        SumTask task2 = new SumTask(51, 100);  
        Thread thread1 = new Thread(task1, "thread-1");  
        Thread thread2 = new Thread(task2, "thread-2");  

        thread1.start();  
        thread2.start();  

        // 정확한 타이밍을 맞추어 기다리기 어려움  
        log("main 스레드 sleep()");  
        sleep(3000);  
        log("main 스레드 깨어남");  

        log("task1.result = " + task1.result);  
        log("task2.result = " + task2.result);  

        int sumAll = task1.result + task2.result;  
        log("task1 + task2 = " + sumAll);  
        log("End");  
    }  

    static class SumTask implements Runnable {  

        int startValue;  
        int endValue;  
        int result;  

        public SumTask(int startValue, int endValue) {  
            this.startValue = startValue;  
            this.endValue = endValue;  
        }  

        @Override  
        public void run() {  
            log("작업 시작");  
            sleep(2000);  
            int sum = 0;  
            for(int i = startValue; i <= endValue; i++) {  
                sum += i;  
            }  
            result = sum;  
            log("작업 완료 result = " + result);  
        }  
    }  
}
// 실행 결과
15:11:57.763 [     main] Start
15:11:57.767 [     main] main 스레드 sleep()
15:11:57.768 [ thread-2] 작업 시작
15:11:57.767 [ thread-1] 작업 시작
15:11:59.786 [ thread-2] 작업 완료 result = 3775
15:11:59.786 [ thread-1] 작업 완료 result = 1275
15:12:00.773 [     main] main 스레드 깨어남
15:12:00.774 [     main] task1.result = 1275
15:12:00.774 [     main] task2.result = 3775
15:12:00.774 [     main] task1 + task2 = 5050
15:12:00.775 [     main] End
  • main 스레드가 sleep(3000) 을 사용해서 3초간 대기한다.
  • thread-1 , thread-2 는 계산에 2초 정도의 시간이 걸린다.
    • 우리는 이 부분을 알고 있어서 main 스레드가 약 3초 후에 계산 결과를 조회하도록 했다.
    • 따라서 계산된 결과를 받아서 출력할 수 있다.

하지만 이렇게 sleep() 을 사용해서 무작정? 기다리는 방법은 대기 시간에 손해도 보고, 또 thread-1 , thread-2 의 수행시간이 달라지는 경우에는 정확한 타이밍을 맞추기 어렵다.

더 나은 방법은 thread-1 , thread-2 가 계산을 끝내고 종료될 때 까지 main 스레드가 기다리는 방법이다.
예를 들어서 main 스레드가 반복문을 사용해서 thread-1 , thread-2 의 상태가 TERMINATED 가 될 때 까지 계속 확인하는 방법이 있다.

while(thread.getState() != TERMINATED) {
    // 스레드의 상태가 종료될 때 까지 계속 반복
}
// 계산 결과 출력

이런 방법은 번거롭고 또 계속되는 반복문은 CPU 연산을 사용한다.
이때 join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있다.

2-8. join - join 사용

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class JoinMainV3 {  
    public static void main(String[] args) throws InterruptedException {  
        log("Start");  
        SumTask task1 = new SumTask(1, 50);  
        SumTask task2 = new SumTask(51, 100);  
        Thread thread1 = new Thread(task1, "thread-1");  
        Thread thread2 = new Thread(task2, "thread-2");  

        thread1.start();  
        thread2.start();  

        // 스레드가 종료될 때 까지 대기  
        log("join() - main 스레드가 thread1, thread2 종료까지 대기");  
        thread1.join();  
        thread2.join();  
        log("main 스레드 대기 완료");  

        log("task1.result = " + task1.result);  
        log("task2.result = " + task2.result);  

        int sumAll = task1.result + task2.result;  
        log("task1 + task2 = " + sumAll);  
        log("End");  
    }  

    static class SumTask implements Runnable {  

        int startValue;  
        int endValue;  
        int result;  

        public SumTask(int startValue, int endValue) {  
            this.startValue = startValue;  
            this.endValue = endValue;  
        }  

        @Override  
        public void run() {  
            log("작업 시작");  
            sleep(2000);  
            int sum = 0;  
            for(int i = startValue; i <= endValue; i++) {  
                sum += i;  
            }  
            result = sum;  
            log("작업 완료 result = " + result);  
        }  
    }  
}

// 실행 결과
15:17:28.496 [     main] Start
15:17:28.501 [     main] join() - main 스레드가 thread1, thread2 종료까지 대기
15:17:28.501 [ thread-1] 작업 시작
15:17:28.503 [ thread-2] 작업 시작
15:17:30.533 [ thread-1] 작업 완료 result = 1275
15:17:30.533 [ thread-2] 작업 완료 result = 3775
15:17:30.544 [     main] main 스레드 대기 완료
15:17:30.545 [     main] task1.result = 1275
15:17:30.546 [     main] task2.result = 3775
15:17:30.547 [     main] task1 + task2 = 5050
15:17:30.548 [     main] End
  • join()InterruptedException 을 던진다.
  • InterruptedException 에 대해서는 뒤에서 설명한다.
  • 실행 결과를 보면 정확하게 5050 이 계산된 것을 확인할 수 있다.

main 스레드에서 다음 코드를 실행하게 되면 main 스레드는 thread-1 , thread-2 가 종료될 때 까지 기다린다.
이때 main 스레드는 WAITING 상태가 된다.

thread1.join();  
thread2.join();

예를 들어서 thread-1 이 아직 종료되지 않았다면 main 스레드는 thread1.join() 코드 안에서 더는 진행하지 않고 멈추어 기다린다.

이후에 thread-1 이 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동한다.

이때 thread-2 이 아직 종료되지 않았다면 main 스레드는 thread2.join() 코드 안에서 진행하지 않고 멈추어 기다린다.
이후에 thread-2 이 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동한다.

이 경우 thread-1 이 종료되는 시점에 thread-2 도 거의 같이 종료되기 때문에 thread2.join() 은 대기하지 않고 바로 빠져나온다.

Waiting (대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태이다.
  • join() 을 호출하는 스레드는 대상 스레드가 TERMINATED 상태가 될 때까지 대기한다.
    • 대상 스레드가 TERMINATED 상태가 되면 호출 스레드는 다시 RUNNABLE 상태가 되면서 다음 코드를 수행한다.

이렇듯 특정 스레드가 완료될 때까지 기다려야 하는 상황이라면 join() 을 사용하면 된다.

하지만 join() 의 단점은 다른 스레드가 완료될 때 까지 무기한 기다리는 단점이 있다.

비유를 하자면 맛집에 한 번 줄을 서면 중간에 포기하지 못하고 자리가 날 때 까지 무기한 기다려야 한다.

만약 다른 스레드의 작업을 일정 시간 동안만 기다리고 싶다면 어떻게 해야할까?

2-9. join - 특정 시간 만큼만 대기

join() 은 두 가지 메서드가 있다.

  • join() : 호출 스레드는 대상 스레드가 완료될 때 까지 무한정 대기한다.
  • join(ms) : 호출 스레드는 특정 시간 만큼만 대기한다.
    • 호출 스레드는 지정한 시간이 지나면 다시 RUNNABLE 상태가 되면서 다음 코드를 수행한다.

예제로 알아보자.
예제를 단순화 하기 위해 스레드는 1개만 만들고, 작업도 하나만 실행하자.

import static util.MyLogger.log;  
import static util.ThreadUtils.sleep;  

public class JoinMainV4 {  
    public static void main(String[] args) throws InterruptedException {  
        log("Start");  
        SumTask task1 = new SumTask(1, 50);  
        Thread thread1 = new Thread(task1, "thread-1");  

        thread1.start();  

        // 스레드가 종료될 때 까지 대기  
        log("join(1000) - main 스레드가 thread1 종료까지 1초 대기");  
        thread1.join(1000);  
        log("main 스레드 대기 완료");  

        log("task1.result = " + task1.result);  
        log("End");  
    }  

    static class SumTask implements Runnable {  

        int startValue;  
        int endValue;  
        int result;  

        public SumTask(int startValue, int endValue) {  
            this.startValue = startValue;  
            this.endValue = endValue;  
        }  

        @Override  
        public void run() {  
            log("작업 시작");  
            sleep(2000);  
            int sum = 0;  
            for(int i = startValue; i <= endValue; i++) {  
                sum += i;  
            }  
            result = sum;  
            log("작업 완료 result = " + result);  
        }  
    }  
}


// 실행 결과
15:32:43.822 [     main] Start
15:32:43.826 [     main] join(1000) - main 스레드가 thread1 종료까지 1초 대기
15:32:43.826 [ thread-1] 작업 시작
15:32:44.837 [     main] main 스레드 대기 완료
15:32:44.842 [     main] task1.result = 0
15:32:44.842 [     main] End
15:32:45.838 [ thread-1] 작업 완료 result = 1275
  • 별도의 스레드에서 1 ~ 50 까지 더하고, 그 결과를 조회한다.
  • join(1000) 을 사용해서 1초만 대기한다.
  • main 스레드는 join(1000) 을 사용해서 thread-1 을 1초간 기다린다.
    • 이때 main 스레드의 상태는 WAITING 이 아니라 TIMED_WAITING 이 된다.
    • 보통 무기한 대기하면 WAITING 상태가 되고, 특정 시간 만큼만 대기하는 경우 TIMED_WAITING 상태가 된다.
    • WAITINGTIMED_WAITING 모두 스케줄러에 없는 상태이다.
  • thread-1 의 작업에는 2초가 걸린다.
  • 1초가 지나도 thread-1 의 작업이 완료되지 않으므로, main 스레드는 대기를 중단한다. 그리고 main 스레드는 다시 RUNNABLE 상태로 바뀌면서 다음 코드를 수행한다.
    • 이때 thread-1 의 작업이 아직 완료되지 않았기 때문에 task1.result = 0 이 출력된다.
  • main 스레드가 종료된 이후에 thread-1 이 계산을 끝낸다. 따라서 작업 완료 result = 1275 이 출력된다.

정리
다른 스레드가 끝날 때 까지 무한정 기다려야 한다면 join() 을 사용하고,
다른 스레드의 작업을 무한정 기다릴 수 없다면 join(ms) 를 사용하면 된다.
물론 기다리다 중간에 나오는 상황인데, 결과가 없다면 추가적인 오류 처리가 필요 할 수 있다.

3. 요약

  • 쓰레드의 생명 주기에 대해 알아보았고, join() 에 대해 알아보았다.
  • join()은 다른 스레드가 끝날 때 까지 무한정 기다린다.
    • join(ms)는 특정 시간동안 스레드가 끝날 때 까지 기다린다.
  • 체크 예외 재정의에 대해 알아보았다.
    • 자식 클래스는 부모클래스보다 많은 예외를 선언하지 못하는 것처럼 메서드 또한 그렇다.
  • main 스레드는 다른 스레드를 생성하고 실행시켜놓고, 자신의 다음 할 일을 진행한다. 다른 스레드가 종료될 때까지 기다리지 않는다.
  • 스택프레임에서는 해당 스택 프레임이 어떤 인스턴스를 가리키는지 this의 값을 저장하고 있다.
728x90
Comments