관리 메뉴

나구리의 개발공부기록

스레드 제어와 생명주기, 스레드 기본 정보, 스레드의 생명 주기(설명, 코드), 체크 예외 재정의, join(시작, 필요한 상황, sleep 사용, join 사용, 특정 시간 만큼만 대기) 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

스레드 제어와 생명주기, 스레드 기본 정보, 스레드의 생명 주기(설명, 코드), 체크 예외 재정의, join(시작, 필요한 상황, sleep 사용, join 사용, 특정 시간 만큼만 대기)

소소한나구리 2025. 2. 10. 16:35
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 스레드 기본 정보

(1) ThreadInfoMain

  • Thread 클래스가 제공하는 정보들 확인
package thread.control;

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());

        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:36:03.660 [     main] mainThread = Thread[#1,main,5,main]
10:36:03.664 [     main] mainThread.threadId() = 1
10:36:03.665 [     main] mainThread.getName() = main
10:36:03.667 [     main] mainThread.getPriority() = 5
10:36:03.667 [     main] mainThread.getThreadGroup() = java.lang.ThreadGroup[name=main,maxpri=10]
10:36:03.667 [     main] mainThread.getState() = RUNNABLE
10:36:03.668 [     main] myThread = Thread[#22,myThread,5,main]
10:36:03.668 [     main] myThread.threadId() = 22
10:36:03.668 [     main] myThread.getName() = myThread
10:36:03.668 [     main] myThread.getPriority() = 5
10:36:03.668 [     main] myThread.getThreadGroup() = java.lang.ThreadGroup[name=main,maxpri=10]
10:36:03.669 [     main] myThread.getState() = NEW
*/

 

(2) 스레드 생성

Thread myThread = new Thread(new HelloRunnable(), "myThread");
  • 스레드를 생성할 때는 실행할 Runnable 인터페이스와 구현체, 스레드의 이름을 전달할 수 있음
  • Runnable 인터페이스: 실행할 작업을 포함하는 인터페이스, HelloRunnable 클래스는 Runnable 인터페이스를 구현한 클래스
  • 스레드 이름: "myThread"라는 이름으로 스레드를 생성, 이 이름은 디버깅이나 로깅 목적으로 유용하며 이름을 생략하면 Thread-0, Thread-1과 같음 임의의 이름이 생성됨

(2) 스레드 객체 정보

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

(3) 스레드 ID - threadId()

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

(4) 스레드 이름 - getName()

log("mainThread.getName() = " + mainThread.getName());
log("myThread.getName() = " + myThread.getName());
  • 스레드의 이름을 반환하는 메서드
  • 생성자에서 스레드 이름을 지정하면 지정한 이름이 반환되고 지정하지 않으면 기본으로 생성된 스레드 이름이 반환됨
  • 스레드 ID는 고유하지만 스레드 이름은 중복될 수 있음

(5) 스레드 우선순위 - getPriority()

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

(6) 스레드 그룹 - getThreadGroup()

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

(7) 스레드 상태 - getState()

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

2. 스레드의 생명 주기

1) 설명

  • 자바 스레드(Thread)의 생명 주기는 여러 상태(state)로 나뉘어지면 각 상태는 스레드가 실행되고 종료되기까지의 과정을 나타냄

(1) NEW(새로운 상태)

  • 스레드가 생성되고 아직 시작되지 않은 상태
  • 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태임

(2) RUNNABLE(실행 가능 상태)

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

(3) BLOCKED(차단 상태)

  • 스레드가 다른 스레드에 의해 동기화 락을 얻기 위해 기다리는 상태
  • synchronized 블록에 진입하기 위해 락을 얻어야 하는 경우 이 상태에 들어감

(4) WAITING(대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태
  • wait(), join() 메서드가 호출될 때 이 상태가 됨
  • 스레드는 다른 스레드가 notify()또는 notifyAll() 메서드를 호출하거나 join()이 완료될 때까지 기다림

(5) TIMED_WAITING(시간 제한 대기 상태)

  • 스레드가 특정 시간 동안 다른 스레드의 작업이 완료되기를 기다리는 상태
  • sleep(long millis), wiat(long millis), join(long millis) 메서드가 호출될 때 이 상태가 됨
  • 주어진 시간이 경과하거나 다른 스레드가 해당 스레드를 깨우면 이 상태에서 벗어남

(6) TERMINATED(종료 상태)

  • 스레드의 실행이 완료된 상태
  • 스레드가 정상적으로 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 들어감
  • 스레드는 한 번 종료되면 다시 시작할 수 없으므로 다시 생성해야함

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

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

2) 코드

(1) ThreadStateMain

  • Thread.currentThread(): 해당 코드를 실행하는 스레드 객체를 조회
  • Thread.sleep()
    • 해당 코드를 호출한 스레드는 TIMED_WAITING 상태가 되면서 특정 시간(밀리초(ms)단위) 만큼 대기함
    • InterruptedException이라는 체크 예외를 던지기 때문에 체크 예외를 잡아서 처리하거나 던져야 함
    • run() 메서드 안에서는 체크 예외를 던질 수 없으므로 반드시 잡아야 함
    • InterruptedException은 인터럽트가 걸릴 때 발생하는데 인터럽트는 뒤에서 설명함
package thread.control;

public class ThreadStateMain {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new MyRunnable(), "myThread"); // NEW
        log("myThread.state1 = " + thread.getState());
        log("myThread.state()");
        thread.start();
        Thread.sleep(1000); // 바로 상태를 찍으면 상태를 출력 못할 수 없어서 1초 슬립
        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("sleep() end");
                log("myThread.state4 = " + Thread.currentThread().getState()); // RUNNABLE

                log("end");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
/* 실행 결과
11:48:04.946 [     main] myThread.state1 = NEW
11:48:04.947 [     main] myThread.state()
11:48:04.947 [ myThread] start
11:48:04.948 [ myThread] myThread.state2 = RUNNABLE
11:48:04.948 [ myThread] sleep() start
11:48:05.954 [     main] myThread.state3 = TIMED_WAITING
11:48:07.952 [ myThread] sleep() end
11:48:07.953 [ myThread] myThread.state4 = RUNNABLE
11:48:07.954 [ myThread] end
11:48:09.963 [     main] myThread.state5 = TERMINATED
11:48:09.963 [     main] end
*/

 

(2) 실행 상태 그림

  • main() 스레드 상태의 설명은 생략
  • state1 = NEW
    • main 스레드를 통해 myThread 객체를 생성
    • 아직 start()를 호출하지 않았기 때문에 NEW 상태임
  • state2 = RUNNABLE
    • myThread.start()를 호출하여 스레드를 실행상태로 만들었으므로 RUNNABLE 상태가 됨
    • 실행 상태가 너무 빨리 지나가기 때문에 main 스레드에서 myThread의 상태를 확인하기 어렵기 때문에 자기 자신인 myThread에서 실행중인 자신의 상태를 확인
  • state3 = TIMED_WAITING
    • Thread.sleep(3000)으로 myThread를 3초간 대기하면서 TIMED_WAITING 상태로 변함
    • TIMED_WAITING 상태는 본인이 확인할 수 없으므로 main에서 상태를 확인
    • 대기를 하지않고 myThread의 상태를 확인하면 myThread가 TIMED_WAITING 상태가 되기 전에 main에서 상태를 확인해버릴 수 있기 때문에 main() 스레드가 1초간 대기하고 상태를 확인
  • state4 = RUNNABLE
    • myThread가 3초의 시간 대기 후 TIMED_WAITING 상태에서 빠져나와 다시 실행 될 수 있는 RUNNABLE 상태로 바뀜
  • state5 = TERMINATED
    • myThread가 run() 메서드를 실행 종료하고 나면 TERMINATED 상태가 됨
    • myThread 입장에서 run()이 스택에 남은 마지막 메서드인데 run()까지 실행되고 나면 스택이 완전히 비워지고 스택이 비워지면 해당 스택을 사용하는 스레드도 종료됨

3. 체크 예외 재정의

1) 체크 예외 재정의

