227 stories
·
1 follower

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

1 Share

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 JMH run: same code, same JDK, same machine.
The JMH run: same code, same JDK, same machine.

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:

The ParallelGC barrier: shift, then store a byte.
The ParallelGC barrier: shift, then store a byte.

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):

The slow path of the G1 barrier, with the full fence as its first instruction.
The slow path of the G1 barrier, with the full fence as its first instruction.

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<String, Object> 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 diagnosis from the Gradle blog, and note that it is not about Gradle alone.
The diagnosis from the Gradle blog, and note that it is not about Gradle alone.

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.

A declarative build explains itself, and that turns out to be an argument about agents.
A declarative build explains itself, and that turns out to be an argument about agents.

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.

Read the whole story
jhunorss
42 minutes ago
reply
Share this story
Delete

double, BigDecimal, or Fixed-Point? Precision, Performance, and Sane Choices for Numbers in Java

1 Comment

There is an evergreen debate in the Java world: Should you always use MARKDOWN_HASHeb731dbabfc7392f8ad8c1cdb326a26aMARKDOWNHASH for money?

The short answer is no. The real answer is: it depends on your computational context: the precision you need, the rounding rules you must follow, and the performance budget you have.

The problem is that this conversation is often driven by dogma rather than engineering. You hear statements like "double is broken," "BigDecimal is slow," "always use fixed-point," or "always use IEEE 754," each treated as an absolute truth. Reality is more nuanced and more interesting.

We start from what IEEE 754 actually does under the hood, move through BigDecimal pitfalls and fixed-point arithmetic, tour the libraries that solve these problems for you, and end with the production traps (serialization, testing, concurrency) that can quietly undo a good numeric choice. Sections include working code.

The floating-point problem

Most floating-point surprises trace back to a single fact: Java's float and double use binary arithmetic, not decimal. Understanding why that matters, and when it doesn't, is the foundation for every choice in this post.

IEEE 754

float and double in Java follow the IEEE 754 standard: float uses binary32, double uses binary64. The keyword here is binary. Values are stored as a signed bit, an exponent, and a mantissa, all in base 2. This means that many numbers that look trivially simple in base 10 have no exact representation in base 2.

Consider the classic example:

public class FloatingPointProblem {
    public static void main(String[] args) {
        double x = 0.1 + 0.2;                              // 1
        System.out.println(x);          // 0.30000000000000004
        System.out.println(x == 0.3);   // false             // 2
    }
}
  1. 0.1 cannot be represented exactly in binary; the compiler stores the nearest binary64 value
  2. Equality check fails because of accumulated representation error

This does not mean that double is broken. It means that the decimal literal 0.1 cannot be represented exactly in binary, just as 1/3 cannot be represented exactly in decimal. When you write 0.1 in Java, the compiler stores the nearest binary64 value: 0.1000000000000000055511151231257827021181583404541015625. Every subsequent operation compounds that tiny initial gap.

double is fast, compact, and purpose-built for numerical computation, but it is inherently approximate in the decimal sense. That is not a defect; it is a design trade-off, and in many domains it is exactly the right one.

Naive equality checks

The most common floating-point bug is not imprecision itself but testing equality with ==.

Never do this:

if (result == expected) { ... }  // dangerous with floating-point

Instead, compare within a tolerance (often called epsilon):

boolean nearlyEqual(double a, double b, double epsilon) {
    return Math.abs(a - b) <= epsilon;
}

This works well when your values live in a known range. But if the magnitudes vary widely (say from 0.0001 to 1_000_000), a fixed epsilon is either too tight for large values or too loose for small ones. In that case, use a relative tolerance:

boolean nearlyEqualRelative(double a, double b, double relTol) {
    double diff = Math.abs(a - b);
    double norm = Math.max(Math.abs(a), Math.abs(b));
    return diff <= relTol * norm;
}

The choice between absolute and relative tolerance depends on your data. Scientific applications often use relative tolerance; financial rounding at display boundaries often uses a fixed number of decimal places. The point is: understand your comparison strategy before you write a single if.

strictfp, the x87 FPU, and Java 17's silent fix

Java 1.0 enforced strict IEEE 754 semantics, but Intel's x87 FPU computed intermediates in 80-bit extended precision, making strict mode slow. Java 1.2 introduced a compromise:

  • default mode, which allowed extended exponent range, producing subtly different results across hardware)
  • strict mode, exact IEEE 754, enabled with strictfp

Almost nobody used strictfp, so floating-point results could differ across platforms, exactly the kind of non-determinism that makes numerical debugging a nightmare.

JEP 306 in Java 17 resolved this. Modern hardware (SSE2, AVX) supports strict IEEE 754 natively with no penalty, so JEP 306 restored always-strict semantics as the default. The strictfp modifier is now a no-op, accepted for backward compatibility, but with no effect.

