티스토리 뷰

앞에서 봤든 JVM은 일대일 매핑 스레드 구조이고 사용자 영역 스레드에서 커널을 생성하면 커널 영역 스레드와 매핑되어서 작업이 처리되는것이다.

즉,

  • 자바 스레드는 JVM에서 User Thread를 생성할 때 시스템 콜을 통해서 커널에서 생성된 Kernel Thread 와 1:1 로 매핑이 되어 최종적으로 커널에서 관리된다.
  • JVM에서 스레드를 생성할 때 마다 커널에서 자바 스레드와 대응하는 커널 스레드를 생성한다.
  • 자바에서는 Platform Thread으로 정의되어 있다. 즉 OS 플랫폼에 따라 JVM이 사용자 스레드를 매핑하게 된다.
플랫폼 스레드
- 스레드에서는 일반적으로 운영체제에서 예약된 커널 스레드와 1:1로 매핑되는 플랫폼 스레드 생성을 지원한다.

플랫폼 스레드 - 커널 스레드 간 매핑 구조

Thread

JVM 에서 스레드에 대해 좀 더 알아보자. 사용자 수준 영역에서는 Thread도 결국 하나의 오브젝트로 관리된다.
따라서 Thread 클래스는 Java의 모든 객체가 되기위한 root인 Object를 상속받으며, 추가로 Runnable 인터페이스를 추가로 상속받는다.

Object

  • Object는 기본적으로 자바 진영에서 객체라면 갖춰야할 기본적인 메소드들을 들고 있으며 여기에는 멀티스레드를 강력히 지원하는 Lock 기능들 (Monitor) 들도 포함된다. (wait, notify, notifyAll, ...) 따라서 JVM Object는 어떤 객체든 동기화 블록의 Lock으로 Monitor 역할을 할 수 있다.
public class Object {
    equals, hashCode, toString
    ...
    
    @IntrinsicCandidate
    public final native void notify();
    
    @IntrinsicCandidate
    public final native void notifyAll();
    
    private final native void wait0(long timeoutMillis) throws InterruptedException;
    ...
}

(참고) @IntrinsicCandidate는 JVM이 성능을 극대화하기 위해, 이 메서드를 미리 최적화된 기계어(Assembly) 코드로 '바꿔치기'해서 실행한다는 표시다. JIT 컴파일러가 자바 바이트 코드로 변환하지 않고 CPU가 이해하는 기계어로 즉시 교체한다는 것.

 

Runnable

  • Runnable은 run() 메소드를 가진 인터페이스이다.
@FunctionalInterface
public interface Runnable {
    /**
     * Runs this operation.
     */
    void run();
}

 

실제 Thread 실행 예시

fun main() {
    val woonsikRunnable = Runnable { println("${Thread.currentThread().name}: hi!") }
    val woonsikWorkerThread = Thread(woonsikRunnable)

    val myFriendRunnable = Runnable { println("${Thread.currentThread().name}: hi!") }
    val myFriendWorkerThread = Thread(myFriendRunnable)

    woonsikWorkerThread.start()
    myFriendWorkerThread.start()
}

같은 코드지만 스레드는 OS 스케줄러에 의해 순서가 결정 되므로 실행 시점이 제각각인걸 확인할 수 있다.

 

스레드 실행 및 종료 start() & run()

사용자 영역 Thread를 start 하면 

private native void start0();

Thread 객체 내부에 start0 이라는 메소드가 호출된다. 이때 native라는 키워드가 존재한다.

이건 시스템 콜을 발생시켜서 커널 영역에 진입한다는 것을 의미한다. 즉 운영체제 영역에서 물리적 스레드가 생성되는 것이다.

 

이것을 통해 커널 영역 Thread가 생성되어 자바 스레드와 1 : 1 매핑이 이뤄진다.
CPU에 할당되기 전까지 대기상태에 있게 되고,
이후 스케줄러에 의해 CPU를 선점하면 Thread의 run() 메소드를 호출하게된다.
그러고 Runnable 구현체가 존재하면 해당 구현체의 Runnable run()을 invoke하게 된다.

 

따라서, Thread 내부 run()을 직접 호출 시 아래와 같은 경고가 발생한다.

This method is not intended to be invoked directly. 
If this thread is a platform thread created with a Runnable task 
then invoking this method will invoke the task's run method.

이 메서드는 직접 호출할 수 없습니다. 
이 스레드가 Runnable 작업으로 생성된 플랫폼 스레드인 경우, 
이 메서드를 호출하면 해당 작업의 run 메서드가 호출됩니다.

 

 

TMI - 플랫폼 일대일 스레드 구조의 한계와 Virtual Thread의 다대다 매핑 구조 설명

