Java : 가상 스레드
- 프로그래밍 언어/JAVA
- 2023. 8. 27.
쓰레드 모델
아래같은 쓰레드 모델이 존재한다. 여기서 자바는 (b) Pure kernel-level 쓰레드 모델을 사용하고 있다. JDK의 구현마다 다르겠지만, 내가 테스트 해본 OpenJDK 17, 21에서 쓰레드풀 사이즈가 증가하는만큼 OS 쓰레드 개수가 증가하기 때문에 유절 레벨 쓰레드 - 커널 레벨 쓰레드는 One To One으로 Binding 된다고 이해했다.
쓰레드는 스케쥴링을 관리하는 주체가 유저 / 커널이냐에 따라 유저 레벨 쓰레드와 커널 레벨 쓰레드가 존재한다. 위의 쓰레드 모델을 살펴보면 다음과 같다.
- (a) Pure user-level : 커널은 프로세스가 CPU를 사용할 수 있도록 스케쥴링 해줌. 프로세스 내부의 유저 레벨 쓰레드의 스케쥴링은 프로세스 라이브러리에서 처리함. 프로세스의 유저레벨 쓰레드 하나가 블록되면, 프로세스 전체가 블록됨.
- (b) Pure kernel-level : 유저 레벨 쓰레드 - 커널 레벨 쓰레드가 Binding 됨. OS는 커널 레벨 쓰레드을 스케쥴링 함. 스케쥴링의 주체가 OS임.
- (c) Combined : 두 방법을 조합해서 사용함.
결론부터 이야기하면 현재 대부분의 JDK는 Pure kernel-level 스레드 모델을 사용하고 있으며, 유저 레벨 쓰레드만큼의 커널 레벨 쓰레드를 만들어서 One to One으로 바인딩해서 사용한다.
One to One 바인딩 확인해보기
Spring Tomcat의 쓰레드 풀 최소 사이즈를 수정하면서 Java 프로세스에 할당된 OS 쓰레드의 갯수를 확인하는 방식으로 One to One인지 확인해봤다. 실험해 본 JDK 환경은 openjdk:17, openjdk:21이며 모두 동일하게 유저 레벨 스레드 / OS 레벨 스레드가 One to One으로 바인딩 되는 것을 확인할 수 있었다.
특정 프로세스에 할당된 OS 레벨 스레드의 갯수를 확인하는 명령어는 다음과 같다.
# 프로세스에 할당된 OS 레벨 쓰레드 목록 → 여기서 Java만 보면 됨.
$ pstree -p
# 전체 프로세스에 할당된 OS 레벨 쓰레드 목록
$ pstree -p | wc -l
결과는 아래 그래프에서 확인할 수 있다. openjdk17, openjdk21은 모두 "x = y" 그래프에 맞춰서 Tomcat Thread Pool Size가 증가할 때 OS Level Thread도 1:1 수준으로 증가하는 것을 확인할 수 있었다.
기존 자바 쓰레드의 이해
여기서부터는 이 블로그에 작성하신 분을 내가 이해한 것을 바탕으로 재구성했다. 원본 글에서 작성해주신 깊은 내용은 이곳에 있다.
Step1. LWP(Light Weight Process)
오라클 레퍼런스를 참조해서 작성된 글이다. 먼저 그림에 있는 용어를 정리해보자.
- JLTs : Java Application Level Thread. 자바에서 사용되는 프로세스.
- KLTs : Kernel Level Thread. OS가 관리하는 쓰레드.
- LWP : Light Weight Process. JLTs - KLTs의 가교 역할을 함.
대략적인 용어는 다음과 같이 정리할 수 있다. 중요한 것은 LWP인데, 어떤 역할을 하는지 정리하면 다음과 같다.
- LWP는 OS에게 KLTs 관리를 부탁함.
- LWP는 Java에서 요청한 Java Thread를 Kernel Thread에 맵핑함.
- LWP는 커널 - 유저 레벨 스레드 사이에서 일종의 인터페이스 역할도 함.
- Java에서 thread.start()를 하는 것이 OS에 전달되도록.
LWP는 어떻게 구현되고, 어떻게 동작할까? LWP는 c++의 Solaris Thread Library로 구현되어있다. Java 소스 코드를 실행하면 JNI(Java Native Interace)를 통해 Solaris Thread Library가 실행되어 LWP가 인터페이스 하도록 되어있다.
Step2. LWP 매핑 시뮬레이션
Java Thread를 사용할 때, start() / run() / join() 같은 메서드가 존재한다. 이 메서드는 OS에 종속적인데, OS에 system call을 보내서 OS에 해당 기능을 요청해야 한다. 자바 쓰레드가 start()라는 자바 코드를 실행하면 JNI를 실행시키고, JNI가 JVM과 C++ Library를 통해서 OS에게 시스템 콜을 요청하는 형태가 된다.
자바 쓰레드가 커널 스레드로 바인딩 되기까지를 간단하게 구현한 프로젝트가 있다. 이 프로젝트의 글을 읽고, 실행해보면 아래 결과가 나온다. 아래 실행 결과를 살펴보면 각각 다음과 같다.
- Starting thread_entry_point : Java가 C++을 통해서 OS에게 스레드 생성 요청 시도.
- Started a linux thread : OS 스레드가 생성 완료됨.
- Running Thread : 자바 쓰레드 객체가 run() (자바 코드)를 실행했을 때 발생하는 출력.
[info] Loading settings from build.sbt ...
[info] Set current project to threading (in build file:/threading/)
[info] Executing in batch mode. For better performance use sbt's shell
[success] Total time: 1 s, completed Jan 1, 2018 3:39:01 AM
[success] Total time: 1 s, completed Jan 1, 2018 3:39:02 AM
[info] Running (fork) com.threading.ThreadingApp
[info] Started a linux thread Starting thread_entry_point 140531437143808!
[info] Started a linux thread 140531428751104!
[info] Starting thread_entry_point Started a linux thread 140531420358400!
[info] Starting thread_entry_point Started a linux thread 140531411965696!
[info] Starting thread_entry_point Started a linux thread 140530323289856!
[info] Running Thread 1
[info] Running Thread 3
[info] Starting thread_entry_point Started a linux thread 140530314897152!
[info] Starting thread_entry_point Started a linux thread 140531437143808!
[info] Running Thread 2
[info] Running Thread 4
[info] Starting thread_entry_point Started a linux thread 140530306504448!
[info] Started a linux thread 140531428751104!
[info] Starting thread_entry_point Starting thread_entry_point Started a linux thread 140530298111744!
[info] Running Thread 5
[info] Started a linux thread 140530314897152!
[info] Started a linux thread 140531411965696!
[info] Started a linux thread 140530289719040!
[info] Started a linux thread 140530281326336!
[info] Started a linux thread 140530272933632!
[info] Started a linux thread 140529987745536!
[info] Started a linux thread 140529979352832!
[info] Started a linux thread 140529970960128!
[info] Started a linux thread 140529962567424!
[info] Running Thread 9
[info] Running Thread 8
[info] Running Thread 7
[info] Running Thread 6
[info] Running Thread 10
[info] Running Thread 15
[info] Running Thread 16
[info] Running Thread 13
[info] Running Thread 14
[info] Running Thread 12
[info] Running Thread 11
[info] Running Thread 17
[info] Running Thread 19
[info] Running Thread 18
위에서는 OS 스레드는 15개, Java 스레드는 19개가 생성된다. 솔라리스 OS 기반에서는 Many to Many로 유저 레벨 스레드 - OS 레벨 스레드가 맵핑되기 때문에 이렇게 동작한다. 반면 Window / Linux에서는 One to One으로 맵핑된다고 한다.
Step3. OpenJDK 커널 코드 분석
위 그림에서 Native Thread가 있다. Native Thread는 C++의 Thread 객체가 OS 레벨 쓰레드를 소유하게 된 상태를 의미한다.
C++ 스레드 상속 구조
C++의 스레드 상속 구조는 아래와 같다. JavaThread / NonJavaThread가 존재하는 것을 볼 수 있다.
// thread.hpp:96
// Class hierarchy
// - Thread
// - JavaThread
// - various subclasses eg CompilerThread, ServiceThread
// - NonJavaThread
// - NamedThread
// - VMThread
// - ConcurrentGCThread
// - WorkerThread
// - WatcherThread
// - JfrThreadSampler
// - LogAsyncWriter
//
// All Thread subclasses must be either JavaThread or NonJavaThread.
// This means !t->is_Java_thread() iff t is a NonJavaThread, or t is
// a partially constructed/destroyed Thread.
스레드 실행 흐름
최초로 thread_native_entry() 메서드가 호출된 후 초기화 후, 적절히 바인딩 되는 것을 볼 수 있다. (사실 잘 이해 못함)
// thread.hpp:113
// Thread execution sequence and actions:
// All threads:
// - thread_native_entry // per-OS native entry point
// - stack initialization
// - other OS-level initialization (signal masks etc)
// - handshake with creating thread (if not started suspended)
// - this->call_run() // common shared entry point
// - shared common initialization
// - this->pre_run() // virtual per-thread-type initialization
// - this->run() // virtual per-thread-type "main" logic
// - shared common tear-down
// - this->post_run() // virtual per-thread-type tear-down
// - // 'this' no longer referenceable
// - OS-level tear-down (minimal)
// - final logging
//
// For JavaThread:
// - this->run() // virtual but not normally overridden
// - this->thread_main_inner() // extra call level to ensure correct stack calculations
// - this->entry_point() // set differently for each kind of JavaThread
C++ 스레드 종류 및 우선 순위
다음과 같은 쓰레드가 지원된다.
// os.hpp:70
enum ThreadPriority { // JLS 20.20.1-3
NoPriority = -1, // Initial non-priority value
MinPriority = 1, // Minimum priority
NormPriority = 5, // Normal (non-daemon) priority
NearMaxPriority = 9, // High priority, used for VMThread
MaxPriority = 10, // Highest priority, used for WatcherThread
// ensures that VMThread doesn't starve profiler
CriticalPriority = 11 // Critical thread priority
};
// os.hpp:455
enum ThreadType {
vm_thread,
gc_thread, // GC thread
java_thread, // Java, CodeCacheSweeper, JVMTIAgent and Service threads.
compiler_thread,
watcher_thread,
asynclog_thread, // dedicated to flushing logs
os_thread
};
C++의 Java Thread 클래스
JavaThread 클래스는 Thread 클래스에게 상속받은 os_thread_list가 하나 있고, _threadObj라는 Java 레벨의 스레드가 있다. JavaThread 클래스는 실제 자바의 쓰레드와 커널 쓰레드를 연결하는 인스턴스다.
// thread.hpp:677
class JavaThread: public Thread {
friend class VMStructs;
friend class JVMCIVMStructs;
friend class WhiteBox;
friend class ThreadsSMRSupport; // to access _threadObj for exiting_threads_oops_do
friend class HandshakeState;
private:
bool _on_thread_list; // Is set when this JavaThread is added to the Threads list
OopHandle _threadObj; // The Java level thread object
/* ...생략... */
}
Thread.start() in Java
Java 코드에서 thread.start()가 호출되면 아래 코드에서 처럼 JNINativeMathod를 통해 JVM_startThread 메서드가 호출된다.
private native void start0();
가장 중요한 흐름은 다음과 같다.
- Solaris Library Thread 생성(솔라리스 OS 경우, c++ Javathread 클래스 인스턴스)
- Kernel Thread 생성
- Solaris Library Thread와 Kenerl Thread를 연결해서 Native Thread 완성
- Native Thread와 JavaApplication Level의 Thread를 연결 (binding)
- 완성된 스레드를 쓰레드큐에 추가
- Java Application Level Thread가 원하는 코드를 native Thread 에게 실행시킴 (Thread.run() in java cod)
아래에는 Native Code의 실행 과정을 살펴볼 수 있다.
JNI Native Methods
// java.base/share/native/libjava/Thread.c
static JNINativeMethod methods[] = {
{"start0", "()V", (void *)&JVM_StartThread},
{"stop0", "(" OBJ ")V", (void *)&JVM_StopThread},
{"isAlive", "()Z", (void *)&JVM_IsThreadAlive},
{"suspend0", "()V", (void *)&JVM_SuspendThread},
{"resume0", "()V", (void *)&JVM_ResumeThread},
{"setPriority0", "(I)V", (void *)&JVM_SetThreadPriority},
{"yield", "()V", (void *)&JVM_Yield},
{"sleep", "(J)V", (void *)&JVM_Sleep},
{"currentThread", "()" THD, (void *)&JVM_CurrentThread},
{"interrupt0", "()V", (void *)&JVM_Interrupt},
{"holdsLock", "(" OBJ ")Z", (void *)&JVM_HoldsLock},
{"getThreads", "()[" THD, (void *)&JVM_GetAllThreads},
{"dumpThreads", "([" THD ")[[" STE, (void *)&JVM_DumpThreads},
{"setNativeName", "(" STR ")V", (void *)&JVM_SetNativeThreadName},
};
#undef THD
#undef OBJ
#undef STE
#undef STR
JNIEXPORT void JNICALL
Java_java_lang_Thread_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls, methods, ARRAY_LENGTH(methods));
}
Native Thread 생성
자식 스레드는 waiting 후 부모에게 허락 singal 받으면 run() 수행
//prims/jvm.cpp:2850
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
JavaThread *native_thread = NULL;
{
MutexLocker mu(Threads_lock);
// javaThread가 이전에 start상태였으면 생성 불가!
if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
throw_illegal_thread_state = true;
} else {
// c++ thread와 os thread를 연결해서 native_thread 생성
// 부모는 자식스레드(Java와 연결될 os 커널스레드)를 생성하고 나옴. (INITIALIZED 상태가 됨)
// 자식스레드(os 스레드)는 부모 스레드가 run() 실행을 허락하기 전까지 대기.
native_thread = new JavaThread(&thread_entry, sz); // <-- 중요!
if (native_thread->osthread() != NULL) {
// 자식 스레드를 실행시키기 전에 꼭 처리되어야 하는 전처리 작업 -> 조건동기화가 필요!
// native thread를 java thread와 연결, 스레드 리스트에 추가
native_thread->prepare(jthread);
}
}
}
/* ...생략... */
Thread::start(native_thread); // 자식 스레드가 run()을 수행하도록 허락
JVM_END
new JavaThread()
부모 스레드는 대기하고 있는 자식 스레드 (OS Thread, JLT와 Binding 될 스레드)를 깨워서 run을 수행하게끔 한다.
// new JavaThread -> create_thread -> pthread_create(..., thread_native_entry, ...)
// os_linux.cpp:660
// Thread start routine for all newly created threads
static void *thread_native_entry(Thread *thread) {
/* ...생략... */
// handshaking with parent thread
{
MutexLocker ml(sync, Mutex::_no_safepoint_check_flag);
// notify parent thread
osthread->set_state(INITIALIZED);
sync->notify_all();
// wait until os::start_thread()
// 여기서 멈춰있음, 부모가 바인딩과 같은 전처리 작업을 수행 후 RUNNABLE 상태로 변경시켜주면 진행
while (osthread->get_state() == INITIALIZED) {
sync->wait_without_safepoint_check();
}
}
// call one more level start routine
thread->run()
/* ...생략... */
}
Thread::start(Thread* thread)
여기서 자식 쓰레드(OS Thread)의 상태를 변경함.
//thread.cpp:518
void Thread::start(Thread* thread) {
if (thread->is_Java_thread()) {
java_lang_Thread::set_thread_status(JavaThread::cast(thread)->threadObj(),
JavaThreadStatus::RUNNABLE);
}
os::start_thread(thread);
}
//os.cpp:873
void os::start_thread(Thread* thread) {
// guard suspend/resume
MutexLocker ml(thread->SR_lock(), Mutex::_no_safepoint_check_flag);
OSThread* osthread = thread->osthread();
osthread->set_state(RUNNABLE); // 여기서 자식의 상태를 변경하므로 자식스레드가 대기상태에서 벗어남
pd_start_thread(thread);
}
자바 스레드 공부 요약
다시 한번 이 블로그에서 요약해주신 내용이 많이 도움되었다고 작성하고 싶다. 요약하면 다음과 같다.
- Java의 초창기에는 Green Thread라고 해서 User Level Thread 모드로만 동작하던 시절이 있었다.
- 현재는 OS마다 다르긴 하지만, 자바에서 User Level 쓰레드가 생성되면 동일한 수의 커널 레벨 쓰레드가 생성되어 One to One Bind가 됨.
- JNI는 자바의 유저 레벨 쓰레드 ↔ 커널 레벨 쓰레드를 바인딩 해주는 역할을 한다.
- 현재 자바의 쓰레드는 OS 레벨에서 스케쥴링 된다.
Java의 쓰레드
자바의 쓰레드는
대부분 유저 레벨 쓰레드 / 커널 레벨 쓰레드가 1대1 맵핑됨.
그러나 JDK 구현에 따라서 달라질 수 있음. JNI의 라이브러리가 어떻게 맵핑을 하느냐에 따라 다른 듯
현재 자바에서는 실행될 때는 JVM이 쓰레드 스케쥴링을 하지 않고 OS에게 스케쥴링을 맡겨 OS의 스케쥴링 정책을 따른다.
그래서 자바의 쓰레드는 커널의 쓰레드의 1:1 매핑하여 동작해서 실제로 병렬성을 만족한다.
참고
- https://medium.com/@unmeshvjoshi/how-java-thread-maps-to-os-thread-e280a9fb2e06
- https://velog.io/@sunaookamisiroko/%EC%9E%90%EB%B0%94%EC%9D%98-%EC%93%B0%EB%A0%88%EB%93%9C%EB%8A%94-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%88%98%EC%A4%80-%EC%93%B0%EB%A0%88%EB%93%9C%EC%9D%B8%EA%B0%80
- https://m.blog.naver.com/whdgml1996/222076116487
- https://e-una.tistory.com/70#2.2.%20One-to-One%20Model,%20%EC%9D%BC%EB%8C%80%EC%9D%BC%20%EB%AA%A8%EB%8D%B8
- https://letsmakemyselfprogrammer.tistory.com/98
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
Effective Java : 아이템 70. 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라. (0) | 2023.08.27 |
---|---|
Effective Java : 아이템 71. 필요없는 체크 예외 사용은 피하라 (0) | 2023.08.27 |
Effective Java : 아이템 62. 다른 타입이 적절하다면 문자열 사용을 피하라. (0) | 2023.08.26 |
Effective Java : 아이템 61. 박싱된 기본 타입보다는 기본 타입을 사용하라. (0) | 2023.08.26 |
Effective Java : 아이템 64. 객체는 인터페이스를 사용해 참조하라. (0) | 2023.08.26 |