It is always important to be mindful of performance when writing code. In fact, once you care about performance, you easily notice areas in the code that need improvement, and at times even very small things look expensive.
Sometimes, however, when you try to micro-optimize by hand, the code becomes more unreadable. There is a cost to that too. Code that is hard to read is harder to maintain, harder to reason about, and harder to improve later. I found Prof. Charles Leiserson’s lecture on Introduction and Matrix Multiplication very interesting for this reason. It is a great watch. As more performance improvements were applied, the code became harder to understand and much more complex.
That is why, in many cases, the JIT compiler helps us by doing some of these low-level optimizations for us at runtime. This allows us to still write code that is clean and readable, while the JVM handles certain optimizations behind the scenes when it can prove they are safe.
In this blog post, we are going to look at one of such optimization: escape analysis in HotSpot, and how it can sometimes make a new Object() in your source code not become a real heap allocation at runtime.
Why Seeing new Makes Us Panic Too Early
Let us look at a simple example.
Imagine we have a small parser class, and inside it we process incoming data many times, maybe in a hot loop or some frequently hit path.
final class PacketProcessor {
static final class Range {
final int start;
final int end;
Range(int start, int end) {
this.start = start;
this.end = end;
}
}
int process(byte[] data) {
Range range = new Range(
data[0] & 0xFF,
data[1] & 0xFF
);
return (range.end - range.start) * 31;
}
}
This is very readable. The temporary values that belong together are grouped together. The code is local, clear, and easy to reason about.
It is, however, very easy to ask questions like: why create an object just to hold two primitive values for a moment?
So a developer may immediately try to “optimize” it by rewriting it into something like this:
final class PacketProcessor {
private int start;
private int end;
int process(byte[] data) {
start = data[0] & 0xFF;
end = data[1] & 0xFF;
return (end - start) * 31;
}
}
The second version may look more optimized, especially since we are only dealing with primitives anyway, and the new Range(...) is now gone.
But what did we actually gain?
The first version keeps the temporary state local to the computation. It is very clear what belongs together. The second version removes that tiny object, but now the temporary state lives on the PacketProcessor itself. That makes the code less local, less clear, and more error-prone. If this processor instance is ever shared across threads, we have also introduced a completely different problem just because we were trying to avoid one small allocation.
And this is the point: new does not always mean a real heap object will necessarily be created at runtime. If that object stays local and does not escape, HotSpot may classify it as NoEscape, and that is the scalar-replaceable case where the allocation can be eliminated from the generated code.
What HotSpot Actually Does Here
Since Range is only used inside process() and does not escape that computation, the JIT compiler may decide there is no need to create a real Range object at runtime. Instead, it may keep only the scalar values that matter, in this case start and end, and perform the computation directly with them. This is the whole idea behind escape analysis and scalar replacement: even though the object is present in the source code, the allocation itself may be eliminated from the generated code.
So, conceptually, the HotSpot JVM may treat this:
Range range = new Range(data[0] & 0xFF, data[1] & 0xFF); return (range.end - range.start) * 31;
more like this:
int start = data[0] & 0xFF; int end = data[1] & 0xFF; return (end - start) * 31;
Not because you wrote it that way, but because the JIT was able to prove that the Range object never needed to become a real heap object in the first place.
When This Does Not Work Anymore
This does not mean every small object can be optimized away. The important condition here is that the object must stay local to the computation. Once it is returned, stored somewhere, or passed around in a way that makes it escape, HotSpot can no longer treat it like the previous Range example.
Range parse(byte[] data) {
return new Range(data[0] & 0xFF, data[1] & 0xFF);
}
Here, the Range object is being returned, so it escapes the method. That means this is no longer the same case as the local temporary object inside process().
Conclusion
In this blog post, we have seen that just because we write new Object() in source code does not always mean a real heap object will necessarily be created at runtime. If the object stays local and does not escape, HotSpot may optimize that allocation away through escape analysis and scalar replacement.
The practical takeaway is simple: do not rush to make clean code worse just because you saw a small object in a hot path. Sometimes the JIT may already be able to handle that case for you. The real job is knowing when to keep the code clear, and when the allocation is actually real and worth worrying about.


![Stop Storing Flat Data Like Objects: Why int[] Beats Integer[] in Java primitive vs object arrays](https://heappulse.com/wp-content/uploads/2026/03/primitive_vs_object_arrays-768x337.png)

