# Virtual Thread Pinning on Synchronized Block
Java 21에서 아래 `synchronized` 블록의 락을 가상 스레드 A가 획득한 상태에서,
다른 가상 스레드 B가 `synchronized` 블록을 만나면 B는 락을 가지고 있지 않으므로 락 획득 시까지 기다려야 한다.
이 때 가상 스레드 B는 캐리어 스레드로부터 언마운트 될까 아니면 마운트 된 상태로 캐리어 스레드를 점유하며 자원을 낭비할까?
```java
synchronized (lock) {
// 시간은 오래 걸리지만 blocking은 없는 사례
}
```
## 캐리어 스레드를 점유/낭비한다
아래의 코드로 확인해 볼 수 있다.
```java
public class VirtualThreadPinningExample {
private static final Object lock = new Object();
public static void main(String[] args) {
giveSomeTimeForJFRInit();
List<Thread> threads = IntStream.range(0, 10)
.mapToObj(index -> Thread.ofVirtual().unstarted(() -> {
String threadInfo = "BEFORE " + index + " - " + Thread.currentThread() + "\n";
synchronized (lock) {
// 시간은 오래 걸리지만 blocking은 없는 사례
getFibonacci(40);
}
threadInfo += "AFTER " + index + " - " + Thread.currentThread() + "\n";
System.out.println(threadInfo);
}))
.toList();
threads.forEach(Thread::start);
threads.forEach(thread -> {
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// n번째 피보나치 수
private static long getFibonacci(int n) {
if (n <= 1) {
return n;
}
return getFibonacci(n - 1) + getFibonacci(n - 2);
}
private static void giveSomeTimeForJFRInit() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
```
Java 21에서 `java VirtualThreadPinningExample.java` 명령으로 실행하면 다음과 같이 출력된다.
```
BEFORE 6 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7
AFTER 6 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7
BEFORE 7 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-8
AFTER 7 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-8
BEFORE 8 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-9
AFTER 8 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-9
BEFORE 9 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-10
AFTER 9 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-10
BEFORE 0 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1
AFTER 0 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1
BEFORE 3 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-4
AFTER 3 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-4
BEFORE 2 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
AFTER 2 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
BEFORE 1 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
AFTER 1 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
BEFORE 5 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-6
AFTER 5 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-6
BEFORE 4 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-5
AFTER 4 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-5
```
먼저 출력 내용이 무엇을 뜻하는지 알아보자.
예를 들어, `VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7`의 각 부분이 의미하는 바는 다음과 같다.
- `#37`는 가상 스레드 식별자이고,
- `runnable`은 가상 스레드의 상태
- `ForkJoinPool-1`는 가상 스레드를 캐리어 스레드에 마운트하는 스케줄러
- `worker-7`은 가상 스레드가 마운트 된 캐리어 스레드
`VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7`는
`37번 가상 스레드가 ForkJoinPool-1 스케줄러에 의해 worker-7 캐리어 스레드에 마운트 되어 실행 중인 상태`를 나타낸다.
위 결과를 다시 살펴보면 가상 스레드별 BEFORE/AFTER 값이 모두 동일한 것을 확인할 수 있다.
즉, 가상 스레드가 `synchronized` 블록에 들어가기 전에 마운트 돼 있던 캐리어 스레드와 `synchronized` 블록을 실행하고 빠져나온 후에 마운트 돼 있는 캐리어 스레드가 동일하다는 뜻이다. 10개만으로 부족해 보인다면 수를 늘려서 확인해도 결과는 마찬가지다.
결국 다음과 같이 결론 지을 수 있다.
:::info
**가상 스레드는 `synchronized`의 락 획득을 기다리는 동안 언마운트 되지 못하고 캐리어 스레드를 점유하며 자원을 낭비한다.**
:::
한편, 가상 스레드와 `synchronized` 얘기가 나오면 꼭 함께 따라나오는 용어가 있다.
바로 **가상 스레드 고정(pinning)** 이다.
그렇다면 위와 같이 `synchronized`가 사용된 상황에서 `synchronized` 블록 전/후로 가상 스레드가 동일한 캐리어 스레드에 마운트 돼 있었다면 고정이 발생한 것일까? 발생한 것이라면 언제 고정이 되는 걸까?
## 고정 발생 시점
JEP 444의 [Executing virtual threads](https://openjdk.org/jeps/444#Executing-virtual-threads) 섹션 중간 정도에 보면 다음과 같이 나와있다.
>There are two scenarios in which a virtual thread cannot be unmounted during blocking operations because it is pinned to its carrier:
>
> 1. When it executes code inside a synchronized block or method, or
> 2. When it executes a native method or a foreign function.
native 메서드나 foreign 함수는 잠시 제쳐두고 `synchronized` 부분만 살펴보면,
**`synchronized` 블록이나 메서드 내부의 코드를 실행할 때**라고 설명하고 있다.
따라서 다음과 같이 정리할 수 있다.
:::info
**가상 스레드는 `sychronized` 블록이나 메서드에 진입할 때 또는 진입한 직후에 고정 된다.**
:::
또한 다음 사실에도 유의해야 한다.
:::warning
**가상 스레드가 `synchronized`의 락 획득을 기다리는 동안 언마운트 되지 못하는 것은 `synchronized` 블록 진입 전에 해당하므로 가상 스레드 고정과는 무관하다.**
:::
## 가상 스레드 고정 문제 진단
앞서 살펴본 JEP 444의 pin 관련 내용 바로 다음에 다음과 같은 문장이 나온다.
>Pinning does not make an application incorrect, but it might hinder its scalability.
>
>고정이 발생한다고 해서 애플리케이션이 잘못 동작하고 있다는 뜻은 아니다. 하지만 고정이 발생하면 확장성을 저해할 수도 있다.
즉, 고정 발생 자체가 문제는 아니다. 그래서 JDK에서도 **가상 스레드 고정 자체를 검출하는 도구가 아니라 가상 스레드 고정 때문에 발생하는 문제를 검출하는 진단 도구를 제공한다.**
### `-Djdk.tracePinnedThreads=full`
다음과 같이 `-Djdk.tracePinnedThreads=full`를 사용해서 예제를 실행해보자.
```
java -Djdk.tracePinnedThreads=full VirtualThreadPinningExample.java
```
출력 결과에 별다른 변화가 없을 것이다.
`synchronized` 블록에 진입하면서 고정이 발생하지만, 그로 인한 문제가 없기 때문에 별다른 변화가 없는 것이다.
이제 고정에 의한 문제가 발생하도록 예제 소스를 수정해서 다시 실행 해보자.
```java
synchronized (lock) {
// 시간은 오래 걸리지만 blocking은 없는 사례
// getFibonacci(40);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
```
피보나치 메서드 대신에 블로킹을 유발하는 `Thread.sleep(50)`을 호출한다.
이렇게 되면 `synchronized` 블록에 들어오면서 가상 스레드가 고정되고,
그 이후 `Thread.sleep(50)`을 호출하면서 블로킹 되어도 언마운트 되지 못하고 캐리어 스레드를 점유하면서 자원을 낭비하는 문제가 발생한다.
다시 `-Djdk.tracePinnedThreads=full` 플래그를 지정하고 실행하면 다음과 같이 눈에 띄는 변화가 나타난다.
```
java -Djdk.tracePinnedThreads=full VirtualThreadPinningExample.java
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(VirtualThreadPinningExample.java:23) <== monitors:1
java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
BEFORE 0 - VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
AFTER 0 - VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
BEFORE 1 - VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2
AFTER 1 - VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2
BEFORE 3 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-4
AFTER 3 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-4
BEFORE 7 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-8
AFTER 7 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-8
BEFORE 8 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-9
AFTER 8 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-9
BEFORE 6 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-7
AFTER 6 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-7
BEFORE 2 - VirtualThread[#30]/runnable@ForkJoinPool-1-worker-3
AFTER 2 - VirtualThread[#30]/runnable@ForkJoinPool-1-worker-3
BEFORE 5 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-6
AFTER 5 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-6
BEFORE 9 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-10
AFTER 9 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-10
BEFORE 4 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-5
AFTER 4 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-5
```
이번에는 다음 내용이 추가로 출력되었다.
```
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(VirtualThreadPinningExample.java:23) <== monitors:1
java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
```
28번 가상 스레드가 `Thread.sleep()`을 호출하면서 `parkNanos()`를 호출하고 언마운트를 시도하지만,
`synchronized` 블록의 락(MONITOR)를 가지고 `synchronized` 블록에 진입한 상태, 즉 이미 고정돼 있으므로 언마운트 될 수 없는 상황을 보여주고 있다.
그런데 이상한 점이 하나 있다.
고정은 10개의 스레드 모두에서 발생하므로 위 로그도 10번 출력돼야 할 것 같은데 왜 1번만 출력되는 걸까?
혹시 고정이 1번만 발생한 것은 아닐까?
### JFR(JDK Flight Recorder)
JEP 444에서 [가상 스레드 관련해서 네 가지 JFR 이벤트가 추가](https://openjdk.org/jeps/444#JDK-Flight-Recorder-JFR)되었다.
- VirtualThreadStart
- VirtualThreadEnd
- VirtualThreadPinned
- VirtualThreadSubmitFailed
이 중에서 현재 관심사인 VirtualThreadPinned에 대한 설명만 살펴보자.
>jdk.VirtualThreadPinned indicates that a virtual thread was parked while pinned, i.e., without releasing its platform thread (see above). This event is enabled by default, with a threshold of 20ms.
>
>jdk.VirtualThreadPinned는 가상 스레드가 고정된 상태에서 park 되었음을, 즉 가상 스레드가 플랫폼 스레드를 놓아주지 못하고 점유하고 있음을 나타낸다. 이 이벤트는 기본으로 활성화 되며 임계값은 20ms이다.
즉, **원래 park 되면 플랫폼 스레드(캐리어 스레드)를 놓아줘야 하지만 고정돼 있기 때문에 놓아주지 못하고 20ms 이상 점유하면 jdk.VirtualThreadPinned 이벤트가 발생한다**는 얘기다.
이제 JFR을 사용해서 가상 스레드 고정으로 인한 문제를 진단해보자.
다음 명령으로 `recording-sync-sleep-21.jfr` 파일에 가상 스레드 관련 기록을 남길 수 있다.
```
java -Djdk.tracePinnedThreads=full -XX:StartFlightRecording=filename=recording-sync-sleep-21.jfr VirtualThreadPinningExample.java
```
실행 후 다음 명령으로 jfr 파일에서 jdk.VirtualThreadPinned 이벤트 내용을 출력할 수 있다.
```
jfr print --events jdk.VirtualThreadPinned recording-sync-sleep-21.jfr
jdk.VirtualThreadPinned {
startTime = 12:45:59.596 (2026-01-03)
duration = 45.0 ms
eventThread = "" (javaThreadId = 36, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.642 (2026-01-03)
duration = 52.7 ms
eventThread = "" (javaThreadId = 34, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.695 (2026-01-03)
duration = 54.7 ms
eventThread = "" (javaThreadId = 33, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.750 (2026-01-03)
duration = 51.1 ms
eventThread = "" (javaThreadId = 38, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.801 (2026-01-03)
duration = 56.9 ms
eventThread = "" (javaThreadId = 35, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.858 (2026-01-03)
duration = 56.2 ms
eventThread = "" (javaThreadId = 39, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.915 (2026-01-03)
duration = 51.7 ms
eventThread = "" (javaThreadId = 32, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:45:59.969 (2026-01-03)
duration = 61.3 ms
eventThread = "" (javaThreadId = 41, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:46:00.032 (2026-01-03)
duration = 53.8 ms
eventThread = "" (javaThreadId = 37, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
jdk.VirtualThreadPinned {
startTime = 12:46:00.087 (2026-01-03)
duration = 54.6 ms
eventThread = "" (javaThreadId = 40, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
java.lang.VirtualThread.parkNanos(long) line: 648
java.lang.VirtualThread.sleepNanos(long) line: 807
java.lang.Thread.sleep(long) line: 507
io.homoefficio.scratchpad.virtualthread.pinning.VirtualThreadPinningExample.lambda$main$0(int) line: 23
...
]
}
```
`jdk.VirtualThreadPinned` 이벤트가 정확히 10번 표시된다. 따라서 고정은 1번이 아니라 10번 발생했다는 사실을 알 수 있다.
#### `jdk.VirtualThreadPinned`의 진정한 의미
하지만, `jdk.VirtualThreadPinned` 이벤트가 정확히 10번 표시되는 것을 **'고정이 10번 발생했다'라고 해석하는 것은 정확하지 않다**는 점에 유의하자.
고정이 10번 발생한 것은 맞지만, 앞서 `jdk.VirtualThreadPinned`의 설명에서 살펴본 것처럼 **고정으로 인해 캐리어 스레드를 비효율적으로 점유하는 문제가 10번 발생했다**라고 해석해야 정확하다.
그렇다면 고정이 되어도 그로 인해 캐리어 스레드를 비효율적으로 점유하는 문제가 발생하지 않으면 `jdk.VirtualThreadPinned` 이벤트는 발생하지 않는다는 얘긴가?
이를 확인하기 위해 다시 `Thread.sleep()` 대신에, 캐리어 스레드를 비효율적으로 점유하지 않는 피보나치 수 계산으로 되돌려서 JFR 이벤트가 어떻게 기록되는지 살펴보자.
```java
synchronized (lock) {
// 시간은 오래 걸리지만 blocking은 없는 사례
getFibonacci(40);
//try {
// Thread.sleep(50);
//} catch (InterruptedException e) {
// throw new RuntimeException(e);
//}
}
```
이번엔 `recording-sync-fibo-21.jfr` 파일에 기록한다.
```
java -Djdk.tracePinnedThreads=full -XX:StartFlightRecording=filename=recording-sync-fibo-21.jfr VirtualThreadPinningExample.java
[0.208s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
[0.208s][info][jfr,startup]
[0.208s][info][jfr,startup] Use jcmd 3033 JFR.dump name=1 to copy recording data to file.
BEFORE 2 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3
AFTER 2 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3
BEFORE 9 - VirtualThread[#41]/runnable@ForkJoinPool-1-worker-10
AFTER 9 - VirtualThread[#41]/runnable@ForkJoinPool-1-worker-10
BEFORE 6 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-7
AFTER 6 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-7
BEFORE 0 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-1
AFTER 0 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-1
BEFORE 5 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-6
AFTER 5 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-6
BEFORE 4 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-5
AFTER 4 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-5
BEFORE 7 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-8
AFTER 7 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-8
BEFORE 3 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-4
AFTER 3 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-4
BEFORE 8 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-9
AFTER 8 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-9
BEFORE 1 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-2
AFTER 1 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-2
```
jfr 명령으로 출력해보면,
```
jfr print --events jdk.VirtualThreadPinned recording-sync-fibo-21.jfr
```
아무것도 출력되지 않는다.
즉, `synchronized`의 락을 획득하고 블록에 진입하면서 고정은 됐지만,
그 이후 `synchronized` 블록 내 코드에서 캐리어 스레드를 비효율적으로 점유하며 자원을 낭비하는 문제는 발생하지 않기 때문에, 아무런 `jdk.VirtualThreadPinned` 이벤트도 기록되지 않았다.
따라서 `jdk.VirtualThreadPinned`에 대해 다음과 같이 결론 지을 수 있다.
:::info
- `jdk.VirtualThreadPinned`는 **가상 스레드가 고정될 때 무조건 발생하는 이벤트가 아니라, 고정으로 인해 캐리어 스레드를 비효율적으로 점유하는 문제가 생길 때 발생하는 이벤트다.**
- 가상 스레드가 고정되더라도 고정 해제 전까지 캐리어 스레드를 비효율적으로 점유하지 않으면 `jdk.VirtualThreadPinned`는 발생하지 않는다.
:::
## 마무리
`synchronized`와 관련한 가상 스레드 이야기는 다음과 같이 정리할 수 있다.
:::info
- Java 21에서,
- `synchronized` 블록에 진입하기 전에 락을 획득하기 위해 기다릴 때, 가상 스레드는 캐리어 스레드를 점유한 상태로 기다리면서 자원을 낭비한다.
- 이 때는 가상 스레드가 고정됐기 때문에 점유하는 것이 아니고,
- `synchronized` 키워드 처리 시 플랫폼 스레드만 락을 획득/보유/반환하는 주체로 작동하도록 되어 있기 때문이다.
- 고정되는 시점은 `synchronized` 락을 획득해서 블록에 진입할 때 또는 블록에 진입한 직후이다.
- JEP 444에서 추가된 JFR 이벤트인 `jdk.VirtualThreadPinned`는 이름만 보면 고정이 될 때 발생하는 이벤트 같지만,
- 실제로는 고정으로 인해 캐리어 스레드를 비효율적으로 점유할 때 발생한다.
:::
참고로 글에서는 `synchronized`만 다루었지만, native 메서드에 대해서는 다루지 않았다.
지금까지 알아본 내용은 모두 Java 21 기준이다.
Java 21에서 `synchronized` 관련 자원 낭비는 다음과 같다.
:::warning
- `synchronized` 블록 진입 전에는
- 가상 스레드가 락 획득을 기다리는 중에 고정되지 않더라도 캐리어 스레드를 점유해서 자원을 낭비하고,
- `synchronized` 블록 진입 후에는
- 가상 스레드가 고정됐기 때문에 블로킹 연산 실행 시에도 언마운트 되지 못하고 캐리어 스레드를 점유한 채 블로킹 연산 완료를 기다리면서 자원을 낭비한다.
:::
`synchronized` 대신에 `ReentrantLock`을 사용하면 락을 가진 상태에서 블로킹 될 때 가상 스레드가 언마운트 될 수 있으므로 자원 낭비를 피할 수 있다.
직접 작성하는 코드는 `ReentrantLock`을 사용하도록 바꾸면 되지만 사용하는 라이브러리에 있는 `synchronized` 블록은 어쩔 수가 없다.
이런 단점 때문에 Java 21 가상 스레드를 실무에 적용하기엔 현실적으로 무리가 있었다.
하지만 이 문제는 다행스럽게도 [JEP 491](https://openjdk.org/jeps/491)이 적용된 Java 24에서 해결됐다.
JEP 491 내용 중 `synchronized` 관련 내용을 요약하면 다음과 같다.
>- 이전에는 `synchronized` 블록의 락을 획득하고 반환하는 실질적인 주체가 플랫폼 스레드이기 때문에 가상 스레드를 캐리어 스레드에 고정할 수 밖에 없었는데,
>- JEP 491에서는 가상 스레드도 `synchronized` 블록의 락을 획득/보유/반환할 수 있도록 `synchronized` 처리 로직을 변경해서,
> - `synchronized` 블록 진입 전/후 모두에서 가상 스레드가 캐리어 스레드에 고정될 필요없이 언마운트 되어 자원 낭비를 방지할 수 있게 되었다.
## 오류 정정 - 1
이 글의 첫 번째 버전은 '`synchronized` 블록의 락 획득을 기다릴 때 가상 스레드가 언마운트 된다'고 완전히 잘못된 결론을 내렸다. ㅠㅜ
언마운트 된다고 오판한 이유는, 특이하게도 `MessageFormat.format()` 실행만으로도 언마운트가 유발되기 때문이다.
`synchronized` 블록을 아예 없애고 `MessageFormat.format()`를 사용해서 BEFORE/AFTER를 출력하도록 수정해서 테스트해보면 다음과 같이 가상 스레드별 BEFORE/AFTER가 달라지는 것을 확인할 수 있다.
```
java -XX:StartFlightRecording=filename=recording-messageformat-21.jfr,settings=VThreadEvents.jfc VirtualThreadPinningExample.java
[0.270s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
[0.270s][info][jfr,startup]
[0.270s][info][jfr,startup] Use jcmd 34551 JFR.dump name=1 to copy recording data to file.
BEFORE 2 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
BEFORE 3 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-4
BEFORE 6 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7
BEFORE 7 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-8
AFTER 6 - VirtualThread[#37]/runnable@ForkJoinPool-1-worker-3
AFTER 7 - VirtualThread[#38]/runnable@ForkJoinPool-1-worker-10
BEFORE 8 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-9
BEFORE 1 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
AFTER 8 - VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1
BEFORE 5 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-6
BEFORE 0 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1
AFTER 5 - VirtualThread[#36]/runnable@ForkJoinPool-1-worker-10
AFTER 0 - VirtualThread[#31]/runnable@ForkJoinPool-1-worker-3
BEFORE 4 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-5
BEFORE 9 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-10
AFTER 2 - VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
AFTER 3 - VirtualThread[#34]/runnable@ForkJoinPool-1-worker-5
AFTER 1 - VirtualThread[#32]/runnable@ForkJoinPool-1-worker-5
AFTER 4 - VirtualThread[#35]/runnable@ForkJoinPool-1-worker-5
AFTER 9 - VirtualThread[#40]/runnable@ForkJoinPool-1-worker-3
```
그래서 새 버전의 글에서는 혼동을 일으키는 `MessageFormat.format()`을 사용하지 않고 `System.out.printf()`를 사용했다.
참고로 JFR 가상 스레드 이벤트 로그를 보면 당연하게도 `VirtualThreadPinned`는 한 번도 발생하지 않는다.
그러면 `MessageFormat.format()`을 사용할 때는 언마운트가 발생하고, `System.out.printf()`를 사용할 때는 언마운트가 발생하지 않는 이유는 무엇일까? 아래는 100% 확실한 것은 아니고 추정이다.
`MessageFormat.format()` 뿐만 아니라 ``"xxxxx".formatted()`` 등 `printf` 가 아닌 다른 방법으로 포맷팅 할 때는 언마운트가 발생한다.
이런 포맷팅 과정에도 `synchronized` 블록이 여러 곳에서 발견되는데, 그럼에도 불구하고 언마운트가 발생하는 이유는, `synchronized`의 락 획득 대기 중에는 `printf`의 경우와 마찬가지로 언마운트 되지 않는데, 락 획득 대기 외의 여러 처리 과정 어딘가에서 안전하게 언마운트 할 수 있는 지점이 있고, **스케줄링 관점에서 볼 때 언마운트 하는 것이 낫다고 판단해서 그 지점에서 언마운트가 발생**한 것으로 보인다. 그 지점이 정확히 어딘지는 파악하지 못했지만, 논리적으로 설명은 된다.
반대로 `printf` 에서도 `synchronized`의 락 획득 대기 외의 여러 처리 과정이 있을텐데 왜 언마운트 되지 않는 걸까?
`printf`는 `MessageFormat.format()` 보다 포맷팅 과정이 더 단순하고 짧은 시간에 처리할 수 있어서, **스케줄링 관점에서 언마운트 하지 않고 그냥 동일한 캐리어 스레드에서 계속 수행하는 것이 더 낫다고 판단해서 언마운트가 발생하지 않은 것**으로 보인다.
## 오류 정정 - 2
두 번째 버전에서도 큼지막한 오류가 있었다.
`blocking이 없으면 pinning도 없다`는 완전히 잘못된 해석이다.
`jdk.VirtualThreadPinned`를 이름만 보고 가상 스레드가 고정될 때 발생하는 이벤트라고 오해했기 때문에 발생한 오류였다.
본문에 설명한 것처럼 `jdk.VirtualThreadPinned` 이벤트는 가상 스레드가 고정되어 캐리어 스레드를 비효율적으로 점유하는 문제를 유발할 때 발생한다.
고정은 블로킹이 있든 없든 발생하며, 고정된 상태에서 블로킹이 되고 이로 인해 문제가 될 때에만 `jdk.VirtualThreadPinned` 이벤트가 발생한다.