[JAVA] 스레드 제어와 생명 주기1

1. 스레드 기본 정보

Thread 클래스는 스레드를 생성하고 관리하는 기능을 제공한다. Thread 클래스가 제공하는 정보들을 확인해보자.

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 = " + 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());
    }
}

실행 결과

20:51:15.464 [     main] mainThread = Thread[#1,main,5,main]
20:51:15.476 [     main] mainThread.threadId()=1
20:51:15.476 [     main] mainThread.getName()=main
20:51:15.487 [     main] mainThread.getPriority()=5
20:51:15.487 [     main] mainThread.getThreadGroup()=java.lang.ThreadGroup[name=main,maxpri=10]
20:51:15.488 [     main] mainThread.getState()=RUNNABLE
20:51:15.489 [     main] myThread = Thread[#21,myThread,5,main]
20:51:15.490 [     main] myThread.threadId()=21
20:51:15.490 [     main] myThread.getName()=myThread
20:51:15.490 [     main] myThread.getPriority()=5
20:51:15.490 [     main] myThread.getThreadGroup()=java.lang.ThreadGroup[name=main,maxpri=10]
20:51:15.491 [     main] myThread.getState()=NEW
  1. 스레드 생성
    • 스레드를 생성할 때는 실행할 Runnable 인터페이스 구현체와 스레드 이름을 전달할 수 있다
  2. 스레드 객체 정보
    • Thread 클래스의 toString() 메서드는 [ID, 이름, 우선순위, 그룹]을 문자열로 반환한다
    • Thread[#21,myThread,5,main]
  3. 스레드 ID
    • 이 ID는 JVM 내에서 각 스레드에 대해 유일하다
    • 스레드가 생성될 때 할당되며, 직접 지정 불가
  4. 스레드 이름
    • 지정해주지 않으면 임의의 이름으로 생성됨
    • 중복 가능
  5. 스레드 우선 순위
    • 우선순위 1(가장 낮음)에서 10(가장 높음)까지 설정 가능
    • 우선 순위는 스레드 스케줄러가 어떤 스레드를 우선 실행할 지 결정하는 데 사용함
  6. 스레드 그룹
    • 스레드를 그룹화하여 관리할 수 있는 기능을 제공함
    • 기본적으로 모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속하게 됨
  7. 스레드 상태 (중요)
    • NEW: 스레드가 아직 시작되지 않은 상태
    • RUNNABLE: 스레드가 실행 중이거나, 실행 준비가 된 상태
    • BLOCKED: 스레드가 동기화 락을 기다리는 상태
    • WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태
    • TIMED_WAITING: 스레드가 일정 시간 동안 기다리는 상태
    • TERMINATED: 스레드가 실행을 마친 상태

2. 스레드의 생명 주기

img.png

  1. New (새로운 상태)
    • 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태
  2. Runnable (실행 가능 상태)
    • 이 상태에서 스레드는 실제 CPU에서 실행될 수 있다
    • start() 메서드가 호출되면 스레드는 이 상태로 들어감
    • 운영체제의 스케줄러가 각 스레드에 CPU 시간을 할당하여 실행하기 때문에, Runnable 상태에 있는 스레드는 스케줄러의 실행 대기열에 포함되어 있다가 차례로 CPU에서 실행됨 (대기 중이든, 실행 중이든 자바에서 둘을 구분할 수 는 없음!)
  3. Blocked (차단 상태)
    • 스레드가 다른 스레드에 의해 동기화 락을 얻기 위해 기다리는 상태
    • synchronized (lock) {...} 코드 블록에 진입하려고 할 때, 다른 스레드가 이미 lock의 락을 가지고 있는 경우 이 상태에 들어감
  4. Waiting (대기 상태)
    • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태
    • wait(), join() 메서드가 호출될 때 이 상태가 된다
    • 스레드는 다른 스레드가 notify()/notifyAll() 메서드를 호출하거나, join()이 완료될 때까지 기다림
  5. Timed Waiting (시간 제한 대기 상태)
    • 스레드가 특정 시간 동안 다른 스레드의 작업이 완료되기를 기다리는 상태
    • sleep(long millis), wait(long timeout), join(long millis) 메서드가 호출될 때 이 상태가 됨
    • 주어진 시간이 경과하거나, 다른 스레드가 해당 스레드를 깨우면 이 상태에서 벗어남
    • 예: Thread.sleep(1000); -> 1000밀리초 = 1초 후 깨어남
  6. Terminated (종료 상태)
    • 스레드가 정상 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 들어감
    • 스레드는 한 번 종료되면 다시 시작할 수 없다
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()); // TIMED_WAITING
        Thread.sleep(4000);
        log("myThread.state5 = " + thread.getState()); // TERMINATED


    }

    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());
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        }
    }
}

