Lesson 58 of 71
Binary Protocols and Encoding
This lesson shows how messages are laid out as bytes on a wire or in a file, and how Java code reads and writes them without creating objects per message. It uses ByteBuffer and byte order from ByteBuffer, Direct Buffers, and Memory-Mapped Files and the sockets from TCP, UDP, Multicast, and the HTTP Client. After it, a reader can design a fixed-layout message, frame it over TCP, and decode it with a flyweight.
| Idea | What it gives |
|---|---|
| Fixed-length fields | Each field at a known offset, read in O(1) |
| Endianness | Agreement on byte order between writer and reader |
| Varints, ZigZag | Small numbers in few bytes |
| Length-prefix framing | Message boundaries on a byte stream |
| Flyweight | One reusable object that reads fields straight from a buffer |
| Sequence numbers | Detection of lost or duplicated messages |
| SBE | Schema-generated flyweights with a standard message header |
Prices in these examples are long values in ten-thousandths (1902500 means 190.2500), a fixed-point form that avoids floating point. Money, BigDecimal, and Rounding covers it in depth.
Example 1: text versus binary
A text encoding writes numbers as digit characters with separators, as in CSV, FIX, or JSON. A binary encoding writes each number in its machine form at a fixed width. The example encodes the same order both ways.
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class TextVsBinary {
public static void main(String[] args) {
long orderId = 9_000_000_123L;
int instrumentId = 42;
long price = 1_902_500; // 190.2500
int quantity = 100;
byte side = 'B';
byte[] text = (orderId + "," + instrumentId + "," + price + "," + quantity + "," + (char) side)
.getBytes(StandardCharsets.US_ASCII);
ByteBuffer binary = ByteBuffer.allocate(25)
.putLong(orderId).putInt(instrumentId).putLong(price).putInt(quantity).put(side);
System.out.println("text bytes: " + text.length);
System.out.println("binary bytes: " + binary.position());
String[] parts = new String(text, StandardCharsets.US_ASCII).split(",");
System.out.println("text decode needs parsing: " + Long.parseLong(parts[2]));
System.out.println("binary decode reads offset 12: " + binary.getLong(12));
}
}text bytes: 27
binary bytes: 25
text decode needs parsing: 1902500
binary decode reads offset 12: 1902500The sizes are close here, but the costs differ. Text needs parsing digit by digit and splitting on separators (which allocates strings), and its length depends on the values. Binary fields sit at fixed offsets, so getLong(12) reads the price with no scan. Text is readable by humans and tools; binary is smaller for large numbers and faster to decode.
Example 2: endianness on the wire
Endianness is the order of the bytes of a multi-byte number. Big-endian puts the most significant byte first and is called network byte order; x86 and ARM CPUs are little-endian. A ByteBuffer is big-endian until order(...) changes it.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.HexFormat;
public class Endianness {
public static void main(String[] args) {
HexFormat hex = HexFormat.ofDelimiter(" ");
ByteBuffer big = ByteBuffer.allocate(4).putInt(0x01020304);
ByteBuffer little = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(0x01020304);
System.out.println("big-endian: " + hex.formatHex(big.array()));
System.out.println("little-endian: " + hex.formatHex(little.array()));
ByteBuffer wrongReader = ByteBuffer.wrap(little.array()); // big-endian by default
System.out.println("misread value: 0x" + Integer.toHexString(wrongReader.getInt()));
System.out.println("reversed back: 0x" + Integer.toHexString(Integer.reverseBytes(wrongReader.getInt(0))));
}
}big-endian: 01 02 03 04
little-endian: 04 03 02 01
misread value: 0x4030201
reversed back: 0x1020304Reading with the wrong order does not fail; it returns a different number. A protocol specification therefore states the byte order of every field. Many internet protocols use big-endian. SBE and many exchange protocols use little-endian, which matches the CPU and avoids a byte swap on each read.
Example 3: varints and ZigZag
A varint stores an integer in 7-bit groups, low group first, with the top bit of each byte set when more bytes follow. Small values take one byte instead of eight. This is the LEB128 form used by Protocol Buffers. ZigZag maps signed values to unsigned ones (0, -1, 1, -2 to 0, 1, 2, 3) so small negative numbers also stay short.
import java.nio.ByteBuffer;
public class Varints {
static void writeVarLong(ByteBuffer buf, long v) {
while ((v & ~0x7FL) != 0) {
buf.put((byte) ((v & 0x7F) | 0x80));
v >>>= 7;
}
buf.put((byte) v);
}
static long readVarLong(ByteBuffer buf) {
long result = 0;
for (int shift = 0; shift < 64; shift += 7) {
byte b = buf.get();
result |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) return result;
}
throw new IllegalStateException("varint too long");
}
static long zigzag(long v) { return (v << 1) ^ (v >> 63); }
static long unzigzag(long v) { return (v >>> 1) ^ -(v & 1); }
public static void main(String[] args) {
ByteBuffer buf = ByteBuffer.allocate(16);
for (long v : new long[] {1, 127, 128, 300, 1L << 40}) {
buf.clear();
writeVarLong(buf, v);
int len = buf.position();
buf.flip();
System.out.println(v + " -> " + len + " bytes, decoded " + readVarLong(buf));
}
for (long v : new long[] {0, -1, 1, -2, -1000}) {
System.out.println("zigzag " + v + " -> " + zigzag(v) + " -> " + unzigzag(zigzag(v)));
}
buf.clear();
writeVarLong(buf, -1);
System.out.println("-1 without zigzag: " + buf.position() + " bytes");
}
}1 -> 1 bytes, decoded 1
127 -> 1 bytes, decoded 127
128 -> 2 bytes, decoded 128
300 -> 2 bytes, decoded 300
1099511627776 -> 6 bytes, decoded 1099511627776
zigzag 0 -> 0 -> 0
zigzag -1 -> 1 -> -1
zigzag 1 -> 2 -> 1
zigzag -2 -> 3 -> -2
zigzag -1000 -> 1999 -> -1000
-1 without zigzag: 10 bytesVarints save space when values are small, such as deltas between consecutive prices or timestamps. The cost is that field offsets are no longer fixed: to reach the third field a decoder must read the first two. For that reason latency-focused protocols keep fixed-width fields on the hot path and use varints only where size matters more.
Example 4: length-prefix framing
TCP delivers a byte stream, and one read can return half a message or several messages. Framing marks where each message ends. A length prefix writes the message size before its body; the reader waits until that many bytes are present. The example feeds the decoder in chunks of arbitrary size, as the network would.
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class LengthPrefixFraming {
static final int HEADER = 2; // unsigned 16-bit length
private final ByteBuffer acc = ByteBuffer.allocate(1024);
List<String> onBytes(ByteBuffer chunk) {
acc.put(chunk);
acc.flip();
List<String> messages = new ArrayList<>();
while (acc.remaining() >= HEADER) {
int len = Short.toUnsignedInt(acc.getShort(acc.position()));
if (acc.remaining() < HEADER + len) break; // body not complete yet
acc.position(acc.position() + HEADER);
byte[] body = new byte[len];
acc.get(body);
messages.add(new String(body, StandardCharsets.US_ASCII));
}
acc.compact();
return messages;
}
static byte[] frame(String... msgs) {
ByteBuffer out = ByteBuffer.allocate(256);
for (String m : msgs) {
byte[] b = m.getBytes(StandardCharsets.US_ASCII);
out.putShort((short) b.length).put(b);
}
byte[] wire = new byte[out.position()];
out.flip().get(wire);
return wire;
}
public static void main(String[] args) {
byte[] wire = frame("NEW 1", "CANCEL 1", "NEW 2");
LengthPrefixFraming decoder = new LengthPrefixFraming();
int[] chunkSizes = {3, 1, 9, 2, 100};
int pos = 0;
for (int size : chunkSizes) {
int n = Math.min(size, wire.length - pos);
List<String> out = decoder.onBytes(ByteBuffer.wrap(wire, pos, n));
pos += n;
System.out.println("read " + n + " bytes -> " + out);
if (pos == wire.length) break;
}
}
}read 3 bytes -> []
read 1 bytes -> []
read 9 bytes -> [NEW 1]
read 2 bytes -> []
read 9 bytes -> [CANCEL 1, NEW 2]compact moves the unread bytes of a partial message to the start of the buffer so the next chunk is appended after them. Other framing methods are a delimiter (a newline, as in Example 1 of the previous lesson, which forbids that byte in the body) and fixed-size messages. A length prefix also lets a reader skip a message it does not understand. The decoder must reject lengths above a limit, or a corrupt prefix makes it wait for data that never comes.
Example 5: a flyweight encoder and decoder
A flyweight is an object that holds no field values itself; it points at a buffer and an offset and reads or writes fields there on demand. One flyweight instance is reused for every message, so decoding allocates nothing.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class FlyweightCodec {
// Layout, little-endian: orderId long @0, instrumentId int @8, price long @12, qty int @20, side byte @24
static final int ORDER_ID = 0, INSTRUMENT = 8, PRICE = 12, QTY = 20, SIDE = 24, LENGTH = 25;
static final class OrderEncoder {
private ByteBuffer buf;
private int offset;
OrderEncoder wrap(ByteBuffer buf, int offset) { this.buf = buf; this.offset = offset; return this; }
OrderEncoder orderId(long v) { buf.putLong(offset + ORDER_ID, v); return this; }
OrderEncoder instrumentId(int v) { buf.putInt(offset + INSTRUMENT, v); return this; }
OrderEncoder price(long v) { buf.putLong(offset + PRICE, v); return this; }
OrderEncoder quantity(int v) { buf.putInt(offset + QTY, v); return this; }
OrderEncoder side(byte v) { buf.put(offset + SIDE, v); return this; }
}
static final class OrderDecoder {
private ByteBuffer buf;
private int offset;
OrderDecoder wrap(ByteBuffer buf, int offset) { this.buf = buf; this.offset = offset; return this; }
long orderId() { return buf.getLong(offset + ORDER_ID); }
int instrumentId() { return buf.getInt(offset + INSTRUMENT); }
long price() { return buf.getLong(offset + PRICE); }
int quantity() { return buf.getInt(offset + QTY); }
char side() { return (char) buf.get(offset + SIDE); }
}
public static void main(String[] args) {
ByteBuffer buf = ByteBuffer.allocateDirect(LENGTH * 3).order(ByteOrder.LITTLE_ENDIAN);
OrderEncoder enc = new OrderEncoder();
for (int i = 0; i < 3; i++) {
enc.wrap(buf, i * LENGTH).orderId(1000 + i).instrumentId(42)
.price(1_902_500 + i * 100).quantity(100 * (i + 1)).side((byte) (i % 2 == 0 ? 'B' : 'S'));
}
OrderDecoder dec = new OrderDecoder();
long notional = 0;
for (int i = 0; i < 3; i++) {
dec.wrap(buf, i * LENGTH);
System.out.println(dec.orderId() + " " + dec.side() + " " + dec.quantity() + " @ " + dec.price());
notional += dec.price() * dec.quantity();
}
System.out.println("notional (1e-4 units): " + notional);
System.out.println("buffer position untouched: " + buf.position());
}
}1000 B 100 @ 1902500
1001 S 200 @ 1902600
1002 B 300 @ 1902700
notional (1e-4 units): 1141580000
buffer position untouched: 0The encoder and decoder use absolute get and put with an index, so the buffer's position never moves and many flyweights can share one buffer. The same classes work on a direct buffer filled by a SocketChannel.read or on a memory-mapped file. The trade-off is that a flyweight is valid only while the buffer region holds that message; code that keeps a value for later copies it out first.
Example 6: message header, sequence numbers, and gap detection
Real protocols put a header before each body: here a length, a message type, and a sequence number. The receiver dispatches on the type and checks the sequence number against the expected one. The sender below skips sequence numbers 4 and 5 to simulate loss, and resends number 2 to simulate a duplicate.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class SequencedStream {
// Header: length u16 @0, type u8 @2, seq u64 @3. Body follows at @11.
static final int HEADER = 11;
static final byte TRADE = 1, HEARTBEAT = 2;
static void write(ByteBuffer out, byte type, long seq, long price) {
int bodyLen = type == TRADE ? 8 : 0;
out.putShort((short) (HEADER + bodyLen)).put(type).putLong(seq);
if (type == TRADE) out.putLong(price);
}
public static void main(String[] args) {
ByteBuffer wire = ByteBuffer.allocate(512).order(ByteOrder.LITTLE_ENDIAN);
long[] seqs = {1, 2, 3, 2, 6, 7};
for (long s : seqs) {
write(wire, s == 7 ? HEARTBEAT : TRADE, s, 1_900_000 + s);
}
wire.flip();
long expected = 1;
int gaps = 0, dups = 0;
while (wire.remaining() >= HEADER) {
int start = wire.position();
int len = Short.toUnsignedInt(wire.getShort(start));
byte type = wire.get(start + 2);
long seq = wire.getLong(start + 3);
wire.position(start + len);
if (seq < expected) { dups++; System.out.println("dup " + seq + " ignored"); continue; }
if (seq > expected) { gaps++; System.out.println("gap " + expected + ".." + (seq - 1) + ", request resend"); }
expected = seq + 1;
switch (type) {
case TRADE -> System.out.println("trade seq " + seq + " price " + wire.getLong(start + HEADER));
case HEARTBEAT -> System.out.println("heartbeat seq " + seq);
default -> System.out.println("unknown type " + type + ", skipped by length");
}
}
System.out.println("gaps " + gaps + ", duplicates " + dups + ", next expected " + expected);
}
}trade seq 1 price 1900001
trade seq 2 price 1900002
trade seq 3 price 1900003
dup 2 ignored
gap 4..5, request resend
trade seq 6 price 1900006
heartbeat seq 7
gaps 1, duplicates 1, next expected 8The length field lets the reader move to the next message even for an unknown type. Exchange feeds that use this pattern often send the same stream on two multicast lines (A and B) and keep the first copy of each sequence number, so the duplicate check doubles as line arbitration. A gap that the other line does not fill triggers a resend request or a snapshot, as described in the previous lesson.
Example 7: SBE concepts with a hand-written header
Simple Binary Encoding (SBE) is a standard from the FIX Trading Community for low-latency binary messages. A message schema in XML defines types and messages; a code generator turns it into flyweight encoder and decoder classes like those in Example 5. Every SBE message starts with a message header of four unsigned 16-bit fields in the default schema: blockLength, templateId, schemaId, and version. The example writes that header by hand to show what it is for; the generated code and build step are covered in Agrona, Aeron, SBE, and Chronicle.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class SbeStyleHeader {
static final int HEADER = 8, SCHEMA_ID = 7;
static final int NEW_ORDER = 1, CANCEL = 2;
static int writeHeader(ByteBuffer b, int at, int blockLength, int templateId, int version) {
b.putShort(at, (short) blockLength).putShort(at + 2, (short) templateId)
.putShort(at + 4, (short) SCHEMA_ID).putShort(at + 6, (short) version);
return at + HEADER;
}
public static void main(String[] args) {
ByteBuffer b = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN);
int p = 0;
// NewOrder v1: orderId(8) price(8) = 16 bytes
int body = writeHeader(b, p, 16, NEW_ORDER, 1);
b.putLong(body, 1001).putLong(body + 8, 1_902_500);
p = body + 16;
// NewOrder v2 adds qty(4) at the end: 20 bytes
body = writeHeader(b, p, 20, NEW_ORDER, 2);
b.putLong(body, 1002).putLong(body + 8, 1_903_000).putInt(body + 16, 300);
p = body + 20;
// Template 9 is unknown to this decoder
body = writeHeader(b, p, 4, 9, 1);
b.putInt(body, 0xCAFE);
p = body + 4;
// Cancel: orderId(8)
body = writeHeader(b, p, 8, CANCEL, 1);
b.putLong(body, 1001);
int end = body + 8;
int decoderVersion = 1; // this decoder knows only the v1 NewOrder fields
int at = 0;
while (at < end) {
int blockLength = Short.toUnsignedInt(b.getShort(at));
int templateId = Short.toUnsignedInt(b.getShort(at + 2));
int schemaId = Short.toUnsignedInt(b.getShort(at + 4));
int version = Short.toUnsignedInt(b.getShort(at + 6));
int bodyAt = at + HEADER;
String text = switch (templateId) {
case NEW_ORDER -> "NewOrder v" + version + " id=" + b.getLong(bodyAt) + " price=" + b.getLong(bodyAt + 8)
+ (version > decoderVersion ? " (newer fields skipped)" : "");
case CANCEL -> "Cancel id=" + b.getLong(bodyAt);
default -> "unknown template " + templateId + " skipped";
};
System.out.println("schema " + schemaId + ": " + text);
at = bodyAt + blockLength; // blockLength, not the decoder's idea of the size
}
}
}schema 7: NewOrder v1 id=1001 price=1902500
schema 7: NewOrder v2 id=1002 price=1903000 (newer fields skipped)
schema 7: unknown template 9 skipped
schema 7: Cancel id=1001The header gives three properties. templateId selects the decoder. blockLength lets an old decoder skip fields a newer version appended, which is how SBE stays backward compatible: new fields go only at the end of a block. schemaId and version let a reader reject messages from an unrelated or incompatible schema. Generated SBE code also handles repeating groups and variable-length data after the fixed block; those must be read in schema order.
Interview points
- Binary fixed-width fields can be read at known offsets with no parsing or allocation; text needs scanning and parsing and has variable length.
- Endianness must be part of the specification.
ByteBufferdefaults to big-endian; SBE defaults to little-endian. - A varint stores 7 bits per byte with a continuation bit; ZigZag keeps small negative numbers short. Varints remove fixed offsets.
- TCP needs framing. A length prefix supports partial reads, many messages per read, and skipping unknown messages; cap the accepted length.
- A flyweight wraps a buffer and offset, uses absolute
get/put, and allocates nothing per message; it is valid only while the buffer holds that message. - Sequence numbers detect gaps (request resend or snapshot) and duplicates (drop); A/B line arbitration uses the same check.
- SBE: XML schema, generated flyweights, header of
blockLength,templateId,schemaId,version; new fields are appended, andblockLengthlets old decoders skip them.
Summary
- The same order took 25 bytes in binary; text length varies with the values.
0x01020304is01 02 03 04big-endian and04 03 02 01little-endian; the wrong order silently reads another number.- A varint of
1is one byte;-1without ZigZag takes ten bytes. - A length-prefix decoder accumulates bytes, emits complete frames, and compacts the rest.
- A flyweight reads fields by absolute offset and leaves the buffer position unchanged.
- A header with length, type, and sequence number supports dispatch, skipping, and gap detection.
- An SBE header carries
blockLength,templateId,schemaId, andversion.