(1) CheckedExceptionMain

  • main()은 체크 예외를 밖으로 던질 수 있지만 run()은 체크 예외를 밖으로 던질 수 없음
  • 자바에서 메서드를 재정의 할 때 재정의 메서드가 지켜야할 예외와 관련된 규칙이 있음
  • Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않으므로 Runnable 인터페이스의 run() 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없어서 컴파일 오류가 발생함
  • 체크 예외
    • 부모 메서드가 체크 예외를 던지지 않는 경우 재정의된 자식 메서드도 체크 예외를 던질 수 없음
    • 자식 메서드는 부모 메서드가 던질 수 있는 체크 예외의 하위 타입만 던질 수 있음
  • 언체크 예외
    • 예외 처리를 강제하지 않으므로 상관없이 던질 수 있음
package thread.control;

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

    static class CheckedException implements Runnable {

        @Override
        public void run() throws Exception{ // 예외 발생
            throw new Exception();          // 예외 발생
        }
    }
}

 

(2) 제약의 이유

  • 부모 클래스의 메서드를 호출하는 클라이언트 코드는 부모 메서드가 던지는 특정 예외만을 처리하도록 작성됨
  • 자식 클래스가 더 넓은 범위의 예외를 던지면 해당 코드는 모든 예외를 처리하지 못할 수 있으며 이는 예외 처리의 일관성을 해치고 예상하지 못한 런타임 오류를 초래할 수 있음
  • 아래의 예제 코드는 자식 클래스에서 부모 클래스에 정의한 예외의 범위를 넘어선 예외를 던지는 것이 가능하다고 가정한 코드임(당연히 실제 동작은 안됨)
  • Test의 main()에서 자바 컴파일러는 Parent p의 method()를 호출한 것으로 인지하게 되어 try-catch로 체크 예외를 잡으려고하면 Parent가 던지는 InterruptedException을 잡게되고, Child가 던지는 Exception은 잡지 않게 됨
  • 이런 상황은 모든 예외를 체크하는 체크 예외의 규약에 맞지 않기 때문에 자바에서 체크 예외의 메서드 재정의는 규칙을 가지게 됨
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 처리
        }
    }
}

 