우리가 병렬 처리를 하기위해 스레드풀을 만들고 사용하는데 100, 200, 1000 개 씩 막 만들면 커널 스레드도 그만큼 생기게 되고, OS에 TCB 같은 1~2mb의 메모리 공간도 차지하게 된다. (플랫폼 스레드는 1:1 매핑이므로) 그리고, 만들어진 스레드가 I/O 작업을 하러 떠나면 매핑되어있던 커널 스레드도 그냥 쉬고있게 된다. (자원이 노는것)
이런 비효율을 개선하고자 Virtuval Thread의 다대다 매핑 구조가 나온것이다. 마치 책상은 10개, 직원은 100명, 직원이 화장실, 통화, 식사 등 Blocking 상태에 빠지면 유후 상태의 직원이 책상에 앉아서 일하는 구조이다.
이때 컨텍스트 스위칭 비용은 전적으로 OS가 아닌(커널 영역 진입하지 않음) JVM 내부(사용자 영역) 코드에서 이뤄지므로 훨씬 가볍고 빠르다. JVM에서는 실제 커널 스레드와 매핑되는 소수의 캐리어 스레드를 만들고 캐리어 스레드(Carrier Thread)를 이용하는 가상의 Thread인 Heap 메모리 공간에 상주하는 가벼운 객체 Virtual Thread를 만들어서 처리하게 된다.
그래서 Virtual Thread가 작업을 하다가 I/O를 당하면 JVM 내부에서 잠시 메모리 공간에 현재 Continuation 상태를 스냅샷 형태로 저장하고 다른 Virtual Thread를 Carrier Thread에 점유시키고 반복하게 되는 구조이다.

 

스레드의 생명주기와 상태 - state

자바의 Thread는 생성, 실행, 종료라는 3가지 동작만 이뤄지지만 각 동작에 따른 6가지의 스레드 상태가 존재한다. (!= OS 커널 스레드)

스레드의 상태는 언제나 단 한가지만 갖게 된다. 상태를 확인하기 위해서는 getState()를 통해 확인할 수 있다.

 

Thread State

  • NEW - 객체 생성 _ 스레드가 객체가 생성되었고, 아직 시작되지 않은 상태
  • RUNNABLE - 실행 대기 _ 실행중 or 실행 가능한 상태
  • WATING - 일시 정지 _ 대기 중인 스레드 상태, 다른 스레드가 특정 작업을 수행하기를 기다린다.
  • TIMED_WATING - 일시 정지 _ 대기시간이 지정된 스레드 상태, 다른 스레드가 특정 작업을 수행하기를 기다린다.
  • BLOCKED - 일시 정지 _ 모니터 락 (Lock)이 해제될 때까지 기다리며 차단된 상태
  • TERMINATED - 종료 _ 실행이 완료된 스레드 상태

스레드 상태 주기

 

 

스레드는 실행에서 어떤 대기냐에 따라 같은 대기여도 다른 상태값으로 표현된다. 이건 코드로 자세히 더 봐보자.

 

스레드 NEW

fun main() {
    val thread = Thread {
        // Thread 작업 내용
        println("Thread is running")
    }

    println("Thread 상태: ${thread.state}")
    // 만들기만 하고 start() 안함.
}

 

스레드 RUNNABLE

fun main() {
    val thread = Thread {
        for (i in 1..10) {
            if (i % 10 == 0) {
                println("Thread 상태: ${Thread.currentThread().state}")
            }
        }
    }
    thread.start()
}

 

스레드 WATING

fun main() {
    val obj = Object()

    val myThread = Thread {
        synchronized(obj) {
            var n = 0
            obj.wait()
            for (i in 1..5) {
                n++
            }
        }
    }

    myThread.start()
    Thread.sleep(100)
    println("Thread 상태: ${myThread.state}")
}

 

스레드 TIMED_WATING

fun main() {
    val myThread = Thread { Thread.sleep(1000L) }

    myThread.start()
    Thread.sleep(100)
    println("Thread 상태: ${myThread.state}")
}

 

스레드 BLOCKED

fun main() {
    val lock = ReentrantLock()

    val lockThread = Thread {
        synchronized(lock) {
            while (true) {
                //
            }
        }
    }

    val blockedThread = Thread {
        synchronized(lock) {
            // 이 블록에 도달하려고 시도할 때 blocked 상태가 됨
        }
    }

    lockThread.start()
    Thread.sleep(100)
    blockedThread.start()
    Thread.sleep(100)
    println("Thread 상태: ${blockedThread.state}")
}

 

스레드 TERMINATE

fun main() {
    val thread = Thread()

    thread.start()
    thread.join()

    println("Thread 상태: ${thread.state}")
}

 

이렇게 총 6가지 스레드의 상태가 존재하고 대기도 어떤 대기냐에 따라 다른 상태임을 볼 수 있다.


TMI Thread.sleep(0)의 동작

Thread.sleep(100)을걸면 100ms 동안 스레드가 TIMED_WATING으로 블로킹 되며 대기 상태에 빠지고 그동안 다른 스레드가 OS 스레드를 점유하게 된다. 근데 Thread.sleep(0)을 걸게되면 실제로 블로킹 되는건 아니 커널 영역에 진입 후,  현재 스레드와 동일하거나 더 높은 우선순위의 스레드가 있을 경우에 실행대기 상태인 스레드에게 CPU를 양보하고 컨텍스트 스위칭이 발생한다. 우선순위가 낮거나 대기 스레드가 없으면 자신의 작업을 이어서 진행하게 된다. 

sleep(100): RUNNING ➡ TIMED_WATING ➡ (100ms 후) ➡ RUNNABLE
sleep(0): RUNNING ➡ RUNNABLE (스케줄러 재경쟁)
반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday