Java

[동시성 톺아보기] Java Thread 주요 관리 API (sleep(), join(), interrupt())

Lea Hwang 2024. 3. 25. 21:09

Java Thread의 주요 관리 API인 sleep(), join(), interrupt()는 스레드의 생명주기를 관리하고 동기화하는 데 중요한 역할을 합니다.  이번 글에서는 세 메서드의 특징과 동작방식에 대해 알아보도록 하겠습니다.

 

sleep()

- 현재 스레드를 지정된 시간 동안 일시 정지시킨 후, 그 시간이 경과하면 스레드를 실행 대기 상태(RUNNABLE)로 전환합니다.

- JVM이 직접 처리할 수 없어 네이티브 메서드와 시스템 호출을 통해 커널 모드에서 수행되며 작업이 완료된 후에 유저 모드로 복귀합니다.

- 모니터 락이나 다른 자원을 해제하지 않고 스레드의 실행만 중단시키므로, 이를 사용하는 동안 데드락이 발생할 위험이 있습니다.

 

API 및 예외처리

public static native void sleep(long millis) throws InterruptedException

현재 스레드를 지정한 밀리초(1,000밀리초가 1초) 동안 수면 상태로 만듭니다.

이 과정에서 스레드는 대기 상태에 머물게 되고, 지정된 시간이 지나면 실행 대기 상태(RUNNABLE)로 전환됩니다.

public static void sleep(long millis, int nanos) throws InterruptedException

스레드를 밀리초와 나노초 단위로 지정된 시간 동안 수면 상태로 만듭니다.

밀리초 값과 나노초 값을 더해 정확한 대기 시간을 설정할 수 있습니다.

 

예외 처리:

인자 값으로 음수를 허용하지 않기 때문에 만약 millis 또는 nanos 값이 음수로 지정면, IllegalArgumentException이 발생합니다. 

 

InterruptedException:

sleep() 동안 스레드가 인터럽트 되면, InterruptedException이 발생하고 스레드는 수면 상태에서 깨어나 실행 대기 상태로 전환됩니다. 이 상태에서 스레드는 OS 스케줄러가 다시 CPU를 할당할 때까지 실행을 재개하기를 기다립니다.

 

작동 방식

1. 정상적인 경우

지정된 시간이 지나면 실행대기상태(RUNNABLE)로 전환됩니다.

public class SleepExample {
   public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            System.out.println("스레드 1이 실행 중이며, 3초간 잠듭니다.");
            
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                System.out.println("스레드 1이 잠자는 동안 중단되었습니다.");
            }
            System.out.println("스레드 1이 깨어났으며, 다시 실행 대기 상태입니다.");
        });

        thread1.start();
    }
}

/**
스레드 1이 실행 중이며, 3초간 잠듭니다.
스레드 1이 깨어났으며, 다시 실행 대기 상태입니다.
 */

 

2. Interrupt 발생시

public class InterruptExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println("스레드 1이 잠에 듭니다.");
                Thread.sleep(3000);
                System.out.println("스레드 1이 깨어났습니다.");
            } catch (InterruptedException e) {
                System.out.println("스레드 1이 잠자는 동안 중단되었습니다.");
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
                System.out.println("스레드 2가 스레드 1을 중단시킵니다.");
                thread1.interrupt(); 
            } catch (InterruptedException e) {
                System.out.println("스레드 2가 대기 중에 중단되었습니다.");
            }
        });

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

/**
스레드 1이 잠에 듭니다.
스레드 2가 스레드 1을 중단시킵니다.
스레드 1이 잠자는 동안 중단되었습니다.
 */

 

스레드 1이 수면 상태로 전환되어 일시 정지하고, 이때 스레드2가 실행 상태로 컨텍스트 스위칭됩니다.

스레드2는 스레드1에 인터럽트를 발생시켜 수면을 중단시키고 실행 대기 상태로 만듭니다.

스레드1이 실행 상태로 돌아오면, InterruptedException을 처리합니다.

 

sleep(0)과 sleep(n) 차이

스레드에게 CPU를 명확하게 양보하고 싶다면 sleep(0) 대신 sleep(n)을 사용하는 것이 더 효과적입니다. 

 

sleep(0)을 호출하면 Java 스레드는 다음과 같이 동작합니다:

