Java

[Java] 쓰레드의 상태

SN.Flower 2023. 10. 5.


쓰레드의 상태 값

Thread 클래스 내에 정의되어있는 State

 

쓰레드는 객체가 생성, 실행, 종료되기까지 다양한 상태를 가진다. 각 쓰레드의 상태는 Thread.State인 enum 타입으로 정의돼 있으며, Thread의 인스턴트 메서드인 getState()로 가져올 수 있다. 이 메서드는 쓰레드의 상태를 Thread.State 타입에 저장된 문자열 상숫값중 하나로 반환한다.

 

쓰레드의 6가지 상태

쓰레드가 가질 수 있는 6가지 상태들의 관계

 

쓰레드가 가질 수 있는 상태는 6가지가 있으며 각 상태의 관계는 위 그림과 같다.

 

NEW, RUNNABLE, TERMINATED

public class NewThread {
    public static void main(String[] args) {
        // 쓰레드 상태 저장
        Thread.State state;

        // 1. Thread 객체 생성(NEW)
        Thread thread = new Thread() {
            @Override
            public void run() {
                // 일정시간 지연
                for (long i = 0; i < 100000000L; i++) {
                }
            }
        };
        state = thread.getState();
        System.out.println("Thread state = " + state);

        // 2. Thread 시작(RUNNABLE)
        thread.start();
        state = thread.getState();
        System.out.println("Thread state = " + state);

        // 3. Thread 종료(TERMINATED)
        try {
            // thread가 완료될 때 까지 main쓰레드가 기다림
            thread.join();
        } catch (InterruptedException e) {
        }
        state = thread.getState();
        System.out.println("Thread state = " + state);
    }
}

코드 실행 결과
start() 메서드를 호출하면 JVM에서 run() 메서드를 호출한다.

 

처음 Thread 객체가 생성되면 NEW의 상태를 가진다. 이후 인스턴트 메서드인 start() 메서드를 호출하면 RUNNABLE 상태가 되며 run() 메서드가 실행된다. 이 상태에서는 실행과 실행 대기를 반복하면서 CPU를 다른 쓰레드들과 나눠서 사용한다. 이후 run() 메서드가 종료되면 TERMINATED 상태가 된다.

 

RUNNABLE 상태에서는 쓰레드 간의 동시성에 따라 실행과 실행 대기를 반복하는데, Thread의 정적 메서드인 yield()를 호출하면 다른 쓰레드에게 CPU 사용을 인위적으로 양보하고 자신은 실행 대기 상태로 전환할 수 있다.

 

이와 관련해서는 아래 코드를 보자.

class MyThread extends Thread {
    boolean yieldFlag;

    @Override
    public void run() {
        while(true) {
            if(yieldFlag) {
                Thread.yield();
            } else {
                System.out.println(getName() + " 실행");
                // 일정시간 지연
                for(long i = 0; i<  1000000000L ; i++) {
                }
            }
        }
    }
}

public class YieldThread {
    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        thread1.setName("thread1");
        thread1.yieldFlag = false;
        thread1.setDaemon(true);
        thread1.start();

        MyThread thread2 = new MyThread();
        thread2.setName("thread2");
        thread2.yieldFlag = true;
        thread2.setDaemon(true);
        thread2.start();

        // 6초 지연 (1초마다 한번씩 양보)
        for(int i = 0; i < 6; i++) {
            try { 
                Thread.sleep(1000); 
            } catch (InterruptedException e) {
            }
            thread1.yieldFlag =! thread1.yieldFlag;
            thread2.yieldFlag =! thread2.yieldFlag;
        }
    }
}

코드 실행 결과

 

위 코드는 6초동안 1초마다 Mythread 클래스의 인스턴스 변수인 yieldFlag의 boolean값을 바꿔 yield() 메서드를 번갈아 호출하며 다른 쓰레드에게 CPU를 양보하는 과정을 나타낸 것이다. 코드 실행 결과를 볼 때 실행되는 쓰레드가 총 6번 바뀌는 것을 볼 수 있다.

yield() 메서드로 CPU 사용을 양보하는 것은 딱 한 차례 양보하는 것으로, 자신의 차례가 돌아오면 다시 CPU를 사용할 수 있다.

 

TIMED_WAITING

RUNNABLE 상태에서 Thread의 정적 메서드인 sleep(long millis)를 호출하거나 인스턴스 메서드인 join(long millis)를 호출하면 TIMED_WAITING 상태가 된다. 여기서 sleep(long millis)와 join(long millis)의 의미를 명확히 구분해야 한다.

 