The practical implication: on Java 17+, floating-point arithmetic is fully reproducible across platforms. If you still see strictfp in legacy code, it can safely be removed.

The NaN and -0.0 landmines

IEEE 754 defines two special values that break common assumptions about how numbers behave.

NaN (Not a Number) has a unique property: it is not equal to itself. This is mandated by the standard, and Java follows it faithfully:

double nan = Double.NaN;
System.out.println(nan == nan);              // false            // 1
System.out.println(Double.compare(nan, nan)); // 0               // 2
System.out.println(Double.isNaN(nan));        // true
  1. The == operator says NaN != NaN, mandated by IEEE 754
  2. Double.compare() considers two NaN values as equal for sorting consistency

The == operator says NaN != NaN, but Double.compare() considers two NaN values as equal for sorting consistency, and Double.valueOf(NaN).equals(Double.valueOf(NaN)) returns true for HashMap consistency. If you use double as logic keys or in conditional branches, always guard with Double.isNaN() first.

-0.0 (negative zero) is the other trap:

System.out.println(0.0 == -0.0);              // true            // 1
System.out.println(Double.compare(0.0, -0.0)); // 1 (0.0 > -0.0) // 2
System.out.println(1.0 / 0.0);                // Infinity
System.out.println(1.0 / -0.0);               // -Infinity       // 3
  1. Arithmetic equality says 0.0 == -0.0
  2. Double.compare and Double.valueOf().equals() distinguish them
  3. Dividing by -0.0 yields -Infinity, not +Infinity

The practical rule: if your double values can be NaN or -0.0, test for them explicitly before using the values in collections, comparisons, or as divisors.

When double is the right choice

Many developers reach for BigDecimal "just to be safe," even when approximation is perfectly acceptable. double is an excellent choice when:

The error is tolerable:
If your result is a ranking score, a similarity metric, or a percentage displayed to one decimal place, a few ULPs of drift are irrelevant. (A ULP (Unit in the Last Place) is the smallest representable difference between two adjacent double values at a given magnitude. It is the natural unit of floating-point error.)

The domain is naturally approximate:
Sensor readings, GPS coordinates, simulation outputs, and statistical aggregates are already noisy. Decimal exactness adds nothing.

Performance matters:
double arithmetic maps directly to hardware FPU instructions. It is orders of magnitude faster than BigDecimal.

Rounding happens at the boundaries:
You compute in double and round to two decimal places only when you display or persist. The interior computation needs speed, not decimal fidelity.

Here is a typical example, computing an average:

double average(double[] values) {
    double sum = 0.0;
    for (double v : values) sum += v;
    return sum / values.length;
}

For a dozen values, this works perfectly. But what happens when you sum ten thousand, or ten million? Each addition introduces a rounding error of a few ULPs, and those errors accumulate. Over long sequences, the final result can drift significantly from the mathematically exact answer, not because double is wrong, but because naive summation is numerically unstable. The good news: you do not need to abandon double to fix this. The next section presents a toolkit of well-known algorithms that keep your computation in double while dramatically reducing accumulated error.

Reducing floating-point errors

The algorithms below all work on plain double, with no BigDecimal needed. The choice between them depends on your trade-off between accuracy, performance, and implementation complexity.

Kahan Compensated Summation

The most famous technique. When you add a small number to a large running sum, the low-order bits of the small number get lost. Kahan summation keeps a separate compensation variable that tracks the accumulated rounding error and feeds it back into the next addition.

double kahanSum(double[] values) {
    double sum = 0.0;
    double compensation = 0.0;                                   // 1
    for (double value : values) {
        double y = value - compensation;
        double t = sum + y;
        compensation = (t - sum) - y;                            // 2
        sum = t;
    }
    return sum;
}
  1. Tracks the accumulated rounding error across iterations
  2. Recovers the lost low-order bits, the core of the algorithm

The error bound is essentially independent of n, versus O(n) for naive summation. However, Kahan has a weakness: if the next term exceeds the running sum, the compensation logic fails. Neumaier's variant fixes this.

Neumaier's improved Kahan-Babuška algorithm

Neumaier (1974) introduced a variant that handles the case where the new term is larger than the running sum, by checking the relative magnitudes and adjusting the compensation accordingly:

double neumaierSum(double[] values) {
    double sum = 0.0;
    double compensation = 0.0;
    for (double value : values) {
        double t = sum + value;
        if (Math.abs(sum) >= Math.abs(value)) {                  // 1
            compensation += (sum - t) + value;
        } else {
            compensation += (value - t) + sum;                   // 2
        }
        sum = t;
    }
    return sum + compensation;                                   // 3
}
  1. Sum is bigger: low-order digits of value are lost
  2. Value is bigger: low-order digits of the sum are lost
  3. Correction applied once at the end