(3) 체크 예외 재정의 규칙

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

(4) 안전한 예외 처리

  • 체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써 개발자는 반드시 체크 예외를 try-catch 블록내에서 처리해야하기 때문에 예외가 적절히 처리되지 않아서 프로그램이 비정상 종료되는 상황을 방지할 수 있음
  • 특히 멀티스레딩 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있음
  • 그러나 이전에 자바 예외 처리 강의에서 설명했듯이 체크 예외를 강제하는 부분들은 자바 초창기의 기조이고 최근에는 체크 예외보다 언체크 예외를 선호함

2) Sleep 유틸리티 

(1) ThreadUtils

  • run()메서드를 재정의 할 때 Thread.sleep()를 호출하면 InterruptedException 체크 예외를 발생시키므로 호출할 때마다 무조건 try-catch로 예외를 잡아야하는것은 번거로우므로 편리하게 사용할 수 있도록 유틸리티를 생성
  • 사용하는 곳에서는 ThreadUtils.sleep(1000); 처럼 호출만 하면 편리하게 사용할 수 있으며 스태틱 임포트를하면 sleep(1000);만으로 편리하게 Thread.sleep()을 사용할 수 있게됨
package util;

public abstract class ThreadUtils {

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

4. join

1) 시작

(1) JoinMainV0

  • 위에서 만들 ThreadUtils.slee()메서드를 사용하여 스레드에서 2초간의 작업이 일어난다는 상황을 가정하도록 코드를 작성
  • 실행해보면 멀티 스레드 환경이기 때문에 실행 순서가 보장이 되지 않아 다른 스레드의 작업 시간 때문에 main 스레드가 먼저 종료되는 것을 확인할 수 있음
  • main 스레드는 thread-1, thread-2를 실행하고 thread-1, thread-2가 끝날때까지 기다리지 않고 바로 자신의 다음 코드를 실행함
  • 만약 main스레드가 thread-1, thread-2의 작업 결과를 받아서 처리하고 싶다면 다른 방안이 필요함
package thread.control.join;

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("작업 끝");
        }
    }
}
/* 실행 결과
15:04:21.090 [     main] start
15:04:21.092 [     main] end
15:04:21.092 [ thread-1] 작업 시작
15:04:21.092 [ thread-2] 작업 시작
15:04:23.098 [ thread-1] 작업 끝
15:04:23.098 [ thread-2] 작업 끝
*/

2) 필요한 상황

