The Bytecode You Did Not Write - JVM Weekly vol. 191

A week without the JDK in the lead role (JDK 27 ships on September 15 and gets its own edition, relax). Instead, three pieces from the last few weeks that share one question: how much of what runs on your JVM did you actually write. The answer, as usual, is "less than you think", but this time I have three very concrete examples, one from each layer.

Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.

1. Why Arrays.fill is 265 times slower on G1

Let me start with Krzysztof Ślusarski, who some of you know from his JVM profiling talks and from his tooling around async-profiler, published Why is Arrays.fill 265 times slower on G1GC? on August 19. The benchmark in it is only the pretext.

Ślusarski starts with two arrays of a million references, Arrays.fill on both, once with ParallelGC, once with G1. There is no allocation in the method, so there are no GC cycles during the measurement and "G1 collects garbage slower" is out from the start. What comes out is 139 milliseconds against half a millisecond. One caveat the author makes himself: the measurement is from an Apple M4 Max under macOS on JDK 25.0.3, and the price of a memory fence depends heavily on the microarchitecture, so the number on server x86 will be different. The mechanism is the same.

The difference comes from the write barrier. A generational GC collecting only the young generation has to know which old objects point into the young one, and scanning the whole old generation is out of the question. Instead the heap is cut into 512-byte cards, the JVM keeps one byte per card, and on every reference store the JIT glues on a couple of instructions that mark the card dirty. That is code you did not write, cannot see in your sources, and which runs on every reference assignment in your application. With ParallelGC it is two instructions:

With G1 it is twenty, four conditional branches and, on the slow path, a full memory fence (dmb ish on ARM64, lock addl on x86):

G1 has three escape hatches to stay out of there: the reference points into the same region as the array, you are storing null, or the card belongs to a young region. In normal code one of them almost always fires. In the benchmark none does, because a new Object[1024 * 1024] is 4,194,320 bytes and the humongous threshold with 8 MB regions is 4,194,304. Sixteen bytes over. A humongous object lands in the old generation at birth, so all three exits fail, and two million stores per operation end on a fence. Removing eight elements from the array is a 110x speedup.

If the piece stopped at "watch out for humongous objects" it would be good. But the barrier does not check for humongous objects, it checks whether the card is young, and being humongous is merely the fastest route into the old generation. The same array, deliberately eight elements below the threshold, gets promoted after four System.gc() calls, and fill slows from 0.5 ms to 85 ms. Every long-lived reference array in your application is already in that state. A control experiment closes the case: storing a reference to another old object is just as slow as storing one to a young object, so the young generation, remembered sets and refinement threads have nothing to do with it. It is the fence itself, which stalls the core until every store in flight is visible to the other cores, so instead of dozens of stores at once the CPU does one.

So what do you do about it? Arrays.fill(a, null) is free, because it takes exit two. System.arraycopy and Arrays.copyOf do not use the per-element barrier at all, they dirty the whole affected card range once after the copy, so the classic doubling trick (write a[0], then copy a[0..i] onto a[i..2i]) gives 900x on the same array. There is also a flag, -XX:G1HeapRegionSize=16m, which brings G1 level with ParallelGC in this benchmark, but the author advises against it, because the application-level fixes are better.

And the punchline, which Ślusarski added as a postscript after Francesco Nigro pointed him at the right JEP: everything above is about JDK 25, and on JDK 26 JEP 522 gives G1 a second card table.

Application threads dirty their own table with no synchronisation against the GC threads, which work on the other one, and G1 swaps them atomically. That removes the reason the fence existed, and the entire slow path with it. The JEP promises 5 to 15% throughput in applications heavy on reference stores, at a cost of 0.2% of the heap for the second table. Ślusarski's own measurement on his humongous array: 108.8 ms on JDK 25, 1.1 ms on JDK 26, with no flag at all. If you are reading this thinking "we run G1 and we do exactly that", your fix may be an upgrade rather than a code change. I covered the JEP itself in vol. 157, but only now has somebody shown what the change is worth on concrete code.

The author's meta-lesson: JMH said G1 was slower, the assembly said which instruction, but only the HotSpot sources said why that instruction is there. And g1BarrierSetAssembler_aarch64.cpp is one line of C++ per machine instruction, so HotSpot reads far better than its reputation suggests.

Since we are on the subject of code you did not write that runs anyway, let us go up a floor.

2. Sloth, the JVM agent that rewrites other people's libraries