1. 스레드가 커널 모드로 전환합니다.
2. 스케줄러는 현재 스레드와 같은 우선순위를 가진 다른 스레드가 실행 대기 상태에 있는지 확인합니다.
3. 만약 같은 우선순위의 실행 대기 상태 스레드가 있다면, 스케줄러는 그 스레드에게 CPU를 할당하고, 컨텍스트 스위칭이 일어나면서 현재 스레드는 대기 상태로 전환됩니다.
4. 반대로 동일한 우선순위의 실행 대기 상태 스레드가 없다면, 컨텍스트 스위칭 없이 현재 스레드는 계속 CPU를 사용하며 실행 상태로 남아 있습니다.

 

sleep(n)을 호출하면 Java 스레드는 다음과 같이 동작합니다: 

1. 스레드가 커널 모드로 전환합니다.
2. 이때 스케줄러는 현재 스레드를 일정 시간(n) 동안 대기 상태로 설정합니다.
3. 대기 시간 동안 스케줄러는 다른 스레드에게 CPU를 할당합니다.
4. 이로 인해 현재 스레드가 대기 상태에 있을 때 다른 스레드로의 컨텍스트 스위칭이 일어납니다.

 

정리

1. 현재 실행 중인 스레드를 운영체제의 스케줄러가 관리하는 대기 상태로 전환시킵니다. 이 동안 스레드는 실행을 멈추고, 지정된 시간(밀리초 단위) 동안 작업을 중지합니다. 
2. 지정된 수면 시간이 경과하면 스레드는 자동으로 실행 대기 상태로 변경됩니다. 이 상태는 스케줄러가 스레드에 CPU 사용을 재할당할 준비가 되었음을 의미하지만, 즉시 실행 상태가 되는 것은 아닙니다. 
3. CPU가 다시 할당되면, 스레드는 중단되었던 지점부터 작업을 재개합니다.
4. 스레드가 동기화된 블록이나 메서드 안에서 sleep() 상태에 들어갈 경우, 해당 스레드는 자신이 보유한 모든 모니터 락을 잃지 않고 계속 유지합니다. 이는 다른 스레드들이 동기화된 블록이나 메서드에 진입하는 것을 방지합니다.
5. 반대로, wait() 메서드는 스레드가 보유한 모든 락을 반납하고, 다른 스레드가 동기화된 영역에 접근할 수 있도록 합니다.
6. Thread.sleep() 상태인 스레드에 interrupt()가 호출되면, 스레드는 대기 상태에서 해제되고 실행상태로 전환되어 즉시 InterruptedException을 던집니다. 
7. Thread.sleep()의 정확성은 OS의 스케줄러와 시스템 성능에 의존적입니다. 과부하된 시스템이나 기타 스케줄링 지연으로 인해 지정한 수면 시간과 실제 대기 시간에 차이가 발생할 수 있습니다.

 


join()

- 한 스레드가 다른 스레드의 작업 종료를 기다리게 하여 스레드 간 실행 순서를 제어하거나 순차적인 작업 흐름을 구성할 때 사용됩니다.

- T1 스레드 내에서 T2.join()을 호출한다는 것은 T1이 T2 스레드의 작업이 완료될 때까지 실행을 중지하고 대기한다는 의미입니다.

- Object 클래스의 wait() 메서드를 내부적으로 활용하여 커널 모드에서 실행되며, 대기 중인 스레드는 wait()을 통해 대기 상태에 들어가고 해당 스레드의 작업이 종료되면 notify()에 의해 깨어나 실행 대기 상태로 전환됩니다.

 

API 및 예외처리

public final synchronized void join(final long millis)
    throws InterruptedException {
        if (millis > 0) {
            if (isAlive()) {
                final long startTime = System.nanoTime();
                long delay = millis;
                do {
                    wait(delay);
                } while (isAlive() && (delay = millis -
                        TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime)) > 0);
            }
        } else if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            throw new IllegalArgumentException("timeout value is negative");
        }
    }

 

- 지정된 시간(밀리초) 동안 다른 스레드의 종료를 기다리도록 합니다.
- 양수인 밀리초로 설정된 경우, 현재 스레드는 대상 스레드가 종료될 때까지 지정된 시간 동안 대기합니다. 만약 대상 스레드가 실행 중이라면, 대기 시간은 실시간으로 조정되어 정확한 대기를 보장합니다.
- 밀리초가 0일 경우, 대상 스레드가 종료될 때까지 현재 스레드는 무한히 대기합니다. 
- 밀리초가 음수일 경우, IllegalArgumentException을 발생시킵니다
- synchronized 키워드로 동기화되어 있어, 한 시점에 하나의 스레드만 메서드를 실행할 수 있으며, 다른 스레드의 종료를 안전하게 기다립니다.

 