우선 Thread.sleep(long millis)이 메서드를 호출한 쓰레드를 일시정지하라는 의미이다. 따라서 호출한 쓰레드가 TIMED_WAITING 상태가 된다. 이때 일시정지 시간동안 CPU를 어떤 쓰레드가 사용하든 상관하지 않는다.

반면에 쓰레드 객체.join(long millis)특정 쓰레드 객체에게 일정 시간동안 CPU를 할당하라는 의미이다. 그리고 이 메서드를 호출한 쓰레드는 sleep(long millis)과 마찬가지로 TIMED_WAITING 상태가 된다.

 

TIMED_WAITING 상태에서 지정된 시간이 다 되거나 일시정지된 쓰레드 객체의 interrupt() 메서드가 호출되면 다시 RUNNABLE 상태가 된다. Thread.sleep(long millis)와 join(long millis)은 둘 다 필수적으로 InterruptedException을 처리해 줘야 하는데, interrupt() 메서드가 호출되면 이 예외가 발생해서 일시정지가 종료되는 것이다.

 

이제 코드를 통해 TIMED_WAITING 상태에 관해서 알아보자.

 

class MyThread extends Thread {
    @Override
    public void run() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            System.out.println(" -- sleep() 진행중 interrupt() 발생");
            // 일정 시간 지연
            for(long i = 0; i < 1000000000L; i++) {
            }
        }
    }
}

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

        MyThread myThread = new MyThread();
        myThread.start();

        //쓰레드 시작 준비시간
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.println("MyThread State = " + myThread.getState()); // TIMED_WAITING

        myThread.interrupt();
        // interrupt 준비시간
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.println("MyThread State = " + myThread.getState()); // RUNNABLE
    }
}

Thread.sleep(millis) 코드 실행 결과

 

위의 코드에서 MyThread 클래스는 run() 메서드의 시작과 동시에 Thread.sleep(3000)을 실행해서 3초동안 TIMED_WAITING 상태가 된다.

main() 메서드에서는 MyThread 객체를 실행 및 실행시켰으며, 실행한 지 0.1초 후에 쓰레드의 상태를 출력했다. 이어서 myThread 객체의 interrupt() 메서드를 호출하고 0.1초 뒤에 쓰레드의 상태를 출력했다.

코드 실행 결과를 보면 쓰레드 시작과 동시에 Thread.sleep() 메서드로 인해 TIMED_WAITING 상태가 되었고, interrupt() 메서드를 통해 RUNNABLE 상태로 바뀐 것을 확인할 수 있다.

 

여기서 살펴볼 부분은 쓰레드를 시작할 때와 interrupt() 메서드를 호출하고나서 출력하기까지 0.1초의 지연 시간을 두는 점이다. 이렇게 지연 시간을 두고 쓰레드의 상태를 출력한 이유는 JVM에서 상태가 변화되기까지의 시간이 걸리기때문이다.

 

쓰레드를 처음 시작할 때는 JVM이 CPU를 독립적으로 사용하기 위한 메모리 할당등의 준비 과정을 미리 거쳐야하고, 이 준비 과정이 끝나야 run() 메서드가 실행된다. 따라서 start() 메서드를 실행한 후 준비 과정이 끝나지 않은 상태에서 바로 쓰레드의 상태를 출력하면 RUNNUABLE 상태가 출력될 수도 있다.

 

interrupt() 메서드 호출이후도 마찬가지이다. interrupt() 메서드를 호출하면 자바 가상 머신은 InterruptedException 객체를 생성해서 해당 쓰레드의 catch(){} 블록에 전달하는 시간이 필요하다. 전달되기 전에 쓰레드의 상태를 출력하면 여전히 TIMED_WAITING상태로 출력이 될 수 있다. 또한, MyThread 클래스 내부에 for문을 통한 지연 시간이 임의로 지정한 0.1초보다 빨리 끝나면 쓰레드의 상태가 TERMINATED로 출력될 수도 있다.

 

class MyThread1 extends Thread {
    @Override
    public void run() {
        // 일정 시간 지연
        for(long i = 0; i < 1000000000L; i++) {
        }
    }
}

class MyThread2 extends Thread {
    MyThread1 myThread1;
    public MyThread2(MyThread1 myThread1) {
        this.myThread1 = myThread1;
    }

