Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
50f571d
Phase 0A: fix test runner for macOS Python 3 + add Makefile for multi…
megamattron Feb 21, 2026
e140563
Phase 0B: add JDK 21 to CI matrix (allow failures)
megamattron Feb 21, 2026
4c8d51a
Phase 0C: add unit tests for HibernateInterceptor, PropertiesEnhancer…
megamattron Feb 21, 2026
0658f3a
Phase 0C: update plan with audit findings and remaining gaps
megamattron Feb 21, 2026
0995dc9
Phase 0D: add TFB benchmark app and record baseline results
megamattron Feb 22, 2026
72827c6
Phase 1D: add Method cache to PropertiesEnhancer.FieldAccessor
megamattron Feb 22, 2026
a129b6b
Phase 0D: correct benchmark baseline and add profiling support
megamattron Feb 22, 2026
f686330
Phase 0D: tune thread/connection pool and record sweep results
megamattron Feb 22, 2026
00cb5d2
Add Phase 3D: virtual thread executor for request handling
megamattron Feb 22, 2026
ae375e8
Add docs/virtual-threads-rationale.md
megamattron Feb 22, 2026
f7adeb6
Phase 1A: remove -noverify flag
megamattron Feb 22, 2026
0610cb1
Phase 1B: remove SecurityManager usage
megamattron Feb 22, 2026
57365a2
Phase 1C: extract ConstructorEnhancer from PropertiesEnhancer
megamattron Feb 22, 2026
d475d8f
Phase 1E: add opt-in JPA standard dirty checking
megamattron Feb 22, 2026
b6fbdbe
benchmark.sh: reduce default duration and warmup
megamattron Feb 22, 2026
0eb4db8
MODERNIZATION_PLAN: add commit links for completed phases
megamattron Feb 22, 2026
ef00b9f
Phase 2C: remove PropertiesEnhancer from enhancer pipeline
megamattron Feb 22, 2026
e6d761d
Phase 2C: fix PropertiesEnhancerTest for default-off enhancer
megamattron Feb 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ jobs:

strategy:
matrix:
jdk: [ 11, 17 ]
jdk: [ 11, 17, 21 ]
os: [ubuntu-latest, windows-latest]
exclude:
- os: windows-latest
jdk: 11
- os: windows-latest
jdk: 21

# JDK 21 is work-in-progress: failures are expected and non-blocking
continue-on-error: ${{ matrix.jdk == 21 }}

name: Check / Tests -> JDK-${{ matrix.jdk }}/${{ matrix.os }}
steps:
Expand Down
448 changes: 372 additions & 76 deletions MODERNIZATION_PLAN.md

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Convenience targets for running Play Framework tests against specific JDK versions.
# JDK paths are resolved dynamically via /usr/libexec/java_home (macOS).
# On Linux, set JAVA_HOME explicitly: JAVA_HOME=/usr/lib/jvm/java-17-openjdk make test-jdk17
#
# Usage:
# make test-jdk17 # full test suite (unit + integration) on JDK 17
# make unittest-jdk17 # unit tests only on JDK 17
# make test-jdk11 # full test suite on JDK 11
# make test-jdk21 # full test suite on JDK 21

JDK11 := $(shell /usr/libexec/java_home -v 11 2>/dev/null)
JDK17 := $(shell /usr/libexec/java_home -v 17 2>/dev/null)
JDK21 := $(shell /usr/libexec/java_home -v 21 2>/dev/null)

.PHONY: test unittest test-jdk11 test-jdk17 test-jdk21 unittest-jdk11 unittest-jdk17 unittest-jdk21

# Default targets use JDK 17 (the primary supported version)
test: test-jdk17
unittest: unittest-jdk17

test-jdk11:
@test -n "$(JDK11)" || (echo "JDK 11 not found. Install: brew install --cask temurin@11"; exit 1)
cd framework && JAVA_HOME=$(JDK11) ant test

test-jdk17:
@test -n "$(JDK17)" || (echo "JDK 17 not found. Install: brew install --cask temurin@17"; exit 1)
cd framework && JAVA_HOME=$(JDK17) ant test

test-jdk21:
@test -n "$(JDK21)" || (echo "JDK 21 not found. Install: brew install --cask temurin@21"; exit 1)
cd framework && JAVA_HOME=$(JDK21) ant test

unittest-jdk11:
@test -n "$(JDK11)" || (echo "JDK 11 not found. Install: brew install --cask temurin@11"; exit 1)
cd framework && JAVA_HOME=$(JDK11) ant unittest

unittest-jdk17:
@test -n "$(JDK17)" || (echo "JDK 17 not found. Install: brew install --cask temurin@17"; exit 1)
cd framework && JAVA_HOME=$(JDK17) ant unittest

