If you’re worried about the impending RAM Supply Apocalypse, and concerned that you won’t be able to afford to upgrade hardware to scale your memory hungry Java applications, then it’s time you pay attention to memory efficiency.
Wasting memory because it saves you time building something will sometimes come back to haunt you. Memory-efficient programming has become a lost art in Java because we have been spoiled with copious amounts of memory. Agentic AI may be ushering in a new age of memory-efficient programming. This may make this kind of programming a highly sought after skill again. If you want to enhance this skill set in Java, then keep reading.
The Memory Efficiency of Java Stream
Java Stream is not memory-efficient at startup. Stream has a nice interface, let’s you write fluent declarative code, and can save you memory when applying multiple lazy operations to large datasets. Stream is wasteful, in ways it really doesn’t need to be, when used with serial processing of small collections. Serial processing of small collections is a common use case for Java Stream, and if you read some of the resources I have linked below you will find that this is the worst use case possible for Java Stream.
Java Stream suffers from a core design problem in its implementation, that can be summed up as follows.
One Stream to rule them all. One Stream to find them. One Stream to bring them all and in the darkness bind them.
(with apologies to Lord of the Rings)
The Java Stream interface is pretty good. The problems begin with the One abstract implementation of Stream named ReferencePipeline. ReferencePipeline is the one Stream that binds together both serial and parallel code paths. For a long time, I considered ReferencePipeline to be an unfortunate design decision because it made the reading and debugging of Stream code next to impossible for humans. This complication was later validated when JetBrains added a Stream specific visual debugging tool to IntelliJ IDEA.
I was not aware until this year how bad Java Stream is in terms of startup memory consumption because of the “there can be only one” design decision. The simple way to think about the problem is that we all pay the startup memory cost for parallel processing when using a serial Stream, which is the most common case.
Agentic AI Finds Needles in the Haystack
I have been using Agentic AI the past few months at work to discover, identify, and correlate memory and performance issues with Java Stream. I have been comparing Java Stream alternatives to equivalent Eclipse Collections alternatives for memory consumption and performance. The discoveries have been surprising. I have blogged about many of these discoveries publicly.
I have been looking at Java Stream performance since before Java Stream was released in Java 8 in 2014. I blogged about one parallel Stream performance problem I discovered and reported that resulted in a class called RandomAccessSpliterator being added to the JDK. If you use List.of() in your code bases, then you are using this Spliterator implementation without knowing it.
I have not previously looked at the memory footprint of Java Stream or Eclipse Collections LazyIterable/ParallelIterable. I never thought to look at startup memory cost of Java Stream, because I assumed it was always a short-lived object and would never be detectable on a Java heap and wouldn’t noticeably impact performance. Using Agentic AI I was able to discover a correlation between the startup memory cost of Stream and measurable performance differences for serial Stream on small collections, where small is defined as a collection of less than 100 elements.
Show Your work
I’ve been blogging for several months after making a batch of discoveries using Agentic AI. I validated each individual discovery by writing code by hand using Java Object Layout (JOL) and Java Microbenchmark Harness (JMH), and then blogged about them. I am going to share the list of blogs I wrote below so you have an index of content to research on your own if you are interested in learning more.
Note: Where I identify memory inefficiencies in Java Stream, they are potential opportunities for future improvements that can help everyone using the API. They just need to be verified, assessed, and prioritized based on cost and benefit.
Memory-Efficient By Design
Eclipse Collections started out its existence solving memory efficiency problems in 32-bit Java 4 in 2004. Memory-efficiency has been prioritized first in the Eclipse Collections design and implementation for the past 22 years. A Feature Rich API was prioritized second, and performance was prioritized third. Eclipse Collections often excels at all three, but sometimes there are necessary tradeoffs.
For the original story of how memory efficiency became the initial design priority of Eclipse Collections, the following is the blog to read.
Java is Memory-Efficient, if You Know How to Use it
Java is a very memory-efficient programming language, if you know how to leverage the features it provides you. The JVM and language have added many great memory enabling features over the years, some which wind up eventually being enabled by default, benefitting everyone without them having to do anything. Compressed Oops and Compact Object Headers are great examples of memory-efficient features which show up initially as optional features and then eventually enabled by default. Compressed Oops have been enabled by default since Java 7. Compact Object Headers will be enabled by default starting in Java 27.
Primitive Support FTW
Java’s support for eight primitives has been a memory and performance benefit and curse since the beginning. Valhalla has plans to eventually solve the original sin of Java having Object and primitive types not able to play nicely together in language features like Generics.
Eclipse Collections has full support for all eight Java primitives across many collection types. You can wait for Valhalla to solve the problem of being able to use List<int> in code, or you can use Eclipse Collections IntList (and seven other primitive lists) today. It’s your choice to either leverage a library that enables you to get full use of Java’s memory-efficient and performant primitives with collections, or to continue to go in a box.
The Java language has had support for hoisting stateless lambdas as statics since lambdas were first introduced in Java 8. Java Stream, other Java collections libraries, or other JVM language probably don’t give you the features you need to make more stateless lambdas static. You may be generating lambda garbage in your code without knowing it, if you are capturing state in a closure.
Eclipse Collections has had support for an additional level of “fat-free” lambdas since around 2007. This was seven years before Java 8 had lambda support added. We needed this “fat-free” lambda support in Goldman Sachs to make using functional APIs less painful in memory-sensitive high-performance coding paths.
The Apache Groovy programming language has recently added an additional level of “fat-free” lambda support in its API targeted for release in Groovy 6.0. The blog above is the resource that make the Groovy development team aware of the need to have library features provided to enable more use of stateless lambdas.
Groovy 6.0 release note on “fat-free” lambda API support
Final Thoughts
I have helped folks I have worked with for years, make their Java applications and libraries more memory-efficient. I have provided the entire Java community, via the open source Eclipse Collections library, access to many of the tools and tricks I have learned and used over the years to fine tune memory savings in banks like Goldman Sachs.
The only thing I am unable to provide you with is the incentive to make your code more memory-efficient. Agentic AI may soon do this for you. If you have read this far, then maybe you have some new tools you were unaware of previously that can help you in the new memory-constrained environment we all find ourselves in again.
Thanks for reading! If you want to know more about Eclipse Collections, I wrote a book about it that you can find linked below.
What if you had to eat every object you allocated?
You don’t need to constantly feed the garbage collector. Give the garbage collector a rest once in a while.
You might not be aware of how many excess allocations your garbage collector is eating when you use methods on Java Stream, especially when the execution path is serial and eager. Every time you call .stream() you are allocating a new large object. You might think this object immediately passes right through the garbage collector, but it may just be getting queued up to be collected later.
I would recommend avoiding these specific iteration patterns in your code base, especially if you’re working on a library or framework, or the code is in a hot code path in your application. These method patterns, when used just on Stream without a lazy pipeline in front are eager garbage generators. In the empty collection case, these methods are borderline malignant. They will cost you much more than the empty collection itself.
These methods should be on java.util.Collection
Eclipse Collections has provided any/all/noneSatisfy, detect (aka findFirst), and count as serial and eager methods for over two decades. If these methods were directly on the java.util.Collection interface, they would be serial and eager, and would produce zero garbage except for the cost of the Predicate, if it is stateful (a closure). The reason these methods do not appear on the Collection interface is explained in the following question and answer on Stack Overflow.
I don’t usually worry about temporary garbage, which is why I’ve never raiseed concerns about these methods before. I didn’t realize that every call to any/all/noneMatch, findFirst and count results in several objects being created.
The first object created is a Stream. I explained the cost of creating different types of Stream in the following blog. If you’re using Java 25 with Compact Object Headers (COH) enabled, then calling .stream() will cost you 80 bytes. If you’re not using a version of Java with COH enabled, then it will cost you 88 bytes.
I linked to each of the methods in the first section on ReferencePipeline, which is the single abstract implementation of Stream. If we take one step into the methods in ReferencePipeline that “make ref”, we will begin to see the temporary garbage generated.
The cost of any/all/noneMatch
The methods any/all/noneMatch call the makeRef method on MatchOps. This method will create three extra objects that are hard to see because they are packaged in inner classes and a lambda which results in a closure.
In addition to .stream(), .filter() will add an addition 64 bytes with COH enabled, or 72 bytes without it enabled. The total cost of .stream().filter() will be 144 or 160 bytes. Then there is the cost of findFirst which is mostly free since it returns a static instance.
It looks like two additional objects will be created here, a ReduceOp, and a CountingSink.ofRef.
Don’t Worry. Be Aware.
Most of the time you should not worry about using these methods. They’re probably not contributing to the slow down of your application in any measurable way. What they are doing is using much more memory and more CPU cycles than it is necessary to accomplish each of these tasks in serial. If you’re using these methods in parallel, then they might be paying for their cost with a benefit in time savings. You will only know this by measuring the benefit with your application specific use cases.
If you’re generally concerned about being wasteful and only using the essential memory and CPU cycles necessary to accomplish a basic task like these then I would suggest either using Eclipse Collections which avoids these costs all together, or building a static utility class that includes serial/eager versions of any/all/noneMatch, findFirst, and count. A class like this is trivial to implement, and was the first class we ever added to Eclipse Collections, which is named Iterate.
The implementations of these methods in the Iterate utility are linked below.
Starting with a static utility class like Iterate is very easy and might seem appealing, but is the beginning of a long path of optimization which eventually results in building Eclipse Collections. You can see the evolution of the path leading from static utility to a full collections library in the if-statements in each of the Iterate code paths above. Eclipse Collections already exists, so I would encourage you not to rebuild it. Just use it. Contribute to it. Become a committer to it. Help evolve Eclipse Collection to solve your problems. Become part of a vibrant open source community building feature-rich Java collections for everyone to use.
Final Thoughts
I wrote this blog because I started seeing the cost of stream().anyMatch() on a Java heap I was investigating using jmap. The objects created by these methods are temporary, so it surprised me to see them at all, let alone in the top five objects of a Java heap in the millions of instances.
I wrote about this particular scenario in this blog.
Now that I have shown you the code paths above, you might be able to recognize which method(s) might be contributing to four of the top five objects on this Java heap histogram captured by calling jmap.
Hint: Look for ReferencePipeline $Head (Stream) and MatchOps inner classes
If you guessed any/all/noneMatch, then you are correct. There is a code path in the tool I was using that is calling these methods repeatedly in a hot loop. The garbage collector was waiting until it needed to free the ram. The total heap size at the point this was taken was 1.5gb. If we total lines 1, 3, 4, 5, then 1gb of the heap was temporary garbage.
The garbage collector can eat all of this, but should it really need to? My answer is a definitive, no. Give the garbage collector a rest.
My current company has settled on using Gradle. It doesn’t make me very happy, but you need to learn to work with constraints. Plus, I must admit that the developers who actually implemented the build files did a pretty good job overall: they used Kotlin instead of Groovy, they moved code to regular plugins, etc. This week, I worked on improvements to a new project and set up Renovate.
Sergey Chernov is a Lead Software Engineer at Salmon, specializing in functional Kotlin and type-safe system design. At Salmon, a technology-driven financial company building banking and lending products in Southeast Asia, Sergey works on authentication and verification systems: the platform layer responsible for keeping user access secure, reliable, and consistent across products. He has 10+ years of experience designing and building scalable backend systems.
Here’s a function that signs a document:
fun signDocument(
documentId: UUID,
code: String,
): Unit
In Kotlin, Unit means the function completes without returning a meaningful value – roughly equivalent to void in Java.
Got it? Now, tell me what could go wrong. You can’t.
Yet, the code might be invalid. The signing window might have closed. The database might be down. The document might already be signed, or expired, or the request might have arrived out of order from a buggy client.
Every one of those is a real outcome this function must reckon with. Not one is visible in the line above.
To discover possible failures and how to handle them, you could open the implementation. Then, the service it calls. Then, the exception handlers, the route mapping, the tests, the OpenAPI spec, and the client code that consumes it.
You could read everything except the one thing that should have told you in the first place: the signature.
At Salmon, I work on authentication and verification. A mishandled failure is rarely cosmetic and the difference between two error cases can be the difference between letting the right person through and the wrong one. I’ve spent a fair bit of time on this question: how do you make a function’s expected failures part of what it tells you, instead of something you have to go digging for?
This article is my answer. It uses Kotlin, but the concept carries to any language with sealed types.
Have no fear of “functional error handling”
“Functional error handling”. That phrase scares people off. They expect monads, category theory, and a lecture. This isn’t the case. The goal is plain: the function signature should be enough to know how to call it and how to handle every expected outcome. Nothing hidden in the body.
If a failure is part of the business logic, it belongs in the function signature, the API contract, and the client’s handling code, not buried in the implementation.
Salmon’s engineering culture runs on a few commitments: real ownership from day one, high standards held in the open, and a refusal to ship things that don’t actually work. A function that hides its failures is at odds with all three.
So, in the case of the example above, the signature I actually want should look like this:
fun signDocument(
documentId: UUID,
code: String,
): Either<DocumentSignError, Unit>
We now have the inputs on the left of the function and the expected failure type and the success type on the right.
Now, before we get to what Either is, we need to agree on what belongs inside DocumentSignError in the first place, because that’s where a lot of the value of this system comes from.
Three kinds of failure, but only one belongs in the signature
Not every bad thing that happens is the same kind of bad thing. I split failures into three groups, and each group gets handled differently.
01 · API CLIENT ERRORS
The caller used the API wrong: this means a malformed JSON, a missing header, an unsupported operation, a request that arrived out of sequence, access that isn’t allowed.
A healthy client should almost never see these, and there is no designed screen for them, because a working app doesn’t produce them. Thus, you can collapse the whole category into coarse HTTP responses: a 400, a 403, a 404. You do not enumerate them one by one in your domain model.
02 · UNEXPECTED EXCEPTIONS
The database is unavailable. A dependency timed out. The network dropped. A null slipped through and you have a NullPointerException, or an invariant broke and you’re in an illegal state. These are not business outcomes.
Nobody designs a user flow for “Postgres fell over.” You do not model these as domain errors. Instead, they become operational signals: a 500 to the client, a full stack trace in the logs, a spike in your error-rate metric, a page to whoever is on call.
03 · DOMAIN ERRORS
Here, the client behaved correctly, yet the operation still can’t succeed.
The signing code was wrong. The window has closed. The document was already signed. Approval is missing. The policy rejected it. These are the failures a real user hits while doing everything right, and your designers have a specific screen for each one.
This is the category that has to be visible. If a healthy client needs to handle two outcomes differently, those two outcomes have to be distinguishable in the type. This is the group that belongs in the contract.
I often see people mistakenly dragging the second group into the other two. For instance, people add DatabaseUnavailable to their error union as if it were a business failure. It isn’t. Let it throw, let the global handler catch it, and keep your domain model honest.
HTTP 400 is not a domain concept. “Signing window closed” is.
In any case, if you recognize and split these three categories correctly, most of the design work is already done. The rest is choosing a mechanism that keeps the second group visible.
Why exceptions and their relatives keep losing
The default in most Java and Kotlin codebases is to validate, then throw:
fun signDocument(documentId: UUID, code: String) {
if (signingWindowClosed(documentId)) throw SigningWindowClosedException()
if (!codeMatches(documentId, code)) throw SignatureRejectedException()
if (alreadySigned(documentId)) throw AlreadySignedException()
// ... sign it
}
The signature says “returns nothing, succeeds.” But the implementation tells a different story, and the compiler will not make the caller listen to it. If someone adds a fourth exception next quarter, every call site still compiles, and every call site silently fails to handle the new case. You find out in production, and that’s not great.
Java tried to fix this with checked exceptions, and the instinct was right: force the caller to handle declared failures or pass them on. But it didn’t scale. And the Stream API doesn’t compose with checked exceptions at all, so you end up doing sneaky throws and wrapping everything back into runtime exceptions.
As it turns out, the better tool is already in the language itself. A sealed interface tells the compiler the complete set of subtypes, this means that when you handle these errors (using Kotlin’s when expression), the compiler can safely verify you haven’t missed a single case:
sealed interface DocumentSignError {
data object SignatureRejected : DocumentSignError
data object SigningWindowClosed : DocumentSignError
data object AlreadySigned : DocumentSignError
}
Now the caller handles every case, and the compiler enforces it:
Add a fourth failure to the sealed interface and this when stops compiling until you handle it. And this is the whole game: the compiler now knows what can fail, and it won’t let you forget.
You just reinvented Either
Once you have a sealed error type, you need a way to say “this function returns either that error or a success.” You can build a wrapper by hand, and people do, for each result type, over and over. That gets verbose fast.
What you’re reaching for is a generic version of the same shape: a value that is one thing or the other, never both. Left for the failure, right for the success. That is Either, and you don’t need a library to understand it. It’s a sealed type with two cases and a handful of helper methods (map, flatMap, fold, getOrElse). If you’ve used Optional in Java or nullable types in Kotlin, you already know how it feels to work with. An Optional is roughly an Either whose left side carries no information, just Unit.
The payoff is that the failure set moves into the public type:
fun signDocument(
documentId: UUID,
code: String,
): Either<DocumentSignError, Unit>
Failures are no longer hidden in the function body; they are part of what the function tells you upfront.
Two unions people get wrong
Unfortunately, two anti-patterns show up constantly once teams adopt this, and both undo most of the benefit.
fun signDocument(documentId: UUID, code: String):
Either<Throwable, Unit>
While this looks typed, the type says only “something can fail.” It does not say which expected failures the caller must handle, because Throwable is open, so a when over it always needs an else. You’re back to not knowing.
This is essentially the same as throwing an error, and it’s why Kotlin’s own Result<T> type didn’t work out and isn’t recommended for domain modeling. If the left side is open, you’ve gained nothing.
The second is one broad union shared across a whole class, in the name of not repeating yourself:
sealed interface DocumentError {
data object SignatureRejected : DocumentError
data object SigningWindowClosed : DocumentError
data object AlreadySigned : DocumentError
data object TemplateNotFound : DocumentError
data object ExportFailed : DocumentError
}
fun signDocument(...) : Either<DocumentError, Unit>
fun prepareSigning(...) : Either<DocumentError, SigningSession>
fun exportDocument(...) : Either<DocumentError, ExportFile>
The compiler is happy, but now every method appears to return every error. signDocument can never produce TemplateNotFound, yet every caller has to account for it anyway. You get exhaustive handling full of impossible branches, which is just catch-all programming wearing a type.
The fix is to define one narrow union per public method:
sealed interface DocumentSignError { /* the three real failures */ }
sealed interface PrepareSigningError { /* its own set */ }
sealed interface ExportError { /* its own set */ }
Then each when handles only what its method can actually return. No else or impossible cases:
A little more typing up front, but worth it every single time you read one of these signatures later.
Composition, without drowning in the plumbing
Real flows chain steps, and each step can fail. Done naively with flatMap, the lambdas nest deeper with every step and the code gets ugly.
You have a few ways out. Plain Kotlin handles it with early return:
val document = findDocument(documentId)
.getOrElse { return it.left() }
Flat, typed, and the pattern itself needs no library: if you hand-roll Either, you write these helpers yourself. The syntax above happens to use Arrow’sgetOrElse and left, but nothing here depends on the abstraction being fancy.
If you want it cleaner, Arrow also gives you an either { } block where bind() unwraps a right value and short-circuits on the first left:
either {
val document = findDocument(documentId).bind()
validateStatus(document).bind()
val signature = validateSignature(document, code).bind()
markSigned(document, signature).bind()
}
This is the same idea Scala has had in the language for years with for-comprehensions. Use Arrow if the ergonomics help your team; it also brings useful types like non-empty lists. (But the contract idea does not depend on Arrow, and I’d rather you adopt the discipline than the dependency.)
The contract should survive the whole trip
A typed failure is only useful if it stays typed across the stack. Here’s the rule I hold to: services and repositories return domain errors, and you map to HTTP at exactly one place, the route boundary.
Expected domain failures become an Either.Left. API-client misuse collapses to a coarse 4xx. Unexpected infrastructure failures and bugs stay as exceptions and become a 500. The controller is the only layer that knows about HTTP, and the layers beneath it speak in business outcomes.
There’s also a bonus most teams don’t realize here: If you publish your API client alongside the service, publish the error types with it. If you do this, the client handles failures with the same sealed union the server produces, and the two stay consistent for free.
How does this impact code review, and AI-generated code?
The day-to-day return on all of this shows up in review. When failures live in the signature, a reviewer can start from the contract instead of doing implementation archaeology. Did the error union change? Is this API-client misuse dressed up as a domain error? Does the new failure map to HTTP? You can answer those by reading the interface, before you ever open the body.
At Salmon and elsewhere, this agility matters more now that a large share of code is drafted by agents.
When a model writes the implementation, an explicit contract is the cheapest way to check whether it did the right thing: you read the types, not the 200 lines underneath. You can put the rule in an agent instructions file, “return a typed error union, don’t throw for expected failures,” and the model will mostly follow it. But the way you verify is by reading the contract, not by trusting the prose.
In fact, on our team at Salmon this is less a personal preference than a shared default: the contract is the unit of review, and a generated implementation doesn’t lower that bar. Deciding which failures an operation can actually produce is a judgment call, and the signature is where that judgment gets written down so the next person, or the next agent, has to respect it. Essentially, the signature is where ownership lives.
The honest tradeoff
This costs you something. More types, more mapping code, more verbose signatures. I won’t pretend otherwise.
But the complexity was already there. The signing window could always close. The code could always be wrong. All this approach does is take that complexity out of the implementation, where it was hiding, and put it in the type, where it’s named, tested, and visible.
You are simply moving the work to where the compiler can help. It surfaces risk to the next caller instead of hiding it, makes clear what the code really does and stops broken paths from compiling. Making failures part of the signature is how those values show up at the smallest scale: one function telling the truth about what it can do. It is also how we work in practice at Salmon: we share these typed contracts across services and their clients, and in review we read the contract before the implementation.
A signature that returns Unit and throws in secret is lying to you about what it does. Make your signatures tell the truth!
The first is languages shipping what the platform is still previewing. Groovy 6 put structured concurrency on Maven Central as a Java library while JEP 533 entered preview number seven. The Vector API entered incubator number twelve. Both in the same release.
Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.
The second I saw coming, because vol. 186 spent a section on five teams asking which part of the coding loop the AI doesn't get. August answered from a different angle: Databricks and JetBrains independently concluded that the semantic model of your code is the asset and the editor around it is a client. And Databricks has the numbers for that.
Plus a benchmark that did not survive contact with a profiler.
1. August: The Rest of the Story
Groovy 6 shipped structured concurrency as a Java library. Not a Groovy feature you can call from Java if you squint. A separate jar, groovy-concurrent-java, no Groovy runtime on the classpath, and this is the sample straight out of the release notes:
import groovy.concurrent.AsyncScope;
import org.apache.groovy.runtime.async.AsyncSupport;
var result = AsyncScope.withScope(scope -> {
var a = scope.async(() -> fetchUser(id));
var b = scope.async(() -> fetchOrders(id));
return Map.of(
"user", AsyncSupport.await(a),
"orders", AsyncSupport.await(b)
);
});
That is StructuredTaskScope with the serial numbers filed off. Which arrives in JDK 27 as JEP 533, seventh preview, after two incubator rounds before that. Nine releases of refinement. Groovy needed one.
JEP 533, delivered in JDK 27 as the seventh preview of an API that started incubating in JDK 19.
The jar also carries Actor, Agent, DataflowVariable, AsyncChannel, BroadcastChannel and ChannelSelect against plain java.util.function types, so Go-style channels and actors arrive in the same download. It is mutually exclusive with the full Groovy runtime (Gradle enforces that through a shared capability) and warns you at runtime if both jars show up.
Caveats, because I like you: 6.0 is still in beta, the whole toolkit is marked incubating, and the release notes open with a warning that the material on the page is under development. Nobody should put @ActiveObject in production this quarter. But it is a fair question why the platform needs nine rounds for an API a volunteer project shipped in one.
On the Groovy side you get async/await on automatic virtual threads (JDK 21+, cached pool on 17 to 20), generators via yield return, for await over channels and reactive streams, and defer for cleanup. GPars got dragged into groovy.concurrent and rebuilt around virtual threads, which is a nicer fate than most 2010-era concurrency libraries got.
The other half of Groovy 6 is a release-notes section titled "Designed for Human and AI Reasoning", written up by Paul King on the Groovy blog, and I have a book coming out on adjacent material, so discount accordingly.
The mechanism is design by contract. @Requires and @Ensures for pre- and postconditions, @Invariant and @Decreases for loop invariants and termination measures, @Modifies naming which fields a method may touch, @Pure for none of them. Then ModifiesChecker and PurityChecker verify the claims at compile time.
The verification is the point. An unverified annotation is a comment, and a model has no more reason to trust a comment than you do. The release notes put a cost model on it: without annotations, checking whether three calls left balance alone means reading every body and every callee, growing as O(fields × calls × call_depth). Which is roughly where every assistant starts saying it would need to see more context.
The Groovy 6 release notes, costing out what a reader has to verify by hand when the annotations are missing.
Shipping alongside: a NullChecker with a flow-sensitive strict mode that works on unannotated code and reads JSpecify-style @NullMarked declarations out of compiled jars, right down to package-info.class; a SqlInjectionChecker that flags a GString interpolated inside quotes (the pattern that silently defeats groovy-sql's parameter binding); and a CombinerChecker that verifies the function you handed injectParallel is associative. That last one catches a bug class that compiles, runs, and returns different answers depending on how the work got partitioned. Good luck finding it in a test suite.
Every JVM language is going to have the contracts argument. Groovy went first, which nobody had on their card.
Amber published a guide on switching over sealed types, and it corrects something I believed until three weeks ago.
The story we all told after JDK 21: seal the hierarchy, switch without a default, let the compiler flag you when someone adds a subtype. ADTs in Java at last.
Preparing for Change: Safe Switching over Sealed APIs, by Angelos Bimpoudis, Alex Buckley and Brian Goetz, points out the guarantee is compile-time only. Your switch covers the permitted subtypes the compiler saw when your code compiled. Library author adds a subclass, you don't recompile, and you get a MatchException at runtime with no arm to match and no default to fall into. Binary compatibility and exhaustiveness pull against each other and the language picked "fail loudly".
So some of this lands on API authors. Publish a sealed hierarchy and you owe clients a note on whether you intend to add to it, because that is what decides between an exhaustive switch and a defensive one. Adding a case to a public sealed interface is closer to adding an abstract method than the syntax lets on. The guide's own line is worth quoting: it is almost never necessary to use default when switching over a sealed type, and often not the best option.
Two of these already had their edition: Value Objects in vol. 180, Simple JSON in vol. 188. Skipping both.
JEP 541 deserves a re-read, because "Java drops Intel Macs" undersells it. The port stays in the tree. The build refuses to configure for it:
$ bash ./configure
...
checking compilation type... native
configure: error: The macOS/x64 port is deprecated and may be removed in a future release. Use --enable-deprecated-ports to suppress this error.
configure exiting with result code 1
--enable-deprecated-ports downgrades that to a warning and makes no promise the result builds, let alone runs. The line further down the JEP matters more: Oracle engineers stop maintaining macOS/x64 as of JDK 27, one release before the deprecation is formal. Maintenance already ended; JEP 541 is the paperwork. Apple laid the runway anyway, with macOS 27 dropping Intel and taking Rosetta 2 with it. Go check whether anyone still has a 2019 Mac Pro doing builds in a cupboard.
JEP 539 is quieter and more consequential. Strict field initialization reads as a language nicety but it is a JVM change: strict fields must be written before anything reads them, so 0 and null are never observable mid-construction. That is Valhalla plumbing, because an identity-free value object cannot afford a half-built window, and it is aimed at compilers emitting class files rather than at you.
JDK 27 goes GA on 15 September with nine JEPs, feature freeze covered in vol. 179.
One of those nine is JEP 537, the Vector API's twelfth incubator, with no substantial implementation changes since JDK 25, and it keeps incubating until Valhalla lands in preview. Twelve rounds. In the same release as preview seven of Structured Concurrency. Soonil N.'s hashtable benchmark in vol. 186 already complained that C++ runs on Swiss tables and Java doesn't, partly for this reason, and the situation has since improved by zero.
JEP 537: twelfth incubator, effort XS, no substantial implementation changes since JDK 25.
Databricks open-sourced Metals v2, and the justification is better than the software (which is saying something). Metals is the official Scala language server, and its maintainers sit at VirtusLab (hello there!). V2 was built inside Databricks by Ólafur Páll Geirsson, Iulian Dragos, Alessandro Patti and team, and it started as a fork. It came back to the upstream scalameta/metals repository at open-sourcing, under Apache 2.0.
For anyone who has only ever used IntelliJ: a language server is the process behind "go to definition" in every other editor. The classic design has it talk to your build tool over BSP to learn the project layout, which means a blocking sync before anything works, minutes on a large monorepo, and a very expensive text editor when that sync breaks.
Databricks' argument: this was fine when humans typed the code and is not fine now, because most of the code at Databricks is now written by agents, and an agent does not wait for a build sync. So v2 indexes sources directly and skips the build for navigation. Their Bazel monorepo is 26 million lines, 24 of them Scala. The index covers 2.9 million symbols across 142,000 files and weighs 936 MB on disk. Time to initial intelligence, meaning the point where fuzzy symbol search, jump-to-definition and find-usages all work, runs at a p50 of 8.7 seconds and a p90 of 36.7 seconds. Fuzzy search across those 2.9 million symbols has a p50 of 10 ms. BSP becomes optional rather than load-bearing.
Then the numbers that make it more than an architecture post. Databricks standardised on Cursor in May 2025; by July 2026, 92% of weekly active IDE users open Cursor against 12% for IntelliJ, and the company did not renew the majority of its IntelliJ seats this year. Cursor's share of Scala and Java file-open events went from 40% to 78% since the first v2 improvements landed in October 2025. In a shop where IntelliJ had been the only editor that could keep up with the monorepo.
v2 also has first-class Java support, which is why large Java shops are piloting Metals for Java alone, a sentence I did not expect to type. Stripe says in the same post that it started rolling it out a month ago. Set expectations: no extract-method, no inline-variable, import suggestions only. Navigation and diagnostics, not an IntelliJ replacement. Cursor, VS Code and Neovim today, with a slide deck for the internals.
The more interesting part is what happens next, which is easy to miss under all those numbers. The stable v2, the one that supersedes today's v1, is being prepared by Databricks with the Cursor team and the Metals maintainers at VirtusLab, and ongoing development is led by VirtusLab, which is also where the issues go. Databricks built this in house and handed ongoing development to VirtusLab. Metals itself is developed at the Scala Center and VirtusLab.
Krzysztof Romanowski, Head of Development Productivity at VirtusLab, in the same post: "AI is changing how developers use IDEs, and Metals v2 moves in the right direction: fast startup, reliable codebase orientation, and an architecture built for large codebases. Databricks validated the approach at exceptional scale, and VirtusLab is excited to help bring this work to the broader Scala and JVM community."
I picked wisely, we are so proud of Metals!
JetBrains is taking IntelliJ apart from the other end. After 2026.2 shipped agent skills and native Copilot support in July, August added two more: the core modularised to speak LSP, written up by Marco Behler, so the Java and Kotlin analysis engines detach from the Swing frontend and can serve VS Code, Cursor and headless agents, and ACP support from Anton Arhipov, a JSON protocol that decouples agent logic from IDE internals so you register Claude, Codex or something in-house in an acp.json.
The detail I'd flag is what Agent Skills, covered by Siva Katamreddy, reach: the Program Structure Interface, IntelliJ's semantic model, plus the project index and terminal. Ask a RAG-backed agent what calls a method and it runs a similarity search and hopes. Ask a PSI-backed one and it queries the resolver behind Alt+F7.
In vol. 181 I called JetBrains and Microsoft opposite bets on the agent harness. Vol. 186 revised that when Copilot support shipped first-party. August is moves three and four in the same direction, and Databricks got to the same place from a monorepo problem rather than a strategy deck.
Which leaves a question neither blog post touches: if the intelligence engine is the product and the editor is a client, what is JetBrains selling in 2029?
Two shorter ones from the same neighbourhood.
Engram wired into IBM Bob over MCP, by Markus Eisele, is a decent worked example of durable project memory as a local knowledge graph rather than a markdown file that grows until nobody reads it. A project rule tells the agent to consult it before non-trivial decisions and to record only decisions, constraints and failed approaches. Bob 2.0 exposes no harness hooks, so project rules are the only lever, which is also why the Claude Code session-start integration has no equivalent here. Check which Engram you are installing, incidentally, because there are at least five unrelated projects using the name and the JVM ecosystem apparently ran out of words for memory.
Agentic fitness functions on InfoQ, by Hemant Kumar Mahato, Łukasz Sieczkowski and Vijayasenthilkumar Kuppusamy, I expected to hate and mostly didn't. A fitness function, per Neal Ford, is an automated check guarding an architectural characteristic; on the JVM that means ArchUnit asserting ..domain.. never touches ..infrastructure... The piece argues for LLM agents with versioned rubrics covering what ArchUnit structurally can't say: boundary fidelity, semantic contract drift, workflow coupling, stale ADR assumptions.
It survives because the authors say the quiet part first. Deterministic fitness functions stay the primary enforcement mechanism for anything measurable, and the agentic ones earn their keep only when a finding recurs, the team agrees it is a real violation, and somebody writes it down as a rubric criterion or a deterministic rule. So it is a detector for rules you haven't written. Non-deterministic CI gates I would still argue about, loudly, with anyone who has debugged a flaky pipeline at 2am.
Same shape as Groovy's contracts, arriving through governance instead of the type system. Groovy has the compiler check the claim. InfoQ has a model check the claim.
Apache Fory JSON published big numbers, then somebody profiled them.
(Spelling note: Fury renamed to Fory. I got this wrong in a draft earlier this month, so consider this atonement.)
Fory's own figures: up to 10.91x Jackson's throughput on 1000 KB payloads, and up to 5.55x on the jvm-serializers MediaContent model, that second run on an M4 Pro under JDK 26.0.1. The mechanism is real, generating schema-specific serializers as bytecode at runtime instead of walking your object graph reflectively.
Then ej-technologies, the JProfiler people, profiled five frameworks to find where Jackson's time goes. Base64-encoding the byte[] thumbnails: 24% of the run. Decoding them: another 23%. Reflective invocation: 13%. So roughly half the time went to JSON not having a binary type, which is a property of the format rather than the library.
Five frameworks, one round trip: the format costs more than the library does.
Switch Jackson to CBORMapper, one line, and it is 2.4x faster with payloads nearly as compact as Fory's. At which point the profile flips, data binding becomes about 40%, and the remaining gap is roughly 3.4x. Real, and a different sentence than "10x to 50x".
Order of operations, then. If humans or other tools read your payloads, Jackson and JSON stay the default. If you own both ends and serialization shows up in a profile, change the backend before you change the library. And most REST services are I/O-bound, so check that serialization is your bottleneck before you spend an afternoon on any of this.
A quick lap around the mailing lists.
On panama-dev, Liam Miller-Cushon opened a thread on 22 August about string processing and the Vector API, and it runs into the same wall as every attempt before it. Scalar charAt loops bottleneck parsers and regex scanning, but getting a String into a MemorySegment for SIMD work means Arena#allocateFrom(String) and a copy that eats the win. The obvious MemorySegment#ofString hits Compact Strings: a String is Latin-1 or UTF-16 internally depending on content, so exposing the array leaks that, and normalising recreates the copy. JNI's GetStringCritical never solved this either. See also: incubator twelve.
On amber-dev, someone floated a mixin construct for interface-scoped forwarding, roughly mixin MapWrapper(HashMap delegate) implements Map, with the compiler generating the forwarding methods. Composition over inheritance mostly loses because delegation is boring to type, and scoping forwarding to a named interface dodges the blanket-delegation objection Brian Goetz raised in the thread itself, that a delegate's optimized implementations go missing. Mailing-list idea, not a draft JEP. If it ever lands, Lombok loses another reason to exist.
Two to close. JetBrains Research has a migration finding worth an argument, in Vladimir Volokhonsky's write-up of the 2025 State of Developer Ecosystem survey: Kotlin is a "pull" migration. C++ to Rust is usually driven by memory-safety requirements, JavaScript to TypeScript by a mandate, Java to Kotlin by developers preferring it. Read charitably that is a compliment to the ergonomics. Read uncharitably, a migration that nothing forces is a migration that can stop half-finished, and you keep two languages in one repo for years. The report doesn't distinguish, and I have seen both.
Filed quietly next to it: Kotlin 2.4 started an 18-month security support window for the JVM standard library per release line, 2.4 running to 3 December 2027, with security fixes backported to every line still inside its window. The policy post went up in May and nobody noticed. More projects should publish one of these.
And Charles Nutter on Josh Long's Bootiful Podcast on JRuby riding the JDK roadmap: no GIL, so Ruby threads map to real JVM threads; invokedynamic still the performance floor; Loom raising the concurrency ceiling; Panama's FFM API as the modern answer to C extensions, historically the reason people couldn't leave MRI; and Valhalla eventually attacking Ruby's allocation costs. Guest languages remain the honest test of whether the big OpenJDK projects deliver, and JRuby has been sitting that exam since 2001.
2. Release Radar
Gradle 9.7.0
Gradle 9.7.0 landed on 6 August with Isolated Projects graduating from experimental to incubating. The feature configures projects in parallel, which only works if projects stop reaching into each other, so allprojects, subprojects and project(":other").someProperty all become violations and shared config moves into convention plugins.
The published numbers carry the argument, and Alex Semin laid them out in the announcement post. Gradle's own 300-subproject build: median IDE sync 84s to 47s. A 2,500-project pure-Java monorepo: configuration with build-script recompilation 10m53s to 2m59s. A 5,000-project Android monorepo: total Android Studio sync 5m09s to 2m44s.
Gradle's early adopters, with the parallelism each number was measured at.
Three things before you flip the flag on a Friday. It is off by default and not recommended for production. Configuration Cache compatibility is a hard prerequisite, and starting without it means debugging two configuration models at once. And each of those numbers came at a specific parallelism, from 6 to 96, so the speedup you get is the one your machine can pay for.
The property finally lost its shame prefix, org.gradle.unsafe.isolated-projects becoming org.gradle.isolated-projects with a --isolated-projects CLI option. There is also a diagnostics mode that reports violations without failing the build, which is how you should start. Elsewhere: ResolutionResult works as a task input under Configuration Cache, Java agents and TestKit stopped fighting, and a spurious cache invalidation triggered by an IntelliJ system property is gone. Embedded Kotlin moves to 2.4.0 and language version 1.9 is rejected. 9.7.1 followed with Resilient Sync, returning a partial model when the build fails on IDE import so you keep completion for the healthy parts instead of an all-red screen.
Quarkus 3.38 went out on 29 July, it is thin, and Guillaume Smet says why in the second paragraph: development effort has moved to Quarkus 4. That is the entry. After 3.37 shipped quarkus-jlink and reflection-free Jackson serializers by default (vol. 186), the 3.x line is winding down.
What did land: weight-based eviction for Hibernate second-level Caffeine cache regions. Previously only count-based, so you could cap entries per region but not the memory they consume, which is the wrong knob when entry sizes vary by an order of magnitude.
Contributor count is 1213, up from 1203 last month. One of the few honest health signals a framework publishes.
First milestone of the Draft 1.0 spec, on Maven Central, written up by Otavio Santana on Foojay. Vendor-neutral abstractions for model orchestration, tool calling and memory persistence, so agentic code stops being welded to one provider's SDK. The specification page is blunt about the status: non-final, draft, no release review, published for early feedback.
The open question is whether anyone wants a Jakarta spec here, or whether LangChain4j plus MCP became the de facto standard while the committee drafted. Jakarta's argument is the one it always makes and it isn't wrong: specs outlive vendors, and the agentic layer is the least stable part of everyone's stack right now. Milestone one is the moment to have that fight, not 1.0.
Ivar Grimstad, Jakarta EE Developer Advocate at the Eclipse Foundation, reports the Jakarta EE 12 Core Profile on track for JakartaOne Livestream 2026. Web Profile and Platform slip to the first half of 2027. Read against vol. 184, where an agent reimplemented Jakarta EE from the spec. Jakarta specifying agents while agents reimplement Jakarta is a loop nobody drew on the roadmap.
Apache Tika 4.0.0 landed on 18 August. The interesting part has nothing to do with file formats.
Tika is the library that detects and extracts text and metadata from over a thousand file types, and it is what sits under a great many ingestion pipelines without anyone thinking about it. In 4.0 the default content handler is Markdown. tika-app, the server's /tika and /rmeta endpoints and the async CLI all emit Markdown instead of XHTML now; you ask for the old behaviour with -x, /tika/xml or --handler x. A text-extraction library changed its default output format because the thing consuming the output stopped being a search index and started being a model.
The new tika-inference and tika-vlm modules go further: vision-language-model parsers backed by Claude, Gemini and OpenAI for documents OCR can't read, emitting vlm:prompt-tokens and vlm:completion-tokens as document metadata. Token counts are a Tika metadata field now. tika-parser-tess4j-module adds in-process Tesseract alongside them.
The breaking changes are real, so read them before you bump the version. Java 17 minimum, where 3.x ran on 11. ForkParser and the whole org.apache.tika.fork package are gone from tika-core, with pipes taking over out-of-process parsing. A JSON config now beats a programmatic context.set(...), which inverts the old precedence. tika-app and tika-server-standard ship as zip distributions, so running the published jar on its own gets you a NoClassDefFoundError. The 3.x line continues at 3.3.2.
Five this month, and not one of them is a model. They are all wiring, which is where August's interesting work turned out to be.
native-langchain4j, the AOT metadata nobody wants to derive twice
native-langchain4j by Alina Yurenko is small, 29 commits and change, and does one useful thing: LangChain4j 1.19.0 plus OpenAI plus GraalVM Native Image with the reachability metadata already worked out. That version is three weeks old, shipped on 14 August with an MCP client tracking the 2026-07-28 revision of the protocol, so the demo is pinned to current rather than to whatever built cleanly last year. Agentic API, streamed responses, per-session chat memory, strict tool calling, one read-only local tool.
Why that is the whole value: Native Image compiles by reachability analysis, walking your code at build time and compiling only what it can see. Orchestration frameworks run on reflection and dynamic proxies, which are invisible to that walk, so classes exist at build time and vanish at runtime. Fixing it means declaring metadata by hand, and the build-fail-add-an-entry-build loop is where most people quit and go back to a JAR.
The README is careful about what it is not doing: the build uses the Native Build Tools Maven plugin and the application's own minimal metadata, with no fallback image and no deprecated builder-class options. That last bit matters, because a fallback image is Native Image quietly shipping a JVM inside your binary and calling it a day. Put a working example like this next to the 39% RSS reduction GraalVM measured from compressed references in 25.2, and the argument that Java is too heavy for serverless is running out of road.
ai-peer-live-coding-agent, constrain the interview, not the tools
ai-peer-live-coding-agent takes an angle on AI-assisted interviews I hadn't seen made concrete. Rather than banning the tools or letting a candidate generate a solution wholesale, it constrains the environment: .cursor/rules/ enforces a Controller-Service-Store structure with standard validation and error handling, a mandatory 10-minute spec-before-code phase, seven discrete testable tasks capped at eight, Docker Compose for a production-like setup, all inside a two-hour session. The rules file has a line I liked: copilot, not autopilot.
What it tests is whether someone can hold architectural intent while a model writes the code, which is closer to the actual job than whiteboard tree traversal has been for a decade. Whether I'd run hiring this way, I genuinely don't know. But it is an artefact in an argument otherwise conducted entirely in LinkedIn posts.
Same .cursor/rules/ and SKILL.md pattern that turned up all over vol. 186, and the same distribution problem James Ward's SkillsJars was trying to solve. These rules live in one repository and go nowhere.
spring-boot-skills, the rules somebody actually packaged
spring-boot-skills by Rrezart Prebreza is that complaint answered, for the framework most of you actually ship. Thirty-odd skills in two version trees, spring-boot-3 and spring-boot-4, MIT licensed, so you copy a folder into .claude/skills/ or adapt the same SKILL.md layout for Codex.
The README opens with the best one-line statement of the problem I have read this year: AI coding agents are great at Python, and they hallucinate in Spring Boot. Then it gets specific, and the list will be familiar. Field injection with @Autowired where you use constructor injection. A brand new exception hierarchy next to the one you already have. Schema SQL written out by hand because nothing told the model you use Flyway. Pre-GA Spring AI artifact names that no longer resolve on Maven Central.
The last one is not a style disagreement. It is a model emitting Maven coordinates that were real during training and are not real now, and prompting harder will not fix it.
So the repo dates itself: fast-moving integrations last verified in August 2026 against Boot 4.1, Spring AI 2.0 and MCP Java SDK 2.0. That date is also the bill, because skills rot the way documentation rots.
mini-claude-code, the agent harness with the lid off
mini-claude-code by DerekYRC is a stripped-down Java implementation of a coding agent, built to be read rather than run. Sixteen branches, s01-agent-loop through s16-mcp-plugin, each holding the smallest amount of code that makes one mechanism work: tool dispatch, permission control, hooks, todo, subagents, skill loading, context compaction, memory, background tasks, cron scheduling, multi-agent teams.
The same author did this before with mini-spring and mini-netty, and it is a genre I wish more of the ecosystem practised. If you want to know what context compaction actually does, s08 is a few hundred lines of Java instead of a blog post with a diagram.
The primary README is in Chinese, with an English one next to it, which is nobody's problem. The missing LICENCE file is. No licence means all rights reserved: read it, learn from it, and copy nothing out of it into anything you ship. Worth opening an issue about, when the entire point of the repository is that people read it.
tda, a Swing app from 2016 that grew an MCP server
TDA by Ingo Rockel is the Thread Dump Analyzer, a Swing desktop tool that reads Java thread dumps and heap information, and supports Java 1.4.x through 21+, which tells you how long it has been doing it. In 3.0, back in January, it grew a Model Context Protocol server, with 3.1 and 3.2 following in April and May.
You start it with java -Djava.awt.headless=true -jar tda.jar --mcp and it exposes analysis as tools rather than handing a model a wall of stack traces. analyze_virtual_threads finds virtual threads whose carrier is stuck in application code. get_zombie_threads returns threads with unresolved SMR addresses. That is the pinning problem from this issue's PS, answered by something that already knows what pinning looks like, rather than by a model pattern-matching on text.
The operational notes are the part I enjoyed most. MCP speaks JSON-RPC over stdin and stdout, so the documentation warns that anything else printing to stdout corrupts the stream and drops the client. And a Swing application has to be told to skip its own UI initialisation before it can serve at all, which is a sentence nobody expected to write about a desktop tool.
PS: JDK 27 goes GA on 15 September, so you have three weeks to find edge cases while fixes are still cheap. If anything of yours runs on virtual threads, the JDK-8377715 regression from vol. 186 is still worth a look. Meanwhile the fourth digit from vol. 188 is out in the wild: BellSoft shipped Liberica builds on 19 August across 26.0.2.1, 25.0.4.1, 21.0.12.1, 17.0.20.1, 11.0.32.1, 8u504, 7u513 and 6u513, 29 fixes and backports in total. If your base image pins a three-part version, it is now pinning to something that no longer gets the newest patches.
Thanks for reading JVM Weekly!
Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.