728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드 기본 정보

Thread 클래스는 스레드를 생성하고 관리하는 기능을 제공한다.

Thread 클래스가 제공하는 정보들을 확인해보자.

하나는 기본으로 제공되는 main 스레드의 정보를, 하나는 직접 만든 myThread 스레드의 정보를 출력해보자.

 

ThreadInfoMain

package thread.control;

import thread.start.HelloRunnable;

import static util.MyLogger.log;

public class ThreadInfoMain {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        log("mainThread======================================");
        log("mainThread = " + mainThread);
        log("mainThread.threadId(): " + mainThread.threadId());
        log("mainThread.getName(): " + mainThread.getName());
        log("mainThread.getPriority(): " + mainThread.getPriority());
        log("mainThread.isDaemon(): " + mainThread.isDaemon());
        log("mainThread.getThreadGroup(): " + mainThread.getThreadGroup());
        log("mainThread.getState(): " + mainThread.getState());

        Thread myThread = new Thread(new HelloRunnable(), "myThread");
        log("myThread======================================");
        log("myThread = " + myThread);
        log("myThread.threadId(): " + myThread.threadId());
        log("myThread.getName(): " + myThread.getName());
        log("myThread.getPriority(): " + myThread.getPriority());
        log("myThread.isDaemon(): " + myThread.isDaemon());
        log("myThread.getThreadGroup(): " + myThread.getThreadGroup());
        log("myThread.getState(): " + myThread.getState());
    }
}

실행결과