    @Override
    public void run() {
        try {
            myThread1.join(3000);
        } catch (InterruptedException e) {
            System.out.println(" -- join(...) 진행중 interrupt() 발생");
            // 일정 시간 지연
            for(long i = 0; i < 1000000000L; i++) {
            } 
        }
    }
}

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

        // 객체 생성
        MyThread1 myThread1 = new MyThread1();
        MyThread2 myThread2 = new MyThread2(myThread1);
        myThread1.start();
        myThread2.start();

        // 쓰레드 시작 준비 시간
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.println("MyThread1 State = " + myThread1.getState()); //RUNNABLE
        System.out.println("MyThread2 State = " + myThread2.getState()); //TIMED_WAITING

        // interrupt 준비 시간
        myThread2.interrupt();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.println("MyThread1 State = " + myThread1.getState()); //RUNNABLE
        System.out.println("MyThread2 State = " + myThread2.getState()); //RUNNABLE

    }
}

join(millis) 코드 실행 결과

 

위의 코드에서 MyThread1은 단순히 시간을 끌기 위한 반복문을 포함하고 있다. 그리고 MyThread2는 내부에 MyThread1 타입의 필드를 갖고 있으며, run() 메서드에서는 시작과 동시에 myThread1.join(3000)을 호출한다. 즉, myThread2 쓰레드를 실행하면 myThread1을 3초동안 먼저 실행하라는 의미이다.

이 시간동안 myThread2는 TIMED_WAITING 상태가 될 것이고, 3초가 지나거나 interrupt() 메서드가 호출되면 myThread2 또한 RUNNABLE 상태가 될 것이다.

main()메서드에서는 해당 과정을 수행했고, 코드 실행 결과도 예상한대로 나왔다.

 

BLOCKED

BLOCKED 상태는 동기화 메서드 또는 동기화 블록을 실행하고자 할 때 이미 다른 쓰레드가 해당 영역을 실행하고 있는 경우에 발생한다. 이렇게 해당 동기화 영역이 잠겨있을 때는 이미 실행하고 있는 쓰레드가 실행을 완료하고, 해당 동기화 영역의 열쇠를 반납할 때까지 기다려야 하는데, 이것이 바로 BLOCKED 상태이다.

 

여기서 유의할 점이 있는데, 예를 들어 동기화된 동일한 영역을 3개의 쓰레드(t1, t2, t3)이 동시에 실행하고, t1 -> t2 -> t3 순으로 동기화 영역에 실행 명령이 도착했다고 가정해보자.

 

당연히 제일 먼저 도착한 t1이 우선적으로 열쇠를 가지고 t2, t3은 BLOCKED 상태가 될 것이다. 그렇다면 t1의 실행이 끝난 후 다음 열쇠를 누가 가지느냐가 문제인데, 두번째로 도착한 t2가 열쇠를 가져가는 것이 아닌 t2와 t3가 다시 경쟁을 해서 먼저 동기화 영역에 실행 명령이 도착하는 쓰레드가 열쇠를 가지게 된다.

 

이제 코드를 통해 위의 경우가 실제로 적용되는지 알아보자.

class MyBlockTest {
    // 공유 객체
    MyClass mc = new MyClass();

    // 세 개의 쓰레드 필드 생성
    Thread t1 = new Thread("thread1") {
        public void run() {
            mc.syncMethod();
        }
    };

    Thread t2 = new Thread("thread2") {
        public void run() {
            mc.syncMethod();
        }
    };

    Thread t3 = new Thread("thread3") {
        public void run() {
            mc.syncMethod();
        }
    };

    void startAll() {
        t1.start();
        t2.start();
        t3.start();
    }

    class MyClass {
        synchronized void syncMethod() {
            // 쓰레드 시작 준비 시간
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
            }

            System.out.println("===="+Thread.currentThread().getName()+"====");
            System.out.println("thread1->" +t1.getState());
            System.out.println("thread2->" +t2.getState());
            System.out.println("thread3->" +t3.getState());
            // 일정 시간 지연
            for(long i = 0; i < 1000000000L; i++) {
            }
        }
    }
}

public class BlockedState {
    public static void main(String[] args) {
        MyBlockTest mbt = new MyBlockTest();
        mbt.startAll();
    }
}

BLOCKED 코드 실행 결과

 

MyBlockTest 클래스에서는 3개의 쓰레드가 익명 이너 클래스로 정의되어 있고, 각 쓰레드는 MyClass 객체 1개의 동기화 메서드인 syncMethod()를 호출하고 있다. main 메서드에서는 MyBlockTest 객체를 생성한 후에 startAll() 메서드를 호출해서 내부의 쓰레드를 실행했다.

 