img.png

3. 체크 예외 재정의

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

그 이유는 아래와 같다

 public interface Runnable {
      void run();
}

자바에서 메서드를 재정의할 때, 재정의 메서드가 지켜야하는 예외 관련 규칙

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

위 규칙을 이해하고 다시 문제를 보면, Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않는다.

따라서 해당 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없다.

자바가 이런 제약을 두는 이유?

  1. 부모 자식 간 예외 처리 일관성
    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 를 반환하는데, 그 자식이 전혀 다른 예외를 반환한다면 클라이언트 는 해당 예외를 잡을 수 없다. 이것은 확실하게 모든 예외를 체크하는 체크 예외의 규약에 맞지 않는다.
  1. 안전한 예외 처리 체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try-catch 블록 내에서 처리하게 된다. 이는 예외 발생 시 예외가 적절히 처리되지 않아서 프로그램이 비정상 종료되는 상황을 방지할 수 있다. 특히 멀티스레딩 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다.

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

3. sleep()

  • Thread.sleep(long millis)을 통해 스레드가 특정 시간동안 TIMED_WAITING 상태로 기다리게할 수 있다
  • 해당 코드는 InterruptedException 체크 예외를 발생시 킨다.
void run() {
      try {
          Thread.sleep(3000);
      } catch (InterruptedException e) {
          throw new RuntimeException(e);
      }
}

4. join()

main 스레드를 부모로 하는 thread-1 , thread-2이 있다. 이때 만약 thread-1 , thread-2 가 종료된 다음에 main 스레드를 가장 마지막에 종료하려면 어떻게 해야할까?

4.1 join() 없이 실행할 경우

package thread.control.join;

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("작업 완료");
        }
    }
}

  • 실행 결과를 보면 기대와 다르게 task1.result , task2.result 모두 0으로 나온다. 그리고 `task1 +
  • task2 의 결과도 0` 으로 나온다. 계산이 전혀 진행되지 않았다. 이 부분을 자세히 분석해보자.

img.png

  • main 스레드는 두 스레드를 시작한 다음에 바로 task1.result , task2.result 를 통해 인스턴스에 있는 결과 값을 조회한다. 참고로 main 스레드가 실행한 start() 메서드는 스레드의 실행이 끝날 때 까지 기다리 지 않는다! 다른 스레드를 실행만 해두고, 자신의 다음 코드를 실행할 뿐이다!

img.png

  • 이때 main 스레드는 이미 자신의 코드를 모두 실행하고 종료된 상태이다. task1 인스턴스의 result 에는 1275 가 담겨있고, task2 인스턴스의 result 에는 3775 가 담겨있다.

4.2 join()으로 해결하기

package thread.control.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("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 = 0;

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

img.png

  • main 스레드에서 다음 코드를 실행하게 되면 main 스레드는 thread-1 , thread-2 가 종료될 때 까지 기다린다. 이때 main 스레드는 WAITING 상태가 된다.
  • 이후에 thread-1 이 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동한다.
  • 마찬가지로 thread-2.join() 코드 안에서 기다리다가 해당 스레드가 종료되면 main 스레드는 RUNNABLE 상태가 되어 다음 코드로 이동한다

5. this의 비밀

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

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

6. 어떻게?

아래 궁금증은 숙제로 남겨 놓자

  • while(thread1.getState()==”TERMINATED”)로 기다리는 것은 CPU를 계속 소모하는데
  • join()으로 기다리는 것은 무엇이 다를까?