Scala 3.9 LTS landed on September 3, announced by Wojciech Mazur of VirtusLab, where I also work. I am not going to walk you through the release itself (SIP-71 into is stable, Scala.js 1.22 arrives with a stable WebAssembly backend, the rest is in the notes), because the more interesting thing is what the release exposes.

Code compiled with Scala 3.0 through 3.7 emits lazy val bytecode that goes through the legacy scala.runtime.LazyVals API, implemented on sun.misc.Unsafe, terminally deprecated in JDK 24. The announcement says that on JDK 26 this only prints a runtime warning, Sloth's own README says outright that such code will not work on JDK 26. That gap is worth keeping in mind while you plan an upgrade. The catch is one you know from every migration: recompiling your own code with Scala 3.8 is not enough, because the lazy val in a library from 2023 still calls Unsafe.

Sloth solves this in a way we would have called vandalism ten years ago: it uses ASM to rewrite the bytecode of such dependencies to the VarHandle-based implementation Scala 3.8 introduced.

It can do that ahead of time, as a post-processing step over the classpath, or just in time, through a JVM agent that rewrites each class as it loads. In Scala CLI 1.16.0 that is the //> using sloth directive (the AOT variant) or //> using slothAgent (the JIT one), unlocked with --power because the whole thing is experimental. The agent variant is the more practical one for a reason the announcement does not give: in the AOT mode the rewrite forces ASM to recompute stack map frames, and without --hierarchy-classpath you get a VerifyError at load time.

Before you put this in a build, two things the announcement leaves out. Sloth is not a compiler-team project: about fifty commits, forty-five of them by Łukasz Biały, with the Scala CLI integration done by Piotr Chabelski. The README describes it as alpha-quality software, and the repository has no licence file at all, which formally means all rights reserved, regardless of it sitting in the VirtusLab organisation.

The mechanics, though, are exactly what Java goes through on every "terminally deprecated": the platform warns, libraries lag, and somebody has to fill the gap. This time the gap is filled by an agent rewriting other people's bytecode.

The same announcement has the less pleasant side of this coin. Runtime reflection in scala-reflect 2.13 depends on ScalaSignature attributes, and since Scala 3.8 the standard library is compiled with Scala 3 and no longer emits them, so scala.reflect.runtime.universe can fail as soon as it initialises. No fix is planned, and the best-known casualty is Apache Spark. Your options: stay on 3.7.x, avoid the code paths that initialise universe, or move the reflection to the Java reflection API. On top of that there is the TASTy reader boundary: Scala 2.13 can consume Scala 3 artifacts up to 3.7, and a project on 3.3 cannot consume artifacts built with 3.9. If you publish libraries, hopping between LTS lines is a publishing decision rather than a cosmetic one.

To give the LTS its due: of the 2,380 pull requests merged into main since 3.4.0, 1,420 also went onto the 3.3 branch (the announcement counts that as "almost 43%", those two numbers give nearly 60%). That scale of backporting is why roughly 56% of Scala 3 libraries are published on 3.3 today, and the people who did that work over the years are Paweł Marks, Wojciech Mazur and Tomasz Godzik. The Open Community Build shows around 1,780 of nearly 2,000 projects building on 3.9 with no or minimal changes. Scala 3.3 gets one more year of maintenance, 3.9 is guaranteed at least three.

One warning about 3.10: implicits from inaccessible companion objects will stop being found, and the Community Build found around 20 projects that break on it. You cannot fix this on your side, because the instance is defined in somebody else's library. If you maintain one, this is the moment.

The JIT added instructions for you, the agent rewrote your dependencies. One layer left, the one where your code gets read but not run.

3. Java as data: Netflix's Conductor and the Gradle that picked agents

The last thing the ecosystem heard about Conductor was a GitHub note from December 2023: Netflix was ending maintenance of Conductor OSS to redirect resources to an internal fork, and the community took over as conductor-oss under Orkes. Netflix Conductor: The Next Chapter, which Aravindan Ramkumar wrote on August 21 on behalf of the Conductor team, tells what happened on the other side.