(1) 상황 가정

  • 1 ~ 100까지 더하는 코드를 작성할 때 CPU 코어를 더 효율적으로 사요하기 위해 여러 스레드로 나누어서 계산
  • main 스레드가 thread-1, thread-2에 각각 작업을 나누어 지시하면 CPU를 더 효율적으로 활용할 수 있어 CPU 코어가 2개라면 이론적으로 연산 속도가 2배 빨라짐
  • main: 두 스레드의 계산 결괄르 받아서 합치기(너무 빠르게 처리되므로 속도 계산에서 제외)
  • thread-1: 1 ~ 50 까지 더하기
  • thread-2: 51 ~ 100 까지 더하기

(2) JoinMainV1

  • SumTask는 계산의 시작값과 계산의 마지막 값을 가지고 계산이 끝나면 그 결과를 result 필드에 담아둠
  • main 스레드는 thread-1, thread-2를 만들고 작업을 할당한 뒤, thread-1은 task1 인스턴스의 run()을 실행하고 thread-2는 task2 인스턴스의 run()을 실행한 후 각각의 스레드는 결과를 result 멤버 변수에 보관함
  • 여기서 수행하는 시간이 2초정도 걸리는 복잡한 계산이라고 가정을 하기 위해 sleep(2000)를 설정하여 2초 후에 계산이 완료되고 result에 결과가 담김
  • main 스레드는 각 스레드의 작업 결과인 task1.result, task.result의 값을 얻어서 사용하고 있는데, 실행 결과를 보면 main 스레드의 호출 결과는 모두 0으로 나오고 main 스레드가 종료되고 각 스레드의 계산결과가 출력되는 것을 확인할 수 있음
package thread.control.join;

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);
        }
    }
}
/* 실행 결과
15:25:58.137 [     main] start
15:25:58.138 [ thread-1] 작업 시작
15:25:58.138 [ thread-2] 작업 시작
15:25:58.141 [     main] task1.result = 0
15:25:58.142 [     main] task2.result = 0
15:25:58.142 [     main] task1 + task2 = 0
15:25:58.142 [     main] end
15:26:00.145 [ thread-2] 작업 끝 result = 3775
15:26:00.145 [ thread-1] 작업 끝 result = 1275
*/

 

(3) 상세 분석

  • 프로그램이 처음 시작되면 main 스레드는 thread-1, thread-2를 생성하고 start()로 실행함
  • thread-1,thread-2는 각각 자신에게 전달된 SumTask 인스턴스의 run()메서드를 스택에 올리고 실행함
  • main 스레드는 두 스레드를 시작한 다음 바로 task1.result, task2.result를 통해 인스턴스에 있는 결과 값을 조회함
  • main 스레드가 실행한 start()메서드는 스레드의 실행이 끝날 때 까지 기다리지 않으므로 다른 스레드를 실행하고 자신의 다음코드를 실행하지만, thread-1, thread-2가 계산을 완료해서 result에 연산 결과를 담을 때까지는 약 2초의 시간이 걸림
  • main 스레드는 계산이 끝나기 전에 result의 결과를 조회하기 때문에 0 값이 출력됨
  • 그 다음 2초가 지나고 thread-1, thread-2가 계산을 완료하고 result에 값을 보관하지만 main 스레드는 이미 자신의 코드를 모두 실행하고 종료된 상태임

** 참고) this의 비밀

  • 어떤 메서드를 호출하는 것을 정확하게 말하면 특정 스레드가 어떤 메서드를 호출하는 것임
  • 스레드는 메서드의 호출을 관리하기 위해 메서드 단위로 스택 프레임을 만들고 해당 스택 프레임을 스택위에 쌓아 올림
  • 이때 인스턴스의 메서드를 호출하면 어떤 인스턴스의 메서드를 호출했는지 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임 내부에 저장해두는데 이것이 자주 사용하던 this임
  • 특정 메서드 안에서 this를 호출하면 바로 스택프레임 안에 있는 this값을 불러서 사용하게 되며 this가 있기 때문에 thread-1, thread-2는 자신의 인스턴스를 구분해서 사용할 수 있음
  • 필드에 접근할 때 this를 생략하면 자동으로 this를 참고해서 필드에 접근함
  • 정리하면 this는 호출된 인스턴스 메서드가 소속된 객체를 가리키는 참조이며 이것은 스택 프레임 내부에 저장되어 있음

3) sleep 사용

