Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    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.

    Seedtended planted 4 min read

    🌱 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 bytesA 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 StringsThe 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:

    1. 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”.
    2. 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.
    3. Read through the view. view.inErrors() looks up the slot’s offsets and parses the number straight from the bytes. No String, no boxed number unless asked.
    4. Strings stay as views. A CharSequence over the bytes (e.g. Agrona’s AsciiSequenceView) can be compared, hashed or written out without ever becoming a String.
    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 \", \n or \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.norm should 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

    Nothing links here yet — it’s a fresh shoot.

    ↪ Links to (1)