The practical difference: for the sequence [1.0, 1e100, 1.0, -1e100], Kahan summation yields 0.0, while Neumaier correctly returns 2.0. This matters in real workloads where your data has values at very different scales: financial portfolios mixing micro-cent adjustments with large transfers, or scientific datasets spanning orders of magnitude.

Pairwise summation

Pairwise, or cascade, summation takes a completely different approach: recursively split the array in half, sum each half, then add the two results. The error grows as O(log n), not as good as Kahan's O(1), but very close in practice, and with a crucial advantage: it requires the same number of arithmetic operations as naive summation and is naturally parallelizable. The implementation is a simple divide-and-conquer recursion with a small base case (typically 16-32 elements summed naively) to amortize recursion overhead. Pairwise summation is the default algorithm in NumPy and Julia for their sum functions, and is used internally in many FFT implementations.

Fused multiply-add

Since Java 9, Math.fma(a, b, c) computes a * b + c with a single rounding step instead of two. This corresponds to the IEEE 754-2008 fusedMultiplyAdd operation. In a normal a * b + c, the multiplication rounds once and the addition rounds again, losing precision at each step. FMA computes the exact product internally and only rounds once when adding c.

double standard = a * b + c;    // two rounding steps
double fma = Math.fma(a, b, c); // one rounding step, more accurate

FMA is particularly valuable for dot products, polynomial evaluation (Horner's method), and any linear combination where you are accumulating products. On hardware that supports FMA natively (most modern x86 and ARM CPUs), there is no performance penalty, often faster than the two-instruction sequence.

JDK itself. The Javadoc for DoubleStream.sum() explicitly states that the implementation "may be implemented using compensated summation or other technique to reduce the error bound." The order of addition is intentionally unspecified to allow flexibility. Similarly, Collectors.summingDouble() uses a compensation-based approach internally (a two-element array to track sum and compensation).

Apache Commons Numbers. The Sum class in commons-numbers-core implements the Sum2S and Dot2S algorithms (Ogita, Rump, Oishi, SIAM J. Sci. Comput, 2005), the most practical ready-to-use compensated arithmetic in the Java ecosystem. The companion Precision class provides production-ready epsilon and ULP comparison (see Apache Commons Numbers).

AlgorithmError boundCost vs naiveParallelizableBest for
Naive summationO(n)1xYesQuick-and-dirty
Pairwise summationO(log n)~1xYesGeneral-purpose (NumPy, Julia)
Kahan compensatedO(1)~4xNo (loop-carried dependency)High-accuracy sequential
Neumaier improvedO(1)~4xNoMixed-magnitude data
Klein second-orderO(1), even tighter~6xNoMaximum accuracy
Apache Commons Sum (Sum2S)O(1)~4xNoProduction Java code

What BigDecimal actually solves

BigDecimal is not "more precise" in some absolute sense. It is decimal, arbitrary-precision, controllable, and auditable. Those four properties are what matter.

Internally, a BigDecimal is an unscaled integer value plus a scale:

unscaled value = 1999, scale = 2  →  19.99

This means that 19.99 is stored as exactly 19.99, not as the nearest binary approximation. Every arithmetic operation preserves decimal semantics, and you control the rounding mode explicitly at every step.

Constructing BigDecimal from double

This is one of the most frequently encountered BigDecimal bugs:

new BigDecimal(0.1);                                             // 1
  1. Captures the inexact binary value; result is 0.100000000000000005551..., not 0.1

The result is 0.1000000000000000055511151231257827021181583404541015625, because the constructor faithfully records the binary64 representation of 0.1. If you want exact decimal semantics, construct from a String or use valueOf:

new BigDecimal("0.1");     // exact: 0.1
BigDecimal.valueOf(0.1);   // also correct: uses Double.toString internally

Controlled Rounding

Where BigDecimal truly shines is in explicit rounding control. In tax, invoicing, and accounting, the rounding rule is not a suggestion; it is a legal requirement.

Computing VAT:

BigDecimal vat = amount.multiply(rate)
                       .setScale(2, RoundingMode.HALF_UP);

Performing division (which often produces non-terminating decimals):

BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP);       // 1
  1. Without specifying scale and rounding mode, divide throws ArithmeticException if the result is non-terminating (e.g., 1 / 3)

Without specifying scale and rounding mode, divide will throw ArithmeticException if the result is non-terminating. This is by design: BigDecimal refuses to silently lose precision. You must be explicit about how many decimal places you want and how to round.

The immutability trap