2024-07-18 13:42:57.490 [     main] mainThread======================================
2024-07-18 13:42:57.494 [     main] mainThread = Thread[#2,main,5,main]
2024-07-18 13:42:57.502 [     main] mainThread.threadId(): 2
2024-07-18 13:42:57.502 [     main] mainThread.getName(): main
2024-07-18 13:42:57.505 [     main] mainThread.getPriority(): 5
2024-07-18 13:42:57.505 [     main] mainThread.isDaemon(): false
2024-07-18 13:42:57.505 [     main] mainThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.506 [     main] mainThread.getState(): RUNNABLE
2024-07-18 13:42:57.506 [     main] myThread======================================
2024-07-18 13:42:57.506 [     main] myThread = Thread[#31,myThread,5,main]
2024-07-18 13:42:57.507 [     main] myThread.threadId(): 31
2024-07-18 13:42:57.507 [     main] myThread.getName(): myThread
2024-07-18 13:42:57.507 [     main] myThread.getPriority(): 5
2024-07-18 13:42:57.507 [     main] myThread.isDaemon(): false
2024-07-18 13:42:57.508 [     main] myThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.508 [     main] myThread.getState(): NEW

 

1. 스레드 생성

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

Thread myThread = new Thread(new HelloRunnable(), "myThread");

 

2. 스레드 객체 정보

myThread 객체를 문자열로 반환하여 출력한다. Thread 클래스의 toString() 메서드는 스레드 ID, 스레드 이름, 스레드 우선순위, 스레드 그룹을 포함하는 문자열을 반환한다.

log("myThread: " + myThread);
>> Thread[#31,myThread,5,main]

 

3. 스레드 ID

스레드의 고유 식별자를 반환하는 메서드이다. 이 ID는 JVM 내에서 각 스레드에 대해 유일하다. ID는 스레드가 생성될 때 할당되며, 직접 지정할 수 없다. 

log("myThread.threadId() = " + myThread.threadId());

 

4. 스레드 이름

스레드의 이름을 반환하는 메서드이다. 생성자에서 `myThread`라는 이름을 지정했기 때문에, 이 값이 반환된다. 참고로 스레드 ID는 중복되지 않지만, 스레드 이름은 중복될 수 있다.

log("myThread.getName() = " + myThread.getName());

 

5. 스레드 우선순위

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

log("myThread.getPriority() = " + myThread.getPriority());

 

6. 스레드 그룹

log("myThread.getThreadGroup() = " + myThread.getThreadGroup());

스레드가 속한 스레드 그룹을 반환하는 메서드이다. 스레드 그룹은 스레드를 그룹화하여 관리할 수 있는 기능을 제공한다. 기본적으로 모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속하게 된다. 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업(예: 일괄 종료, 우선순위 설정 등)을 수행할 수 있다. 

여기서 부모 스레드란, 새로운 스레드를 생성하는 스레드를 말한다. 스레드는 기본적으로 다른 스레드에 의해 생성된다. 이러한 생성 관계에서 새로 생성된 스레드는 생성한 스레드를 부모로 간주한다. 그래서 지금 위 실행결과를 보면 myThreadmain 스레드에 의해 생성되었으니까 main 스레드가 부모 스레드가 되고 그룹도 main이 되는 것이다. 

 

그러나, 이 스레드 그룹은 거의 사용되지 않는다.

 

7. 스레드 상태

log("myThread.getState() = " + myThread.getState());

스레드의 현재 상태를 반환하는 메서드이다. 반환되는 값은 Thread.State 열거형에 정의된 상수 중 하나이다. 주요 상태는 다음과 같다.

  • NEW: 스레드가 아직 시작되지 않은 상태이다.
  • RUNNABLE: 스레드가 실행중이거나, 실행될 준비가 된 상태이다.
  • BLOCKED: 스레드가 동기화 락을 기다리는 상태이다.
  • WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태이다.
  • TIMED_WAITING: 일정 시간 동안 기다리는 상태이다 (예: Thread.sleep())
  • TERMINATED: 스레드가 실행을 마친 상태이다.

 

스레드의 생명 주기

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

스레드의 상태

  • New: 스레드가 생성되었으나 아직 시작되지 않은 상태
  • Runnable: 스레드가 실행중이거나 실행될 준비가 된 상태
  • Blocked: 스레드가 동기화 락을 기다리는 상태
  • Waiting: 스레드가 무기한으로 다른 스레드의 작업을 기다리는 상태
  • Timed Waiting: 스레드가 일정 시간 동안 다른 스레드의 작업을 기다리는 상태
  • Terminated: 스레드의 실행이 완료된 상태

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

 

1. NEW (새로운 상태)

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

2. Runnable (실행 가능 상태)

  • 스레드가 실행될 준비가 된 상태이다. 이 상태에서 스레드는 실제로 CPU에서 실행될 수 있다.
  • 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 (종료 상태)

  • 스레드의 실행이 완료된 상태이다.
  • 스레드가 정상적으로 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 돌아간다.
  • 스레드는 한 번 종료되면 다시 시작할 수 없다.

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

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 상태가 된다.

 

스레드의 생명 주기 - 코드로 직접 보기

실제로 이 일련의 과정을 코드로 직접 봐보자. 

ThreadStateMain

package thread.control;

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);
        log("myThread.state3 = " + thread.getState());
        Thread.sleep(4000);
        log("myThread.state5 = " + thread.getState());
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            try {
                log("start");
                log("myThread.state2 = " + Thread.currentThread().getState());

                log("sleep() start");
                Thread.sleep(3000);
                log("sleep() end");
                log("myThread.state4 = " + Thread.currentThread().getState());
                log("end");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
  • 첫번째로 MyRunnable 이라는 Runnable을 구현한 클래스를 만든다. 이 클래스에서는 run()을 재정의하여 스레드가 동작할 행위들을 구현한다. 
  • main()에서는 이 MyRunnable()task로 하는 스레드를 생성한다. 그 스레드의 이름은 `myThread`가 된다.
  • myThread.start()를 하기 전, 현재 myThread의 상태를 찍어본다. (NEW)
  • 그리고 myThread.start()를 호출한다.
  • MyRunnablerun()은 실행된 후 자기 자신의 상태를 찍어본다 (RUNNABLE)
  • 그리고 3초동안의 sleep(3000)을 실행한다. 
  • 이 3초 동안 자는 시간에 myThread의 상태를 확인하기 위해 main()에서 start()를 호출 후 1초 정도의 텀을 두고 myThread 상태를 찍어본다. (TIMED_WAITING)
  • 3초가 지나고 myThreadrun() 안에서 자기 자신의 상태를 찍는다 (RUNNABLE)
  • run() 메서드가 종료된다.
  • main() 에서는 run()이 잘 종료될때까지 잠시 4초 정도 기다리고 다시 이 myThread의 상태를 찍어본다. (TERMINATED)

 

근데, 자꾸 불편한게 있다. Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 같은 체크 예외를 밖으로 던질 수가 없다. 왜 그럴까? 그 이유를 알아보자.

 

재정의 메서드에서의 체크 예외

Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 체크 예외를 밖으로 던질 수 없었다. 왜 그럴까?

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

@FunctionalInterface
public interface Runnable {
    void run();
}

 

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

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

그렇다. Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않는다. 따라서 Runnable 인터페이스의 run() 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없다.

 

그럼 Runnable을 구현하는 클래스에서 재정의 한 run() 메서드는 아무런 체크 예외도 던질 수 없다는 것을 알았다. 근데 왜 이런 제약을 두지?

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

 

다음 예시를 보면서 더 이해해보자 (실제 동작할 수 없는 코드이다)

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 처리
        }
    } 
}
  • new Child();Child 인스턴스를 생성했고 변수 p의 타입은 Parent로 받았다. 
  • p.method()를 호출하면 컴파일러 입장에서는 타입이 Parent 이기 때문에 당연히 부모가 선언한 체크 예외인 InterruptedExceptioncatch에서 잡는다.
  • 그러나, 런타임에서는 p.method()를 호출하는 순간 재정의된 메서드가 있다면 재정의된 Childmethod()가 호출된다. 이게 규칙이다.
  • 만약, 그렇게 해서 정상적으로 끝났으면 상관이 없지만 에러가 발생하면 Exception이 던져지게 된다. 하지만 코드에서는 Exception을 잡을수가 없는 사태가 발생한다. 

