Thread에 대한 기초적인 os 지식은 이 글(쓰레드(Thread)와 동기화 문제)을 참고하기 바람
Thread는 user가 관리하느냐, os가 관리하느냐에 따라 User-Level-Thread 또는 Kernel-Level-Thread 로 나뉜다. 두 가지의 장점을 합친 Hybrid 모델도 존재한다. Java에서는 Kernel thread와 User thread를 모두 사용하는 Hybrid 모델이다. (jdk 1.2 버전까지는 100% User-Level-Thread인 GreenThread라는 것이 있었다고 한다.) 그림을 잘 보면, User-level thread 3개가 2개의 Kernel-Level-Thread를 공유하고 있는 것을 알 수 있다. OS 수업을 듣다가, java 는 구체적으로 이런 방식의 작업을 어떻게 가능하게 하는지, 스레드를 어떻게 관리하는 건지 궁금해졌다. 찾아본 내용을 3단계의 깊이에 걸쳐 이야기하도록 하겠다.
Step1. LWP(Light Weight Process)
Oracle 레퍼런스를 참고했다. 먼저 그림과 용어에 대해서 설명이 필요하다. JavaApplication-level의 스레드는 Java 프로그래머가 1차적으로 마주하는 스레드를, Kernel-level 스레드는 OS가 관리하는 스레드를 지칭한다. 앞으로는 JavaApplication-Level-Threads 를 JLTs, Kernel-Level-Threads를 KLTs 라고 지칭하겠다.
중요한 것은 LWP 이다. KLTs 와 JLTs의 중간다리 역할을 한다. 즉 os에게 KLTs 관리를 부탁하고, 자바 어플리케이션 프로그래머가 요청한 JavaThread를 KernelThread에 매핑한다. 보통 스레드를 다른 말로 LWP(Light Weight Process) 라고도 하는데, 이 맥락에서는 LWP가 단순 스레드만을 의미하지 않는다. 위 그림에서 볼 수 있다시피, 커널과 user-level 스레드 사이에서 일종의 인터페이스 같은 역할을 수행한다. Solaris의 맥락에서는 LWP 의미가 다르다는 점을 기억하자.
LWP의 기능은 Solaris Threads Library로 구현되어 있다. LWP와 JavaThread를 binding 하는 역할을 수행한다. JavaThread는 unbound 된 상태가 default 이다. 이렇게 함으로써, unbound JLTs 의 요청에 맞게 pool of LWPs 의 크기를 조절한다.
참고로 라이브러리는 c++ 코드로 구현되어 있다. Java 소스 코드를 실행하면 JNI(JavaNativeInterface, 외부의 다른 소스코드를 실행할 수 있도록 하는 인터페이스)를 통해 이 라이브러리 코드가 실행되도록 하는 원리이다.
Step2. LWP 매핑 시뮬레이션
java에서 스레드를 사용해 본 사람은 대충 알겠지만 start() / run() / join() / sleep() 같은 인터페이스를 갖는다. 해당 메서드는 os에 종속적이다. 즉 system call 을 통해서 os 에 해당 기능을 요청해야한다. os에 요청하는 코드는 jvm에 c++ 코드로 작성되어 있다. 요청과 응답 flow 는 대략 아래와 같다.
실제 JVM 코드는 꽤 복잡하고 방대하기 때문에 바로 들여다보기에는 무리가 있었다. 찾아보던 중 java thread 생성 관련해서 위의 동작을 간단하게 구현한 project 가 있어서 공유한다. 코드를 일일이 보기 귀찮으면, 위에서 설명한 LWP의 개념과 OS까지의 요청흐름 정도만 알아도 될 듯하다.
위 프로젝트(단순하게 Java에서 스레드 19개를 생성)의 실행결과는 아래와 같다.
"Starting thread_entry_point" 는 JVM이 os에게 스레드를 요청 시도(c++)할 때의 출력.
"Started a linux thread 140531437143808" 는 os가 실제로 스레드를 생성 완료했을 때의 출력.
"Running Thread 1"는 JavaThread 객체가 run메소드(java 코드)를 실행했을 때의 출력.
[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
결과를 잘 보면 JavaThread는 1~19번까지 19개가 생성된 것을 볼 수 있다. "Started a linux thread 140531437143808" 라는 linux thread 실행 문구도 19번 출력된 것을 볼 수 있다. 그런데 진짜 잘보면 linux thread의 번호가 중복되는 게 존재한다. 중복되는 스레드 번호를 제외한 스레드 개수는 15개이다. JVM이 한번 생성한 osThread 를 배열처럼 들고 있어서 osThread 가 실행을 완료한 시점에 바로 삭제하지 않는 것 같다. JavaThread 요청이 있으면, 바로 새로 osThread를 만드는것이 아니라 가용 osThread를 매핑해주는 방식인 듯하다. 정확히는 커널 코드를 직접 봐야 알 수 있을 듯하다.
Step3. Openjdk 커널 코드 분석
Native thread는 Library-Level의 c++ thread 객체가, OS kernel thread 소유하게 된 상태를 의미한다. 구현 코드를 볼 때 중요하다.
1) Thread
Thread 클래스는 java에서 사용되는 거의 모든 스레드의 조상이라고 보면 된다. Thread 인스턴스는 KLT를 멤버변수로 가지고 있다. 내부적으로는 osThread 라고 한다. Thread 인스턴스가 OS에 의해서 kernel thread를 실제로 할당받은 상태를 native thread 라고 할 수 있다.
(1) 스레스 상속 구조
// 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.
(2) 스레드 실행 흐름
// 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
(3) 스레드 종류 및 우선순위
// 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
};
2) JavaThread
JavaThread 는 Thread로부터 상속받은 osthread 변수가 하나 있고, _threadObj라는 java level 스레드가 있다. 즉 자바스레드와 커널 스레드를 연결하는 인스턴스인 셈이다.
// 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
/* ...생략... */
}
3) Thread.start in Java
java 코드에서 thread.start()를 호출 시 아래의 JNINativeMethod를 통해 JVM_startThread 메소드가 호출된다. 여기서 가장 중요한 흐름은 아래와 같다.
- Solaris Library Thread 생성 (c++ JavaThread 클래스 인스턴스)
- Kernel Thread 생성
- Solaris Library Thread와 kernel Thread를 연결해서 native Thread 완성
- native Thread 와 JavaApplication-level Thread 를 연결 (binding)
- 완성된 스레드를 스레드큐에 추가
- JavaApplication-level Thread 가 원하는 코드를 native Thread 에게 실행시킴
(Thread.run() in Java code)
// 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));
}
//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
자식 스레드는 waiting 후 부모에게 허락 signal 받으면 run() 수행
(`condition variable`을 이용한 조건 동기화처럼 보인다.)
// 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()
/* ...생략... */
}
부모스레드는 대기하고 있는 자식스레드(osthread, JLT와 binding될 스레드)를 깨워서 run을 수행하게끔 한다.
//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에서는 User-Level thread의 요청을 감당할 수 있을만큼만, Kernel-Level thread를 생성해서 운용한다. 즉 java thread 와 kernel thread 의 개수가 1대 1이 아니라는 얘기.. (물론 binding 은 1대1이다.)
- native thread 를 어떻게 pool 처럼 관리하는지도 알아보고 싶은데, 너무 힘들다... 아마 osthread 가 종료되는 시점에서 다른 java thread 의 요청이 있으면 binding 해서 사용하거나, tcp 연결 종료하는 것처럼 time-wait 상태를 둘 수도 있겠다.
- Thread.run() 을 호출하기 전에는 어디서 block 되어 있는거지..?
- 이거 알아보느라 일주일은 찾아보면서 공부한 것 같다... 힘들다..
'Java > Java 파헤치기' 카테고리의 다른 글
Java 한글 인코딩 안될때 jdk 버전 확인해야함. (1) | 2022.05.06 |
---|---|
Spring MSA 를 간단하게 구현해보자 (+ 최신 release, 모니터링, heapdump) (0) | 2022.05.05 |
[JVM] 레퍼런스 (0) | 2022.04.24 |
JIT compiler (0) | 2022.04.10 |