unittest-jdk21:
@test -n "$(JDK21)" || (echo "JDK 21 not found. Install: brew install --cask temurin@21"; exit 1)
cd framework && JAVA_HOME=$(JDK21) ant unittest
214 changes: 214 additions & 0 deletions docs/virtual-threads-rationale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# Virtual Threads — Why They Matter for Play 1.x

This document explains what virtual threads are, why they're a good fit for Play 1.x's
blocking programming model, why three specific framework changes are all required to
realise the benefit, and what the actual performance improvement looks like.

Related plan entries: Phase 2B, Phase 3A, Phase 3D in `MODERNIZATION_PLAN.md`.

---

## The fundamental problem: blocking is only expensive if threads are expensive

When a Play controller action runs a database query, the thread executing it stops and
waits. It holds its position in the thread pool, occupying a slot, doing nothing, until
the database responds. For a PostgreSQL query taking 1ms, a thread is idle 99.9% of the
time it is "handling" that request.

This is why thread pool sizing matters so much. With `play.pool=11` (the default on a
10-core machine) and 256 concurrent connections all waiting on database queries, 245
connections are always queued. Throughput is capped at roughly:

```
max_throughput ≈ pool_size / query_latency
```

11 threads / 1ms = ~11,000 requests/second maximum, regardless of how fast the hardware
or database is.

The conventional fix is a larger thread pool. But platform threads are expensive — each
costs ~1MB of stack memory, and the OS scheduler must context-switch between them. The
pool sweep run during Phase 0D showed this directly: at `play.pool=256`, throughput
*dropped* compared to `play.pool=64` because 256 threads competing for 10 CPU cores
costs more in context switching than it gains from reduced queuing.

The result is a permanent tuning compromise: too small a pool and requests queue; too
large a pool and the OS thrashes. The right value changes with hardware and workload.

---

## What virtual threads do

A virtual thread is a thread managed by the JVM rather than the OS. When a virtual
thread blocks — on a database query, a network call, a `Thread.sleep()` — the JVM
**unmounts** it from the OS thread (called the *carrier*) that was running it. The
carrier is immediately free to pick up another virtual thread. When the blocking
operation completes, the JVM remounts the virtual thread on any available carrier and
it resumes from where it left off.

The practical consequence: you can have tens of thousands of virtual threads in flight
on a handful of carrier threads. Each carrier is always doing real work. There is no
queuing, no thread pool sizing problem, no context switching overhead between blocked
requests.

For Play 1.x specifically, this is the ideal model. The simple synchronous programming
style is preserved:

```java
// This code looks blocking — and it is — but with virtual threads the blocking
// is free: the carrier unmounts and handles another request while this one waits.
World world = World.findById(id);
world.randomNumber = ThreadLocalRandom.current().nextInt(1, 10001);
world.save();
```

No callbacks. No `CompletableFuture` chains. No reactive operators. The application
code stays unchanged and gains the concurrency characteristics of a reactive framework.

---

## Why three framework changes are all required

Virtual threads only unmount when they are *allowed* to unmount. A virtual thread gets
**pinned** — forced to stay on its carrier — whenever it enters a `synchronized` block
or `synchronized` method. While pinned, it behaves exactly like a platform thread: the
carrier is blocked, no other virtual thread can run on it, and the benefit disappears.

Play 1.x has two significant sources of `synchronized` pinning that must be removed
before virtual threads are effective:

### Phase 2B — Hibernate 6 (removes DB-layer pinning)

Every database operation in Hibernate 5 passes through `synchronized` session
management code. An EntityManager open, a query, a flush, a transaction commit — all of
these acquire `synchronized` locks internally. The virtual thread is pinned for the
entire DB operation, which is exactly when you most need it to unmount.

Hibernate 6 replaced those `synchronized` blocks with `ReentrantLock`. A virtual thread
can release a `ReentrantLock` before parking and re-acquire it on resumption, so DB
operations no longer pin. This is the highest-impact change: without it, virtual threads
are pinned during the majority of each request's execution time for any DB endpoint.

H2's JDBC driver also uses `synchronized` internally. Switching to PostgreSQL (which
uses a JDBC driver written without pervasive synchronization on the hot path) removes
this source of pinning as well.

### Phase 3A — Netty 4 (removes server-layer pinning)

When the Invoker thread finishes processing a request and calls `channel.write()` to
send the HTTP response, Netty 3's write path acquires a `synchronized` lock on the
channel object (`AbstractNioWorker.writeNow()`). This was confirmed by decompiling the
bytecode — `monitorenter`/`monitorexit` wraps the `currentWriteEvent` and
`inWriteNowLoop` field accesses. Every response write pins the virtual thread for the
duration.

