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: + * + *
    + *
  1. 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. + *
  2. 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

+ * + *
    + *
  1. 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. + *
  2. 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); + } +}