Java Thread, JDK 뒤져보기

2023. 11. 14. 18:23자바

728x90
반응형

오늘 아침 지하철을 타고 오면서 문득 이런 궁금증이 떠올랐습니다. 

'cpu 사양에 따라 가용한 쓰레드의 개수는 한정적일텐데, 자바에서는 어떻게 쓰레드의 개수를 마구마구 늘릴 수 있는거지?'

 

사실 user thread니 kernel thread니 이런저런 이야기를 들어본 적은 있지만 정확하게 알고있지 않다고 생각해 한번 정리해보고자 합니다.


Thread의 종류

출처: 오라클 공식문서

 

 

Thread는 크게 'User Thread', 'kernel Thread', 'Hardware Thread' 세 유형으로 구분해서 설명할 수 있습니다.

 

 

User Thread


User Thread는 어플리케이션을 통해 생성되는 쓰레드를 의미합니다. 쉽게 생각해서 우리가 Java의 new Thread()를 호출하면 User Thread가 생성된다고 생각하면 됩니다.

 

 

 

하지만 OS를 조금만 공부를 해보면 알 수 있겠지만 애플리케이션 단에서 생성한 쓰레드는 I/O 작업을 직접할 수 없습니다. I/O 작업을 하기 위해서는 OS에서 대신 해줘야합니다. 다시 말해 User Thread는 kernel Thread와 바인딩한 뒤에 kernel Thread에게 I/O 작업을 맡겨야합니다. 그렇다고 해서 User Thread는 Kernel Thread에 무조건 1대1로 바인딩해서 작업하는 것은 아닙니다. 이러한 관계에 대해서는 잠시 후에 살펴보도록 하겠습니다.

 

Kernel Thread


Kernel Thread는 CPU에서 실제로 실행되는 작업들을 가진 Thread입니다. 위에서 언급한 I/O 작업 이외에도 다양한 작업들을 수행할 수 있습니다. 어쨌든 기억해둬야할 점은 CPU에서 실제로 수행되는 Thread는 Kernel Thread라는 점입니다.

 

 

Kernel Thread는 OS에서 제공하는 System call로 생성할 수 있습니다. 문제는 OS별로 Thread를 생성하는 System call이 다르다는 점입니다. Window의 경우에는 'CreateThread()'를 호출해 Thread를 생성하고, 유닉스나 리눅스에서는 'pthread_create()'를 호출해 Thread를 생성해야 합니다. 따라서 운영체제가 바뀔 때마다 Thread를 생성하기 위해 실제로 호출해주는 System call이 달라져야합니다. 하지만 다행스럽게도 Java를 사용하면 JVM이 OS에 맞게 알아서 System call을 호출해주기 때문에 우리는 이를 고려하지 않아도 됩니다. 

 

출처: https://vijayj.hashnode.dev/compilation-interpretation-and-nature-of-java

 

(엄마 MAC이 이상해...)

 

 

Kernel Thread는 위에서 언급한 것처럼 여러 형태의 작업을 수행할 수 있습니다. 또한 Kernel Thread는  Hardware Thread와 매핑이 되는 대상이기도 합니다. Hardware Thread의 개수는 CPU core의 개수에 의해 정해집니다. 따라서 Hardware Thread의 개수는 한정적이기 때문에 하나의 Hardware Thread는 여러 Kernel Thread와 매핑될 수 있습니다. 

 

 

하나의 Hardware Thread 안에서 여러 Kernel Thread가 있기 때문에 이들 간에 컨텍스트 스위칭이 발생할 수 있습니다. 컨텍스트 스위칭이 자주 발생하면 비용이 많이 발생하기 때문에 이를 유의해야합니다. 

 

 

 

Hardware Thread


Hardware Thread는 가상의 core에 해당하는 Thread입니다. 바로 와닿지는 않은 설명이라고 느끼실 수 있는데요. 일단 이해가 안된다면 실제 CPU core에 해당하는 Hardware Thread를 의미한다고 생각하고 넘어가죠. Hardware Thread는 자기 자신에게 할당된 여러 Kernel Thread를 컨텍스트 스위칭해가며 실행합니다.

 