Netty 4's strict EventLoop model eliminates this. Any write from outside the EventLoop
thread is submitted as a task to a lock-free `MpscQueue`:

```java
// Netty 4: write from non-EventLoop thread
eventLoop.execute(() -> channel.writeAndFlush(response));
// calling (virtual) thread holds no lock — it can unmount freely
```

Netty 4 is also required for JDK 21 compatibility independent of virtual threads, since
Netty 3 uses `sun.misc.Unsafe` and `sun.nio.ch` internals that are inaccessible on
JDK 21+ without `--add-opens`.

### Phase 3D — Virtual thread executor (the actual switch)

With the pinning sources removed, the switch itself is straightforward: replace the
fixed-size `ScheduledThreadPoolExecutor` in `play.Invoker` with a
`newVirtualThreadPerTaskExecutor()`. Every incoming request gets its own virtual thread.
The `play.pool` configuration value becomes irrelevant for request handling.

Background jobs (`@Every`, `@On` cron annotations) should remain on a small
platform-thread `ScheduledExecutorService` — long-lived scheduled tasks are not a good
fit for virtual threads.

As a bonus, once the Invoker runs on virtual threads, `Controller.await(int millis)` can
be implemented as a plain `Thread.sleep()` — the virtual thread parks for the specified
duration and the carrier handles other requests in the meantime. This replaces the
JavaFlow continuation bytecode transformation (Phase 3C) entirely on JDK 21+.

---

## The pinning picture across all three changes

| Pinning source | Netty 3 + Hibernate 5 | After 2B only | After 3A only | After 2B + 3A |
|---|---|---|---|---|
| Hibernate session management | Every DB op | **Gone** | Every DB op | **Gone** |
| H2/PostgreSQL JDBC driver | Every DB op | Reduced¹ | Every DB op | Reduced¹ |
| Netty write path | Every response | Every response | **Gone** | **Gone** |

¹ PostgreSQL's JDBC driver has far less `synchronized` usage than H2; switching to
PostgreSQL for production benchmarking removes most of this.