This may be the single most common BigDecimal bug in production code:

BigDecimal amount = new BigDecimal("19.995");
amount.setScale(2, RoundingMode.HALF_UP);                        // 1
System.out.println(amount);                 // still 19.995
  1. BUG: return value is discarded; BigDecimal is immutable, every method returns a new instance

BigDecimal is immutable. Every method (setScale, add, multiply, divide) returns a new instance. The original is never modified. Static analysis tools like SonarQube flag this (rule S2201), but it still slips through code reviews with surprising frequency. The fix is trivial: amount = amount.setScale(2, RoundingMode.HALF_UP);.

The equals() vs compareTo() Trap

This bug appears constantly in enterprise code:

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");

System.out.println(a.equals(b));      // false                   // 1
System.out.println(a.compareTo(b));   // 0                       // 2
  1. equals() compares both value and scale: 2.0 (scale 1) is not equal to 2.00 (scale 2)
  2. compareTo() compares only the numeric value; use this for numerical equality

equals() compares both value and scale. Since 2.0 has scale 1 and 2.00 has scale 2, they are not equal according to equals(). This has consequences everywhere: HashMap keys, Set membership, assertions in tests. The rule is simple: use compareTo() == 0 for numerical equality of BigDecimal values. If you must use BigDecimal as a Map key, normalize with stripTrailingZeros() first, but be aware that in older Java versions (prior to JDK 8u), stripTrailingZeros() on zero values had inconsistent behavior.

Performance Ccost

BigDecimal operations allocate objects on the heap, perform arbitrary-precision integer arithmetic internally, and manage scale and precision metadata at every step. Compared to double, which compiles down to a single FPU instruction, the overhead is enormous. Benchmarks published by Peter Lawrey (Vanilla Java / Chronicle Software) have shown throughput differences of 100x or more for tight arithmetic loops.

Note: Though the work is regularly cited on the Web, I couldn't find the original research.

But this does not mean BigDecimal is wrong. It means it has a real cost, and that cost must be justified by the domain. If you are computing invoices at 100 transactions per second, BigDecimal is perfectly fine. If you are running a matching engine processing millions of price updates per second, it is not.

Never use System.nanoTime() loops; JIT dead-code elimination, GC pauses, and branch prediction make them meaningless. Use JMH:

@Benchmark
public void doubleCalc(Blackhole bh) {
    double result = (100.10 + 200.20) / 2.0;
    bh.consume(result);                                          // 1
}

@Benchmark
public void bigDecimalCalc(Blackhole bh) {
    BigDecimal a = new BigDecimal("100.10");
    BigDecimal b = new BigDecimal("200.20");
    BigDecimal result = a.add(b).divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP);
    bh.consume(result);
}
  1. Blackhole.consume() prevents the JIT from eliminating dead code; without it, you benchmark nothing

Fast double rounding

Peter Lawrey's blog (Vanilla Java / Chronicle Software) demonstrated that rounding a double to a fixed number of decimal places can be done at very different speeds depending on the technique. His original benchmark measured three approaches for rounding to two decimal places: cast-based rounding (~6 ns), Math.round()-based (~17 ns), and BigDecimal.setScale() (~932 ns).

The cast-based approach is the fastest. Here is the original form from his blog, for two decimal places:

static double roundToTwoPlaces(double d) {
    return ((long) (d < 0 ? d * 100 - 0.5 : d * 100 + 0.5)) / 100.0;  // 1
}
  1. Shift decimal point, bias +/-0.5 for half-up, truncate via cast, shift back; handles negatives by branching on sign

The pattern generalizes naturally:

static double roundHalfUp(double value, int decimalPlaces) {
    double factor = Math.pow(10, decimalPlaces);
    return value >= 0
        ? Math.floor(value * factor + 0.5) / factor
        : Math.ceil(value * factor - 0.5) / factor;
}

This works correctly for both positive and negative values, within the safe integer range of double (up to 2^53). It requires HALF_UP rounding only; for banker's rounding, i.e., HALF_EVEN, use Math.rint() or BigDecimal with RoundingMode.HALF_EVEN. For audit-mandated traceability, BigDecimal with explicit RoundingMode is easier to defend to a regulator.