Hyper Thread

하나의 CPU core 당 두 개의 Hardware Thread를 두는 것을 의미합니다. 왜 이렇게 사용하는지를 상황을 가정하여 설명해보겠습니다.

 

CPU core 당 Hardware Thread가 하나만 있는 상황을 가정해볼게요. Hardware Thread는 여러 Kernel Thread의 작업을 실제로 수행하게 됩니다. 만약 몇몇 Kernel Thread에 I/O 작업이 포함되어있다면 어떻게 될까요? OS를 조금 공부해보신 분들은 I/O 작업은 시간이 오래 소요된다는 사실을 알고 계실거예요. I/O 작업은 오래 걸리는데, 이 작업이 다 수행되는 것을 blocking한 채로 기다려야한다면 다른 kernel Thread는 계속 기다려야겠죠. CPU도 놀고 있고, 다른 Kernel Thread의 response time도 늦어지게 됩니다.

 

CPU는 IO 작업이 이뤄지는 동안 계속 쉬어야합니다.

 

따라서 이와 같은 상황에 대비해서 조금 더 Thread를 효율적으로 사용하고자 CPU core 하나 당 두 개 이상의 Hardware Thread를 두는 것이죠.

hyper Threading

 

그래서 I/O 작업을 진행할 때는 Context Switching을 해서 다른 Hardware Thread가 동작하게됩니다. 물론 위에서 보는 것처럼 hardware Thread 모두가 blocking되는 현상도 있을 수 있지만 중요한 점은 Hardware Thread를 조금 더 효율적으로 사용할 수 있다는 점입니다. I/O 작업을 진행하더라도 그리 다른 Hardware Thread가 동작하니 더 최대한 CPU 작업을 수행할 수 있도록 합니다.

 

 

정리하자면 Hyper Thread가 적용되지 않은 경우에는 CPU core 하나 당 Hardware Thread가 하나이고, Hyper Thread가 적용된 경우에는 CPI core 하나 당 두 개의 Hardware Thread가 생깁니다. Kernel Thread는 Hardware Thread를 가상의 core로 인식하고 매핑이 됩니다. 그렇기 때문에 OS 관점에서는 Hardware Thread는 실제 core는 아니지만 가상의 core로 여겨지는 것이죠.

 

 

현재 저희 프로젝트에서 사용하고 있는 AWS 인스턴스는 t4g.small입니다. 

처음 t4g.small을 할당받았을 때는 서버용 인스턴스이기에 당연히 하이퍼스레드가 적용되었을 거라 생각했는데요. 이를 살펴보니 코어당 기본 스레드가 1개여서 총 스레드 개수는 2개인 것을 확인할 수 있습니다.

 

 

Kernel Thread는 Hardware Thread를 실제 물리적인 쓰레드인 것처럼 인식하기 때문에 아래와 같이 매핑된다고 보면 됩니다.

 

위에서 언급한 것처럼 t4g.small에서의 Hardware Thread는 각 코어 당 하나씩 존재합니다. Kenel Thread는 Hardware Thread에 할당되어서 본인의 작업이 수행되기를 기다립니다. 어떤 쓰레드 작업을 선택해서 Hardware Thread에서 동작시킬 지에 대한 문제는 스케줄링(Scheduling) 문제라고 합니다. 이 문제에 관해서는 Round robin이나 SJF, Priority Scheduling 등등 여러 알고리즘이 있습니다. 이 부분은 사실 본 포스팅의 목적과는 다소 동떨어진 느낌이 있어서 일단 이 정도까지 소개하고 넘어가도록 하겠습니다.

 

 

User Thread와 Kernel Thread와의 매핑 모델

 

재미있는 점은 User Thread랑 Kernel Thread와 무조건 하나씩 매핑되는 관계는 아니라는 점입니다. User Thread와 Kernel Thread와의 매핑 관계는 다음과 같이 총 세 가지 유형으로 구분합니다.

 