Attempting Phase 3D without 2B and 3A means virtual threads are pinned during DB
operations (the majority of each request's time for DB endpoints) and during every
response write. The result is a different implementation with effectively the same
throughput characteristics as platform threads.

---

## What the performance improvement looks like

This is not about making a single request faster. A request that queries PostgreSQL
still takes ~1ms. Virtual threads do not change per-request latency.

What changes is what happens with many concurrent requests all waiting on I/O
simultaneously:

| | Platform threads (current) | Virtual threads (after 2B + 3A + 3D) |
|---|---|---|
| Threads while waiting on DB | Holds OS thread slot, does nothing | Unmounted — carrier handles another request |
| Pool tuning required | Yes — sweet spot varies by hardware + DB latency | No |
| Throughput ceiling | `pool_size / query_latency` | `db_connection_pool / query_latency` |
| Context switching overhead | Grows with pool size | Fixed at carrier count (~20 IO threads) |
| Memory per concurrent request | ~1MB stack per platform thread | ~few KB per virtual thread |

The ceiling shifts from the thread pool to the database connection pool and the database
itself — which is the right bottleneck to have. Thread pool capacity is no longer a
constraint on how many concurrent requests the framework can serve.

### Why the Phase 0D pool sweep understates the benefit

The pool sweep (H2 in-memory, all endpoints) showed only modest gains from increasing
`play.pool` because H2 is an in-memory database with sub-millisecond query times. With
fast queries, even a small thread pool rarely blocks long enough for queuing to hurt.

The payoff is proportional to query latency. With PostgreSQL on a real network:

- Each query ≈ 1–5ms round trip
- 256 concurrent connections × 2ms average = 512ms of blocking per "cycle"
- Platform threads need 256+ pool slots to avoid queuing during that 512ms
- Virtual threads need 0 pool slots — they unmount and remount transparently

At high concurrency against a real database, virtual threads allow Play 1.x to serve
throughput proportional to database capacity rather than thread count — competitive with
reactive frameworks like Vert.x or Spring WebFlux, without requiring any change to the
synchronous, blocking programming model that existing Play 1.x applications are written
against.

---

## Summary

Virtual threads are a good fit for Play 1.x because Play is fundamentally a blocking,
thread-per-request framework. The programming model does not need to change. What needs
to change is what blocking *costs*: currently, it costs an OS thread slot; after 2B +
3A + 3D, it costs nothing.

The three changes form a single unit:

```
2B Hibernate 6 ──► removes synchronized from DB operations
3A Netty 4 ──► removes synchronized from response writes
3D VT executor ──► replaces platform thread pool with virtual threads
(only effective once 2B and 3A have removed the pinning)
```

Each change is independently valuable (2B and 3A are also required for JDK 21+
compatibility regardless of virtual threads), but their performance benefit compounds:
together they eliminate all significant pinning sources and allow the virtual thread
executor to function as intended.
7 changes: 4 additions & 3 deletions framework/build.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

<project name="play! framework" default="jar" basedir="." xmlns:if="ant:if" xmlns:unless="ant:unless">

<property name="baseversion" value="1.7.x" />
<property name="baseversion" value="1.8.0" />
<property name="version" value="1.8.0.larva" />

<path id="project.classpath">
<fileset dir=".">
Expand Down Expand Up @@ -65,7 +66,7 @@

<target name="compile" description="compile without cleaning">
<mkdir dir="classes"/>
<javac encoding="utf-8" srcdir="src" destdir="classes" debug="true" source="17" target="17">
<javac encoding="utf-8" srcdir="src" destdir="classes" debug="true" source="11" target="11">
<classpath refid="project.classpath" />
</javac>
<copy todir="classes">
Expand Down Expand Up @@ -257,7 +258,7 @@
<condition property="pythonExecutable" value="python">
<and><os family="windows"/></and>
</condition>
<condition property="pythonExecutable" value="python">
<condition property="pythonExecutable" value="python3">
<and><os family="unix"/></and>
</condition>

Expand Down
10 changes: 0 additions & 10 deletions framework/pym/play/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,6 @@ def java_cmd(self, java_args, cp_args=None, className='play.server.Server', args
if javaVersion.startswith("1.5") or javaVersion.startswith("1.6") or javaVersion.startswith("1.7") or javaVersion.startswith("1.8") or javaVersion.startswith("9") or javaVersion.startswith("10") :
print("~ ERROR: java version prior to 11 are no longer supported: current version \"%s\" : please update" % javaVersion)

java_args.append('-noverify')

java_policy = self.readConf('java.policy')
if java_policy != '':
policyFile = os.path.join(self.path, 'conf', java_policy)
if os.path.exists(policyFile):
print("~ using policy file \"%s\"" % policyFile)
java_args.append('-Djava.security.manager')
java_args.append('-Djava.security.policy==%s' % policyFile)

if 'http.port' in self.play_env:
args += ["--http.port=%s" % self.play_env['http.port']]
if 'https.port' in self.play_env:
Expand Down
2 changes: 1 addition & 1 deletion framework/pym/play/commands/eclipse.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def execute(**kargs):
if javaVersion.startswith("1.5") or javaVersion.startswith("1.6") or javaVersion.startswith("1.7"):
print("~ ERROR: java version prior to 1.8 are no longer supported: current version \"%s\" : please update" % javaVersion)

vm_arguments = vm_arguments +' -noverify'
vm_arguments = vm_arguments

if application_name:
application_name = application_name.replace("/", " ")
Expand Down
47 changes: 47 additions & 0 deletions framework/src/play/classloading/enhancers/ConstructorEnhancer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package play.classloading.enhancers;

import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtNewConstructor;
import javassist.NotFoundException;
import play.Logger;
import play.classloading.ApplicationClasses.ApplicationClass;
import play.exceptions.UnexpectedException;

/**
* Ensures every application class has a public no-arg constructor.
*/
public class ConstructorEnhancer extends Enhancer {

@Override
public void enhanceThisClass(ApplicationClass applicationClass) throws Exception {
CtClass ctClass = makeClass(applicationClass);
if (ctClass.isInterface() || ctClass.getName().endsWith(".package")) {
return;
}

if (!hasDefaultConstructor(ctClass)) {
try {
CtConstructor defaultConstructor = CtNewConstructor.make(
"public " + ctClass.getSimpleName() + "() {}", ctClass);
ctClass.addConstructor(defaultConstructor);
} catch (Exception e) {
Logger.error(e, "Failed to generate default constructor for " + ctClass.getName());
throw new UnexpectedException(
"Failed to generate default constructor for " + ctClass.getName(), e);
}
}

applicationClass.enhancedByteCode = ctClass.toBytecode();
ctClass.defrost();
}

private boolean hasDefaultConstructor(CtClass ctClass) throws NotFoundException {
for (CtConstructor constructor : ctClass.getDeclaredConstructors()) {
if (constructor.getParameterTypes().length == 0) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class ContinuationEnhancer extends Enhancer {

public static boolean isEnhanced(String appClassName) {
ApplicationClass appClass = Play.classes.getApplicationClass( appClassName);
if ( appClass == null) {
if ( appClass == null || appClass.javaClass == null) {
return false;
}

Expand Down
Loading
Loading