Detector: StatefulLambdaDetector
Select it with: includes = DetectorType.STATEFUL_LAMBDA
TaskScheduler.scheduleCountingTasks() creates a single int[] count = {0}
array and captures it in a lambda that is submitted to multiple threads. Each
thread increments count[0]++ without synchronization. Because int[] is a
mutable container (not a primitive), the JVM permits its capture even though it
is mutated — the captured variable is the array reference, which is
effectively-final, but the array element is not.
Under concurrency:
- Two threads both read
count[0]asN. - Both write
N+1. - One increment is silently lost.
The final count is always less than n, but the exact value is
non-deterministic.
StatefulLambdaDetector keys on the identity of the lambda instance. A finding is one
instance that ran on more than one thread while mutating what it captured. A fresh lambda per
submission is a different object every time and produces nothing, which is correct: nothing was
shared.
Every hook names the captured object as well: recordCapturedMutation(task, "count", count, thread). With the object the detector judges each capture on its own, under its own lock, and
skips state that is thread-safe by type, such as an AtomicInteger. The older
recordCapturedMutation(task, "count", thread) cannot tell two captures apart, so it judges all
of a lambda's mutations as one.
That is exactly what this example used to get wrong. Its demonstration built a lambda inside the
test body, so 400 body executions made 400 lambdas with one thread each, and the report was empty
three runs out of three (issue #346). TaskScheduler now builds the task once, in its
constructor, and submits that same object over and over, which is both what the detector needs and
what the bug is.
TaskScheduler.observeTask is the seam that reports the executions and the mutations; it defaults
to no-ops, so the production path never touches the test library.
Remove the @Disabled annotation from test_concurrent_detectsStatefulLambda and run the test:
STATEFUL LAMBDA SHARED ACROSS THREADS DETECTED:
- 'counting-task' executed on 400 threads (...) with concurrent captured-state mutations: [...]
failOn = FailOn.LOW is what turns that report into a failed run.
The 400 is worth a second look: it is the number of distinct thread ids, and @AsyncTest runs on
virtual threads by default, one per body execution. With threads = 8, invocations = 50 that is
400, not 8. See issue #349.
TaskScheduler.peakTracker() is the same bug in a quieter shape: one shared IntConsumer that
reads the captured peak on every call and writes it only when a sample beats it. Most calls
only read, so a round can hold a single writing thread, and a detector that saw only the writes
would call the tracker unshared. observePeakTracker reports the reads with
recordCapturedRead(task, peak, thread), which puts the reading threads on the capture beside the
writer: one writer beside an unguarded reader is reported, readers with no writer are not.
testStatefulLambdaDetector_peakReadBesideItsWriter_reports and
testStatefulLambdaDetector_readersWithNoWriter_isSilent pin both directions; remove @Disabled
from test_concurrent_detectsPeakReadRacingItsWriter to see it under @AsyncTest.
Use AtomicInteger or LongAdder for the captured counter, or give each
submitted task its own independent state. For the peak, use
AtomicInteger.accumulateAndGet(sample, Math::max), or read and write it inside
synchronized (peak): the detector sees the capture's own monitor, so a read under the writer's
lock is not reported.