This is an automated email from the ASF dual-hosted git repository.
He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new 980d2bd844 fix: auto-tune ForkJoinPool minimum-runnable on JDK 21+
(#2890)
980d2bd844 is described below
commit 980d2bd844fc37d9c75b5ed0f53c2203c67abfcb
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Fri Apr 24 02:54:23 2026 +0800
fix: auto-tune ForkJoinPool minimum-runnable on JDK 21+ (#2890)
* fix: auto-tune ForkJoinPool minimum-runnable on JDK 21+
Motivation:
JDK-8300995 / JDK-8321335 changed compensation-thread creation in
ForkJoinPool asyncMode (FIFO) to be much more conservative. Pekko fork-join
dispatchers using the prior default `minimum-runnable = 1` are then prone
to starvation under blocking workloads on JDK 21+, which has shown up as
flaky nightly runs (#2870) and is the root cause behind the workflow
override added in #2889.
Modification:
* Introduce `ForkJoinExecutorConfigurator.resolveMinimumRunnable`, an
internal helper that computes the effective `minimum-runnable` value
from the configured value, the dispatcher parallelism, and the running
JDK major version. A negative configured value (the new default `-1`)
triggers the JDK-aware policy: on JDK 21+ the value becomes
`min(8, max(1, parallelism / 2))`; on JDK < 21 it stays at `1`.
Non-negative values are honoured verbatim, so explicit `0` still
disables compensation entirely and explicit positive values (including
`1`) keep their existing meaning.
* Change
`pekko.actor.default-dispatcher.fork-join-executor.minimum-runnable`
in `reference.conf` to the sentinel `-1` and update the doc block to
describe the new auto-selection rule.
* Add `ForkJoinExecutorConfiguratorSpec` with three groups of assertions:
(1) pure-function matrix on `resolveMinimumRunnable`; (2) directional
checks asserting the auto policy strictly raises the value on JDK 21+
and never exceeds the documented cap of 8; (3) wiring integration that
builds a `ForkJoinExecutorServiceFactory` from a real dispatcher config
and verifies the resolved value reaches the factory (guarding against
regressions of the resolver wiring).
Result:
Production users on JDK 21+ now benefit from the same starvation
mitigation that #2889 bolted onto the nightly CI workflow. Source and
binary compatibility are preserved (constructor defaults stay at `1`,
no signature changes, no MiMa filter required). Users wanting to opt
out can set `minimum-runnable = 1` (or any explicit value) to restore
the previous behaviour.
* fix: address PR review feedback
* License header: replace abbreviated header on the new
ForkJoinExecutorConfiguratorSpec with the canonical Apache 2.0
header used by other clean-room test files in the project
(per pjfanning's review comment).
* Narrow auto-policy scope from JDK 21+ to JDK 25+: nightly evidence
shows the asyncMode (FIFO) compensation-thread regression
(JDK-8300995 / JDK-8321335) surfaces most clearly on the JDK 25
line, while JDK 21 has been running fine on the legacy default of
1 for years. Keep the default unchanged on JDK 21 to avoid a
silent behaviour change for users who are not affected.
* Document the new auto-tuning behaviour in both
docs/src/main/paradox/dispatchers.md (classic) and
docs/src/main/paradox/typed/dispatchers.md, including the opt-out
instructions.
* Update reference.conf doc comment, configurator scaladoc, and the
spec assertions / pending guards to reflect the JDK 25+ scope.
---
.../ForkJoinExecutorConfiguratorSpec.scala | 177 +++++++++++++++++++++
actor/src/main/resources/reference.conf | 16 +-
.../dispatch/ForkJoinExecutorConfigurator.scala | 39 ++++-
docs/src/main/paradox/dispatchers.md | 8 +
docs/src/main/paradox/typed/dispatchers.md | 8 +
5 files changed, 237 insertions(+), 11 deletions(-)
diff --git
a/actor-tests/src/test/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfiguratorSpec.scala
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfiguratorSpec.scala
new file mode 100644
index 0000000000..5bf7c12255
--- /dev/null
+++
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfiguratorSpec.scala
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.dispatch
+
+import java.util.concurrent.ThreadFactory
+
+import org.apache.pekko
+import pekko.testkit.PekkoSpec
+import pekko.util.JavaVersion
+
+import com.typesafe.config.{ Config, ConfigFactory }
+
+object ForkJoinExecutorConfiguratorSpec {
+ // Keep the root config explicit so the dispatcher-config integration checks
below
+ // see exactly the values this spec is asserting on (and not whatever the
project's
+ // global test configuration happens to set).
+ val config: Config = ConfigFactory.parseString("""
+ |fj-auto-default-dispatcher {
+ | executor = "fork-join-executor"
+ | fork-join-executor {
+ | parallelism-min = 8
+ | parallelism-factor = 1.0
+ | parallelism-max = 64
+ | }
+ |}
+ |fj-auto-small-dispatcher {
+ | executor = "fork-join-executor"
+ | fork-join-executor {
+ | parallelism-min = 1
+ | parallelism-factor = 1.0
+ | parallelism-max = 1
+ | }
+ |}
+ |fj-explicit-zero-dispatcher {
+ | executor = "fork-join-executor"
+ | fork-join-executor {
+ | parallelism-min = 8
+ | parallelism-factor = 1.0
+ | parallelism-max = 64
+ | minimum-runnable = 0
+ | }
+ |}
+ |fj-explicit-seven-dispatcher {
+ | executor = "fork-join-executor"
+ | fork-join-executor {
+ | parallelism-min = 8
+ | parallelism-factor = 1.0
+ | parallelism-max = 64
+ | minimum-runnable = 7
+ | }
+ |}
+ """.stripMargin)
+}
+
+class ForkJoinExecutorConfiguratorSpec extends
PekkoSpec(ForkJoinExecutorConfiguratorSpec.config) {
+
+ import ForkJoinExecutorConfigurator.resolveMinimumRunnable
+
+ "ForkJoinExecutorConfigurator.resolveMinimumRunnable" must {
+
+ "honour explicit zero (compensation disabled)" in {
+ resolveMinimumRunnable(configured = 0, parallelism = 16, jdkMajorVersion
= 25) shouldBe 0
+ resolveMinimumRunnable(configured = 0, parallelism = 16, jdkMajorVersion
= 17) shouldBe 0
+ }
+
+ "honour explicit positive overrides verbatim" in {
+ resolveMinimumRunnable(configured = 1, parallelism = 16, jdkMajorVersion
= 25) shouldBe 1
+ resolveMinimumRunnable(configured = 7, parallelism = 16, jdkMajorVersion
= 25) shouldBe 7
+ resolveMinimumRunnable(configured = 100, parallelism = 16,
jdkMajorVersion = 25) shouldBe 100
+ }
+
+ "auto-resolve to 1 on JDK < 25 regardless of parallelism (preserves legacy
behaviour)" in {
+ // JDK 21 keeps the legacy default per reviewer guidance: only JDK 25
nightlies
+ // showed the compensation-thread regression badly enough to warrant a
default change.
+ resolveMinimumRunnable(configured = -1, parallelism = 1, jdkMajorVersion
= 17) shouldBe 1
+ resolveMinimumRunnable(configured = -1, parallelism = 8, jdkMajorVersion
= 17) shouldBe 1
+ resolveMinimumRunnable(configured = -1, parallelism = 64,
jdkMajorVersion = 11) shouldBe 1
+ resolveMinimumRunnable(configured = -1, parallelism = 16,
jdkMajorVersion = 21) shouldBe 1
+ }
+
+ "auto-resolve using parallelism / 2 on JDK 25+ with min cap 1 and max cap
8" in {
+ resolveMinimumRunnable(configured = -1, parallelism = 1, jdkMajorVersion
= 25) shouldBe 1
+ resolveMinimumRunnable(configured = -1, parallelism = 2, jdkMajorVersion
= 25) shouldBe 1
+ resolveMinimumRunnable(configured = -1, parallelism = 4, jdkMajorVersion
= 25) shouldBe 2
+ resolveMinimumRunnable(configured = -1, parallelism = 8, jdkMajorVersion
= 25) shouldBe 4
+ resolveMinimumRunnable(configured = -1, parallelism = 16,
jdkMajorVersion = 25) shouldBe 8
+ resolveMinimumRunnable(configured = -1, parallelism = 64,
jdkMajorVersion = 25) shouldBe 8
+ }
+
+ "produce a strictly higher value on JDK 25+ than on JDK 21 for plausible
dispatcher sizes" in {
+ // Directional check: the auto policy must move the needle on the JDK
line that needs it
+ // (and only on that line — JDK 21 stays at the legacy default per
reviewer guidance).
+ for (parallelism <- Seq(4, 8, 16, 32, 64)) {
+ val legacy = resolveMinimumRunnable(configured = -1, parallelism,
jdkMajorVersion = 21)
+ val modern = resolveMinimumRunnable(configured = -1, parallelism,
jdkMajorVersion = 25)
+ withClue(s"parallelism=$parallelism legacy=$legacy modern=$modern: ") {
+ modern should be > legacy
+ }
+ }
+ }
+
+ "never exceed the documented max cap of 8" in {
+ for (parallelism <- 1 to 256; jdk <- Seq(21, 25, 30)) {
+ resolveMinimumRunnable(configured = -1, parallelism, jdk) should be <=
8
+ }
+ }
+ }
+
+ "ForkJoinExecutorConfigurator wiring" must {
+
+ // Build a factory from a real dispatcher config and return the resolved
+ // minimum-runnable. This proves the config value actually reaches the
+ // ForkJoinExecutorServiceFactory — guarding against the trivial regression
+ // of reverting resolveMinimumRunnable to a direct `config.getInt` read.
+ def resolvedMinimumRunnable(dispatcherId: String): Int = {
+ // `system.dispatchers.config(id)` resolves the dispatcher's full config
with
+ // reference.conf defaults applied (so `virtualize`, `minimum-runnable`,
etc.
+ // all have values).
+ val dispatcherConfig = system.dispatchers.config(dispatcherId)
+ val configurator = new ForkJoinExecutorConfigurator(
+ dispatcherConfig.getConfig("fork-join-executor"),
+ system.dispatchers.prerequisites)
+ val tf: ThreadFactory = system.dispatchers.prerequisites.threadFactory
+ val factory = configurator
+ .createExecutorServiceFactory(dispatcherId, tf)
+ .asInstanceOf[configurator.ForkJoinExecutorServiceFactory]
+ factory.minimumRunnable
+ }
+
+ "respect explicit minimum-runnable = 0" in {
+ resolvedMinimumRunnable("fj-explicit-zero-dispatcher") shouldBe 0
+ }
+
+ "respect explicit minimum-runnable = 7" in {
+ resolvedMinimumRunnable("fj-explicit-seven-dispatcher") shouldBe 7
+ }
+
+ "auto-scale the default (minimum-runnable not set) on JDK 25+" in {
+ if (JavaVersion.majorVersion < 25) pending
+
+ val resolved = resolvedMinimumRunnable("fj-auto-default-dispatcher")
+ // The dispatcher declares parallelism-min = 8 so effective parallelism
is at
+ // least 8; auto = min(8, max(1, parallelism/2)) must be at least 4 and
never
+ // exceed the documented cap of 8.
+ resolved should be >= 4
+ resolved should be <= 8
+ }
+
+ "keep the legacy value of 1 on JDK < 25 when the default is left
untouched" in {
+ if (JavaVersion.majorVersion >= 25) pending
+
+ resolvedMinimumRunnable("fj-auto-default-dispatcher") shouldBe 1
+ }
+
+ "never drop below 1 even for parallelism = 1 dispatchers" in {
+ val resolved = resolvedMinimumRunnable("fj-auto-small-dispatcher")
+ // parallelism = 1 implies parallelism/2 = 0, which the min-cap must
lift to 1.
+ // On JDK < 25 the legacy value of 1 is already the expected answer.
+ resolved shouldBe 1
+ }
+ }
+}
diff --git a/actor/src/main/resources/reference.conf
b/actor/src/main/resources/reference.conf
index b2afe5f374..fb2fc0a7d0 100644
--- a/actor/src/main/resources/reference.conf
+++ b/actor/src/main/resources/reference.conf
@@ -490,12 +490,16 @@ pekko {
# When blocked worker count causes active threads to drop below this
threshold, the pool
# may create a compensation thread to maintain progress.
#
- # The default value of 1 matches the JDK ForkJoinPool behaviour prior
to this setting being
- # exposed. Higher values (e.g. parallelism/2) can reduce starvation
risk for actor-heavy
- # workloads on JDK 21+ where ForkJoinPool asyncMode (FIFO)
compensation-thread creation
- # became more conservative (see JDK-8300995 / JDK-8321335).
- # Set to 0 to disable compensation entirely.
- minimum-runnable = 1
+ # The special value -1 (default) selects a JDK-aware policy:
+ # * JDK 25+ : effective value = min(8, max(1, parallelism / 2))
+ # * JDK < 25: effective value = 1 (preserves the JDK behaviour prior
to this setting)
+ # Auto-selection on JDK 25+ mitigates the asyncMode (FIFO)
compensation-thread regression
+ # tracked in JDK-8300995 / JDK-8321335 that, in Pekko nightly tests,
surfaces most clearly
+ # on the JDK 25 line and can cause actor-heavy workloads to starve.
+ #
+ # Set explicitly to 0 to disable compensation entirely. Set to any
non-negative integer
+ # to override the auto-selection (e.g. 1 to restore the previous
default behaviour).
+ minimum-runnable = -1
# This config is new in Pekko v1.2.0 and only has an effect if you are
running with JDK 21 and above,
# When set to `on` but the underlying runtime does not support virtual
threads, an Exception will be thrown.
diff --git
a/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
b/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
index 9dc2c95318..7135594d35 100644
---
a/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
+++
b/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
@@ -17,8 +17,32 @@ import java.util.concurrent.{ ExecutorService, ForkJoinPool,
ForkJoinTask, Threa
import com.typesafe.config.Config
+import org.apache.pekko.annotation.InternalApi
+import org.apache.pekko.util.JavaVersion
+
object ForkJoinExecutorConfigurator {
+ /**
+ * INTERNAL API
+ *
+ * Resolves the effective `minimum-runnable` value for a fork-join
dispatcher.
+ *
+ * A negative value (default `-1` in reference.conf) selects the JDK-aware
policy:
+ * on JDK 25+ the value is `min(8, max(1, parallelism / 2))` to mitigate the
+ * asyncMode (FIFO) compensation-thread regression tracked in
+ * JDK-8300995 / JDK-8321335 (the impact is most visible on the JDK 25 line
in
+ * Pekko nightly tests); on older JDKs the value stays at `1` to preserve the
+ * pre-existing behaviour. Non-negative configured values are honoured
verbatim,
+ * so `0` still disables compensation entirely.
+ */
+ @InternalApi private[pekko] def resolveMinimumRunnable(
+ configured: Int,
+ parallelism: Int,
+ jdkMajorVersion: Int): Int =
+ if (configured >= 0) configured
+ else if (jdkMajorVersion >= 25) math.min(8, math.max(1, parallelism / 2))
+ else 1
+
/**
* INTERNAL PEKKO USAGE ONLY
*/
@@ -140,16 +164,21 @@ class ForkJoinExecutorConfigurator(config: Config,
prerequisites: DispatcherPrer
""""task-peeking-mode" in "fork-join-executor" section could only
set to "FIFO" or "LIFO".""")
}
+ val parallelism = ThreadPoolConfig.scaledPoolSize(
+ config.getInt("parallelism-min"),
+ config.getDouble("parallelism-factor"),
+ config.getInt("parallelism-max"))
+
new ForkJoinExecutorServiceFactory(
id,
validate(tf),
- ThreadPoolConfig.scaledPoolSize(
- config.getInt("parallelism-min"),
- config.getDouble("parallelism-factor"),
- config.getInt("parallelism-max")),
+ parallelism,
asyncMode,
config.getInt("maximum-pool-size"),
- config.getInt("minimum-runnable")
+ ForkJoinExecutorConfigurator.resolveMinimumRunnable(
+ config.getInt("minimum-runnable"),
+ parallelism,
+ JavaVersion.majorVersion)
)
}
}
diff --git a/docs/src/main/paradox/dispatchers.md
b/docs/src/main/paradox/dispatchers.md
index 37cd6eb1b6..27fbd42803 100644
--- a/docs/src/main/paradox/dispatchers.md
+++ b/docs/src/main/paradox/dispatchers.md
@@ -44,6 +44,14 @@ You can read more about parallelism in the JDK's
[ForkJoinPool documentation](ht
When Running on Java 9+, you can use `maximum-pool-size` to set the upper
bound on the total number of threads allocated by the ForkJoinPool.
+When running on Java 25+, the `minimum-runnable` setting for the
`fork-join-executor` defaults to a JDK-aware value
+(`min(8, max(1, parallelism / 2))`) instead of the historical `1`. This raises
the number of compensation threads the
+pool will create when workers block, mitigating the asyncMode (FIFO)
compensation regression tracked in
+[JDK-8300995](https://bugs.openjdk.org/browse/JDK-8300995) /
[JDK-8321335](https://bugs.openjdk.org/browse/JDK-8321335)
+that, in Pekko nightly tests, surfaces most clearly on the JDK 25 line and can
cause actor-heavy workloads to starve.
+Set `minimum-runnable` explicitly (any non-negative integer) to opt out of the
auto-selection — for example
+`minimum-runnable = 1` restores the previous default, and `minimum-runnable =
0` disables compensation entirely.
+
**Experimental**: When Running on Java 21+, you can use `virtualize=on` to
enable the virtual threads feature.
When using virtual threads, all virtual threads will use the same `unparker`,
so you may want to
increase the number of `jdk.unparker.maxPoolSize`.
diff --git a/docs/src/main/paradox/typed/dispatchers.md
b/docs/src/main/paradox/typed/dispatchers.md
index c1506af836..abbb694586 100644
--- a/docs/src/main/paradox/typed/dispatchers.md
+++ b/docs/src/main/paradox/typed/dispatchers.md
@@ -129,6 +129,14 @@ You can read more about parallelism in the JDK's
[ForkJoinPool documentation](ht
When Running on Java 9+, you can use `maximum-pool-size` to set the upper
bound on the total number of threads allocated by the ForkJoinPool.
+When running on Java 25+, the `minimum-runnable` setting for the
`fork-join-executor` defaults to a JDK-aware value
+(`min(8, max(1, parallelism / 2))`) instead of the historical `1`. This raises
the number of compensation threads the
+pool will create when workers block, mitigating the asyncMode (FIFO)
compensation regression tracked in
+[JDK-8300995](https://bugs.openjdk.org/browse/JDK-8300995) /
[JDK-8321335](https://bugs.openjdk.org/browse/JDK-8321335)
+that, in Pekko nightly tests, surfaces most clearly on the JDK 25 line and can
cause actor-heavy workloads to starve.
+Set `minimum-runnable` explicitly (any non-negative integer) to opt out of the
auto-selection — for example
+`minimum-runnable = 1` restores the previous default, and `minimum-runnable =
0` disables compensation entirely.
+
**Experimental**: When Running on Java 21+, you can use `virtualize=on` to
enable the virtual threads feature.
When using virtual threads, all virtual threads will use the same `unparker`,
so you may want to
increase the number of `jdk.unparker.maxPoolSize`.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]