Java 24에 들어간 변경으로, synchronized 때문에 생기던 가상 스레드 pinning을 제거했다. JEP는 JDK Enhancement Proposal의 약자로, 자바에 들어가는 변경 하나하나에 붙는 번호다.
무엇을 고쳤나 — 소유권을 누구 이름으로 기록할 것인가
synchronized가 잡는 모니터는 "이 블록에는 한 번에 하나만 들어간다"를 보장하는 장치다. JVM은 그 모니터가 지금 누구 것인지를 어딘가에 기록해야 하는데, 그 기준이 바뀐 것이 이 변경의 전부다.
[Java 21~23]
- 모니터 소유자 = 캐리어 스레드(OS 스레드)의 정체성
- 가상 스레드를 다른 캐리어로 옮기면 소유자가 바뀐 것처럼 보인다
- 옮길 수 없다 → 캐리어에 고정(pinning)
[Java 24+]
- 모니터 소유자 = 가상 스레드 자신의 정체성
- 어느 캐리어 위에 있든 소유자는 그대로
- 자유롭게 언마운트·재마운트 가능
이를 위해 HotSpot(오라클 JDK·OpenJDK의 JVM 구현)의 모니터 구현을 가상 스레드를 인지하도록 다시 썼다.
무엇이 가능해졌나
| 동작 | 21~23 | 24+ |
|---|---|---|
synchronized 안에서 블로킹 I/O | 캐리어 고정 | 언마운트됨 |
synchronized 안에서 Object.wait() | 캐리어 고정 | 언마운트됨 |
| 모니터 재획득 대기 | 캐리어 고정 | 언마운트됨 |
왜 이게 판을 바꿨나
가상 스레드 도입을 실제로 막던 게 이거였다. 드라이버·커넥션 풀·로깅 라이브러리 내부의 synchronized는 내가 고칠 수 없는 남의 코드라, 라이브러리 생태계 전체가 바뀌기를 기다려야 했다. JEP 491은 그 요구를 JVM 한 곳에서 해결했다.
부수적으로 이 변경은 리액티브를 고수할 근거 하나를 지웠다. "가상 스레드로 DB를 때리면 결국 pinning으로 막힌다"가 R2DBC·WebFlux를 유지할 실질적 이유였는데, 그게 사라졌다.
남은 pinning
네이티브 프레임(JNI·FFM), 클래스 로딩·정적 초기화, 리눅스 로컬 파일 I/O 셋은 여전히 캐리어를 고정한다.
면접 포인트
"가상 스레드에서 synchronized가 문제인가?"라는 질문에 버전을 나눠서 답하면 확실히 구분된다 — 21~23에서는 그랬고, 24의 JEP 491에서 제거됐으며, 네이티브 프레임·클래스 초기화·파일 I/O만 남았다.
실제로 무엇이 달라지나
// 라이브러리 내부에 흔한 패턴 — 내가 쓴 코드가 아니어도 걸린다
synchronized (connectionPool) {
return dataSource.getConnection(); // 여기서 블로킹
}
-
[Java 21~23] — 이 블록에 들어간 가상 스레드는 캐리어에 고정된다
- 캐리어가 8개(코어 수)뿐이라 8개가 물리면 전체가 멈춘다
-
[Java 24+] — 언마운트되어 캐리어를 놓아 준다
- 같은 코드, 같은 라이브러리, 재컴파일도 불필요
pinning이 있었는지 확인하는 법
# Java 21~23: pinning 발생 시 스택 트레이스를 찍어 준다
java -Djdk.tracePinnedThreads=full -jar app.jar
여기서 남의 라이브러리 이름이 찍히면 손쓸 방법이 없었다. JEP 491이 중요한 이유가 이것이다 — 생태계 전체가 바뀌기를 기다리는 대신 JVM 한 곳에서 해결했다.