재정의 메서드에서 체크 예외 규칙

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

안전한 예외 처리

체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try - catch 블록 내에서 처리하게 된다. 이는 예외 발생 시 예외가 적절히 처리되지 않아서 프로그램이 비정상적으로 종료되는 상황을 방지할 수 있다. 특히, 멀티 스레드 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다. 하지만 이제는 체크 예외를 강제하는 이런 부분들이 구시대적 흐름이고 최근에는 체크 예외보단 언체크 예외를 선호한다. 그리고 Runnable은 Java 1.0에서 만들어진 내용이다. 그리고 이제는 체크 예외도 던질 수 있는 Callable 이라는 인터페이스가 탄생한다. 이는 이후에 다루겠다.

 

그래서 Thread.sleep(int millis) 호출할때마다 try - catch 블록으로 잡는게 귀찮으니 다음과 같이 유틸리티를 만들자. 그리고 이것을 사용하자.

ThreadUtils

package util;

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

 

join - 시작

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

 

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

 

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

JoinMainV0

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

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("작업 시작");
            ThreadUtils.sleep(2000);
            log("작업 완료");
        }
    }
}

실행결과 (스레드의 실행 순서는 보장되지 않기 때문에 실행 결과는 약간 다를 수 있다)

2024-07-18 16:43:04.713 [     main] start
2024-07-18 16:43:04.717 [     main] end
2024-07-18 16:43:04.717 [ thread-1] 작업 시작
2024-07-18 16:43:04.717 [ thread-2] 작업 시작
2024-07-18 16:43:06.719 [ thread-2] 작업 완료
2024-07-18 16:43:06.719 [ thread-1] 작업 완료
  • thread-1, thread-2 모두 main 스레드가 생성하고 start()를 호출해서 실행한다.
  • thread-1, thread-2는 각각 특정 작업을 수행한다. 작업 수행에 약 2초 정도가 걸린다고 가정하기 위해 sleep()을 사용해서 2초간 대기한다. 
  • main 스레드는 thread-1, thread-2를 실행하고 바로 자신의 다음 코드를 실행한다. 여기서 핵심은 main 스레드가 thread-1, thread-2가 끝날때까지 기다리지 않는다는 점이다. main 스레드는 단지 start()를 호출해서 다른 스레드를 실행만 하고 바로 자신의 다음 코드를 실행한다.

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

 