(1) One To One

 

(2) Many To One

 

(3) Many To Many

 

 

각각 어떤 장단점이 있을지, 특징으로는 어떤 것이 있을지 간단하게 알아보도록 하겠습니다.

 

 

One To One


 

먼저 One To One  관계입니다. 개발자가 Thread를 생성할 때 이와 매핑이 되는 하나의 Kernel Thread가 하나 생성되게 됩니다. 위의 Kernel Thread들이 모두 하나의 프로세스에 존재한다고 가정해보겠습니다. 그렇게 된다면 1대 1 매핑이기 때문에 한 Thread가 I/O 작업을 해서 블럭 상태가 되더라도 다른 Thread가 동작할 수 있다는 점이 특징입니다. 또한 하나의 프로세스에서 여러 Thread가 존재하게 되기 때문에 race condition이 발생한다는 점 또한 특징입니다.

 

Many To One


여러 User Thread가 하나의 Kernel Thread와 매핑이 되는 관계를 의미합니다. 여러 개의 User Thread가 하나의 Kernel Thread에 매핑이 되기 때문에 context switching이 발생하지 않게 됩니다. 따라서 User Thread 간의 스위칭이 빠르게 됩니다. 하지만 One to One 모델과는 반대로 한 Thread가 block된다면 모든 Thread 또한 block이 되어 동작을 멈춥니다.

 

Non block IO를 활용하여 이를 개선해볼 수 있다고 합니다.

 

 

Many To Many


 

 

이는 User Thread와 Kernel Thread의 장점을 모두 합한 모델입니다. 그래서 한 Thread가 block되더라도 다른 Thread를 동작시킬 수 있으면서 context switching 비용 또한 줄인 모델입니다. 다만 구현 상의 복잡함이 있다고 합니다. 

 

예상컨데, block이 되었을 때 어떤 Thread를 동작시킬 건지, context switching 비용을 줄이기 위해서 어떤 스케줄링을 할 것인지 등등에 대해 고민해야하는 포인트들이 많아서 구현 상의 어려움이 있지 않을까 생각하게 되네요.

 

 

자바에서의 쓰레드


 

자바는 이 중에서 어떤 관계에 속할까요? 사실 이 부분에 대한 궁금증이 제가 오랜만에 블로그 글을 포스팅하게 만들었습니다. 여기저기 자료를 찾아봐도 설명이 다 다르더라고요.

 

자바는 One To One 매핑 관계라는 Stack Overflow 글.

 

 

Oracle 공식문서에서 Solaris-Native Thread에서는 자바가 Many To Many 관계라고 설명합니다.

 

 

블로그를 뒤져봐도 말이 서로서로 달라서 한번 직접 소스코드를 뒤져보기로 결정했습니다.

 

 

Thread  코드 확인해보기


 

가장 먼저 살펴보았던 것은 Thread 클래스입니다.

 

자바에서는 Thread를 생성하고 start()를 호출하면 Thread에게 할당한 작업을 실질적으로 수행하게 됩니다.

 

Thread 내의 start() 메소드

 

 

위의 코드는 Thread의 start() 메소드인데요. 내부적으로 보면 start0()이라는 메소드를 호출하고 있습니다. 

 

 

그리고 start0() 메소드는 native 키워드로 선언된 메소드입니다. 

사실 이 때 native 키워드를 처음 보았는데요. native는 자바가 아닌 다른 언어로 작성된 메소드를 호출할 때 사용하는 키워드라고합니다. 이에 대해서 궁금하신 분께서는 'JNI'라는 키워드로 학습을 진행하셔도 좋을 것 같습니다.

 

native 키워드로 사용이 된 것은 결국 jdk의 소스코드를 뒤져봐야지 확인할 수 있습니다. 그래서... 보다 정확하게 자바 Thread에 대해 알기 위해 한번 jdk 소스코드를 뒤져보기로 결정했습니다. (사실 jdk 공식 문서를 보면 나올까 했지만... 나오지 않더라고요...)

 

 

