We switched to Java 21 virtual threads and got a deadlock in TPC-C for Postgres
Java 21’s new virtual threads are exposing subtle deadlock risks in existing Java code, particularly when older synchronization primitives like `synchronized` and `Object.wait()` are used inside libraries such as JDBC connection pools. Commenters note that these constructs can “pin” carrier threads and exhaust the limited underlying thread pool, breaking programs that were safe with many OS threads. The consensus is that virtual threads are promising for I/O-heavy workloads but not yet a drop‑in replacement everywhere, and that developers should audit libraries, prefer modern concurrency utilities (locks, semaphores, concurrent collections), and tune or wait for runtime fixes before broad adoption.
Nature of the deadlock
- Consensus that virtual threads didn’t “magically” create a new class of deadlock; the benchmark exposed an existing design issue under new constraints.
- Deadlock arose because virtual threads were blocked inside
c3p0connection pool code usingsynchronized+Object.wait, exhausting carrier threads. - The fix in the article—guarding DB connections with a
Semaphore—keeps virtual threads from entering c3p0 until a connection is available, so they block in VT‑aware code instead of inside the pool. - Several commenters emphasize this is a generic JDBC/connection-pool issue, not specific to PostgreSQL.
Virtual threads, pinning, and forward progress
- Virtual threads can’t be unmounted when:
- Running inside
synchronizedblocks/methods, or - Executing native/foreign code, or certain legacy blocking calls like
Object.wait.
- Running inside
- In these cases the carrier (OS) thread is “pinned”; if many such calls happen, the VT scheduler’s carrier pool (default max 256, configurable via
jdk.virtualThreadScheduler.maxPoolSize) can be exhausted. - Some clarify that
Object.waitboth releases the monitor and is implemented in native code, so the problem is not justsynchronizedbut also thatwaititself doesn’t cooperate with VT scheduling. - Concern is less about correctness of locks and more about loss of “forward progress” when all carriers are blocked.
“Drop‑in replacement” vs. new semantics
- Documentation and some messaging framed virtual threads as mostly drop‑in; others point to the adoption guide, which explicitly warns against blindly swapping all platform threads for VTs.
- One camp: if your design requires more simultaneously runnable threads than carriers, the design was fragile; VT just exposes that.
- Other camp: previous code was correct under the assumption that the OS could always spawn more threads; introducing a hard carrier cap silently changes system behavior and can break such code.
- There is disagreement on whether documentation adequately warned about deadlocks vs. merely “scalability” issues.
Workarounds and best practices discussed
- Replace
synchronized/wait/notifypatterns with:java.util.concurrentlocks, semaphores, and conditions.- Concurrent queues for producer–consumer patterns.
- Avoid long‑running or blocking operations inside
synchronized; treat that as bad style even without VTs. - Use carrier‑thread pools and configuration:
- Increase
jdk.virtualThreadScheduler.maxPoolSizewhere appropriate. - Offload CPU‑bound or native calls to platform-thread executors.
- Increase
- Use diagnostic tools like
-Djdk.tracePinnedThreadsto find problem areas. - Some suggest switching from older pools like c3p0 to actively maintained ones (e.g., HikariCP), though even modern pools are still adjusting for VTs.
Maturity of virtual threads and future fixes
- Many view virtual threads as powerful but not yet “set and forget”; they’re best for IO‑bound, modern code that avoids legacy primitives.
- Several note that the
synchronized/waitbehavior is a known, temporary implementation limitation and that the JDK team is working on making these VT‑friendly. - Some recommend waiting for a future LTS where
synchronizedandObject.waitfully cooperate with virtual threads before adopting them widely, especially in codebases with many third‑party libraries.
Comparisons and meta-discussion
- Comparisons drawn to Go, Erlang, Node.js, Python async, and .NET’s thread pool/async: all had early quirks when mixing blocking and non‑blocking models.
- Side discussions question whether green/M:N threading can ever be fully transparent in ecosystems built around OS threads.
- Smaller tangents cover HN title editorializing and widespread dislike of obvious AI‑generated hero images on technical posts.