자바 스레드는 사실 OS 스레드다
new Thread() 를 부르면 실제로 무슨 일이 일어나는가
자바 코드에서 new Thread(() -> {...}).start() 를 호출하면, JVM 은 이걸 그대로 운영체제에게 위임합니다. 리눅스라면 clone() 시스템 콜이 호출되고, 커널이 새로운 실행 단위를 만들어 스케줄러 대기열에 올립니다. 이 스레드는 자바 객체이기 전에 OS 가 관리하는 진짜 커널 자원입니다.
Thread t = new Thread(() -> {
System.out.println("실행 중: " + Thread.currentThread());
});
t.start();이 한 줄이 가볍게 보이지만, 내부적으로는 스택 메모리 예약, 커널 자료구조 할당, 스케줄러 등록이라는 무거운 작업을 동반합니다. 자바 객체로서의 Thread 는 이 커널 자원을 감싸는 얇은 래퍼에 불과합니다.
스택 크기라는 숨은 비용
JVM 플랫폼 스레드의 기본 스택 크기는 -Xss 옵션으로 조정 가능하지만, 대부분 손대지 않고 기본값(리눅스 64비트 기준 보통 1MB)을 씁니다. 이 1MB 는 가상 메모리 주소 공간에서 예약되는 것이지 즉시 물리 메모리를 다 쓰는 건 아니지만, 스레드 수가 늘어나면 무시할 수 없는 압박이 됩니다.
| 스레드 수 | 예약 스택 메모리(기본 1MB 기준) |
|---|---|
| 200 | 약 200MB |
| 5,000 | 약 5GB |
| 100,000 | 약 100GB (사실상 생성 불가) |
이 표가 왜 중요한지 감이 안 온다면 이렇게 바꿔 생각해 보세요. 동시 접속자 10만 명을 스레드 하나당 한 명씩 담당하는 구조로 서비스하려면, 스레드 스택만으로 서버 메모리가 바닥납니다. 실제로는 그 전에 운영체제의 스레드 수 제한(ulimit -u, /proc/sys/kernel/threads-max)에 먼저 걸립니다.
컨텍스트 스위칭은 "공짜 멀티태스킹"이 아니다
스레드가 여러 개 있어도 CPU 코어 수는 한정돼 있습니다. 코어 4개짜리 서버에서 스레드 500개를 굴리면, 운영체제 스케줄러는 이 500개를 짧은 시간 단위(타임 슬라이스)로 번갈아 코어에 배정합니다. 스레드를 교체할 때마다 레지스터 값, 프로그램 카운터, 캐시 상태를 저장하고 복원해야 합니다.
이 비용 자체는 스레드 하나당 마이크로초 단위로 작지만, 문제는 스레드 수가 늘어날수록 교체 빈도도 같이 늘어난다는 점입니다. CPU 가 실제 작업(payload)보다 "누구를 다음에 실행할지 결정하고 전환하는" 오버헤드에 더 많은 시간을 쓰는 순간이 옵니다. 이걸 스레드 스레싱(thrashing)이라고 부르기도 합니다. top 이나 vmstat 으로 컨텍스트 스위치 횟수(cs 컬럼)가 비정상적으로 높다면 이 증상을 의심해볼 만합니다.
그래서 가상 스레드는 무엇을 다르게 하는가
가상 스레드는 이 두 가지 비용 — 스택 메모리, 컨텍스트 스위칭 — 을 모두 줄이는 방향으로 설계됐습니다. 스택은 처음엔 아주 작게 시작해서 필요한 만큼만 힙에서 자라나고(고정 1MB 예약이 아님), 스케줄링은 커널이 아니라 JVM 내부의 스케줄러(ForkJoinPool 기반)가 담당합니다. 커널 컨텍스트 스위칭 없이 JVM 레벨에서 훨씬 싸게 전환할 수 있다는 뜻입니다.
다만 이 말을 "가상 스레드는 마법처럼 스레드 문제를 없앤다"로 오해하면 안 됩니다. 정확히는, 가상 스레드는 커널 스레드(캐리어 스레드) 위에서 동작합니다. 여러 개의 가상 스레드가 하나의 캐리어 스레드를 번갈아 씁니다. 이 관계가 어떻게 작동하는지, 그리고 왜 어떤 상황에서는 이 관계가 깨지는지(pinning)는 다음 장들에서 다룹니다. 지금 기억해야 할 건 하나입니다 — 가상 스레드도 결국 OS 스레드 위에서 실행된다는 사실. 이걸 잊으면 뒤에 나오는 pinning 문제가 왜 생기는지 이해하기 어렵습니다.