Java의 new Thread()는 어떻게 OS 커널 스레드가 될까?

Java의 new Thread()는 어떻게 OS 커널 스레드가 될까?

Java에서 new Thread() 를 호출하면 OS의 커널레벨 스레드와 1대1로 매핑된다고 흔히들 공부하실 겁니다. 다만 이 인스턴스가 OS에서 다루는 스레드까지 도대체 어떤 과정을 통해서 매핑되는 지는 와닿지 않으실겁니다.

사진 출처: 우아한 기술 블로그

개발자 이전에 공학도로서 한번 top-down으로 파고들며 분석해보겠습니다. OS부터 올라오기 보단 친숙한 Java 애플리케이션 레벨 부터 시스템콜을 호출 하는 부분까지 실제 동작 과정을 코드를 타고가며 관측해보겠습니다.

코드 분석

Java 표준 라이브러리 - Thread.java

자바 코드에서 new Thread()를 호출하면 JDK 내부 자바 표준 라이브러리의 java.base 모듈 java.lang 패키지의 Thread Class를 생성합니다. java.lang 은 모든 자바 코드에 기본적으로 포함되는 패키지라 import 구문 없이 사용할 수 있습니다.

Thread의 start 메서드를 보면 내부적으로 start0 메서드를 호출합니다.

JNI - Java Native Interface

start0 메서드는 native 키워드가 붙어있습니다. 이는 JNI(Java Native Interface)로 Java 코드 내에서 다른 언어로 작성된 라이브러리 등을 호출할 수 있게 해줍니다.

Java 언어에서는 처리하기 힘든 부분을 외부 언어로 구현하고, 이를 잇기 위한 인터페이스로서 native 키워드를 제공합니다.

JDK의 Java측 코드(자바 표준 라이브러리측)를 많이 뜯어보신 분은 아실 수 있겠지만, JDK의 대부분의 동작은 Java언어로 쓰여진 표준 라이브러리 코드cpp로 쓰여진 코어영역 코드가 합쳐져서 JDK를 이룹니다. 주로 OS에 직접 접근해야 되는 부분들은 cpp로 쓰여지고 JNI로 연결되어있습니다.

그럼 start0의 구현부는 어디에 있을까요?

OpenJDK 의 Thread class의 JNI 매핑부를 보면 “start0” 을 JVM_StartThread와 매핑해주고 있습니다. 해당 함수를 찾아가보겠습니다.

JDK - jvm.cpp

jvm.cpp에 해당 함수가 정의되어 있습니다. 발췌해서 보겠습니다.

우선 여러 (Java 런타임에서의) 스레드에서 한 스레드 인스턴스의 start()를 동시에 호출할 때의 race condition을 막기 위해 뮤텍스 락을 취득합니다. 한 Java Application Level Thread 인스턴스에 여러 커널 스레드가 연결되지 않도록 합니다.

바로 이어지는 코드입니다. 자바에서 스레드별 할당되는 스택 메모리 영역의 크기를 계산하고 JavaThread 객체를 만듭니다.

JDK - JavaThread.cpp

아까 호출한 JavaThread의 생성자 코드입니다. os::create_thread로 실제 OS 별 커널 스레드 생성을 위임합니다.

번외로 주석을 읽어보면 lock을 다루기위해 메모리 부족 등으로 os::create_thread가 실패할 수는 있지만, 교착 상태에 빠지는 걸 방지하고자 예외를 던지지 않고 호출자에게 책임을 넘긴다는 내용을 볼 수 있습니다.

JDK - os_linux.cpp

이전 JavaThread.cpp에서 호출한 os::create_thread는 운영체제(OS)별로 다르게 구현되어 있습니다. OpenJDK의 리눅스 구현부인 os_linux.cpp 파일을 살펴보겠습니다.

코드를 보면 pthread_create() 함수를 호출하는 것을 확인할 수 있습니다. 리눅스에서 새로운 스레드를 생성하는 API 입니다. 아까 계산한 stack_size같은 값들을 이용해 pthread_attr_t 구조체에 스레드의 속성을 설정하고, pthread_create()를 호출하여 OS에게 스레드 생성을 요청합니다.

조금 더 엄밀하게 보면, OS에서 thread라는 개념 자체는 하나의 실행 단위이기에 특정한 하드웨어적인 실체를 OS에게 달라고 하는건 아니고 결국 SW인데, 리눅스에서 이 스레드는 하나의 경량 프로세스(LWP)로 구현되어 clone 시스템 콜을 통해 호출됩니다.