(1) JoinMainV2

  • 특정 스레드를 기다리게 하는 가장 간단한 방법은 sleep()을 사용하는 것이므로 main 스레드를 3초간 쉬도록 코드를 추가(JoinMainV1의 코드와 나머지 코드는 같으므로 생략)
  • 실행해보면 기대하던 바와같이 결과가 조회되는 것을 확인할 수 있는데, thread-1과 thread-2가 작업에 2초 정도의 시간이 걸린다는 것을 알고 있으므로 main 스레드를 3호추에 계산 결과를 조회하도록 했기 대문에 계산된 결과를 받아서 출력할 수 있음
  • 그러나, 이렇게  sleep()을 사용해서 무작정 기다리는 방법은 대기 시간도 손해보고, thread-1과 thread-2의 수행시간을 예측할 수 없으면 정확한 타이밍을 맞추기 어려움
  • main스레드에서 반복문을 사용하여 thread-1, thread-2가 계산을 끝내고 종료(TERMINATED 상태)될 때 까지 계속 확인하면서 main 메서드가 기다리는 방법을 생각할 수 있는데, 이방법은 번거로움을 넘어서 CPU 연산이 계속 발생하는 문제가 있음
  • 이럴때 join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있음
package thread.control.join;

public class JoinMainV2 {
    public static void main(String[] args) {

    // ... 기존 코드 동일

        log("main 스레드 sleep()");
        sleep(3000);
        log("main 스레드 깨어남");

    // ... 기존 코드 동일
}
/* 실행 결과
15:59:22.384 [     main] start
15:59:22.386 [ thread-1] 작업 시작
15:59:22.386 [     main] main 스레드 sleep()
15:59:22.386 [ thread-2] 작업 시작
15:59:24.392 [ thread-2] 작업 끝 result = 3775
15:59:24.392 [ thread-1] 작업 끝 result = 1275
15:59:25.391 [     main] main 스레드 깨어남
15:59:25.393 [     main] task1.result = 1275
15:59:25.394 [     main] task2.result = 3775
15:59:25.394 [     main] task1 + task2 = 5050
15:59:25.397 [     main] end
*/

4) join 사용

(1) JoinMainV3

  • sleep()을 제거하고, join()을 호출하도록 코드를 작성
  • join()은 InterruptedException을 던짐
  • 실행해보면 작업이 끝나자마자 나머지 코드들이 바로 출력되는 것을 확인할 수 있음
package thread.control.join;

public class JoinMainV3 {
    public static void main(String[] args) throws InterruptedException {

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

        // ... 기존 코드 동일
}
/* 실행 결과
16:06:59.754 [     main] start
16:06:59.756 [     main] join() - main 스레드가 thread1, thread2 종료까지 대기
16:06:59.756 [ thread-1] 작업 시작
16:06:59.756 [ thread-2] 작업 시작
16:07:01.760 [ thread-2] 작업 끝 result = 3775
16:07:01.762 [ thread-1] 작업 끝 result = 1275
16:07:01.762 [     main] main 스레드 대기 완료
16:07:01.763 [     main] task1.result = 1275
16:07:01.763 [     main] task2.result = 3775
16:07:01.763 [     main] task1 + task2 = 5050
16:07:01.764 [     main] end
*/

 

(2) 실행 분석

  • main 스레드에서 thread1.join()을 호출하게 되면 thread-1이 종료될 때까지 기다리며 main 스레드는 WAITING 상태가 됨
  • 이후에 thread-1이 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동함
  • thread2.join()이 호출되고 thread-2가 아직 종료되지 않았다면 main 스레드는 다시 thread-2가 종료될 때까지 기다리고 thread-2가 종료되면 다음코드를 실행함
  • JoinMainV3의 예시에서는 thread-1이 종료되는 시점에 thread-2도 거의 같이 종료되기 때문에 thread2.join()은 대기하지 않고 바로 빠져나옴
  • Waiting(대기상태)는 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한으로 기다리는 상태이며 join()을 호출하는 스레드는 대상 스레드가 TERMINATED 상태가 될 때까지 대기함
  • 대상 스레드가 TERMINATED 상태가 되면 호출 스레드는 다시 RUNNABLE 상태가 되면서 다음 코드를 수행하게 되므로 특정 스레드가 완료될 때까지 기다려야 하는 상황이라면 join()을 사용하면 됨
  • join()은 다른 스레드가 완료될 때까지 무기한 기다리는 단점이 있어 이를 보완하여 일정 시간 동안만 기다리게 할 수도 있음

5) 특정 시간 만큼만 대기