이 경우 앞에서 말했던 예시와 같이 3개의 쓰레드가 같은 동기화 메서드를 실행하려고하므로 우선 제일 먼저 동기화 영역에 도착한 thread1이 실행되고, 나머지 2개의 쓰레드는 BLOCKED 상태가 된다. 이후 2번째로 도착했던 thread2가 무조건 열쇠를 가지게 되는 것이 아닌 다시 경쟁해서 먼저 동기화 영역에 도착한 thread3가 열쇠를 가질 수 있다는 것도 확인할 수 있다.

 

WAITING

마지막 일시정지 상태는 WAITING이다.

먼저, 일시정지하는 시간을 정하지않고 쓰레드 객체.join() 메서드를 호출하면 join된 쓰레드 객체의 실행이 완료되거나 interrupt() 메서드가 호출되기 전까지 WAITING 상태가 된다.

 

쓰레드의 인스턴스 메서드, 정확히는 Object 클래스의 인스턴스 메서드이지만 Thread 클래스도 Object의 자식 클래스이므로 쓰레드에 포함되는 wait() 메서드를 호출할 때도 해당 쓰레드는 WAITING 상태가 된다. wait() 메서드에 대해 주의해야할 점은 두가지가 있다.

 

첫번째는 wait() 메서드로 WAITING 상태가 된 쓰레드는 다른 쓰레드에서 notify()나 notifyAll()을 호출해야만 RUNNABLE 상태가 될 수 있다는 점이다. 쓰레드가 실행 중에 wait() 메서드를 만나면 그 자리에서 WAITING 상태가 되고, notify()나 notifyAll() 메서드가 호출되면 일시정지했던 다음줄부터 실행된다.

두번째는 wait(), notify(), notifyAll() 메서드는 반드시 동기화 블록에서만 사용할 수 있다는 점이다.

 

이제 코드를 통해서 WAITING 상태에 대한 예시를 알아보자.

class DataBox {
    boolean isEmpty = true;
    int data;

    synchronized void inputData(int data) {
        if(!isEmpty) {
            try {
                //WAITING
                wait();
            } catch (InterruptedException e) {
            }
        }
        this.data = data;
        isEmpty = false;
        System.out.println("입력데이터 : "+data);
        notify();
    }

    synchronized void outputData() {
        if(isEmpty) {
            try {
                // WAITING
                wait();
            } catch (InterruptedException e) {
            }
        }
        isEmpty = true;
        System.out.println("출력데이터 : "+data);
        notify();
    }
}

public class Waiting_WaitNotify {
    public static void main(String[] args) {
        DataBox dataBox = new DataBox();

        Thread t1 = new Thread() {
            public void run() {
                for(int i = 1; i <= 3; i++) {
                    dataBox.inputData(i);
                }
            };
        };

        Thread t2 = new Thread() {
            public void run() {
                for(int i = 1; i <= 3; i++) {
                    dataBox.outputData();
                }
            };
        };

        t1.start();
        t2.start();
    }
}

WAITING 코드 실행 결과

 

DataBox 클래스는 wait() 메서드와 notify() 메서드를 사용해서 쓰기와 읽기를 반복하는 기능을 가지고 있다.

main() 메서드에서 생성한 t1 쓰레드를 쓰기 쓰레드, t2 쓰레드를 읽기 쓰레드라고 해보자.

 

쓰기 쓰레드에서는 isEmpty 불리언값을 이용해 데이터가 비어 있는지를 검사하게된다. 만약 isEmpty가 true라면 데이터가 비어있다고 판단해서 data 값을 넣어준 후, isEmpty를 false로 바꾼 뒤 동기화 영역을 같이 사용하고 있는 읽기 쓰레드의 WAITING 상태를 notify() 메서드를 호출해서 RUNNABLE 상태로 바꿔준다. 이와 반대로 isEmpty가 false라면 쓰기를 완료한 데이터를 아직 읽기 쓰레드가 읽지 않은 것으로 간주해서 wait() 메서드를 실행하고 자신을 WAITING 상태로 만들어준다.

 

읽기 쓰레드는 쓰기 쓰레드와 반대로 isEmpty가 true라면 아직 읽을 데이터가 없다고 간주해서 자신이 WAITING 상태가 된다. 이와 반대로 isEmpty가 false라면 읽을 데이터가 있다는 것이므로 해당 데이터를 읽고 isEmpty를 true로 바꾼 뒤 쓰기 쓰레드를 RUNNABLE 상태로 바꾸기 위한 notify() 메서드를 호출한다.

 

위의 과정을 통해 두 쓰레드는 읽기와 쓰기를 반복하면서 하나씩 늘어나는 수를 올바르게 출력할 수 있게된다.


Reference

  • 자바 완전 정복 | 김동형 지음

댓글