자세히 보기 위해 pthread 가 무엇인지 한번 보고 가겠습니다.

glibc - pthread_create()

pthreadPOSIX Thread의 약자로, 유닉스 계열 OS(리눅스, macOS 등)에서 스레드를 다루기 위한 표준 API 규격입니다. 즉, 특정 라이브러리 이름이라기보다는 “이런 함수들을 제공해야 한다”는 약속, 표준, 인터페이스에 가깝습니다.

운영체제를 설치하면 이 표준을 구현한 라이브러리가 기본적으로 포함되어 있으며, 리눅스에서는 보통 glibc 라이브러리가 NPTL(Native POSIX Thread Library)을 통해 pthread API를 제공합니다.

pthread 부터는 어셈블리어와 c언어가 섞여있고 소스를 해부해서 보는게 쉽지 않기에 최하단에서 스레드를 생성하는 시스템콜을 호출하는 것을 보겠습니다.

sysdeps/unix/sysv/linux/x86_64/clone.S

어셈블리어로 clone 시스템 콜을 호출합니다.

syscall - clone

clone 시스템 콜은 프로세스를 생성하는 시스템 콜 중 하나로 fork와 달리 부모 프로세스의 메모리 주소 공간을 자식 프로세스도 공유하며 쓸 수 있습니다.

리눅스 커널은 전통적인 의미의 ‘스레드’라는 별도의 실행 단위를 두지 않고, 대신 경량 프로세스(LWP, Light-Weight Process) 라는 개념을 사용합니다.

clone 시스템 콜은 다양한 flag 조합을 통해 “어떤 자원을 공유하고, 어떤 자원을 독립적으로 가질지” 를 세밀하게 제어하며 새로운 실행 단위를 생성할 수 있습니다. 이때 독립적인 메모리 공간등을 점유하지 않고 부모 프로세스의 자원 대부분을 공유하도록 설정하여 생성된 프로세스를 LWP라 합니다.

스레드는 결국 프로세스의 실행 단위입니다. clone을 통해 부모 프로세스와 자원을 공유하지만 독립적으로 스케줄링되는 경량 프로세스(LWP)를 만든다면 이를 스레드라 볼 수 있습니다.

정리

자바의 Thread 객체가 생성되고 실행되기까지 여러 추상화 계층을 거치며 SW의 가장 하단인 OS에 닿기까지의 과정을 살펴봤습니다. 정리하자면 아래와 같은 흐름입니다.

  1. Java 애플리케이션 레벨: new Thread().start() 호출
  2. JDK 표준 라이브러리: native 키워드로 선언된 start0() 메서드 호출
  3. JNI (Java Native Interface): start0을 JVM의 네이티브 함수(JVM_StartThread)와 매핑
  4. JVM (C++ 영역): OS에 독립적인 스레드 객체(JavaThread) 생성 및 OS에 스레드 생성 위임 (os::create_thread)
  5. OS별 구현부 (os_linux.cpp): POSIX 표준인 pthread_create() 함수 호출
  6. C 라이브러리 (glibc): 내부적으로 clone 시스템 콜을 호출할 준비
  7. 시스템 콜 인터페이스: 어셈블리어를 통해 커널 모드로 전환하며 clone 시스템 콜 호출
  8. 리눅스 커널: clone 시스템 콜을 실행하여 자원을 공유하는 새로운 실행 단위, 즉 경량 프로세스(LWP) 생성

이런 내부 과정을 모른 채로도 스레드를 편히 다룰 수 있게 해주는 게 추상화의 매력인 거 같습니다. 그럼에도 이산 가능한 영역을 다루는 개발자로서 SW가 기계적으로 어떻게 동작하는 지를 보는 건 참 좋은 공부가 되는 거 같네요.

사진 출처: 우아한 기술 블로그

JDK 21부터는 Virtual Thread 를 통해 JVM 영역 안에서 실제 커널 스레드와 N:M으로 연결되는 유저 레벨 경량 스레드를 만들 수 있습니다. 이 가상 스레드도 오늘 본 Thread class를 활용/상속 받으면서 구현되는데 이 부분은 이미 저랑 비슷하게 소스코드 뜯어보는 글을 라인에서 썼더라구요

따라서 가상 스레드 소스 코드까지는 워낙 아티클이 많아서 직접 다루지는 않겠습니다. 글이 되게 좋아서 궁금하신 분은 읽어보시면 좋을 것 같네요.