diff --git a/dotCMS/src/test/java/com/dotcms/jdk/CompactObjectHeadersDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/CompactObjectHeadersDemo.java
new file mode 100644
index 000000000000..d78811a12139
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/CompactObjectHeadersDemo.java
@@ -0,0 +1,455 @@
+package com.dotcms.jdk;
+
+import com.sun.management.HotSpotDiagnosticMXBean;
+import com.sun.management.ThreadMXBean;
+import java.lang.management.ManagementFactory;
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Live demo for the Java 25 talk: what {@code -XX:+UseCompactObjectHeaders} (JEP 519) actually
+ * costs and buys, measured on dotCMS's own objects.
+ *
+ *
Every Java object carries a fixed prefix the JVM owns and your code never sees: the
+ * object header . Classically 12 bytes — an 8-byte mark word (identity hash, lock state, GC
+ * age, forwarding pointer; multiplexed, so a plain object leaves most of it unused) plus a 4-byte
+ * compressed klass pointer (which class this is). Compact headers narrow the klass pointer to 22
+ * bits and fold it into the spare room of the mark word, giving a single 8-byte header.
+ * Same information, one word less, on every object in the heap.
+ *
+ *
The flag is not on by default in JDK 25 ({@code UseCompactObjectHeaders = false
+ * {product}}). dotCMS opted in deliberately: {@code container/tomcat9/bin/setenv.sh} for the
+ * production container and {@code parent/pom.xml} for the surefire/failsafe JVMs.
+ *
+ *
Why this demo relaunches itself
+ *
+ * The flag is read once at JVM startup, so no single process can show both sides and no JUnit test
+ * can compare them — which is exactly why {@code CacheSizingUtilTest} was rewritten to assert
+ * layout-independent invariants instead of byte counts. This class therefore spawns two child
+ * JVMs, identical but for {@code -XX:+/-UseCompactObjectHeaders}, and prints their results side by
+ * side.
+ *
+ * How it measures
+ *
+ * Two independent instruments, neither of which is a sizing API reporting on itself:
+ *
+ *
+ * Allocation counter (the headline number) — {@code ThreadMXBean
+ * .getCurrentThreadAllocatedBytes()} around the fill loop, divided by N. The JVM counts the
+ * bytes it actually handed out, so this is exact and does not depend on the collector running.
+ * Heap delta (the cross-check) — used heap before and after the fill, with the backing
+ * array allocated before the baseline so its own N reference slots are excluded. Noisy
+ * by nature, but it answers the fair question the counter cannot: the bytes are not merely
+ * un-allocated, they are genuinely not resident.
+ *
+ *
+ * Payloads are shared singletons, so what the first three rows measure is the object shell itself.
+ * Both numbers are printed; the headline is snapped to the object alignment, which is exact rather
+ * than cosmetic because every footprint — and every sum of footprints — is a multiple of it.
+ *
+ * When the dotCMS classpath is present the real records
+ * {@code com.dotcms.content.index.domain.ContentSearchHit} / {@code SiteSearchHit} are loaded and
+ * measured. Standalone, the demo falls back to local twins with an identical field layout and says
+ * so in the output.
+ *
+ *
What to look for on the slide
+ *
+ *
+ * shape 12-byte hdr 8-byte hdr saved
+ * java.lang.Object 16 B 8 B 8 B the header and nothing else
+ * ContentSearchHit 40 B 32 B 8 B 5 refs + 1 float = 24 B of real data
+ * SiteSearchHit 40 B 40 B 0 B one MORE field, and it costs nothing
+ * realistic hit 3176 B 2992 B 184 B the same hit carrying its _source
+ *
+ *
+ * Every row is exact and reproducible run to run — that is what the allocation counter buys. The
+ * last row is a graph of some sixty objects, so its absolute size depends on how this demo builds the
+ * map; what carries over to production is the ratio, not the constant.
+ *
+ * The second and third rows are the point. {@code SiteSearchHit} carries one extra reference than
+ * {@code ContentSearchHit}, yet with 12-byte headers both weigh the same : objects are aligned
+ * to 8 bytes, so 12+24=36 rounds up to 40 and the extra field lands in padding that was already paid
+ * for. With 8-byte headers the short shape lands exactly on 32 and the long one is the one rounding
+ * up — so the very same field now costs 8 bytes.
+ *
+ *
The lesson is not "objects got 4 bytes smaller". It is that compact headers change which
+ * refactors are worth doing : splitting these two shapes apart (#36899) returned nothing before
+ * the flag and returns 8 bytes per hit after it.
+ *
+ *
The final row keeps that honest, and is the row to end on. Against a hit carrying its own
+ * 20-field {@code _source}, the split is worth 0.25% while the flag itself is worth 5.8% —
+ * because the flag shrinks every object in the graph , the map nodes and strings and char
+ * arrays, not just the record you were looking at. That is also why the payoff shows up in the
+ * caches, where dotCMS holds millions of small objects, rather than in any one data class.
+ *
+ *
Run it — one command prints both columns:
+ *
+ *
+ * # standalone, no build required (uses local twins)
+ * java dotCMS/src/test/java/com/dotcms/jdk/CompactObjectHeadersDemo.java
+ *
+ * # against the real dotCMS records (after ./mvnw install -pl :dotcms-core --am -DskipTests)
+ * java -cp dotCMS/target/classes:dotCMS/target/test-classes com.dotcms.jdk.CompactObjectHeadersDemo
+ *
+ *
+ * Options: {@code -Dn=1000000} instances per shape, {@code -Dchild=on|off} to run a single side in
+ * the current JVM instead of spawning children.
+ *
+ * Not a JUnit test on purpose: the whole point is a comparison across two JVMs, and a test runs in
+ * the one surefire started — which is precisely why {@code CacheSizingUtilTest} had to give up on byte
+ * counts. It is a {@code main} demo, compiled by the real build so it cannot silently rot, and named
+ * {@code *Demo} so surefire skips it. {@code System.out} is deliberate: the console output is
+ * the artifact being shown to an audience; the Logger-only rule targets production code.
+ *
+ * @author Fabrizio Araya
+ * @see JEP 519 — Compact Object Headers
+ * @see VirtualThreadCarrierTimelineDemo
+ */
+public final class CompactObjectHeadersDemo {
+
+ /** Instances allocated per shape. Large enough that per-object rounding is invisible. */
+ private static final int N = Integer.getInteger("n", 1_000_000);
+
+ /** Instances allocated and discarded before measuring, to move JIT/reflection warmup out of the window. */
+ private static final int WARMUP = 20_000;
+
+ /** Payloads shared by every instance, so only the object shell shows up in the heap delta. */
+ private static final String SHARED_ID = "shared-id";
+ private static final String SHARED_INDEX = "shared-index";
+ private static final Map SHARED_MAP = Map.of();
+ private static final List SHARED_LIST = List.of();
+
+ private CompactObjectHeadersDemo() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final String child = System.getProperty("child");
+ if (child != null) {
+ runOneSide();
+ return;
+ }
+ runBothSides();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Shapes under measurement
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Local stand-in for {@code ContentSearchHit}: 5 references + 1 float. */
+ record ContentHitTwin(String getId, String getIndex, Map getSourceAsMap,
+ float getScore, Map getFields, List getSortValues) {
+ }
+
+ /** Local stand-in for {@code SiteSearchHit}: the same plus one highlights reference. */
+ record SiteHitTwin(String getId, String getIndex, Map getSourceAsMap,
+ float getScore, Map getFields, List getSortValues,
+ Map> getHighlights) {
+ }
+
+ /**
+ * One shape to measure.
+ *
+ * @param divisor how much to scale N down for this shape — a shape that retains kilobytes cannot
+ * be allocated a million times, and does not need to be: the measurement noise is
+ * a fraction of a byte per instance either way.
+ */
+ private record Shape(String label, String note, int divisor, Supplier factory) {
+ }
+
+ private static List shapes() {
+ final List shapes = new ArrayList<>();
+
+ shapes.add(new Shape("java.lang.Object", "header only, no fields", 1, Object::new));
+
+ final Supplier realContent = realHitFactory(
+ "com.dotcms.content.index.domain.ContentSearchHit", false);
+ final Supplier realSite = realHitFactory(
+ "com.dotcms.content.index.domain.SiteSearchHit", true);
+
+ shapes.add(new Shape("ContentSearchHit",
+ realContent != null ? "real record, 5 refs + 1 float" : "TWIN, 5 refs + 1 float", 1,
+ realContent != null ? realContent
+ : () -> new ContentHitTwin(SHARED_ID, SHARED_INDEX, SHARED_MAP, 1.0f,
+ SHARED_MAP, SHARED_LIST)));
+
+ shapes.add(new Shape("SiteSearchHit",
+ realSite != null ? "real record, 6 refs + 1 float" : "TWIN, 6 refs + 1 float", 1,
+ realSite != null ? realSite
+ : () -> new SiteHitTwin(SHARED_ID, SHARED_INDEX, SHARED_MAP, 1.0f,
+ SHARED_MAP, SHARED_LIST, Map.of())));
+
+ shapes.add(new Shape("realistic hit", "+ its own 20-field _source map", 20,
+ CompactObjectHeadersDemo::realisticHit));
+
+ return shapes;
+ }
+
+ /**
+ * Builds a factory for a real dotCMS hit record when it is on the classpath, or {@code null}
+ * when running standalone. The canonical constructor is
+ * {@code (String, String, Map, float, Map, List[, Map])}.
+ */
+ private static Supplier realHitFactory(final String className, final boolean withHighlights) {
+ try {
+ final Class> type = Class.forName(className);
+ final Constructor> ctor = withHighlights
+ ? type.getDeclaredConstructor(String.class, String.class, Map.class, float.class,
+ Map.class, List.class, Map.class)
+ : type.getDeclaredConstructor(String.class, String.class, Map.class, float.class,
+ Map.class, List.class);
+ final Object[] argv = withHighlights
+ ? new Object[] {SHARED_ID, SHARED_INDEX, SHARED_MAP, 1.0f, SHARED_MAP,
+ SHARED_LIST, SHARED_MAP}
+ : new Object[] {SHARED_ID, SHARED_INDEX, SHARED_MAP, 1.0f, SHARED_MAP,
+ SHARED_LIST};
+ ctor.newInstance(argv); // fail fast here rather than inside the measurement loop
+ return () -> {
+ try {
+ return ctor.newInstance(argv);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ };
+ } catch (ReflectiveOperationException | LinkageError notOnClasspath) {
+ return null;
+ }
+ }
+
+ /**
+ * A hit as it actually arrives from the engine: the record shell plus a private 20-entry
+ * {@code _source} map. Nothing is shared, so the heap delta is the whole retained cost — which is
+ * the honesty check against the shell-only rows above.
+ */
+ private static Object realisticHit() {
+ final Map source = new LinkedHashMap<>();
+ for (int i = 0; i < 20; i++) {
+ source.put("field_" + i, "value_" + i);
+ }
+ return new SiteHitTwin("id-" + source.hashCode(), "working-index", source, 1.0f,
+ new HashMap<>(), List.of(), Map.of());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Single side: measure in THIS JVM and print machine-readable rows
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static final ThreadMXBean THREADS =
+ (ThreadMXBean) ManagementFactory.getThreadMXBean();
+
+ private static void runOneSide() {
+ for (final Shape shape : shapes()) {
+ final int instances = N / shape.divisor();
+ final double allocated = allocatedBytesPerInstance(shape.factory(), instances);
+ final double heap = heapDeltaPerInstance(shape.factory(), instances);
+ System.out.println("ROW\t" + shape.label() + "\t" + snapToAlignment(allocated) + "\t"
+ + String.format("%.2f", allocated) + "\t" + String.format("%.0f", heap) + "\t"
+ + shape.note());
+ }
+ }
+
+ /**
+ * Bytes the JVM handed this thread per instance — the exact figure. Warmup runs first so the JIT
+ * and the reflection caches do their one-time allocation outside the measured window, and the
+ * backing array is allocated before it so its own slots are not counted.
+ */
+ private static double allocatedBytesPerInstance(final Supplier factory, final int instances) {
+ warmUp(factory);
+ final Object[] holder = new Object[instances];
+
+ final long before = THREADS.getCurrentThreadAllocatedBytes();
+ for (int i = 0; i < instances; i++) {
+ holder[i] = factory.get();
+ }
+ final long after = THREADS.getCurrentThreadAllocatedBytes();
+
+ if (holder[instances - 1] == null) {
+ throw new IllegalStateException("unreachable");
+ }
+ return (double) (after - before) / instances;
+ }
+
+ /**
+ * Rounds a measurement to the nearest multiple of {@code ObjectAlignmentInBytes} (8 here).
+ *
+ * This is not cosmetic. The JVM pads every object up to the alignment, so a true footprint is
+ * always a multiple of it — and so is any sum of them. The heap delta below carries a fraction of
+ * a byte of noise per instance (JIT and reflection allocate a little of their own), which snapping
+ * removes without inventing anything. The raw average is printed too so the correction is visible.
+ */
+ private static long snapToAlignment(final double raw) {
+ final long alignment = Long.parseLong(vmOption("ObjectAlignmentInBytes"));
+ return Math.round(raw / alignment) * alignment;
+ }
+
+ /** Lets the JIT compile the factory and reflection build its caches before anything is measured. */
+ private static void warmUp(final Supplier factory) {
+ final Object[] warmup = new Object[WARMUP];
+ for (int i = 0; i < WARMUP; i++) {
+ warmup[i] = factory.get();
+ }
+ if (warmup[WARMUP - 1] == null) {
+ throw new IllegalStateException("unreachable");
+ }
+ }
+
+ /**
+ * Heap delta per instance — the independent cross-check. The backing array is allocated and
+ * settled before the fill, so its own reference slots are excluded. This one is genuinely
+ * noisy: it depends on the collector having settled, and it under-reports for shapes that retain
+ * large graphs. Read it as confirmation of the allocation counter, never instead of it.
+ */
+ private static double heapDeltaPerInstance(final Supplier factory, final int instances) {
+ warmUp(factory);
+ final Object[] holder = new Object[instances];
+ final long baseline = settledHeap();
+
+ for (int i = 0; i < instances; i++) {
+ holder[i] = factory.get();
+ }
+
+ final long filled = settledHeap();
+ final double perInstance = (double) (filled - baseline) / instances;
+
+ // Keep the array strongly reachable across the second measurement.
+ if (holder[instances - 1] == null) {
+ throw new IllegalStateException("unreachable");
+ }
+ return perInstance;
+ }
+
+ /** Used heap after coaxing the collector, so the delta reflects live objects only. */
+ private static long settledHeap() {
+ final Runtime runtime = Runtime.getRuntime();
+ long used = Long.MAX_VALUE;
+ for (int attempt = 0; attempt < 6; attempt++) {
+ System.gc();
+ final long now = runtime.totalMemory() - runtime.freeMemory();
+ if (now >= used) {
+ break; // stopped shrinking
+ }
+ used = now;
+ }
+ return used;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Parent side: spawn both children and print the comparison
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static void runBothSides() throws Exception {
+ System.out.println();
+ System.out.println("Compact object headers (JEP 519) — measured on " + N + " instances per shape");
+ System.out.println("JVM: " + Runtime.version() + " compressed oops: " + vmOption("UseCompressedOops")
+ + " object alignment: " + vmOption("ObjectAlignmentInBytes") + " bytes");
+ System.out.println("Default for UseCompactObjectHeaders on this JDK: " + vmOption("UseCompactObjectHeaders")
+ + " (dotCMS turns it on in setenv.sh and parent/pom.xml)");
+
+ final Map classicRaw = new LinkedHashMap<>();
+ final Map compactRaw = new LinkedHashMap<>();
+ final Map classic = measureIn("-XX:-UseCompactObjectHeaders", classicRaw);
+ final Map compact = measureIn("-XX:+UseCompactObjectHeaders", compactRaw);
+ final Map notes = new LinkedHashMap<>();
+ shapes().forEach(s -> notes.put(s.label(), s.note()));
+
+ System.out.println();
+ System.out.printf("%-20s %14s %14s %10s %s%n",
+ "shape", "12-byte hdr", "8-byte hdr", "saved", "");
+ System.out.println("-".repeat(86));
+ for (final String label : notes.keySet()) {
+ final long before = classic.getOrDefault(label, -1L);
+ final long after = compact.getOrDefault(label, -1L);
+ System.out.printf("%-20s %11d B %11d B %8d B %s%n",
+ label, before, after, before - after, notes.get(label));
+ }
+ System.out.println();
+ System.out.println("Read the two hit rows together: one extra reference field costs "
+ + (classic.getOrDefault("SiteSearchHit", 0L) - classic.getOrDefault("ContentSearchHit", 0L))
+ + " B with the classic header and "
+ + (compact.getOrDefault("SiteSearchHit", 0L) - compact.getOrDefault("ContentSearchHit", 0L))
+ + " B with the compact one.");
+ System.out.println("Same field, same code. 8-byte alignment decides what you actually pay.");
+
+ final long hitBefore = classic.getOrDefault("realistic hit", 0L);
+ final long hitAfter = compact.getOrDefault("realistic hit", 0L);
+ final long splitSaving = classic.getOrDefault("SiteSearchHit", 0L)
+ - compact.getOrDefault("ContentSearchHit", 0L);
+ System.out.println();
+ System.out.printf("But keep the magnitudes straight. On a hit that carries its own _source, "
+ + "the flag returns %d B of %d (%.1f%%), while splitting the two record shapes "
+ + "apart returns %d B of the same %d (%.2f%%).%n",
+ hitBefore - hitAfter, hitBefore, 100.0 * (hitBefore - hitAfter) / hitBefore,
+ splitSaving, hitBefore, 100.0 * splitSaving / hitBefore);
+ System.out.println("The flag pays off because it shrinks EVERY object in the graph — the map "
+ + "nodes, the strings, the char arrays — not because it shrank your record.");
+ System.out.println();
+ System.out.println("Unrounded measurements — allocation counter, then the heap-delta cross-check:");
+ System.out.printf(" %-20s %26s %26s%n", "", "12-byte header", "8-byte header");
+ System.out.printf(" %-20s %13s %12s %13s %12s%n",
+ "", "allocated", "heap", "allocated", "heap");
+ for (final String label : notes.keySet()) {
+ System.out.printf(" %-20s %s %s%n",
+ label, classicRaw.get(label), compactRaw.get(label));
+ }
+ System.out.println();
+ }
+
+ /** Runs this class in a child JVM with the given flag and parses its ROW lines. */
+ private static Map measureIn(final String headerFlag, final Map raw)
+ throws Exception {
+ final String java = ProcessHandle.current().info().command()
+ .orElse(System.getProperty("java.home") + "/bin/java");
+
+ final List command = new ArrayList<>(List.of(java,
+ headerFlag,
+ "-Xmx2g",
+ "-Dchild=" + headerFlag,
+ "-Dn=" + N));
+
+ // Launched as `java Demo.java`, this class lives in a memory class loader and no child could
+ // find it on a class path — so hand the child the source file and let it compile it again.
+ final String sourceFile = System.getProperty("jdk.launcher.sourcefile");
+ if (sourceFile != null) {
+ command.add(sourceFile);
+ } else {
+ // --enable-preview: dotCMS compiles with it, and preview-marked classes refuse to load
+ // without it. Harmless when the class path holds no preview classes.
+ command.addAll(List.of("--enable-preview",
+ "-cp", System.getProperty("java.class.path"),
+ CompactObjectHeadersDemo.class.getName()));
+ }
+
+ final Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
+ final Map rows = new LinkedHashMap<>();
+ final List transcript = new ArrayList<>();
+ try (var reader = process.inputReader()) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ transcript.add(line);
+ if (line.startsWith("ROW\t")) {
+ final String[] parts = line.split("\t");
+ rows.put(parts[1], Long.parseLong(parts[2]));
+ raw.put(parts[1], String.format("%10s B %10s B", parts[3], parts[4]));
+ }
+ }
+ }
+ if (process.waitFor() != 0 || rows.isEmpty()) {
+ transcript.forEach(System.out::println);
+ throw new IllegalStateException("child JVM failed for " + headerFlag);
+ }
+ return rows;
+ }
+
+ private static String vmOption(final String name) {
+ try {
+ return ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class)
+ .getVMOption(name).getValue();
+ } catch (RuntimeException unsupported) {
+ return "n/a";
+ }
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/CustomCollectorDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/CustomCollectorDemo.java
new file mode 100644
index 000000000000..0e134f174362
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/CustomCollectorDemo.java
@@ -0,0 +1,297 @@
+package com.dotcms.jdk;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Live demo for the Java 25 talk: when a custom {@link Collector} is worth writing, and when the
+ * JDK already wrote it for you.
+ *
+ * A collector is four pieces and three type parameters {@code }: what goes in , the
+ * mutable accumulator it builds up internally, and what comes out . That A and R may
+ * differ is the whole trick — accumulate into something mutable because that is what is efficient,
+ * and let the finisher convert it once, at the end.
+ *
+ *
+ * supplier the bucket ArrayList::new
+ * accumulator put one element in it List::add
+ * combiner merge two buckets (a, b) -> { a.addAll(b); return a; }
+ * finisher turn A into R List::copyOf
+ *
+ *
+ * Part 1 — the one NOT worth writing
+ *
+ * {@link #toImmutableList()} is the collector everybody writes first. It works, and it is redundant:
+ * {@code Stream.toList()} and {@code Collectors.toUnmodifiableList()} have shipped since Java 16 and
+ * 10. It earns its place here only as the anatomy lesson, plus two traps that are worth seeing once:
+ *
+ *
+ * {@code IDENTITY_FINISH} silently skips the finisher. Declaring it does not fail — the
+ * stream simply never calls the finisher, so a method promising an immutable list hands back a
+ * plain mutable {@code ArrayList}. The characteristic means "the accumulator already is
+ * the result"; declaring it while having a finisher is lying to the stream.
+ * Copy and view are not interchangeable. {@code List.copyOf} copies and rejects
+ * {@code null}; {@code Collections.unmodifiableList} wraps and accepts it. In a codebase where
+ * an unset content field arrives as {@code null}, that difference shows up in production, not
+ * in tests.
+ *
+ *
+ * Part 2 — the one that IS worth writing
+ *
+ * {@link #mergingReportingConflicts} merges a stream into a map and reports which keys collided .
+ * {@code Collectors.toMap} offers only two bad answers to a duplicate key: throw
+ * {@code IllegalStateException} (losing every other collision), or take a merge function and discard
+ * the loser in silence. Neither can tell the caller that something clashed at all.
+ *
+ * This is the criterion for writing your own: all four pieces have to do real work.
+ *
+ *
+ * the supplier builds an accumulator holding two coordinated structures at once — no JDK
+ * collector does that, and {@code Collectors.teeing} cannot, because it feeds two independent
+ * collectors while here the detection is the merge;
+ * the combiner is not decoration: joining two halves in parallel can surface a collision
+ * neither half saw alone;
+ * the finisher returns an immutable, typed {@code record}, so the caller cannot forget to
+ * look at the conflicts — they are in the return type.
+ *
+ *
+ * The dotCMS-shaped use is merging field maps from several contentlets, or consolidating settings
+ * from several sources, where {@code toMap} forces a choice between blowing up and silently
+ * overwriting.
+ *
+ *
The counterexample worth keeping in mind
+ *
+ * {@code ContentletIndexAPIImpl.addContentToIndex} splits contentlets three ways by
+ * {@code IndexPolicy} with {@code CollectionsUtils.partition} plus positional
+ * {@code get(0)/get(1)/get(2)} — two parallel lists the compiler never cross-checks. That one does
+ * not want a custom collector: {@code Collectors.groupingBy(Contentlet::getIndexPolicy)} has
+ * been the right answer since Java 8. Writing a collector there would be using the new toy for its
+ * own sake.
+ *
+ * Run it (no build required):
+ *
+ *
+ * java dotCMS/src/test/java/com/dotcms/jdk/CustomCollectorDemo.java
+ *
+ *
+ * Not a JUnit test on purpose: the console output is the artifact being shown to an
+ * audience. It is a {@code main} demo, compiled by the real build so it cannot silently rot, and named
+ * {@code *Demo} so surefire skips it. {@code System.out} is deliberate for the same reason; the
+ * Logger-only rule targets production code.
+ *
+ * @author Fabrizio Araya
+ * @see CompactObjectHeadersDemo
+ */
+public final class CustomCollectorDemo {
+
+ private CustomCollectorDemo() {
+ }
+
+ public static void main(final String[] args) {
+ anatomy();
+ identityFinishTrap();
+ copyVersusView();
+ worthWriting();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Part 1 — anatomy, on a collector you do not actually need
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** The collector everyone writes first. Correct, and already in the JDK twice over. */
+ static Collector, List> toImmutableList() {
+ return Collector.of(
+ ArrayList::new, // supplier
+ List::add, // accumulator
+ (left, right) -> { left.addAll(right); return left; }, // combiner
+ List::copyOf); // finisher
+ }
+
+ /** Same, but freezing with a read-only view instead of a copy. Not the same thing. */
+ static Collector, List> toUnmodifiableView() {
+ return Collector.of(
+ ArrayList::new,
+ List::add,
+ (left, right) -> { left.addAll(right); return left; },
+ Collections::unmodifiableList);
+ }
+
+ private static void anatomy() {
+ header("1. Anatomy — supplier, accumulator, combiner, finisher");
+
+ final List names = List.of("ana", "beto", "caro", "dani");
+ final List result = names.stream().map(String::toUpperCase)
+ .collect(toImmutableList());
+
+ System.out.println(" result " + result);
+ System.out.println(" class " + result.getClass().getSimpleName()
+ + " <- no longer an ArrayList: the finisher ran");
+ try {
+ result.add("EVA");
+ System.out.println(" add() NO ERROR — the list is not immutable!");
+ } catch (UnsupportedOperationException expected) {
+ System.out.println(" add() UnsupportedOperationException");
+ }
+
+ // The combiner is only ever called on a parallel stream. A broken one passes every
+ // sequential test and fails the day somebody writes parallelStream().
+ final List parallel = names.parallelStream().map(String::toUpperCase)
+ .collect(toImmutableList());
+ System.out.println(" in parallel " + parallel + " (same: "
+ + result.equals(parallel) + ") <- the combiner only runs here");
+
+ System.out.println(" Stream.toList() " + names.stream().toList()
+ + " <- which is why you rarely need to write this one");
+ }
+
+ private static void identityFinishTrap() {
+ header("2. Trap — IDENTITY_FINISH silently skips the finisher");
+
+ final Collector, List> lying = Collector.of(
+ ArrayList::new,
+ List::add,
+ (left, right) -> { left.addAll(right); return left; },
+ List::copyOf,
+ Collector.Characteristics.IDENTITY_FINISH); // <- the lie
+
+ final List shouldBeImmutable = Stream.of("a").collect(lying);
+ System.out.println(" declared immutable (the finisher says List::copyOf)");
+ System.out.println(" actual class " + shouldBeImmutable.getClass().getSimpleName());
+ shouldBeImmutable.add("b");
+ System.out.println(" mutated it " + shouldBeImmutable
+ + " <- no exception, no warning, no failure anywhere");
+ }
+
+ private static void copyVersusView() {
+ header("3. Copy vs view — and what each does with null");
+
+ final List withNull = Arrays.asList("a", null);
+ System.out.println(" unmodifiableList " + withNull.stream().collect(toUnmodifiableView())
+ + " <- wraps; nulls survive");
+ try {
+ withNull.stream().collect(toImmutableList());
+ System.out.println(" List.copyOf accepted null");
+ } catch (NullPointerException expected) {
+ System.out.println(" List.copyOf NullPointerException <- copies; rejects null");
+ }
+ System.out.println(" Stream.toList() " + withNull.stream().toList()
+ + " <- immutable AND null-tolerant");
+ try {
+ withNull.stream().collect(Collectors.toUnmodifiableList());
+ } catch (NullPointerException expected) {
+ System.out.println(" toUnmodifiableList() NullPointerException <- immutable, null-hostile");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Part 2 — the collector worth writing
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * What the collector returns: the merged map and the keys that collided. Both immutable, so
+ * the caller cannot mutate the result and cannot overlook the conflicts.
+ */
+ record MergeResult(Map merged, Set conflicts) {
+
+ boolean clean() {
+ return conflicts.isEmpty();
+ }
+ }
+
+ /** The mutable accumulator. It never escapes the collector — the finisher freezes it. */
+ private static final class Acc {
+
+ private final Map map = new LinkedHashMap<>();
+ private final Set conflicts = new LinkedHashSet<>();
+
+ void put(final K key, final V value) {
+ // containsKey BEFORE the put: afterwards it is always true. And containsKey rather than
+ // `map.put(...) != null`, so a key whose previous value was null still counts as a clash.
+ if (map.containsKey(key)) {
+ conflicts.add(key);
+ }
+ map.put(key, value);
+ }
+
+ Acc merge(final Acc other) {
+ other.map.forEach(this::put); // may discover a clash neither half saw alone
+ this.conflicts.addAll(other.conflicts);
+ return this;
+ }
+ }
+
+ /**
+ * Merges into a map and reports which keys collided — the answer {@code Collectors.toMap} cannot
+ * give, since its only options are to throw or to discard the loser in silence.
+ */
+ static Collector> mergingReportingConflicts(
+ final Function keyFn, final Function valueFn) {
+ return Collector.of(
+ Acc::new,
+ (acc, element) -> acc.put(keyFn.apply(element), valueFn.apply(element)),
+ Acc::merge,
+ acc -> new MergeResult<>(
+ Collections.unmodifiableMap(new LinkedHashMap<>(acc.map)),
+ Set.copyOf(acc.conflicts)));
+ }
+
+ /** A field contributed by some contentlet — the shape this collector exists for. */
+ record Field(String name, String value) {
+ }
+
+ private static void worthWriting() {
+ header("4. Worth writing — merge a map AND report the collisions");
+
+ final List fields = List.of(
+ new Field("title", "Home"),
+ new Field("body", "..."),
+ new Field("title", "Home v2"), // <- the collision
+ new Field("author", "ana"));
+
+ final MergeResult merged =
+ fields.stream().collect(mergingReportingConflicts(Field::name, Field::value));
+
+ System.out.println(" merged " + merged.merged());
+ System.out.println(" conflicts " + merged.conflicts());
+ System.out.println(" clean() " + merged.clean());
+ try {
+ merged.merged().put("x", "y");
+ System.out.println(" put() NO ERROR — not immutable!");
+ } catch (UnsupportedOperationException expected) {
+ System.out.println(" put() UnsupportedOperationException");
+ }
+
+ final MergeResult parallel =
+ fields.parallelStream().collect(mergingReportingConflicts(Field::name, Field::value));
+ System.out.println(" in parallel " + parallel.merged()
+ + " conflicts=" + parallel.conflicts() + " <- the combiner detects them too");
+
+ System.out.println();
+ System.out.println(" What Collectors.toMap offers instead:");
+ try {
+ fields.stream().collect(Collectors.toMap(Field::name, Field::value));
+ } catch (IllegalStateException expected) {
+ System.out.println(" toMap(k, v) IllegalStateException"
+ + " <- and it cannot say how many others clashed");
+ }
+ System.out.println(" toMap(k, v, merge) "
+ + fields.stream().collect(Collectors.toMap(Field::name, Field::value, (a, b) -> b))
+ + " <- did anything clash? no way to know");
+ }
+
+ private static void header(final String title) {
+ System.out.println();
+ System.out.println(title);
+ System.out.println("-".repeat(78));
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/SimplestGathererDidactic.java b/dotCMS/src/test/java/com/dotcms/jdk/SimplestGathererDidactic.java
new file mode 100644
index 000000000000..5fa8a28e34c8
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/SimplestGathererDidactic.java
@@ -0,0 +1,261 @@
+package com.dotcms.jdk;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Gatherer;
+
+/**
+ * The smallest possible gatherer, taken apart — the teaching companion to {@link StreamGatherersDemo},
+ * which shows what gatherers are for rather than how to write one.
+ *
+ * Start with {@link #passThrough()}. It does nothing at all, and that is the point: it already
+ * contains the entire API, so there is nothing else to learn afterwards.
+ *
+ *
+ * static Gatherer<String, Void, String> passThrough() {
+ * return Gatherer.of((state, element, downstream) -> {
+ * downstream.push(element);
+ * return true;
+ * });
+ * }
+ *
+ *
+ * The three type parameters
+ *
+ * {@code Gatherer} reads: what goes in , the state , what comes
+ * out . {@code Void} means "I do not need to remember anything" — the simplest case, and the
+ * reason {@link Gatherer#of(Gatherer.Integrator)} takes a single lambda with no state supplier.
+ *
+ * The three things the lambda receives
+ *
+ *
+ * state your memory between elements — null here, because Void
+ * element the one going past right now
+ * downstream the rest of the pipeline; you hand it results with push()
+ *
+ *
+ * And the returned {@code boolean} means "keep going" . Returning {@code false} ends the stream
+ * on the spot, without consuming the rest of the source — something no {@code Collector} can do.
+ *
+ * Why this one shape covers the old operations
+ *
+ * The only real decision is how many times you call {@code push} . Each method below differs
+ * from {@code passThrough} by a single line:
+ *
+ *
+ * source [ana, beto, caro]
+ * passThrough [ana, beto, caro] push once, unchanged
+ * onlyLong [beto, caro] push zero or one time -> behaves like filter
+ * upperCase [ANA, BETO, CARO] push something else -> behaves like map
+ * twice [ana, ana, beto, beto, caro, caro] push more than once -> behaves like flatMap
+ *
+ *
+ * So a gatherer can do everything the pre-existing operations do. What makes it new is the
+ * first parameter, {@code state}.
+ *
+ * Then: state, the parameter that was being ignored
+ *
+ * {@link #numbered()} and {@link #whenChanged()} stop ignoring it. Three things change, and only
+ * three:
+ *
+ *
+ * Gatherer<String, Counter, String> the middle type is no longer Void
+ * Gatherer.ofSequential(Counter::new, a supplier for the initial state comes first
+ * (counter, element, downstream) the same lambda — but the first parameter now means something
+ *
+ *
+ * The state must be an object whose field you mutate , never a plain {@code int}: the
+ * gatherer hands the same instance to every invocation, so the memory has to live inside something
+ * that survives between them. {@code ofSequential} is the honest factory here — order-dependent state
+ * cannot be split across threads, and it says so instead of asking for a combiner that could not be
+ * written correctly.
+ *
+ *
+ * numbered [1. ana, 2. beto, 3. caro] impossible with map: output depends on position
+ * whenChanged [ok, error, ok] impossible with filter: predicate sees one element
+ *
+ *
+ * That is the whole idea. Batching, sliding windows and running totals are this same shape with a
+ * richer piece of state — see {@link StreamGatherersDemo}, which also shows what the JDK already
+ * ships so you do not write them yourself.
+ *
+ * Run it (no build required):
+ *
+ *
+ * java dotCMS/src/test/java/com/dotcms/jdk/SimplestGathererDidactic.java
+ *
+ *
+ * {@code System.out} is deliberate: the console output is the artifact being shown. Not a
+ * JUnit test, and named so surefire skips it, matching the other demos in this package.
+ *
+ * @author Fabrizio Araya
+ * @see StreamGatherersDemo
+ */
+public final class SimplestGathererDidactic {
+
+ private static final List SOURCE = List.of("ana", "beto", "caro");
+
+ /** Has a run of repeats in the middle, and one value that comes back later. */
+ private static final List REPEATED = List.of("ok", "ok", "ok", "error", "error", "ok");
+
+ /** Words that come back, so a per-word tally has something to count. */
+ private static final List WITH_REPEATS =
+ List.of("ana", "beto", "ana", "caro", "ana", "beto");
+
+ private SimplestGathererDidactic() {
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Step 0 — the whole API, doing nothing
+ // ─────────────────────────────────────────────────────────────────────────
+
+ static Gatherer passThrough() {
+ return Gatherer.of((state, element, downstream) -> {
+ downstream.push(element); // hand it to the rest of the pipeline
+ return true; // true = carry on with the next element
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Steps 1-3 — one line different each time
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Push zero or one time, and you have written {@code filter}. */
+ static Gatherer onlyLong() {
+ return Gatherer.of((state, element, downstream) -> {
+ if (element.length() > 3) {
+ downstream.push(element);
+ }
+ return true;
+ });
+ }
+
+ /** Push something other than what came in, and you have written {@code map}. */
+ static Gatherer upperCase() {
+ return Gatherer.of((state, element, downstream) -> {
+ downstream.push(element.toUpperCase());
+ return true;
+ });
+ }
+
+ /** Push more than once, and you have written the expanding half of {@code flatMap}. */
+ static Gatherer twice() {
+ return Gatherer.of((state, element, downstream) -> {
+ downstream.push(element);
+ downstream.push(element);
+ return true;
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Step 4 — using the parameter that was being ignored: state
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * The state has to be an object whose field you mutate , never a plain {@code int}. The
+ * gatherer hands the same instance to every invocation, and the lambda cannot reassign its own
+ * parameter in a way the next invocation would see — so the memory has to live inside
+ * something. One tiny class is the clearest form of that.
+ */
+ private static final class Counter {
+
+ private int seen;
+ }
+
+ /**
+ * Numbers the elements. Impossible with {@code map}: numbering depends on how many went past
+ * before, and {@code map} sees one element with no idea of its position.
+ */
+ static Gatherer numbered() {
+ return Gatherer.ofSequential(
+ Counter::new, // 1. the initial state
+ (counter, element, downstream) -> { // 2. same lambda as before
+ counter.seen++; // ...but now it remembers
+ return downstream.push(counter.seen + ". " + element);
+ });
+ }
+
+ /** State does not have to be a number: here it is the previous element. */
+ private static final class Previous {
+
+ private String value;
+ }
+
+ /**
+ * Emits an element only when it differs from the one just before it. Note this is not
+ * {@code distinct()}: a value that comes back later is emitted again, it just may not repeat back
+ * to back. And it is not {@code filter} either — a predicate sees one element and cannot know what
+ * preceded it.
+ */
+ static Gatherer whenChanged() {
+ return Gatherer.ofSequential(
+ Previous::new,
+ (previous, element, downstream) -> {
+ if (element.equals(previous.value)) {
+ return true; // swallow it, but keep going
+ }
+ previous.value = element;
+ return downstream.push(element);
+ });
+ }
+
+ /** State does not have to be one value: here it is a tally, one counter per distinct word. */
+ private static final class Tally {
+
+ private final Map timesSeen = new LinkedHashMap<>();
+ }
+
+ /**
+ * Says how many times each word has appeared so far . Same shape as {@link #numbered()} —
+ * the only change is that the state went from one counter to one counter per word.
+ *
+ * Note what it can and cannot answer. Walking the stream it knows "this is the 2nd {@code ana}",
+ * because that is settled by the time the element goes past. It cannot say "{@code ana} appears
+ * twice in total": the total is only known once the source is exhausted, and by then every element
+ * has already been pushed. Emitting the totals is the job of a finisher , the next step.
+ */
+ static Gatherer occurrence() {
+ return Gatherer.ofSequential(
+ Tally::new,
+ (tally, element, downstream) -> {
+ // merge returns the NEW value, so this counts and reads in one call
+ final int times = tally.timesSeen.merge(element, 1, Integer::sum);
+ return downstream.push(element + " (" + times + ")");
+ });
+ }
+
+ public static void main(final String[] args) {
+ System.out.println();
+ System.out.println("The same gatherer, one line apart");
+ System.out.println("-".repeat(78));
+ print("source", SOURCE);
+ print("passThrough", SOURCE.stream().gather(passThrough()).toList());
+ print("onlyLong", SOURCE.stream().gather(onlyLong()).toList());
+ print("upperCase", SOURCE.stream().gather(upperCase()).toList());
+ print("twice", SOURCE.stream().gather(twice()).toList());
+ System.out.println();
+ System.out.println(" The only decision is how many times you call push().");
+
+ System.out.println();
+ System.out.println("Now using state — the parameter ignored above");
+ System.out.println("-".repeat(78));
+ print("source", SOURCE);
+ print("numbered", SOURCE.stream().gather(numbered()).toList());
+ System.out.println();
+ print("source", REPEATED);
+ print("whenChanged", REPEATED.stream().gather(whenChanged()).toList());
+ System.out.println();
+ print("source", WITH_REPEATS);
+ print("occurrence", WITH_REPEATS.stream().gather(occurrence()).toList());
+ System.out.println();
+ System.out.println(" numbered() cannot be written with map: the output depends on how many");
+ System.out.println(" elements went past before this one. whenChanged() cannot be written with");
+ System.out.println(" filter: a predicate sees one element and nothing about its neighbours.");
+ System.out.println();
+ }
+
+ private static void print(final String label, final List value) {
+ System.out.printf(" %-14s %s%n", label, value);
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/StreamGatherersDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/StreamGatherersDemo.java
new file mode 100644
index 000000000000..d62cf5b07e70
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/StreamGatherersDemo.java
@@ -0,0 +1,253 @@
+package com.dotcms.jdk;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Gatherer;
+import java.util.stream.Gatherers;
+import java.util.stream.IntStream;
+
+/**
+ * Live demo for the Java 25 talk: stream gatherers — the extension point the middle of a stream
+ * never had.
+ *
+ * A {@code Stream}'s intermediate vocabulary closed in Java 8 and never grew: {@code map},
+ * {@code filter}, {@code limit}, {@code skip}, {@code sorted}, {@code distinct}, {@code peek},
+ * {@code flatMap}. Since Java 8 you have been able to write your own terminal operation — that
+ * is what a {@code Collector} is. There was never a way to write your own intermediate one.
+ *
+ *
The reason it matters is that the existing operations are amnesic. {@code map} sees one element
+ * and produces one; {@code filter} sees one and decides keep-or-drop; even {@code flatMap}, which can
+ * change the element count, decides looking at a single element. None can remember anything about what
+ * it already saw — which rules out a whole family of ordinary operations:
+ *
+ *
+ * batches of 50 must accumulate 50 before emitting anything
+ * sliding window must remember the previous elements
+ * running total must remember the sum
+ * collapse consecutive duplicates must remember the previous element
+ * stop once a condition holds must remember that it held
+ *
+ *
+ * All of them need state between elements, and there was nowhere to put it. So the way out was
+ * always the same: {@code collect(toList())}, leave the stream, and finish by hand with a {@code for}
+ * and a variable outside it. A gatherer is where that state goes — {@code .gather(...)} takes a
+ * stream and returns a stream, so the pipeline survives.
+ *
+ * The two costs of doing it by hand, both of which are in this codebase
+ *
+ *
+ * Materialising a list you only wanted to walk in pieces.
+ * {@code OSIndexAPIImpl.getIndexAlias} (around lines 846-855) does
+ * {@code stream().map(...).collect(toList())} and immediately
+ * {@code Lists.partition(physicalNames, ALIAS_LOOKUP_BATCH_SIZE)}. The whole list exists solely
+ * so that it can be cut up. That is {@code .gather(windowFixed(N))} written the long way.
+ * {@code Lists.partition} appears in a dozen more places.
+ * Forgetting the tail. {@code PopulateContentletAsJSONUtil.processInsertRecord}
+ * (around 466-471) flushes when the batch reaches {@code MAX_BATCH_SIZE}, and its caller
+ * (around 332-334) has to remember {@code if (!paramsInsert.isEmpty()) doInsertBatch(...)} for
+ * the partly-filled remainder. Two places that must agree, duplicated again for updates.
+ * {@code windowFixed} emits the short final window on its own — watch for the lone
+ * {@code [id7]} in the output below.
+ *
+ *
+ * What the JDK ships
+ *
+ * {@code windowFixed}, {@code windowSliding}, {@code fold}, {@code scan} and {@code mapConcurrent}.
+ * Gatherers are final since Java 24 (JEP 485), so none of this needs {@code --enable-preview}.
+ *
+ * {@code mapConcurrent} is the one that connects this topic to Loom: it runs each element on a
+ * virtual thread , under a concurrency limit you choose, and preserves encounter order .
+ * It is the JDK's answer to "parallelise the I/O in this stream" — the thing {@code parallelStream()}
+ * never did well, because it uses the common ForkJoinPool, sized for CPU work and shared with the
+ * whole process.
+ *
+ *
Run it (no build required; takes a couple of seconds for the concurrency section):
+ *
+ *
+ * java dotCMS/src/test/java/com/dotcms/jdk/StreamGatherersDemo.java
+ *
+ *
+ * Not a JUnit test on purpose: the console output is the artifact being shown to an
+ * audience. It is a {@code main} demo, compiled by the real build so it cannot silently rot, and named
+ * {@code *Demo} so surefire skips it. {@code System.out} is deliberate for the same reason; the
+ * Logger-only rule targets production code.
+ *
+ * @author Fabrizio Araya
+ * @see JEP 485 — Stream Gatherers
+ * @see CustomCollectorDemo
+ */
+public final class StreamGatherersDemo {
+
+ /** Stands in for the index names {@code OSIndexAPIImpl} batches before asking OpenSearch. */
+ private static final List INDEX_NAMES =
+ List.of("idx1", "idx2", "idx3", "idx4", "idx5", "idx6", "idx7");
+
+ private StreamGatherersDemo() {
+ }
+
+ public static void main(final String[] args) {
+ batching();
+ statefulBuiltIns();
+ writingYourOwn();
+ concurrentMapping();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 1. The batching case, which is the one already written by hand here
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static void batching() {
+ header("1. Batching — and the partial final window nobody remembers to flush");
+
+ System.out.println(" source " + INDEX_NAMES + " (7 items, batch size 3)");
+
+ final String label = " windowFixed(3) ";
+ final String windows = INDEX_NAMES.stream().gather(Gatherers.windowFixed(3)).toList().toString();
+ System.out.println(label + windows);
+ // Point at the short final window wherever it lands, rather than at a hand-counted column.
+ System.out.println(" ".repeat(label.length() + windows.lastIndexOf('['))
+ + "^^^^^^^ the short tail, emitted for free");
+ System.out.println(" windowSliding(3) "
+ + INDEX_NAMES.stream().gather(Gatherers.windowSliding(3)).toList());
+ System.out.println();
+ System.out.println(" The list is never materialised: windowFixed is a stream step, so the");
+ System.out.println(" batches are produced lazily as the source is consumed. Compare with");
+ System.out.println(" collect(toList()) followed by Lists.partition(...), which must build the");
+ System.out.println(" whole list first purely in order to cut it up.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 2. The other built-ins that carry state
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static void statefulBuiltIns() {
+ header("2. Carrying state — scan and fold");
+
+ final List amounts = List.of(10, 20, 30, 40);
+ System.out.println(" source " + amounts);
+ System.out.println(" scan (running total) "
+ + amounts.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList()
+ + " <- one output per input");
+ System.out.println(" fold (single value) "
+ + amounts.stream().gather(Gatherers.fold(() -> 0, Integer::sum)).toList()
+ + " <- one output in total");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 3. Writing your own: state, plus a boolean that can end the stream
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Remembers the previous element, so runs of equal values collapse into one. */
+ private static final class Previous {
+
+ private T value;
+ }
+
+ /**
+ * Collapses consecutive duplicates. Note this is not {@code distinct()}: a value may
+ * reappear later, it just may not repeat back to back.
+ */
+ static Gatherer collapsingRuns() {
+ return Gatherer.ofSequential(
+ Previous::new,
+ (state, element, downstream) -> {
+ if (Objects.equals(state.value, element)) {
+ return true; // swallow it, keep going
+ }
+ state.value = element;
+ return downstream.push(element);
+ });
+ }
+
+ /** Remembers that the cut-off condition already fired. */
+ private static final class Latch {
+
+ private boolean tripped;
+ }
+
+ /**
+ * Emits elements until the first {@code ERROR} and then ends the stream. Returning {@code false}
+ * from the integrator short-circuits — the rest of the source is never consumed, which no
+ * {@code Collector} can do.
+ */
+ static Gatherer untilError() {
+ return Gatherer.ofSequential(
+ Latch::new,
+ (state, element, downstream) -> {
+ if (state.tripped || "ERROR".equals(element)) {
+ state.tripped = true;
+ return false; // false ends the stream here
+ }
+ return downstream.push(element);
+ });
+ }
+
+ private static void writingYourOwn() {
+ header("3. Writing your own — initial state + what to do with each element");
+
+ final List statuses = List.of("OK", "OK", "OK", "ERROR", "ERROR", "OK");
+ System.out.println(" source " + statuses);
+ System.out.println(" collapsingRuns() " + statuses.stream().gather(collapsingRuns()).toList()
+ + " <- runs collapse, later repeats survive");
+ System.out.println(" untilError() " + statuses.stream().gather(untilError()).toList()
+ + " <- returning false ends the stream");
+ System.out.println();
+ System.out.println(" A gatherer can therefore short-circuit, which a Collector cannot: a");
+ System.out.println(" collector always drains the whole stream before it can produce anything.");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 4. mapConcurrent — where this topic meets Loom
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static void concurrentMapping() {
+ header("4. mapConcurrent — bounded concurrency on virtual threads, order preserved");
+
+ final List ids = IntStream.rangeClosed(1, 12).boxed().toList();
+
+ final long startSequential = System.nanoTime();
+ final List sequential = ids.stream().map(StreamGatherersDemo::slowIo).toList();
+ final long sequentialMs = millisSince(startSequential);
+
+ final long startConcurrent = System.nanoTime();
+ final List concurrent =
+ ids.stream().gather(Gatherers.mapConcurrent(4, StreamGatherersDemo::slowIo)).toList();
+ final long concurrentMs = millisSince(startConcurrent);
+
+ System.out.printf(" sequential %5d ms%n", sequentialMs);
+ System.out.printf(" mapConcurrent(4) %5d ms%n", concurrentMs);
+ System.out.println(" same order as input " + sequential.equals(concurrent));
+ System.out.println(" ran on virtual threads " + ranVirtual(ids));
+ System.out.println();
+ System.out.println(" The bound is explicit and local. parallelStream() would instead borrow");
+ System.out.println(" the common ForkJoinPool — sized for CPU work and shared process-wide —");
+ System.out.println(" which is the wrong resource for blocking I/O.");
+ }
+
+ /** Stands in for a blocking call: 100 ms of waiting, no CPU. */
+ private static String slowIo(final int id) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ return "r" + id;
+ }
+
+ /** Reports whether mapConcurrent actually ran the mapper on virtual threads. */
+ private static boolean ranVirtual(final List ids) {
+ return ids.stream()
+ .gather(Gatherers.mapConcurrent(4, id -> Thread.currentThread().isVirtual()))
+ .allMatch(Boolean::booleanValue);
+ }
+
+ private static long millisSince(final long startNanos) {
+ return (System.nanoTime() - startNanos) / 1_000_000;
+ }
+
+ private static void header(final String title) {
+ System.out.println();
+ System.out.println(title);
+ System.out.println("-".repeat(78));
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierStarvationDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierStarvationDemo.java
new file mode 100644
index 000000000000..eab2b8328ee2
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierStarvationDemo.java
@@ -0,0 +1,98 @@
+package com.dotcms.jdk;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Live demo for the Java 25 talk: a virtual-thread executor never runs out of workers — it runs
+ * out of JVM.
+ *
+ * Ten tasks, each an uninterruptible {@code while(true)} CPU loop, are submitted to either a
+ * virtual-thread-per-task executor or a fixed pool of two platform threads. In both cases only two
+ * tasks ever execute, because there are only two carriers / two pool threads. The difference is the
+ * blast radius:
+ *
+ *
+ * VT executor fixed platform pool (2)
+ * tasks that started 2 of 10 2 of 10
+ * unrelated code elsewhere ran? NO YES
+ *
+ *
+ * With platform threads the starvation is contained: eight tasks wait in your queue,
+ * under your bound, and the OS preempts the two runners so the rest of the process keeps
+ * going. With virtual threads the scarce resource is the carrier pool, which is
+ * {@code availableProcessors()} wide, global to the JVM and shared with every other virtual
+ * thread in the process — so an unrelated task that never touched this executor is starved too.
+ * The VT scheduler is cooperative and will not take a carrier back by force.
+ *
+ *
This is the mechanism behind issue #37038 (fixed in PR #37041): a blocking file read pins its
+ * carrier for the whole call exactly like this CPU loop does, and with 2-4 carriers in a container a
+ * handful of slow reads on a network mount stalled content indexing process-wide.
+ *
+ *
Run it (no flags needed — it pins the carrier count itself so the demo is reproducible
+ * on a 24-core laptop):
+ *
+ *
+ * java -Dmode=vt dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierStarvationDemo.java
+ * java -Dmode=platform dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierStarvationDemo.java
+ *
+ *
+ * Not a JUnit test on purpose: the hog tasks ignore interrupts and can never be reclaimed, so
+ * running them inside a shared surefire JVM would starve the rest of the suite. It is a {@code main}
+ * demo, compiled by the real build so it cannot silently rot, and named {@code *Demo} so surefire
+ * does not pick it up. {@code System.out} is deliberate here — the console output is the
+ * artifact being shown to an audience; the Logger-only rule targets production code.
+ *
+ * @see VirtualThreadYieldVsParkDemo
+ * @see VirtualThreadCarrierTimelineDemo
+ */
+public class VirtualThreadCarrierStarvationDemo {
+
+ private static final int CARRIERS = 2;
+ private static final int TASKS = 10;
+
+ public static void main(final String[] args) throws InterruptedException {
+ // Read lazily on first virtual thread creation, so setting it here is enough: the demo does
+ // not depend on the audience's core count, and needs no command-line flag.
+ if (System.getProperty("jdk.virtualThreadScheduler.parallelism") == null) {
+ System.setProperty("jdk.virtualThreadScheduler.parallelism", String.valueOf(CARRIERS));
+ }
+
+ final String mode = System.getProperty("mode", "vt");
+ final boolean virtual = "vt".equals(mode);
+ final AtomicInteger started = new AtomicInteger();
+
+ System.out.println("mode = " + mode
+ + " | carriers = " + System.getProperty("jdk.virtualThreadScheduler.parallelism")
+ + " | availableProcessors = " + Runtime.getRuntime().availableProcessors());
+
+ final ExecutorService pool = virtual
+ ? Executors.newVirtualThreadPerTaskExecutor()
+ : Executors.newFixedThreadPool(CARRIERS);
+
+ for (int i = 0; i < TASKS; i++) {
+ pool.submit(() -> {
+ started.incrementAndGet();
+ long spin = 0;
+ while (true) { // never blocks, never parks, never yields the carrier
+ spin++;
+ }
+ });
+ }
+
+ Thread.sleep(500);
+ System.out.println("tasks that STARTED: " + started.get() + " of " + TASKS
+ + (virtual ? " (all " + TASKS + " virtual threads exist — 8 never ran a line)" : ""));
+
+ // A completely unrelated part of the system, which knows nothing about the pool above.
+ Thread.ofVirtual().name("innocent").start(
+ () -> System.out.println(">>> the INNOCENT virtual thread RAN"));
+
+ Thread.sleep(2000);
+ System.out.println("--- done: no '>>>' above means unrelated code was starved too ---");
+
+ // The hogs ignore interrupts by design, so there is nothing to shut down gracefully.
+ System.exit(0);
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierTimelineDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierTimelineDemo.java
new file mode 100644
index 000000000000..4aeef8fd6ea3
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierTimelineDemo.java
@@ -0,0 +1,593 @@
+package com.dotcms.jdk;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Live demo for the Java 25 talk: you can watch the carriers . Six virtual threads are handed
+ * to two carriers, and the demo prints, tick by tick, which virtual thread each carrier was actually
+ * running.
+ *
+ *
How to read the picture. One row is one carrier: a real OS thread, the only
+ * thing in the JVM that can actually execute code. One cell is one 100 ms tick, oldest on
+ * the left, newest on the right. Inside the cell:
+ *
+ *
+ * 1 .. 6 which of the six virtual threads was mounted on that carrier during that tick
+ * . nobody was mounted: the carrier was idle and free for any other task in the JVM
+ * C the unrelated 'canary' virtual thread, submitted mid-run, that only wants a carrier
+ * for a microsecond
+ *
+ *
+ * So a row that keeps repeating the same digit means one virtual thread took that carrier and
+ * never gave it back. A row whose digits keep changing, with dots in between, means the tasks are
+ * releasing the carrier and taking turns on it:
+ *
+ *
+ * mode=cpu (never releases) mode=sleep (releases on every sleep)
+ * carrier-1 | 111111111111111111 carrier-1 | 1.4.2.6.3.1.5.2.
+ * carrier-2 | 222222222222222222 carrier-2 | 2.5.3.1.4.6.2.3.
+ * 2 of 6 tasks ever got a carrier 6 of 6 tasks got a carrier
+ * canary: NEVER RAN canary: ran in 1 ms
+ *
+ *
+ * Same six tasks, same two carriers, same executor in both cases. The only difference is whether
+ * the blocking call releases the carrier - which is the whole point: a virtual thread is
+ * cheap, a carrier is not, and the carrier pool is {@code availableProcessors()} wide, global to the
+ * JVM and shared with every other virtual thread in the process. Nothing takes a carrier back by
+ * force; the scheduler is cooperative.
+ *
+ *
How the carrier is observed. {@code VirtualThread.toString()} appends the carrier while
+ * the thread is mounted and omits it while it is not:
+ *
+ *
+ * VirtualThread[#26,task-1]/runnable@ForkJoinPool-1-worker-1 <- mounted, prints as a digit
+ * VirtualThread[#28,task-3]/timed_waiting <- unmounted, prints as a dot
+ *
+ *
+ * The JDK names the carriers {@code ForkJoinPool-1-worker-N}; the demo relabels them
+ * {@code carrier-N}, because "worker" is exactly the word that makes an audience think it is looking
+ * at the virtual threads instead of at the scarce OS threads underneath them.
+ *
+ * A platform thread polls that every tick. It has to be a platform thread: run the monitor on
+ * a virtual thread ({@code -Dmonitor=virtual}) and the tool itself freezes mid-drawing, which is the
+ * same failure the rest of the JVM is suffering.
+ *
+ * Modes ({@code -Dmode=}):
+ *
+ *
+ * compare (default) all four phases: sleep, socket, file, cpu - in that order
+ * sleep Thread.sleep in a loop - unmounts on every sleep
+ * socket read() on a socket nobody writes to - unmounts once, forever
+ * file read() on a FIFO nobody writes to - blocking FILE i/o: measure it
+ * cpu uninterruptible while(true) loop - never unmounts
+ *
+ *
+ * The order of {@code compare} is itself part of the lesson. {@code sleep} and {@code socket} give
+ * the carrier back, so the JVM survives them; {@code file} parks its tasks in a read that keeps the
+ * carrier, but writing to the FIFO releases them, so the phase can clean up after itself; {@code cpu}
+ * cannot be cleaned up at all - its tasks ignore interrupts - so it has to run last. A phase that
+ * starts with no carrier left to obtain says {@code SKIPPED} instead of drawing an empty grid.
+ *
+ *
{@code file} is the shape of issue #37038 (fixed in PR #37041): a blocking file read on a
+ * network mount, on a container with 2-4 carriers, stalled content indexing process-wide. Run
+ * {@code file} and {@code socket} back to back - the rows tell you which kind of blocking Loom knows
+ * how to unmount and which one just eats a carrier.
+ *
+ *
+ * java dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierTimelineDemo.java
+ * java -Dmode=file dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadCarrierTimelineDemo.java
+ * java -Dmode=socket -Dcolor=false ...VirtualThreadCarrierTimelineDemo.java
+ *
+ *
+ * Not a JUnit test on purpose: the cpu tasks ignore interrupts and can never be reclaimed, so
+ * running them inside a shared surefire JVM would starve the rest of the suite. It is a {@code main}
+ * demo, compiled by the real build so it cannot silently rot, and named {@code *Demo} so surefire
+ * skips it. {@code System.out} is deliberate: the console output is the artifact being shown
+ * to an audience; the Logger-only rule targets production code.
+ *
+ * @see VirtualThreadCarrierStarvationDemo
+ * @see VirtualThreadYieldVsParkDemo
+ */
+public class VirtualThreadCarrierTimelineDemo {
+
+ private static final int CARRIERS = 2;
+ private static final int TASKS = 6;
+ private static final int TICK_MS = 100;
+ private static final int TICKS = 40;
+ private static final int CANARY_TICK = 5;
+ private static final char FREE = '.';
+ private static final char CANARY = 'C';
+ private static final boolean COLOR = !"false".equals(System.getProperty("color"));
+ private static final String CSI = ((char) 27) + "[";
+
+ /** What each of the six tasks does, and how its rows should be read. */
+ private record Workload(String name, String detail, String expectation, Runnable body) {
+
+ }
+
+ public static void main(final String[] args) throws Exception {
+ // Read lazily on first virtual thread creation, so setting it here is enough: the demo does
+ // not depend on the audience's core count and needs no command-line flag.
+ if (System.getProperty("jdk.virtualThreadScheduler.parallelism") == null) {
+ System.setProperty("jdk.virtualThreadScheduler.parallelism", String.valueOf(CARRIERS));
+ }
+ final String mode = System.getProperty("mode", "compare");
+ if ("compare".equals(mode)) {
+ // Order matters, and it is part of the lesson. sleep and socket release the carrier, so
+ // the JVM survives them. file parks its tasks in a blocking read that keeps the carrier,
+ // but they can be unblocked by writing to the FIFO, so the phase cleans up after itself.
+ // cpu cannot be cleaned up - the tasks ignore interrupts - so it has to be the last one.
+ runPhase("sleep");
+ //runPhase("socket");
+ //runPhase("file");
+ runPhase("cpu"); // terminal: nothing in this JVM gets a carrier after this
+ } else {
+ runPhase(mode);
+ }
+ System.out.flush();
+ System.exit(0); // the cpu tasks ignore interrupts; there is nothing to shut down
+ }
+
+ private static void runPhase(final String mode) throws Exception {
+ final List cleanup = new ArrayList<>();
+ final AtomicLong releases = new AtomicLong();
+ // Every phase must leave the JVM as it found it, or the next phase measures the leftovers
+ // instead of its own workload. Whatever can retire, retires when this flips.
+ final AtomicBoolean running = new AtomicBoolean(true);
+ final Workload workload = workload(mode, releases, running, cleanup);
+
+ System.out.println();
+ System.out.println("mode = " + workload.name() + " | " + workload.detail());
+ System.out.println("carriers = " + System.getProperty("jdk.virtualThreadScheduler.parallelism")
+ + " of " + Runtime.getRuntime().availableProcessors() + " cpus"
+ + " | tasks = " + TASKS + " virtual threads"
+ + " | tick = " + TICK_MS + " ms");
+ System.out.println();
+ printLegend(workload);
+ System.out.println();
+
+ if (!carrierAvailable()) {
+ System.out.println(" SKIPPED: no carrier can be obtained any more. An earlier phase took"
+ + " both carriers and");
+ System.out.println(" never gave them back, so there is nothing left to measure"
+ + " here. Run this");
+ System.out.println(" mode in its own JVM: -Dmode=" + mode);
+ System.out.println();
+ running.set(false);
+ closeAll(cleanup);
+ return;
+ }
+
+ final Map labels = new LinkedHashMap<>();
+ final List watched = new ArrayList<>();
+ final Timeline timeline = new Timeline(labels);
+ final Runnable body = workload.body();
+ for (int i = 0; i < TASKS; i++) {
+ // The label is decided here, not after start(), because the task reports its own carrier
+ // the instant it starts running - which can happen before start() has even returned.
+ final char label = (char) ('1' + i);
+ final Thread task = Thread.ofVirtual().name("task-" + (i + 1)).start(() -> {
+ timeline.selfReport(label);
+ body.run();
+ });
+ labels.put(task.threadId(), label);
+ watched.add(task);
+ }
+
+ final AtomicLong canaryRanAt = new AtomicLong(-1);
+ final AtomicLong canarySubmittedAt = new AtomicLong();
+ final Runnable monitor = () -> {
+ for (int tick = 0; tick < TICKS; tick++) {
+ if (tick == CANARY_TICK) {
+ // A completely unrelated part of the system, which knows nothing about the six
+ // tasks above and only wants a carrier for a microsecond.
+ canarySubmittedAt.set(System.nanoTime());
+ final Thread canary = Thread.ofVirtual().name("canary").start(() -> {
+ timeline.selfReport(CANARY);
+ canaryRanAt.set(System.nanoTime() - canarySubmittedAt.get());
+ });
+ labels.put(canary.threadId(), CANARY);
+ watched.add(canary);
+ }
+ timeline.sample(watched);
+ timeline.draw();
+ sleep(TICK_MS);
+ }
+ };
+
+ if ("virtual".equals(System.getProperty("monitor"))) {
+ Thread.ofVirtual().name("monitor").start(monitor).join();
+ } else {
+ monitor.run();
+ }
+
+ System.out.println();
+ System.out.printf(" tasks that ever held a carrier : %d of %d%n",
+ timeline.everMounted().stream().filter(label -> label != CANARY).count(), TASKS);
+ System.out.printf(" times a carrier changed hands : %d (digit changes above)%n",
+ timeline.handOffs());
+ System.out.printf(" blocking calls that RELEASED a carrier : %d (counted by the tasks)%n",
+ releases.get());
+ System.out.printf(" unrelated 'canary' virtual thread : %s%n",
+ canaryRanAt.get() < 0
+ ? "NEVER RAN in " + (TICKS * TICK_MS) + " ms <-- the whole JVM is starved"
+ : String.format("ran %.2f ms after it was submitted",
+ canaryRanAt.get() / 1_000_000d));
+ System.out.println();
+
+ running.set(false); // tell the tasks to retire...
+ closeAll(cleanup); // ...and unblock the ones parked in a read that ignores the flag
+ }
+
+ /**
+ * The audience has to be told what the drawing means before it starts moving, so the legend is
+ * printed once per phase, right above the rows it explains.
+ */
+ private static void printLegend(final Workload workload) {
+ System.out.println(" HOW TO READ THE ROWS BELOW");
+ legend("one row", "one CARRIER: a real OS thread, the only thing that can run code");
+ legend("one cell", "one " + TICK_MS + " ms tick - oldest on the left, newest on the right");
+ legend("1 .. " + TASKS, "which virtual thread that carrier was running during that tick");
+ legend(String.valueOf(FREE), "carrier IDLE: nothing was mounted, so it was free for anyone else");
+ legend(String.valueOf(CANARY), "an unrelated virtual thread, submitted at tick " + CANARY_TICK
+ + ", that only needs a carrier for a microsecond");
+ legend("same digit", "one virtual thread is holding that carrier and never gives it back");
+ legend("digits change", "the tasks release the carrier, so they take turns on it");
+ legend("expect", workload.expectation());
+ }
+
+ /** Fixed-width key column, wrapped text: a legend nobody can read is not a legend. */
+ private static void legend(final String key, final String text) {
+ final String pad = " %-14s %s%n";
+ final String indent = " ".repeat(17);
+ final StringBuilder line = new StringBuilder();
+ boolean first = true;
+ for (final String word : text.split(" ")) {
+ if (line.length() + word.length() + 1 > 74) {
+ System.out.printf(first ? pad : "%s%s%n", first ? key : indent, line);
+ line.setLength(0);
+ first = false;
+ }
+ line.append(line.isEmpty() ? "" : " ").append(word);
+ }
+ System.out.printf(first ? pad : "%s%s%n", first ? key : indent, line);
+ }
+
+ /**
+ * Submits a throw-away virtual thread and sees whether it ever gets to run. A phase that follows
+ * a carrier-eating workload cannot measure anything, and an empty drawing is a worse answer than
+ * saying so out loud.
+ */
+ private static boolean carrierAvailable() throws InterruptedException {
+ final AtomicBoolean ran = new AtomicBoolean();
+ Thread.ofVirtual().name("probe").start(() -> ran.set(true)).join(1_000);
+ return ran.get();
+ }
+
+ private static void closeAll(final List cleanup) {
+ for (final AutoCloseable closeable : cleanup) {
+ try {
+ closeable.close();
+ } catch (final Exception ignore) {
+ // demo teardown, best effort
+ }
+ }
+ }
+
+ private static Workload workload(final String mode, final AtomicLong releases,
+ final AtomicBoolean running, final List cleanup) throws Exception {
+ switch (mode) {
+ case "cpu":
+ return new Workload("cpu", "while(true) spin++ (no block, no park, no yield)",
+ "every row stuck on one digit: two tasks own both carriers forever, "
+ + "the other four never get to run",
+ () -> {
+ long spin = 0;
+ while (true) { // deliberately ignores 'running': that IS the lesson,
+ spin++; // nothing can take a carrier back by force
+ }
+ });
+ case "sleep":
+ return new Workload("sleep", "15 ms of work, then Thread.sleep(35), in a loop",
+ "digits keep changing, with dots in between: every sleep releases "
+ + "the carrier, so all six tasks take turns on the two carriers",
+ () -> {
+ while (running.get()) {
+ burn(15);
+ sleep(35);
+ releases.incrementAndGet();
+ }
+ });
+ case "socket": {
+ final ServerSocket server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ cleanup.add(server);
+ // Accept every connection and never write a byte, so all readers block in read().
+ Thread.ofPlatform().daemon().name("silent-server").start(() -> {
+ while (!server.isClosed()) {
+ try {
+ cleanup.add(server.accept());
+ } catch (final Exception closed) {
+ return;
+ }
+ }
+ });
+ return new Workload("socket", "read() on a socket nobody ever writes to",
+ "a digit for a moment, then dots everywhere: a blocked socket read "
+ + "releases the carrier, so both carriers end up idle",
+ () -> {
+ if (!running.get()) {
+ return; // the phase is over; do not take a carrier for nothing
+ }
+ try (Socket socket = new Socket(InetAddress.getLoopbackAddress(),
+ server.getLocalPort())) {
+ releases.incrementAndGet();
+ socket.getInputStream().read(); // blocks forever
+ } catch (final Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ }
+ case "file": {
+ final Path fifo = fifo();
+ cleanup.add(() -> unblockFifoReaders(fifo));
+ return new Workload("file", "read() on a FIFO nobody ever writes to (" + fifo + ")",
+ "this is the measurement: if the rows stay stuck on a digit, blocking "
+ + "FILE i/o kept the carrier instead of releasing it",
+ () -> {
+ if (!running.get()) {
+ return; // opening the FIFO now would park forever on a carrier
+ }
+ try (InputStream in = Files.newInputStream(fifo)) {
+ in.read(); // blocks forever
+ } catch (final Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ }
+ default:
+ throw new IllegalArgumentException(
+ "unknown mode '" + mode + "' - use compare|cpu|sleep|socket|file");
+ }
+ }
+
+ /**
+ * The point of {@code file} is that a blocking file read keeps its carrier, so when the phase ends
+ * both carriers are still held by tasks parked inside {@code read()}. Opening the FIFO for writing
+ * hands each of them a byte; they return, finish, and the carriers come back. Without this the
+ * phase would poison every phase after it - which is exactly what {@code cpu} does, and why
+ * {@code cpu} runs last.
+ */
+ private static void unblockFifoReaders(final Path fifo) throws Exception {
+ final Thread writer = Thread.ofPlatform().daemon().name("fifo-writer").start(() -> {
+ try (OutputStream out = Files.newOutputStream(fifo)) { // returns once a reader is parked
+ for (int i = 0; i < TASKS * 4; i++) {
+ out.write('x');
+ out.flush();
+ sleep(25); // let the readers that were still queued get their turn too
+ }
+ } catch (final Exception ignore) {
+ // nobody left to unblock; the JVM is about to end the phase anyway
+ }
+ });
+ writer.join(2_000); // never let teardown wedge the demo
+ }
+
+ private static Path fifo() throws Exception {
+ final Path fifo = Path.of(System.getProperty("java.io.tmpdir"), "vt-carrier-demo.fifo");
+ Files.deleteIfExists(fifo);
+ final int exit = new ProcessBuilder("mkfifo", fifo.toString())
+ .redirectErrorStream(true).start().waitFor();
+ if (exit != 0 || !Files.exists(fifo)) {
+ throw new IllegalStateException("mkfifo failed (exit " + exit + ") - mode=file needs a FIFO");
+ }
+ return fifo;
+ }
+
+ /** A virtual thread telling the timeline which carrier it woke up on. */
+ private record Mount(String carrier, Character label) {
+
+ }
+
+ /** One row per carrier, one column per tick, plus the bookkeeping the summary needs. */
+ private static final class Timeline {
+
+ /** {@code worker-10} has to sort after {@code worker-2}, so compare the number, not the text. */
+ private static final Comparator BY_CARRIER_NUMBER =
+ Comparator.comparingInt(Timeline::carrierNumber).thenComparing(Comparator.naturalOrder());
+
+ private final Map labels;
+ /**
+ * Sorted, not insertion-ordered: which carrier is discovered first is a race, and a row order
+ * that changes between runs is one more thing the audience has to explain away.
+ */
+ private final Map lanes = new TreeMap<>(BY_CARRIER_NUMBER);
+ /** Mounts reported by the tasks themselves; drained by the monitor on the next tick. */
+ private final ConcurrentLinkedQueue selfReported = new ConcurrentLinkedQueue<>();
+ private final Map current = new LinkedHashMap<>();
+ private final Set everMounted = new LinkedHashSet<>();
+ private int handOffs;
+ private int linesDrawn;
+
+ private Timeline(final Map labels) {
+ this.labels = labels;
+ }
+
+ /**
+ * Called by each task on its own carrier, before it blocks. A {@code socket} task is mounted
+ * for a few microseconds - connect, then {@code read()} - so a 100 ms sampler only catches it
+ * by luck, and when it misses every task the drawing has no rows at all. The task reporting
+ * its own carrier is the same observation, just from a finer-grained observer; without it the
+ * picture depends on how loaded the machine is.
+ */
+ private void selfReport(final char label) {
+ final String carrier = carrierOf(Thread.currentThread());
+ if (carrier != null) {
+ selfReported.add(new Mount(carrier, label)); // the monitor owns all the other state
+ }
+ }
+
+ private void sample(final List watched) {
+ final Map mounted = new LinkedHashMap<>();
+ for (final Thread virtual : watched) {
+ final String carrier = carrierOf(virtual);
+ if (carrier != null) {
+ final Character label = labels.get(virtual.threadId());
+ mounted.put(carrier, label);
+ everMounted.add(label);
+ lanes.computeIfAbsent(carrier, unused -> backFilled());
+ }
+ }
+ // Mounts too short for this tick to see. Every report counts towards "ever held a
+ // carrier", but a single cell can only show one label: a live reading wins over a report,
+ // and among several reports on the same carrier the first one wins.
+ for (Mount report = selfReported.poll(); report != null; report = selfReported.poll()) {
+ everMounted.add(report.label());
+ lanes.computeIfAbsent(report.carrier(), unused -> backFilled());
+ mounted.putIfAbsent(report.carrier(), report.label());
+ }
+ for (final Map.Entry lane : lanes.entrySet()) {
+ final Character now = mounted.get(lane.getKey());
+ final Character before = current.get(lane.getKey());
+ if (now != null && !now.equals(before)) {
+ handOffs++;
+ }
+ current.put(lane.getKey(), now);
+ lane.getValue().append(now == null ? FREE : now);
+ }
+ }
+
+ /**
+ * A carrier is only visible while the virtual thread is mounted:
+ * {@code VirtualThread[#26,task-1]/runnable@ForkJoinPool-1-worker-1}.
+ */
+ private static String carrierOf(final Thread virtual) {
+ final String description = virtual.toString();
+ final int at = description.lastIndexOf('@');
+ return at < 0 ? null : description.substring(at + 1);
+ }
+
+ /** A carrier discovered late still needs a full-width row, so back-fill it as free. */
+ private StringBuilder backFilled() {
+ final int width = lanes.isEmpty() ? 0 : lanes.values().iterator().next().length();
+ return new StringBuilder(String.valueOf(FREE).repeat(width));
+ }
+
+ private void draw() {
+ if (linesDrawn > 0) {
+ System.out.print(CSI + linesDrawn + "A"); // redraw the block in place
+ }
+ // The block gets taller as carriers are discovered, so erase downwards first: without
+ // this the taller frame leaves the previous frame's last row stranded on screen.
+ System.out.print(CSI + "0J");
+ if (lanes.isEmpty()) {
+ System.out.println(" (waiting: no task has been given a carrier yet)");
+ linesDrawn = 1;
+ return;
+ }
+ int lines = 0;
+ int width = 0;
+ for (final Map.Entry lane : lanes.entrySet()) {
+ System.out.printf(" %-10s | %s%n", carrierName(lane.getKey()), colorize(lane.getValue()));
+ width = Math.max(width, lane.getValue().length());
+ lines++;
+ }
+ System.out.printf(" %-10s | %s%n", "time", axis(width));
+ lines++;
+ linesDrawn = lines;
+ }
+
+ /** A second marker under the cells, so the row reads as elapsed time, not as an abstract band. */
+ private static String axis(final int width) {
+ final int ticksPerSecond = Math.max(1, 1000 / TICK_MS);
+ final StringBuilder out = new StringBuilder(width + 4);
+ for (int tick = 0; tick < width; tick++) {
+ if (tick % ticksPerSecond == 0) {
+ out.append(tick / ticksPerSecond).append('s');
+ } else if (out.length() <= tick) {
+ out.append(' ');
+ }
+ }
+ return out.toString();
+ }
+
+ /**
+ * The JDK calls them {@code ForkJoinPool-1-worker-N}, but "worker" reads as "one of my
+ * tasks"; what the row actually is, is a carrier.
+ */
+ private static int carrierNumber(final String carrier) {
+ final int dash = carrier.lastIndexOf('-');
+ try {
+ return dash < 0 ? Integer.MAX_VALUE : Integer.parseInt(carrier.substring(dash + 1));
+ } catch (final NumberFormatException notNumbered) {
+ return Integer.MAX_VALUE;
+ }
+ }
+
+ private static String carrierName(final String carrier) {
+ final int dash = carrier.lastIndexOf("-worker-");
+ return dash < 0 ? carrier : "carrier-" + carrier.substring(dash + "-worker-".length());
+ }
+
+ private static String colorize(final CharSequence row) {
+ if (!COLOR) {
+ return row.toString();
+ }
+ final StringBuilder out = new StringBuilder(row.length() * 12);
+ for (int i = 0; i < row.length(); i++) {
+ final char cell = row.charAt(i);
+ if (cell == FREE) {
+ out.append(CSI).append("90m").append(cell).append(CSI).append("0m");
+ } else if (cell == CANARY) {
+ out.append(CSI).append("1;97m").append(cell).append(CSI).append("0m");
+ } else {
+ out.append(CSI).append("1;3").append((cell - '1') % 6 + 1).append('m')
+ .append(cell).append(CSI).append("0m");
+ }
+ }
+ return out.toString();
+ }
+
+ private Set everMounted() {
+ return everMounted;
+ }
+
+ private int handOffs() {
+ return handOffs;
+ }
+ }
+
+ /** Keeps the carrier busy without blocking, so the sampler can see who is mounted. */
+ private static void burn(final long millis) {
+ final long until = System.nanoTime() + millis * 1_000_000L;
+ long spin = 0;
+ while (System.nanoTime() < until) {
+ spin++;
+ }
+ }
+
+ private static void sleep(final long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java
new file mode 100644
index 000000000000..ff7f72644046
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java
@@ -0,0 +1,84 @@
+package com.dotcms.jdk;
+
+/**
+ * Live demo for the Java 25 talk: {@code Thread.yield()} gives up the turn, not the carrier. Only
+ * a park actually frees it.
+ *
+ * Two virtual threads loop forever on two carriers while a third, unrelated virtual thread waits
+ * to be scheduled. What the loop body does decides whether the third one ever runs:
+ *
+ *
+ * mode=busy while (true) { spin++; } innocent NEVER runs
+ * mode=yield Thread.yield() innocent NEVER runs <-- the surprise
+ * mode=sleep Thread.sleep(1) innocent runs immediately
+ *
+ *
+ * Why {@code yield} is not enough: a carrier is a {@code ForkJoinPool} worker and it drains
+ * its own local queue first, LIFO . A virtual thread that yields is re-submitted to that same
+ * local queue, so the worker immediately picks it back up. The third thread was submitted externally
+ * and sits in the shared submission queue, which a worker only visits once its local queue is empty.
+ * {@code Thread.sleep} parks on a timer, the local queue genuinely empties, and only then does the
+ * worker go looking and find the waiting task.
+ *
+ *
The talk conclusion: there is no escape hatch for CPU-bound work on a virtual thread . If
+ * a task does not block on something that parks, it does not belong on a virtual thread. File I/O
+ * fails the same test for the same reason — it never parks either (see #37038 / PR #37041).
+ *
+ *
Run it (no flags needed; it pins the carrier count itself):
+ *
+ *
+ * java -Dmode=busy dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java
+ * java -Dmode=yield dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java
+ * java -Dmode=sleep dotCMS/src/test/java/com/dotcms/jdk/VirtualThreadYieldVsParkDemo.java
+ *
+ *
+ * Not a JUnit test on purpose, and {@code System.out} is deliberate — see the note on
+ * {@link VirtualThreadCarrierStarvationDemo}.
+ *
+ * @see VirtualThreadCarrierStarvationDemo
+ * @see VirtualThreadCarrierTimelineDemo
+ */
+public class VirtualThreadYieldVsParkDemo {
+
+ private static final int CARRIERS = 2;
+
+ public static void main(final String[] args) throws InterruptedException {
+ // Read lazily on first virtual thread creation, so setting it here is enough.
+ if (System.getProperty("jdk.virtualThreadScheduler.parallelism") == null) {
+ System.setProperty("jdk.virtualThreadScheduler.parallelism", String.valueOf(CARRIERS));
+ }
+
+ final String mode = System.getProperty("mode", "busy"); // busy | yield | sleep
+ System.out.println("mode = " + mode
+ + " | carriers = " + System.getProperty("jdk.virtualThreadScheduler.parallelism"));
+
+ for (int i = 0; i < CARRIERS; i++) {
+ Thread.ofVirtual().name("hog-" + i).start(() -> {
+ long spin = 0;
+ while (true) {
+ spin++;
+ try {
+ switch (mode) {
+ case "yield" -> Thread.yield(); // re-queued on the SAME worker, LIFO
+ case "sleep" -> Thread.sleep(1); // parks: the local queue empties
+ default -> { /* busy: never asks to get off the carrier */ }
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ });
+ }
+
+ Thread.sleep(200); // let the hogs take both carriers
+
+ // A completely unrelated part of the system.
+ Thread.ofVirtual().name("innocent").start(
+ () -> System.out.println(">>> the INNOCENT virtual thread RAN"));
+
+ Thread.sleep(3000);
+ System.out.println("--- done: no '>>>' above means the innocent never ran ---");
+ System.exit(0);
+ }
+}