Value Objects — Optimize¶
Ten performance angles on Value Objects, ordered from "easiest, biggest win" to "advanced". Each entry: the cost, what HotSpot/JIT/GC do today, and how to make the code friendlier to those optimizations. Most VOs do not need optimization — but when a VO sits on a hot path (Money in a high-frequency pricing engine, a cache key, a comparator) the difference between "naive" and "optimization-aware" can be ten times.
1. Record header overhead vs. primitive fields¶
Every Money(long cents, String currency) instance on the heap today costs:
- An object header (12 bytes with compressed oops, 16 without).
- A reference to the
String currency. - The
long cents(8 bytes).
A Money[] of one million entries occupies ~24 MB of object headers alone. Where memory matters:
- Prefer
longoverBigDecimalfor amounts (no extra heap allocation). - Prefer
Currencyenum or internedStringfor currency (one shared reference). - Consider a flat columnar layout — two parallel arrays
long[] cents; String[] currencies;— when you process millions of money values in tight loops.
JEP 401 (Value Classes and Objects, preview) erases the header for value classes when flattened, which makes this concern disappear for code targeting that JEP.
2. Escape Analysis and scalar replacement¶
HotSpot's Escape Analysis (EA) inspects every allocation. If a Money instance does not escape its enclosing method, the JIT can scalar-replace it: the object is never allocated; its components live in registers/stack slots.
long total = 0;
for (var item : cart) {
var line = new Money(item.cents(), "USD"); // does not escape
total += line.cents();
}
This loop allocates zero Money objects after warm-up, because the JIT sees line is local. EA succeeds when:
- The VO is small and
final. - The VO is not stored in a field, returned, or passed to a virtual call.
- The VO is not synchronized on or
==-compared with another non-local reference.
Records satisfy all four naturally. Defensive synchronized blocks on a VO would defeat EA — never lock on a VO.
3. JEP 401 — value classes and flattening¶
When you mark a class value, the JVM is allowed to:
- Lay it out inline inside arrays (
Money[]becomes ~12 bytes/element, not ~36). - Pass it in registers across method boundaries.
- Skip allocation entirely for short-lived instances (more aggressive than today's EA).
The migration is mechanical: add the value modifier; drop any ==, synchronized(vo), or no-arg construction reliance. If you've followed the senior-level rules, your code is already JEP 401-ready.
JEP 401 is preview at the time of writing; design for it now so the day it ships your code benefits immediately.
4. Intern cache for frequently constructed VOs¶
For VOs with a small value space and high construction rate — Currency.USD, common HTTP status codes, common time zones — keep a one-element-per-value cache:
public record Currency(String code) {
private static final java.util.concurrent.ConcurrentMap<String, Currency> POOL =
new java.util.concurrent.ConcurrentHashMap<>();
public static Currency of(String code) {
return POOL.computeIfAbsent(code.toUpperCase(java.util.Locale.ROOT), Currency::new);
}
}
Trade-offs:
- Eliminates per-construction allocation and validation cost in the hot path.
- Requires that the value space is bounded — interning unbounded user input leaks memory.
- The constructor must still validate; the factory just memoizes.
A common Java idiom — Integer.valueOf(int) caches -128..127 for the same reason.
5. JIT inlining of equals and hashCode¶
HotSpot inlines equals aggressively if the call site is monomorphic (always the same concrete type). Records help because they're implicitly final — no virtual dispatch.
Two things that defeat inlining:
- Megamorphic call sites. If
equalsis invoked viaObject#equalsacross many runtime types, the JIT keeps it virtual. Avoid generic helpers that erase the concrete type. - Custom
equalswith branches/loops. A record's generatedequalsis a flat conjunction over components — easy to inline. A hand-rolledequalsthat walks aList<String>defeats inlining.
Where equals is on a hot path (cache keys, dedup), prefer a record with primitive components.
6. Autoboxing on primitive-only VOs¶
This causes no autoboxing — Money is a reference type. But:
If your VO wraps a primitive and you map by that primitive, switch to a primitive-keyed map (Eclipse Collections, fastutil, or HPPC) to avoid the boxed Integer. Likewise, if a VO has many integer-typed components and you serialize as an Object[], every primitive gets boxed.
7. hashCode stability and caching¶
A String-heavy VO recomputes its hash on every map lookup unless the JVM caches it. String.hashCode is cached (private int hash;). A record's hashCode is not cached — it recomputes from components each call.
For VOs used heavily as map keys, cache hashCode in a non-record final class:
public final class CacheKey {
private final String region;
private final long userId;
private final int hash;
public CacheKey(String region, long userId) {
this.region = region; this.userId = userId;
this.hash = java.util.Objects.hash(region, userId);
}
@Override public int hashCode() { return hash; }
@Override public boolean equals(Object o) {
return o instanceof CacheKey k && k.userId == userId && k.region.equals(region);
}
}
This sacrifices the record's terseness for measurable speedup on a hot map.
8. String interning for high-cardinality string components¶
If your Money codebase routinely sees only ~10 distinct currency strings but millions of instances, each instance still holds a distinct String reference unless you intern. Two cures:
- Use
java.util.Currency(already interned by the JDK). - Intern via a dedicated pool (
String.intern()is global and slow; build your ownMap<String,String>).
The same idea applies to product codes, country codes, region names — any low-cardinality String component of a hot VO.
9. Avoid wide reflection at the boundary¶
A common slowdown for VOs is the boundary: Jackson, MapStruct, ORM. Reflection-based field setters bypass the canonical constructor and skip validation, but they're also slow.
Configure each tool for constructor-based instantiation:
- Jackson: register the parameter-names module, mark the canonical constructor
@JsonCreator. - MapStruct: use constructor-based mapping, no
@Setter. - Hibernate: enable byte-code enhancement and the records API; or use
@Embeddablewith field access.
The win is twofold: correctness (constructor runs, validation runs) and speed (Jackson's compiled deserializer is faster than reflective field-setting in modern versions).
10. Lazy / cached derived fields¶
A VO whose method is expensive (Email.domain() parses the substring; Money.formatted() builds a localized string) and is called repeatedly on the same instance can cache the derived value if the cache is computed lazily and immutably observable.
A naive cache breaks immutability:
Two safer patterns:
- Stable lambda +
Suppliers.memoize(Guava): pre-computed at construction, costs one allocation up front. finalcache field set in the constructor: pre-compute the derived value once and store it.
public final class Email {
private final String value;
private final String domain; // derived but final
public Email(String value) {
if (...) throw new IllegalArgumentException();
this.value = value.toLowerCase(java.util.Locale.ROOT);
this.domain = this.value.substring(this.value.indexOf('@') + 1);
}
public String domain() { return domain; }
}
This costs one extra reference per instance and pays off if domain() is called more than once per VO lifetime.
Quick rules¶
- Don't optimize a VO until profiling shows it on the hot path. Most VOs cost nothing measurable.
- Records are EA-friendly and JIT-friendly by default; prefer them over hand-written classes.
- Use
longcents overBigDecimalfor money where the currency permits it. - Intern bounded value spaces (currencies, country codes, status codes) via a per-VO factory cache.
- Never
synchronized(vo)— it defeats EA and is forbidden under JEP 401. - Cache
hashCodeonly in a non-recordfinal class, only when the VO is a hot map key. - Push validation to the constructor; deserializers should call the constructor, not reflective field setters.
- Pre-compute expensive derived fields in the constructor and store in
finalfields. - For huge homogeneous collections of VOs, consider a columnar layout (two parallel primitive arrays) instead of
Vo[]. - Migrate to
value record(JEP 401) the moment your target JDK enables it; the code change is a single keyword.
Memorize this¶
- The default cost of a record VO is one object header per instance and a recomputed
hashCodeper lookup — both small, but they add up at scale. - Escape Analysis + scalar replacement is the JIT's gift to small immutable VOs; the cost of
new Money(...)in a tight loop is near zero after warm-up, provided the VO doesn't escape. - JEP 401 value classes promise true flattening into arrays and across method boundaries; design every VO so adding the
valuemodifier is the only change needed. - Cache
hashCodeonly when profiling demands it; never introduce a mutable cache field that breaks immutability. - Intern factory pools win big for bounded value spaces, but leak memory for unbounded ones.
- Reflection-based deserialization is both slower and unsafer than constructor-based; configure every boundary tool to call the canonical constructor.
- Reference: JEP 395 (records); JEP 401 (value classes); HotSpot Escape Analysis docs; Aleksey Shipilev's "Black Magic of (Java) Method Dispatch".