작동 방식

join()

public class JoinExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            try {
                System.out.println("스레드가 3초 후에 작동합니다.");
                Thread.sleep(5000);
                System.out.println("스레드 작동 완료.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread.start();

        System.out.println("메인 스레드가 thread 의 완료를 기다리며 대기합니다.");

        try {
            thread.join(); 
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println("다시 메인 스레드를 실행합니다.");
    }
}

/**
 * 메인 스레드가 thread 의 완료를 기다리며 대기합니다.
 * 스레드가 3초 후에 작동합니다.
 * 스레드 작동 완료.
 * 다시 메인 스레드를 실행합니다.
 */

join()은 이미 스레드의 종료를 기다리는 로직을 내부적으로 동기화 처리를 해 줍니다. 따라서 직접 synchronized 블록이나 락 객체를 사용하지 않고도 스레드가 다른 스레드의 종료를 기다리게 만들 수 있습니다.

 

wait()notify()

public class WaitNotifyExample {
    public static void main(String[] args) throws InterruptedException {
        final Object lock = new Object();
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("스레드 1 실행 중...");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    System.out.println("스레드 1 대기 중 인터럽트 발생.");
                }
                System.out.println("스레드 1 다시 실행 중, 작업 완료됨.");
            }
        });

        thread1.start();
        
        Thread.sleep(1000);

        synchronized (lock) {
            lock.notify(); 
            System.out.println("메인 스레드가 스레드 1에게 작업을 계속하라고 알림.");
        }

        System.out.println("메인 스레드가 스레드 1의 종료를 기다립니다.");
        thread1.join(); 
        System.out.println("스레드 1이 종료됐으므로 메인 스레드 작업을 재개합니다.");
    }
}

/**
 * 스레드 1 실행 중...
 * 메인 스레드가 스레드 1에게 작업을 계속하라고 알림.
 * 메인 스레드가 스레드 1의 종료를 기다립니다.
 * 스레드 1 다시 실행 중, 작업 완료됨.
 * 스레드 1이 종료됐으므로 메인 스레드 작업을 재개합니다.
 */

- thread1은 시작되어 wait()을 호출하여 lock 객체에 대해 대기 상태로 들어갑니다.
- 메인 스레드는 1초를 대기한 후, lock 객체의 모니터 락을 획득하고 notify()를 호출하여 thread1을 대기 상태에서 깨웁니다.
- thread1은 작업을 마치고 종료됩니다.
- 메인 스레드는 thread1.join() 호출을 통해 thread1이 종료될 때까지 대기합니다.
- thread1의 작업이 끝나면 메인 스레드는 대기를 멈추고 그다음 작업을 계속합니다.

 

interrupt()

public class InterruptExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println("스레드 1이 대기 상태로 들어갑니다.");
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                System.out.println("스레드 1이 인터럽트 되어 InterruptedException을 처리합니다.");
            }
        });

        Thread thread2 = new Thread(() -> {
            while (!Thread.interrupted()) {
                // ...
            }
            System.out.println("스레드 2도 인터럽트 되어 종료합니다.");
        });

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

        try {
            Thread.sleep(1000); 
        } catch (InterruptedException e) {
            System.out.println("메인 스레드가 인터럽트 되었습니다.");
        }

        System.out.println("메인 스레드가 스레드 1과 스레드 2에게 인터럽트를 보냅니다.");
        thread1.interrupt(); 
        thread2.interrupt(); 
    }
}

/**
 * 스레드 1이 대기 상태로 들어갑니다.
 * 메인 스레드가 스레드 1과 스레드 2에게 인터럽트를 보냅니다.
 * 스레드 1이 인터럽트 되어 InterruptedException을 처리합니다.
 * 스레드 2도 인터럽트 되어 종료합니다.
 */

 

정리