join - 필요한 상황

1 - 100까지 더하는 코드를 생각해보자. 

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

이 코드는 스레드를 하나만 사용하기 때문에 CPU 코어도 하나만 사용할 수 있다. CPU 코어를 더 효율적으로 사용하려면 여러 스레드로 나누어 계산하면 된다. 

  • 1 - 50까지 더하기
  • 51 - 100까지 더하기

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

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

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

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("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

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

  • thread-1: task1 (1 - 50까지 더하기)
  • thread-2: task2 (51 - 100까지 더하기)

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

 

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

 

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

 

실행결과

2024-07-18 16:59:17.656 [     main] start
2024-07-18 16:59:17.660 [ thread-1] 작업 시작
2024-07-18 16:59:17.660 [ thread-2] 작업 시작
2024-07-18 16:59:17.668 [     main] task1.result = 0
2024-07-18 16:59:17.669 [     main] task2.result = 0
2024-07-18 16:59:17.670 [     main] task1 + task2 = 0
2024-07-18 16:59:17.670 [     main] end
2024-07-18 16:59:19.663 [ thread-2] 작업 완료 result = 3775
2024-07-18 16:59:19.663 [ thread-1] 작업 완료 result = 1275

그런데 실행 결과를 보면 기대와 다르게 task1.result, task2.result 모두 0으로 나온다. 그리고 task1 + task2의 결과도 0으로 나온다. 계산이 전혀 진행되지 않았다. 왜 그럴까?

main 스레드는 thread-1, thread-2에 작업을 지시하고, thread-1, thread-2가 계산을 완료하기도 전에 먼저 계산 결과를 조회했다. 참고로 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이다.

 

 

각설하고, 이제 과연 main이 어떻게 thread-1, thread-2을 기다리고 계산 결과를 가져올 수 있을까? 

가장 간단하고 단순한 방법은 sleep을 사용하는 것이다.

 

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

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("main 스레드 sleep()");
        ThreadUtils.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("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

각 스레드가 작업을 한 2초정도 하니까 3초 정도 main 스레드가 잠을 자고 있다가 일어나면 계산이 다 됐을꺼라고 가정하는 것이다. 즉 무슨 말이냐면, 실제로 스레드의 작업에 대한 정확한 시간은 모두가 모른다는 것이다.

실행결과

2024-07-18 17:21:49.452 [     main] start
2024-07-18 17:21:49.454 [ thread-1] 작업 시작
2024-07-18 17:21:49.454 [     main] main 스레드 sleep()
2024-07-18 17:21:49.454 [ thread-2] 작업 시작
2024-07-18 17:21:51.478 [ thread-2] 작업 완료 result = 3775
2024-07-18 17:21:51.478 [ thread-1] 작업 완료 result = 1275
2024-07-18 17:21:52.460 [     main] main 스레드 깨어남
2024-07-18 17:21:52.462 [     main] task1.result = 1275
2024-07-18 17:21:52.463 [     main] task2.result = 3775
2024-07-18 17:21:52.464 [     main] task1 + task2 = 5050
2024-07-18 17:21:52.464 [     main] end

 

원하는 결과는 나왔지만, 얼마나 자고 있어야 하는지 전혀 모른다. 지금이야 명시적으로 run()안에서 2초의 대기상태라는 명확한 시간이 존재하지만, 실제 서비스라고 생각해보면 절대 알 수 없다.

 

그래서 이럴때 join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있다.

 

join() 사용

다음 코드를 보자. 

JoinMainV2

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV2 {
    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("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

  • thread1.start(), thread2.start()를 호출한 후 thread1.join(), thread2.join()을 호출한다.
  • thread1.join()을 호출하는 순간 호출한 스레드(main)은 thread1이 작업이 끝날때까지 무기한 기다린다. (WAITING)
  • thread1이 작업이 다 끝나고 다시 RUNNABLE 상태가 된 main은 다음 코드로 간다. 다음 코드가 thread2.join()이므로 또 무기한 기다리는 상태가 된다 (WAITING) 물론, thread1thread2가 소요되는 시간이 거의 비슷하기 때문에 아마 thread1이 작업이 끝남과 동시에 thread2도 작업이 끝날것같다. 그렇게 되면 thread1.join()이 풀림과 동시에 거의 바로 thread2.join()도 풀려서 바로 다음 코드로 진행된다.
  • thread2가 작업이 다 끝나고 다시 mainRUNNABLE 상태가 되며 다음 코드를 진행한다.

실행결과

2024-07-19 09:47:06.821 [     main] start
2024-07-19 09:47:06.826 [ thread-2] 작업 시작
2024-07-19 09:47:06.826 [     main] join() - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:47:06.826 [ thread-1] 작업 시작
2024-07-19 09:47:08.835 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:47:08.835 [ thread-2] 작업 완료 result = 3775
2024-07-19 09:47:08.835 [     main] main 스레드 대기 완료
2024-07-19 09:47:08.836 [     main] task1.result = 1275
2024-07-19 09:47:08.836 [     main] task2.result = 3775
2024-07-19 09:47:08.836 [     main] task1 + task2 = 5050
2024-07-19 09:47:08.836 [     main] end

이렇게 특정 스레드를 `무기한` 기다리는 WAITING 상태로 만드는 방법이 join()이라고 할 수 있다. 하지만 join()의 단점은 정말 무기한 기다리기 때문에, 죽을때까지 기다리기만 할수도 있다. 예외 상황이 발생하면. 그런 단점을 해결하기 위해 특정 시간만큼만 대기할 수 있다. 

 

join(int millis) - 특정 시간 만큼만 대기

JoinMainV3

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

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(500) - main 스레드가 thread1, thread2 종료까지 대기");
        thread1.join(500);
        thread2.join(500);
        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("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

이렇게 join(500)으로 시간을 밀리초로 넘기면 이 넘긴 시간만큼만 기다리게 된다. 실행해보자. 아마 각 스레드는 2초정도의 시간이 필요하기 때문에 결과를 가져올 수 없을것이다. 

실행결과

2024-07-19 09:48:40.893 [     main] start
2024-07-19 09:48:40.897 [     main] join(500) - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:48:40.897 [ thread-2] 작업 시작
2024-07-19 09:48:40.897 [ thread-1] 작업 시작
2024-07-19 09:48:41.907 [     main] main 스레드 대기 완료
2024-07-19 09:48:41.914 [     main] task1.result = 0
2024-07-19 09:48:41.914 [     main] task2.result = 0
2024-07-19 09:48:41.914 [     main] task1 + task2 = 0
2024-07-19 09:48:41.915 [     main] end
2024-07-19 09:48:42.901 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:48:42.901 [ thread-2] 작업 완료 result = 3775

 

당연히 이때 main 스레드의 상태는 TIMED_WAITING 상태가 된다. 무기한 기다리는 상태가 아니라 특정 시간만큼만 기다리는 상태이기 때문이다.

 

정리

기본적으로 스레드는 어떤 상태들이 존재하고 생명 주기가 어떻게 되는지 알아보았다. 그리고 join()을 사용해서 다른 스레드의 작업이 끝나는 것을 기다려야 하는 경우 무기한 기다리는 방법과 특정 시간만큼만 기다리는 방법을 알아보았다. 이 Xxx.join()을 마주한 스레드는 Xxx가 작업이 다 끝날때까지 그 코드라인에서 기다리고 다음 라인으로 넘어가지 않는다. 다음 포스팅에서는 좀 더 깊이 있는 스레드 제어와 생명 주기에 대해 알아보자.

 

728x90
반응형
LIST

+ Recent posts