The scale is something: roughly 200,000 workflow definitions across about 150 applications and some 420 million executions a month, and the billion-a-year threshold, a milestone not long ago, now falls roughly every quarter. The engine was rewritten (Conductor 4.0: a record per task instead of one wide Cassandra partition, from 2,500 to 30,000 tasks per workflow, about 40% off p99), and evaluation went asynchronous through exclusive queues, so only one worker looks at a workflow at a time. Good reading on distributed systems. This edition's theme, though, is in the Workflow SDK. For most of Conductor's life you wrote workflows in JSON, wired task outputs with ${taskRef.output.field} templates, and on the worker side cast Map to the types you expected. The SDK turns that into Java, but in a very specific way. A method with @TaskMethod runs at runtime and can contain arbitrary code. A method with @WorkflowMethod does not run: it expresses the workflow as ordinary method calls, and a Gradle plugin parses it at build time into the same JSON definition the server always received. The server never sees Java code. A third annotation, @WorkflowStub, generates typed stubs from task and workflow names alone, so teams can use each other's tasks without exchanging libraries.

The mental model Netflix offers: you are describing a graph in Java, not running Java. So the parser only handles constructs that map onto Conductor's operators (if/else, switch, do-while, parallel forks, sub-workflows). The reason is operational: because a workflow is a declared graph, you can look at every branch, loop and fork during execution and see live where it is stuck. Imperative code gives you none of that, because a timeline is not a diagram. That this is Turing-completeness traded for legibility is my addition, since they frame it purely as a matter of visibility. What they do list under "what's next" is skills, plugins and MCP for the SDK.

On August 19 Gradle made exactly the same move one floor down, and that is Gradle Is Going Agentic by Tom Tresansky and Laura Kassovic. The Gradle team has room for roughly three large projects a quarter. Declarative Gradle and Isolated Projects took two of them, and the third went to Agentic Gradle instead of the work on Configuration Cache by default in Gradle 10: official skills covering specific Gradle tasks, benchmarks that measure whether a skill actually helps, and removing friction between an agent and the build. The middle one is a commitment: no skill ships until it can be shown that the agent does better on a real task with it.

Configuration Cache by default moved to Gradle 11, and in the meantime you have org.gradle.configuration-cache=true in gradle.properties and advice not to wait, because the cache itself has been ready for a while. The authors diagnose it this way: more and more of the people who run Gradle every day are not typing ./gradlew build themselves, they paste the failure to an agent and ask for a fix. The agent is part of the build loop whether the build tool invited it or not.

The sentence I would pull out of the post, though, is about Declarative Gradle rather than about agents, and it reads like a paragraph lifted from the Netflix piece.

Configuration Cache is a workaround: you execute the script once, serialise the result and never execute it again. Declarative Gradle is the fix at the source: the script stops being a program and becomes a description you read without executing. Gradle deliberately postponed making the workaround the default so it could deliver the fix, and that is the same decision Netflix made for workflows.

The difference is in who reads. At Netflix the reader of the graph is a human looking for where the workflow got stuck, with agents still only on the roadmap. At Gradle the reader is the agent, and the authors say outright that the tool agents find easy to reason about is the one teams keep reaching for, because so much of the reaching now happens through an agent. Plus a framing I will sign my name under: making a build tool legible to an agent is the same discipline as making it legible to a human, which means clearer failures and fewer hidden footguns. Both companies arrive at the same place from opposite directions: one turned Java into data and is only now planning for agents, the other starts from agents and so turns the build into data.

My opinion, and here I would like a cup of coffee: this is a bigger change than "Gradle adds skills". For twenty years the argument for Gradle against Maven was "a real language instead of XML". Now the Gradle team itself says the dynamic script is the obstacle, because the reader cannot understand it without running it. Maven was right in a way nobody was planning for in 2008.

If you want to see where this leads on the language side, Babylon and HAT, which Juan Fumero presented at JavaOne 2026 (Under the HAT), use code-reflection APIs to translate sections of Java programs into CUDA and OpenCL. Java as a description something gets generated from, again. But that is a topic for its own edition.

Some context to close: the company behind Gradle renamed itself in June from Gradle Technologies to Develocity, after its commercial product. The build tool does not change its name, its owner or its licence, and "Gradle by Develocity" in search results is, in their own words, a fixed naming bug. There was no acquisition.


PS: If the first section left you wanting more Ślusarski, his older piece on humongous objects is the natural prequel. His own note on the site's front page fits this edition's theme too: he writes that since 2026 he uses models as a tool in most of his investigations, and points specifically at the table translating assembly into pseudocode in this article as AI-generated.

PS2: JDK 27 next week, I promise, and this time with a date on the calendar: GA falls on September 15. And if anyone fancies talking about write barriers in person, Confitura is on September 25 and 26 at the ADN Conference Center on Grzybowska 56 in Warsaw.

Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论