Flyweight Views over Byte Buffers
Flyweight = share what's common, pass in what varies. Applied to byte buffers: one reusable view object reads fields straight from the bytes (no per-message objects). For JSON with a repeated shape, build the shape once and share it; each message gets only a small index of offsets. It's Rust's borrowing, done by discipline instead of the compiler.
🌱 A rough idea, planted recently. Expect rough edges — it will grow.
On this page
[!abstract] In one sentence Don’t turn every message into objects. Share the structure that every message has in common, and give each message only a lightweight view over its own bytes. Fields are decoded when read, and nothing is allocated per message.
The classic pattern (GoF)
The Gang of Four flyweight splits state in two:
- Intrinsic state: shared, immutable, the same for many objects. Stored once.
- Extrinsic state: what varies per use. Passed in rather than stored.
The textbook example is a text editor. There’s one glyph object per character shape, not per character on the page; the position on the page is passed in when drawing. Java already does this in places: Integer.valueOf caches small numbers, and String.intern() shares identical strings.
The low-latency version: a view over a buffer
In high-throughput systems (SBE, Agrona, Chronicle), “flyweight” means something slightly different: one mutable object that wraps a byte buffer at an offset and reads fields straight from the bytes on demand.
final class CounterView { // one instance, reused for every message
private DirectBuffer buf; private int off;
CounterView wrap(DirectBuffer buf, int off) { this.buf = buf; this.off = off; return this; }
long inErrors() { return buf.getLong(off); } // decoded on read, nothing allocated
int linkState(){ return buf.getInt(off + 8); }
}
CounterView view = new CounterView(); // allocated once
void onMessage(DirectBuffer buf, int off) {
view.wrap(buf, off);
if (view.inErrors() > limit) alert(view.linkState());
}
- No allocation per message. The same view is re-pointed at each message.
- Lazy: fields you never read are never decoded.
- The message layout is the shared part: field offsets are fixed per message type, so they’re constants.
Applying it to JSON
JSON doesn’t have fixed offsets, but high-rate JSON streams (device telemetry, event feeds) usually do have a fixed shape: the same keys, mostly in the same order, message after message. Treat that shape as the flyweight.
| Shared once (intrinsic) | Per message (extrinsic) |
|---|---|
| The shape: key → slot number, expected types, field names stored as bytes | A small int[] index of each value’s (start, end, type) in this message’s bytes |
Pre-hashed key bytes, so keys are matched without creating Strings | The byte buffer itself, owned by the network layer |
| Decoders per slot (long, decimal, enum, string view) | One reused view object that reads slots via the index |
How it works:
- Index pass: scan the bytes once and record where each value starts and ends in a reused
int[]. This is the same idea as simdjson’s “tape”. - Match keys against the shared shape. Compare raw key bytes or their hashes with the shape’s pre-encoded keys. If fields arrive in the usual order, try the expected next slot first, which is nearly free.
- Read through the view.
view.inErrors()looks up the slot’s offsets and parses the number straight from the bytes. NoString, no boxed number unless asked. - Strings stay as views. A
CharSequenceover the bytes (e.g. Agrona’sAsciiSequenceView) can be compared, hashed or written out without ever becoming aString.
final class AsciiView implements CharSequence { // a "borrowed" string
private DirectBuffer buf; private int off, len;
AsciiView wrap(DirectBuffer b, int o, int l) { buf = b; off = o; len = l; return this; }
public int length() { return len; }
public char charAt(int i) { return (char) buf.getByte(off + i); }
public CharSequence subSequence(int s, int e) { throw new UnsupportedOperationException(); }
public String toString() { return buf.getStringWithoutLengthAscii(off, len); } // the one deliberate allocation
}
Why repeated shapes help so much: the work of understanding the message structure is done once per type rather than once per message. JavaScript engines do the same thing with V8 “hidden classes” (shapes): objects with the same keys share one layout description.
The catch: lifetimes are on you
In Rust, &'a str is exactly this pattern: a borrowed view into the input buffer. The compiler guarantees the view never outlives the buffer. In Java nothing checks this, so you need discipline:
- A view is only valid until the buffer is reused. Storing it in a map or passing it to another thread gives you silently wrong data later. Copy out (
toString(), or primitives) anything you need to keep. - One view per thread. Views are mutable and re-pointed, so sharing one across threads is a data race.
- Escapes: JSON strings can contain
\",\nor\uXXXX. Either flag “needs unescaping” in the index and decode only those slowly, or reject them if the protocol forbids them. - Debugging: a view shows whatever it points at now, so logs and debuggers can mislead. Give it a
toString()that copies, and use it only in logs. - Fallback: if a message doesn’t match the shape (new field, different order, missing key), fall back to a general parser instead of guessing.
- Validation: lazy decoding means a malformed value might only fail when it’s read. Validate the fields you depend on at index time.
When to use it
- Do: proven hot paths with a high message rate and a stable shape, where profiling shows allocation or GC pauses in the p99 (The Fastest JSON Parser Can Still Be Too Slow).
- Don’t: ordinary APIs and CRUD services. Jackson data binding is simpler, safer and fast enough.
- Measure: JMH
-prof gc→gc.alloc.rate.normshould be about 0 bytes per message on the hot path. - Consider first: DSL-JSON or Jackson streaming with reused objects may get you most of the way with less bespoke code. If you control both ends, a binary format (SBE) makes the flyweight trivial, because offsets become constants.
- #flyweight
- #design-patterns
- #performance
- #json
- #garbage-collection
- #jvm
- #rust
- #zero-allocation
↩ Linked from (0)
Nothing links here yet — it’s a fresh shoot.
↪ Links to (1)
Related by topic
- Growing
Snapshot and Delta Sync over WebSockets
Real-time UIs - Seed
Anti-Corruption Layer
Backend - Evergreen
Message Delivery Semantics
Distributed systems - Seed
Kubernetes
Platform engineering