Java 24で導入された、JEP 491: Synchronize Virtual Threads without Pinningはwithout Pinningです!ここが重要です。
3行で
- Java 24からでもVirtualThread上で
synchronizedを使ってもいいわけではなく、これまでどおりできるだけ使わない。 - JEP 491はPlatform Threadが
synchronizedを含むVirtual Threadによって占有されることを防ぎ、ほかのVirtual Threadを処理できるようにする。 - 複数のVirtual Threadで同じオブジェクトをモニタ(≒ロック)している
synchronized処理はこれまで通りロックの取り合いで止まる。
背景
Java 24は、JEP 491: Synchronize Virtual Threads without Pinningが導入されました。
Java 24がリリースされた日にJavaOneがやっていたのですが、そこでの質問や会話、SNSなどでこのJEPによってsynchronizedを使っても良いと理解している人が多いなと思うことがありました。
おおくの人がVirtual Threadに注目・期待していることもあり、これは危険だなと思ったので、実際の振る舞いも紹介します。
synchronizedの振る舞いについて
Virtual Threadが登場する前の並列処理
まずはじめに、昔からある普通のスレッドでsynchronized を使うとどうなるかを見てみます。Synchronized Taskは、synchronizedの中でブロッキング処理をするタスクです。この例では、Task 1 と Task 2 が同じオブジェクトでロックするとします。これらを2つのスレッドでそれぞれ実行してみます。

すると、先に実行したTask 1がロックを取得し、後から実行したタスク 2はTask 1がロックを解除するまでロック待ちになります。点線のある位置で、Task 2 がロックを取得すると、同様に処理を行います。
Java 23 までのVirtual Thread
Java 23 までは、 Virtual Thread 上で synchronizedを使うと、そのVirtual Threadは別のVirtual ThreadにPlatform Threadを譲りません。synchronizedの中でブロッキング処理をすると、実際に処理しているPlatform Threadをブロックします。
そのため、このPlatform Threadはその間に他のVirtualThreadを処理できません。
詳細を知りたい方はJEP 491: Synchronize Virtual Threads without PinningのVirtual threads are pinned in synchronized methodsとThe reason for pinningをごらん下さい。
実際に2つの例を見てみます。 1つ目は同じPlatform Thread上に、同じオブジェクトをロックするタスクがVirtual Threadとして連続で来た例です。 2つ目は異なるPlatform Thread 上に、同じオブジェクトをロックするタスクがVirtual Threadとして同じタイミングで来た例です。
これらの例を図解します。 図は、Platform Thread からの視点と、各Virtual Treadからの視点の両方をまとめて記載しています。
1つ目の例は、2つのタスクがそれぞれ処理されます。
synchronizedなのでブロッキングしていても他の Virtual Thread に処理を受け渡さないため、タスクがそれぞれ順番に処理されます。
順番に処理されるのでロック待ちはありません。
これらのタスクが終わるまで、他の種類のタスクも同様に待たされます。

2つ目の例は、2つのタスクが異なるPlatform Threadで処理されます。
これは、普通のスレッドで処理される場合と同じで、ロックを取れなかったタスクはロックを取得するまで待ちます。
Virtual Threadはsynchronizedの間、Platform Threadを占有し続けるため、ほかのVirtual Threadは待ち続けます。
また、ロック待ちをしているほうの Virtual Thread では、Platform Thread がロック待しています。
そのため、そのPlatform Threadは、ほかのVirtual Threadを処理する期間が短くなります。
並列度が上がるとロック待ちはどんどん長くなっていくため、そうなるとPlatform ThreadはVirtual Threadを処理できなくなります。

Java 24 からのVirtual Thread
Java 24は JEP 491: Synchronize Virtual Threads without Pinning が導入されました。
これはVirtual Threadが synchronized 内でブロッキング処理をしても、Platform Threadを他のVirtual Threadに譲ります。
(重要)しかし、
synchronizedのロックの動きはこれまで通り有効で、Virtual Thread 内では、ロック待ちが行われます。

検証
次のソースコードは、先ほどまで図に記載していたものです。
synchronizedの中でブロッキング処理をするタスクと、ただログを出力するタスクがあります。
synchronizedの中でブロッキング処理をするタスクは、SynchronizedTask です。
ログを出力するタスクは、NoSynchronizedTaskです。
ブロッキング処理はスレッドを3秒間スリープさせます。
このコードは、SynchronizedTask を 2つ、NoSynchronizedTask を 1 つ、この順番で実行します。
package org.example; import jdk.jfr.Configuration; import jdk.jfr.Recording; import java.nio.file.Paths; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; // java -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main public class Main { public static void main(String[] args) throws Exception { try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) { executorService.submit(new SynchronizedTask()); executorService.submit(new SynchronizedTask()); executorService.submit(new NoSynchronizedTask()); System.out.println("Main finished putting tasks"); } } } class SynchronizedTask implements Runnable { private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss"); private static final Object monitorObject = new Object(); @Override public void run() { System.out.println("Synchronized Task start at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread()); synchronized (monitorObject) { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { throw new RuntimeException(e); } } System.out.println("Synchronized Task end at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread()); } } class NoSynchronizedTask implements Runnable { private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss"); @Override public void run() { System.out.println("No Synchronized Task run at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread()); } }
今回は、Platform Threadが詰まるのを容易に再現するため、Platform Threadを1つにします。これは、JVM起動引数に-Djdk.virtualThreadScheduler.maxPoolSize=1を付与します。
Java 23の結果
Java 23 では、 SynchronizedTask 同士が順番に処理されています。 1つ目のタスクが11:58:57に開始し11:59:00に終わってから、同時刻に2つ目のタスクが開始し、11:59:03に終わります。 NoSynchronizedTaskは、2つ目のタスクが終わった11:59:03に実行されてます。
java-23/bin/java.exe -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main Main finished putting tasks Synchronized Task start at 11:58:57 @VirtualThread[#38]/runnable@ForkJoinPool-1-worker-1 Synchronized Task end at 11:59:00 @VirtualThread[#38]/runnable@ForkJoinPool-1-worker-1 Synchronized Task start at 11:59:00 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1 Synchronized Task end at 11:59:03 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1 No Synchronized Task run at 11:59:03 @VirtualThread[#41]/runnable@ForkJoinPool-1-worker-1
Java 24の結果
Java 24 では、 すべてのタスクが12:25:38に開始しています。 1つ目の Synchronized Task は、12:25:41に終了し、2つ目は 1 つ目の終了時間から3秒後である12:25:44に終わっています。 Synchronized Task の終了を待つこと無く No Synchronized Task を実行できています。
java-24\bin\java.exe -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main Main finished putting tasks Synchronized Task start at 12:25:38 @VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1 Synchronized Task start at 12:25:38 @VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1 No Synchronized Task run at 12:25:38 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1 Synchronized Task end at 12:25:41 @VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1 Synchronized Task end at 12:25:44 @VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1
まとめ
Java 24は、JEP 491: Synchronize Virtual Threads without Pinningが導入されました。without Pinningです。依然としてロック待ちは発生しますのでご注意ください。
Virtual Threadは、ブロッキングを意識せず、大量のタスクを実行しやすくなりました。
ですが、Virtual Thread で大量に`synchronized‘なタスクを実行すると、タイムアウトするまで無限に待たされることになるかもしれないので注意が必要です。