1. join()이 호출되면, 이를 호출한 스레드(main thread)는 대기 상태로 들어가며, 대상 스레드(thread1)가 작업을 완료할 때까지 기다립니다.
2. 대상 스레드의 작업이 종료되면, join()을 호출한 스레드는 실행 대기 상태로 전환되어 CPU 할당을 기다립니다.
3. CPU가 할당되면, join()을 호출한 스레드는 중단된 위치에서 실행을 재개합니다.
4. 만약 여러 스레드에 대해 join()이 호출된 경우, 모든 대상 스레드들이 종료될 때까지 호출한 스레드는 이 대기,실행 재개 과정을 반복합니다.
5. join()을 호출한 스레드가 인터럽트 되면, 그 스레드는 대기 상태에서 벗어나 즉시 실행 상태로 전환되고, 발생한 InterruptedException을 처리합니다.


interrupt() 

- 자바 스레드에 인터럽트 신호를 보내어 실행 흐름을 방해하는 기능입니다.

- 이를 통해 스레드는 현재 작업을 중단하고 인터럽트 이벤트를 처리하게 됩니다.

- 스레드는 interrupted 상태를 통해 인터럽트 발생 여부를 파악할 수 있으며, 이 상태는 기본적으로 false입니다. 인터럽트 메커니즘은 스레드의 중지, 작업 취소, 종료 등에 사용될 수 있고, 스레드는 다른 스레드뿐만 아니라 자기 자신을 인터럽트 할 수도 있습니다. interrupt()를 여러 번 호출할 수 있으며, 호출할 때마다 해당 스레드의 인터럽트 상태는 true로 설정됩니다.

 

인터럽트 상태 확인

static boolean interrupted()

현재 스레드의 인터럽트 상태를 반환하고 인터럽트 상태를 초기화합니다. 이는 한 번의 인터럽트 체크 이후 인터럽트 상태를 리셋하는 역할을 합니다. 상태가 true에서 false로 초기화되기 때문에, 인터럽트 상태를 지속적으로 확인 할 필요가 있을 경우에는 isInterrupted()를 사용하는 것이 적절합니다.

public class Interrupted {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while (!Thread.interrupted()) {
                System.out.println("스레드가 작동 중입니다.");
            }
            System.out.println("스레드가 인터럽트 되었습니다.");
            System.out.println("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        });

        thread.start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread.interrupt();
    }
}

/**
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * 스레드가 작동 중입니다.
 * ...
 * 스레드가 인터럽트 되었습니다.
 * 인터럽트 상태: false
 */

 

boolean isInterrupted()

이 인스턴스 메서드는 호출된 스레드의 인터럽트 상태를 반환하지만, 인터럽트 상태를 초기화하지는 않습니다. 이 메서드는 인터럽트 상태를 확인할 목적으로 사용할 때, 인터럽트 상태를 유지하고자 할 때에 적합합니다.

public class IsInterrupted {
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 작동 중입니다.");
            System.out.println("스레드 1 인터럽트 상태: " + Thread.currentThread().isInterrupted());
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드 2가 스레드 1을 인터럽트 합니다.");
            thread1.interrupt();
            System.out.println("스레드 2 인터럽트 상태: " + Thread.currentThread().isInterrupted());
        });

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

/**
 * 스레드 2가 스레드 1을 인터럽트 합니다.
 * 스레드1 작동 중입니다.
 * 스레드 2 인터럽트 상태: false
 * 스레드 1 인터럽트 상태: true
 */

 

InterruptedException

이 예외가 발생하면, 스레드의 인터럽트 상태는 자동으로 초기화되므로 (false가 됨), 인터럽트 상태를 참조하는 다른 코드가 있다면 예외 처리에서 스레드에 다시 interrupt()를 호출하여 인터럽트 상태를 true로 설정해야 할 수도 있습니다. 

 

 

결론

- sleep(): 스레드가 지정된 시간 동안 일시 정지되며, 이 기간 후 실행 대기 상태로 전환됩니다.
- join(): 스레드가 다른 스레드의 종료를 기다리며, 해당 스레드의 작업이 완료되면 대기를 멈추고 계속 실행합니다.
- interrupt(): 인터럽트 신호를 보내 스레드의 실행 흐름을 중단시킬 수 있으며, 스레드는 인터럽트 이벤트를 처리하도록 강제됩니다.


참고:

[Java의 동시성 #1] Threads (생성, 사용, sleep, interrupt, join)
https://seunghyunson.tistory.com/27
[Java] 쓰레드 5 - 쓰레드의 실행제어(sleep, interrupt, join, yield)
https://cano721.tistory.com/165
[Java] 스레드(Thread) 개념 정리 - sleep(), wait(), join()
https://khys.tistory.com/15