(1) JoinMainV4

  • join(ms): join메서드를 호출 시 밀리초를 인수로 넘겨주면 넘겨준 시간만큼 대기한다음 지정한 시가이 지나면 RUNNABLE 상태가 되면서 다음 코드를 수행함
  • 실행 결과를 보면 thread-1이 1초만 기다리고 바로 다음 코드를 수행했기 때문에 result의 값이 0으로 출력되고 그다음 thread-1이 종료되여 result의 값이 출력되는 것을 확인할 수 있음
package thread.control.join;

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() - main 스레드가 thread1 종료까지 1초 대기");
        thread1.join(1000);
        log("main 스레드 대기 완료");

        log("task1.result = " + task1.result);
        log("end");
    }
    // ... 기존 코드 동일 생략
}
/* 실행 결과
16:20:36.123 [     main] start
16:20:36.125 [     main] join() - main 스레드가 thread1 종료까지 1초 대기
16:20:36.125 [ thread-1] 작업 시작
16:20:37.135 [     main] main 스레드 대기 완료
16:20:37.138 [     main] task1.result = 0
16:20:37.138 [     main] end
16:20:38.132 [ thread-1] 작업 끝 result = 1275
*/

 

(2) 실행 분석

  • main스레드는 join(1000)을 사용해서 thread-1을 1초간 기다림
  • 이때 main 스레드의 상태는 WAITING이 아니라 TIMED_WAITING이 됨, 즉 무기한 대기하면 WAITING, 특정 시간만큼 대기하면 TIMED_WAITING 상태가 됨
  • thread-1의 작업에는 2초가 걸리지만 1초가 지나도 thread-1의 작업은 완료되지 않았기 때문에 main 스레드는 대기를 중단고 RUNNABLE 상태로 바뀌면서 다음 코드를 수행함
  • 이때 thread-1의 작업이 아직 완료되지 않았기 때문에 task1.result = 0이 출력됨
  • main 스레드가 종료된 이후에 thread-1이 계산을 끝내고 result = 1275가 출력됨

(3) 정리

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

5. 문제와 풀이

1) join() 활용1

(1) 문제 설명

  • 다음 코드를 작성하고 코드를 실행하기 전에 로그가 어떻게 출력될지 예측해보고 총 실행 시간이 얼마나 걸리지 예측해보기
더보기
package thread.control.test;

public class JoinTest1Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new MyTask(), "t1");
        Thread t2 = new Thread(new MyTask(), "t2");
        Thread t3 = new Thread(new MyTask(), "t3");

        t1.start();
        t1.join();

        t2.start();
        t2.join();

        t3.start();
        t3.join();
        System.out.println("모든 스레드 실행 완료");
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            for (int i = 1; i <= 3; i++) {
                log(i);
                sleep(1000);
            }
        }
    }
}

 

(2) 정답

더보기

실행 결과

16:31:35.168 [       t1] 1
16:31:36.175 [       t1] 2
16:31:37.177 [       t1] 3
16:31:38.183 [       t2] 1
16:31:39.189 [       t2] 2
16:31:40.194 [       t2] 3
16:31:41.200 [       t3] 1
16:31:42.205 [       t3] 2
16:31:43.212 [       t3] 3
모든 스레드 실행 완료

 

실행 시간: 9초

2) join() 활용2

(1) 문제 설명

  • 문제 1의 코드를 변경해서 전체 실행 시간을 3초로 앞당기기
  • 실행 결과를 참고
더보기

실행 결과

10:29:46.321 [ t1] 1

10:29:46.321 [ t3] 1

10:29:46.321 [ t2] 1

10:29:47.325 [ t2] 2

10:29:47.329 [ t3] 2

10:29:47.329 [ t1] 2

10:29:48.330 [ t3] 3

10:29:48.330 [ t1] 3

10:29:48.330 [ t2] 3

모든 스레드 실행 완료

 

실행 시간: 3초

 

(2) 정답

더보기
package thread.control.test;

public class JoinTest1Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new MyTask(), "t1");
        Thread t2 = new Thread(new MyTask(), "t2");
        Thread t3 = new Thread(new MyTask(), "t3");

        t1.start();
        t2.start();
        t3.start();
        
        t1.join();
        t2.join();
        t3.join();

        System.out.println("모든 스레드 실행 완료");
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            for (int i = 1; i <= 3; i++) {
                log(i);
                sleep(1000);
            }
        }
    }
}
728x90