Amazon Corretto jdk 17


우선 제가 프로젝트에서 사용한 jdk는 Amazon Corretto jdk 17입니다. 때문에 해당 jdk 버전의 소스코드를 뒤져보기로 결심했습니다. jdk는 여러 언어로 작성되어있지만 지금부터 알아볼 Thread 부분은 C++로 작성이 되어있습니다.

 

corretto jdk 17 버전 Thread.c 파일

 

 

우선적으로 start0이라는 메소드와 연결된 메소드를 직접 찾아보았습니다. 'JVM_StartThread'라는 메소드와 연결이 되어있는 것을 확인할 수 있네요.

 

 

그리고 JVM_StartThread 메소드를 검색해서 들어가면 아래와 같은 로직이 들어가있는 것을 확인할 수 있습니다.

corretto jdk 17 버전 jvm.cpp 파일

 

new JavaThread()를 호출해서 자바의 Thread를 생성해주고 있는 것을 확인할 수 있네요!

그리고 생성한 자바의 Thread 내의  osThread를 참조하고 있는 모습을 볼 수 있습니다. 만약 osthread가 null이 아니라고 한다면 prepare()를 호출해서 사용할 수 있도록 준비하는 코드도 확인할 수 있습니다.

 

사실 JavaThread에서 osthread를 사용하는 것이 아니라 JavaThread가 상속하고 있는 Thread라는 클래스에서 이를 사용하고 있습니다.

corretto jdk 17 버전 JavaThread.hpp 파일

 

 

그리고 Thread에서는 osThread를 다음과 같이 설명하고 있습니다.

corretto jdk 17 버전 Thread.hpp 파일

 

OSThread 또한 클래스로 정의되어있기 때문에 이를 직접 확인해볼 수 있는데요. OSThread는 thread_id_t 를 멤버 변수로 가지고 있습니다.

corretto jdk 17 버전 osthread.hpp 파일

 

일단 OSThread가 어떤 한 Kernel Thread와 연결이 되는지는 주석을 통해 확인할 수 있습니다. 그러나 어떤 관계 모델을 가지고 있는지 직접 확인하기는 어렵네요.

 

다만 유추해볼 수 있는 부분은 있습니다. 바로 Thread에서 OSThread를 설명하는 부분인데요. 해당 Thread에 대한 정보는 Platform-specific하다는 점입니다. 이 의미는 Kernel Thread의 매핑은 OS마다 다르다고 해석해볼 수 있겠네요.

 

그리고 혹시 몰라서 open jdk도 확인해보았는데, 위와 동일하게 os에게 Thread 생성하는 것을 맡기더라고요. 

 

 

자바에서의 쓰레드 모델은 OS 마다 다르다.


 

정리하자면 java에서는 User Thread를 하나씩 생성할 때마다 OS에 따라서 Kernel Thread과의 매핑이 달라질 수 있습니다. 그래서 One-to-One 모델이 될 수도 있고, Many-to-Many가 될 수도 있는 것이죠. 다만 오라클 공식문서에 따르면 Solaris 운영체제 하에서는 Many-to-Many 모델이 될 수 있다는 점을 유의하면 될 것 같은데요. 이 부분이 오해의 소지를 불러일으켜서 Java는 Many-to-Many다!라고 생각하신 분들이 많으신 것 같습니다.

 

아무튼 누군가가 여러분들에게 Java는 One-to-One 모델이냐? 라는 질문을 주셨다면 OS에 따라 다르다~라고 답변을 주시면 정확한 답변이 되겠네요!!

 

 

글을 작성하다가 중간에 예비군도 다녀오고... 면접 준비도 하느라 블로그 글을 작성하지 못했는데요. 오늘 면접이 하나 끝나서 글 포스팅합니다! 틀린 내용이 있다면 댓글로 남겨주시면 감사하겠습니다!

728x90
반응형