ApproachSpeed (Lawrey's bench)Rounding modesBest for
Cast trick / Math.floor~6 nsHALF_UP onlyHot paths, fixed scale
Math.round(value * factor) / factor~17 nsHALF_UP (Java 7+)Readable middle ground
BigDecimal.valueOf(v).setScale(n, mode)~932 nsAll modesAudit, regulatory
decimal4j DoubleRounder.round(v, n)Fast (cast-based internally)ConfigurableProduction-ready, tested

Use BigDecimal.valueOf(value) and not new BigDecimal(value) in the BigDecimal approach to avoid capturing the inexact binary representation.

Fixed-point arithmetic

If even the fast rounding tricks above feel like too much overhead, there is a more radical alternative. Many high-performance financial systems do not use BigDecimal at all. They use fixed-point arithmetic on long.

The idea is trivially simple: instead of storing 19.99 as a floating-point or decimal value, store 1999 as an integer representing cents. All arithmetic happens on integers, which are fast, deterministic, and allocation-free.

public record Money(long cents) {
    public Money plus(Money other) {
        return new Money(Math.addExact(cents, other.cents));      // 1
    }

    public Money multiply(long multiplier) {
        return new Money(Math.multiplyExact(cents, multiplier));  // 1
    }

    public BigDecimal toBigDecimal() {
        return BigDecimal.valueOf(cents, 2);                      // 2
    }
}
  1. Math.addExact and Math.multiplyExact throw ArithmeticException on overflow instead of silently wrapping, critical for financial systems
  2. Converts back to BigDecimal at the boundary, preserving scale

The advantages of fixed-point are compelling: no heap allocation, no GC pressure, no arbitrary-precision overhead, deterministic results, and performance that rivals raw double. The disadvantages are equally real: you must manage the scale yourself, handle overflow explicitly, implement rounding manually, and convert at system boundaries.

Applying a percentage like VAT in a fixed-point requires care. The standard approach uses basis points (hundredths of a percent, so 22% = 2200 basis points):

static long applyVat(long netCents, long vatBasisPoints) {
    long numerator = Math.multiplyExact(netCents, 10_000 + vatBasisPoints);
    return (numerator + 5_000) / 10_000;                         // 1
}
  1. Returns the VAT-inclusive gross total (net + VAT); + 5_000 before dividing by 10_000 implements half-up rounding in pure integer arithmetic, with no floating-point involved

Everything stays in integer arithmetic. This is the pattern used by many low-latency trading systems and payment processors.

Real-world libraries and frameworks

The Java ecosystem offers several mature libraries for numeric precision. Each occupies a different niche. Here is a practical guide.

JavaMoney and Moneta

JSR 354 defines a standard API for monetary amounts and currencies in Java, aka JavaMoney. The reference implementation, Moneta, provides two concrete types:

  • Money: backed by BigDecimal. Maximum precision, full decimal control.
  • FastMoney: backed by long with a fixed scale of 5 decimal places. Maximum performance.

This mirrors exactly the trade-off we have been discussing. The JSR 354 API (MonetaryAmount) abstracts over both, so you can choose the implementation that fits your use case without changing your business logic. The API also provides currency handling, rounding policies, formatting, and exchange rate conversion.

Use case: Enterprise applications that need a standardized money type with currency awareness, formatting, and conversion. Ideal when you want a well-defined abstraction layer and do not need to squeeze out the last nanosecond.

Joda-Money

Created by Stephen Colebourne, author of Joda-Time and java.time, Joda-Money is a deliberately simpler alternative to JSR 354. It provides Money, a fixed-scale class, backed by BigDecimal, and BigMoney, of arbitrary scale. There's no FastMoney equivalent; the focus is a clean, minimal API for applications where money is a secondary concern (e-commerce, SaaS billing, reporting).

import org.joda.money.Money;
import org.joda.money.CurrencyUnit;

Money price = Money.of(CurrencyUnit.EUR, 19.99);
Money total = price.multipliedBy(3);
Money vat   = total.multipliedBy(0.22, RoundingMode.HALF_UP);

Use case: Simpler alternative to JSR 354 when you need a robust money type but do not require the full JSR API surface (conversion providers, custom currencies, monetary queries). The 2.x branch requires Java 21+; the 1.x branch works with Java 8+.

decimal4j

decimal4j implements fixed-point decimal arithmetic on long, with a configurable scale of up to 18 decimal places, pluggable rounding modes, and a zero-garbage API designed for low-latency systems. It offers both immutable and mutable decimal types, and the scale is encoded in the type itself (e.g., Decimal2f for 2 decimal places).

import org.decimal4j.immutable.Decimal2f;

Decimal2f price = Decimal2f.valueOf("19.99");
Decimal2f total = price.multiply(3);

The mutable variant, MutableDecimal2f, avoids object allocation entirely by modifying its internal state and returning this, which is useful in tight loops where GC pressure matters.

The library also provides DoubleRounder, a utility for fast rounding of double values to a fixed number of decimal places (see Fast double Rounding above).

Use case: Low-latency systems where you need fixed-point arithmetic with a clean API and do not want to manage raw long arithmetic yourself. Trading systems, pricing engines, financial microservices.

Apache Commons Numbers

commons-numbers-core is not a money library; it is a numerical precision toolkit. Its key components for our purposes are:

Sum:
Compensated summation and dot product using the same Sum2S/Dot2S algorithms described earlier. This is a drop-in replacement for naive summation loops that need higher accuracy.

Precision:
Utilities for floating-point comparison: equals(double, double, double eps) for epsilon-based comparison, equals(double, double, int maxUlps) for ULP-based comparison, and rounding with configurable RoundingMode. Also provides EPSILON (machine epsilon, 2^-53) and SAFE_MIN (smallest normalized double, 2^-1022) as named constants.

import org.apache.commons.numbers.core.Precision;

boolean eq = Precision.equals(0.1 + 0.2, 0.3, 1);               // 1
boolean eq2 = Precision.equals(a, b, 1e-10);                    // 2
  1. ULP-based comparison: equal if within 1 ULP
  2. Epsilon-based comparison

Use case: Any application that does floating-point computation and needs reliable comparison, compensated summation, or compensated dot products. Scientific computing, analytics, ML pipelines, numerical simulations.

Library Comparison

The table also includes ta4j, Technical Analysis for Java, whose Num interface abstracts over DecimalNum (BigDecimal) and DoubleNum (double), letting you swap precision for performance without changing business logic, the same pattern Moneta uses with Money vs FastMoney.

LibraryBacking typeAllocation-freeCurrency supportBest for
Moneta MoneyBigDecimalNoYes (JSR 354)Enterprise, accounting
Moneta FastMoneylong (5 dp)MostlyYes (JSR 354)Moderate-perf with JSR API
Joda-MoneyBigDecimalNoYes (ISO 4217)Simpler projects, billing
decimal4jlong (0-18 dp)Yes (mutable variant)NoLow-latency, fixed-scale
Commons Numbers Sumdouble (compensated)YesNoAccurate summation/dot products
Commons Numbers PrecisiondoubleYesNoComparison, rounding
ta4j DecimalNum/DoubleNumBigDecimal / doubleNo / YesNoTechnical analysis, backtesting

Decision Guide

Choosing the right numeric type is an engineering decision, not a religious one.

TypePrecisionSpeedUse when
double~15 decimal digits, approximateFastest (HW FPU)Analytics, ML, simulations, graphics, ranking, metrics
float~7 decimal digits, approximateFast, half the memoryGPU shaders, image/audio, large ML vectors, massive datasets
BigDecimalArbitrary, exact decimalSlowest (~100x vs double)Accounting, invoicing, tax, audit, regulatory, reconciliation
long fixed-pointExact within fixed scaleVery fast, no allocationTrading, payment processors, low-latency pricing
decimal4jExact, 0-18 decimal placesFast, zero-garbageStructured fixed-point with rounding API, less boilerplate than raw long

Production pitfalls

Choosing the right numeric type is only half the battle. The other half is making sure your choice survives contact with JSON serializers, test frameworks, and multi-threaded runtimes.

JSON Serialization

Serializing numeric values to JSON is one of the most common sources of silent precision loss, because the JSON specification defines only one number type and has no concept of scale or precision.

BigDecimal and scale loss. When Jackson serializes a BigDecimal, it writes it as a JSON number by default. This means new BigDecimal("19.10") becomes 19.1 in JSON: the trailing zero, which carries meaning in BigDecimal (scale = 2 vs scale = 1), is silently dropped. On deserialization, you get a BigDecimal with a different scale. If your code uses equals() (which compares scale), this breaks. If your financial system relies on the scale to determine the number of decimal places for rounding, this is a bug.

The common fix is to serialize BigDecimal as a JSON string:

public class Invoice {
    @JsonFormat(shape = JsonFormat.Shape.STRING)                  // 1
    private BigDecimal amount;
}
  1. Preserves the exact textual representation: "19.10" stays "19.10" in JSON

Alternatively, you can configure the Jackson ObjectMapper globally:

ObjectMapper mapper = new ObjectMapper();
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
mapper.configure(SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN, true);

WRITE_BIGDECIMAL_AS_PLAIN prevents Jackson from using scientific notation (e.g., 1.2E+3), and USE_BIG_DECIMAL_FOR_FLOATS ensures that incoming JSON numbers are deserialized as BigDecimal rather than Double.

double and spurious decimals. When Jackson serializes a double, you might see 19.989999999999998 instead of 19.99. This is the representation error surfacing at the serialization boundary. If you are using double internally and rounding at the boundary, make sure the rounding happens before serialization, not after.

long fixed-point. If your internal representation is long cents, you need a custom serializer/deserializer that converts between 1999 (internal) and 19.99 (JSON). This is extra code, but it gives you full control and eliminates any ambiguity.

Testing floating-point code

Testing numeric code has its own set of traps. The core issue: you cannot use exact equality for double results, but choosing the right tolerance is a skill that is rarely taught.

JUnit 5 provides assertEquals with a delta:

import static org.junit.jupiter.api.Assertions.assertEquals;

@Test
void testAverage() {
    double result = average(new double[]{0.1, 0.2, 0.3});
    assertEquals(0.2, result, 1e-15);                            // 1
}
  1. Delta = tolerance: too tight and the test fails on CI; too loose and it misses regressions

AssertJ offers a more expressive API:

import static org.assertj.core.api.Assertions.assertThat;
import org.assertj.core.data.Offset;
import org.assertj.core.data.Percentage;

@Test
void testAverageAssertJ() {
    double result = average(new double[]{0.1, 0.2, 0.3});
    assertThat(result).isCloseTo(0.2, Offset.offset(1e-15));     // 1
    assertThat(result).isCloseTo(0.2, Percentage.withPercentage(0.0001)); // 2
}
  1. Absolute tolerance
  2. Relative tolerance: useful when the output magnitude varies across test cases

For BigDecimal tests, use compareTo, not equals, in your assertions, or normalize scale first:

@Test
void testVatCalculation() {
    BigDecimal result = calculateVat(new BigDecimal("100.00"), new BigDecimal("0.22"));
    assertThat(result.compareTo(new BigDecimal("22.00"))).isZero();
}

double is not atomic

JLS §17.7 states that a write to a non-volatile double or long is treated as two separate 32-bit writes. A reading thread can observe a torn value (high bits from one write, low bits from another), producing a meaningless bit pattern.

private double sharedPrice;            // UNSAFE: another thread may read a torn value
private volatile double sharedPrice;   // SAFE: volatile guarantees atomic read/write

On 64-bit JVMs, double writes happen to be atomic at the hardware level, but the JLS does not guarantee this. If you share a double across threads without volatile, your code is incorrect per the spec.

Parallel streams

The Javadoc for DoubleStream.sum() explicitly warns that the order of addition is intentionally not defined. This has a direct consequence: DoubleStream.parallel().sum() can return different results on different invocations with the same input data.

double[] values = {0.1, 0.2, 0.3, 1e15, -1e15, 0.4};
double seqSum = DoubleStream.of(values).sum();             // sequential: consistent
double parSum = DoubleStream.of(values).parallel().sum();  // parallel: may differ between runs

The difference is usually tiny, within a few ULPs for well-conditioned data. But for ill-conditioned sums (large positive and negative terms that nearly cancel), the difference can be significant. If your application requires reproducible results across runs or machines, either use sequential streams or use a deterministic summation algorithm like Kahan or Neumaier.

This connects directly to the earlier section on summation algorithms: floating-point addition is not associative, so different partition orders yield different results.

Concurrent accumulation with DoubleAdder

When multiple threads need to update a shared sum (metrics counters, running totals, real-time aggregates), the naive approaches all have problems. A synchronized block serializes all threads. An AtomicReference with a CAS loop allocates a new BigDecimal on every retry. Both create severe contention under load.

DoubleAdder (added in Java 8) solves this with striped cells: internally, it maintains multiple partial sums across different memory locations, so threads rarely contend on the same cache line. The final sum is only computed when you call sum().

import java.util.concurrent.atomic.DoubleAdder;

DoubleAdder totalRevenue = new DoubleAdder();
totalRevenue.add(19.99);           // called from many threads, minimal contention
double current = totalRevenue.sum(); // aggregates all stripes when read

The trade-off: DoubleAdder.sum() is not atomic: it reads the stripes sequentially, so if other threads are writing concurrently, the result is an approximation. For exact point-in-time snapshots, you still need external synchronization. But for metrics, dashboards, and monitoring (where approximate-but-fast beats exact-but-slow), DoubleAdder is the right primitive.

For BigDecimal accumulation, there is no built-in equivalent. The common pattern is a LongAdder on fixed-point cents, converted to BigDecimal only when reading.

Project Valhalla and Value Types

One of the strongest arguments for double or long over BigDecimal has always been performance, specifically the cost of heap allocation and garbage collection pressure for every arithmetic operation. Project Valhalla may fundamentally change this equation.

JEP 401 (Value Classes and Objects), currently in preview, introduces value classes, classes that give up object identity in exchange for potential stack allocation and flattening. A value class instance is compared by its field values rather than by reference identity, and the JVM is free to eliminate the object header and allocate it on the stack or inline it into arrays.

What does this mean for numeric types? Consider a Money wrapper:

value class Money {  // preview syntax, requires --enable-preview
    private final long cents;
    // ... arithmetic methods ...
}

With value semantics, Money could be inlined into arrays without per-element object headers, passed in registers instead of on the heap, and eliminated entirely by escape analysis. The GC pressure that makes BigDecimal expensive in tight loops could largely disappear.

The implications extend beyond custom types. BigDecimal itself is unlikely to become a value class, because its stringCache field carries mutable state and deep compatibility constraints. New purpose-built decimal types could be designed as value classes from the start, and standard types like Optional and LocalDate may benefit too.

Value classes are currently in preview, aka JEP 401, with early-access builds available since JDK 23. This is the next major architectural shift in the JVM, and it will reshape the performance trade-offs we have discussed throughout this article.

Conclusion

The statement "never use double for money" is too simplistic. A more honest version would be: never use any numeric type without understanding its precision model, rounding behavior, overflow characteristics, performance profile, and how those properties interact with the requirements of your domain.

double is fast and approximate, ideal for computation where decimal exactness is irrelevant. BigDecimal is precise and auditable, essential where rounding rules are legally mandated. Fixed-point on long is fast and deterministic, often the best compromise for high-performance financial systems.

The real engineering mistake is not choosing double over BigDecimal. It is choosing any of them without understanding why.

To go further:


Originally published at A Java Geek on June 14th, 2026.

The post double, BigDecimal, or Fixed-Point? Precision, Performance, and Sane Choices for Numbers in Java appeared first on foojay.

Read the whole story
jhunorss
16 hours ago
reply
🎓
Share this story
Delete

Save Memory in Java by Making Memory Efficiency Your Top Priority

1 Share

Leverage Java libraries and frameworks which focus on memory efficiency.

Photo by Markus Winkler on Unsplash

Code in Java like it’s 2004

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.

🫙 Empty Should be Empty
🔄 Performance of Lazy and Eager Iteration Patterns on Small Lists in Java
🚗 Some Benefits of Enabling Compacy Object Headers in Java 25 for Streams
🏁 Measuring the Startup Memory Cost for Lazy Iteration Patterns in Java
🧮 Counting and Collecting Collectors
🆓 “Fat-Free” Lambdas in Java
🐆 Snow Leopards and Tribbles in Java Heaps

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.

Sweating the small stuff in Java

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.

Go Primitive in Java, or Go in a Box

Stateless Lambdas FTW

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.

"Fat-Free" Lambdas in Java

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.

I am the creator of and committer for the Eclipse Collections OSS project, which is managed at the Eclipse Foundation. Eclipse Collections is open for contributions. I am the author of the book, Eclipse Collections Categorically: Level up your programming game.

Read the whole story
jhunorss
1 day ago
reply
Share this story
Delete

Allocation Hungry Any/All/None, FindFirst, and Count Methods on Java Stream

1 Share

Some methods on Java Stream are eager and allocation hungry.

Photo by Brett Jordan on Unsplash

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.

Search for these patterns in your code base.

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.

Is there a reason the Java Collection interface doesn't have direct allMatch, anyMatch & noneMatch APIs using Predicates

Temporary garbage that cannot be unseen

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.

Some Benefits of Enabling Compact Object Headers in Java 25 for Streams

This is where the ReferencePipeline.Head object is created in StreamSupport.stream(Spliterator, boolean).

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.

The cost of findFirst

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.

The cost of count

The method count on Stream also has the cost of .stream().filter() starting at either 144 or 160 bytes. Then there is the cost of count itself.

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.

Snow Leopards and Tribbles in Java Heaps

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.

Thanks for reading!

I am the creator of and committer for the Eclipse Collections OSS project, which is managed at the Eclipse Foundation. Eclipse Collections is open for contributions. I am the author of the book, Eclipse Collections Categorically: Level up your programming game.

Read the whole story
jhunorss
1 day ago
reply
Share this story
Delete

Solving Gradle metadata and Renovate integration

1 Share
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.

Read the whole story
jhunorss
1 day ago
reply
Share this story
Delete

Signatures, be true: domain errors and functional handling in Kotlin

2 Shares
Sergey Chernov

Sergey Chernov

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:

when (error) {
    SignatureRejected   -> showSignatureRejected()
    SigningWindowClosed -> showSigningWindowClosed()
    AlreadySigned       -> showAlreadySigned()
}

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:

when (error) {
    SignatureRejected   -> showSignatureRejected()
    SigningWindowClosed -> showSigningWindowClosed()
    AlreadySigned       -> showAlreadySigned()
}

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’s getOrElse 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.

service.signDocument(request)
    .mapLeft { error -> error.toHttpResponse() }

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!

Read the whole story
jhunorss
1 day ago
reply
Share this story
Delete
Next Page of Stories