JSON: Parsing and Building Without Garbage
sj.json is a hand-written, incremental JSON parser and a companion streaming builder — no derive macros, no reflection, no intermediate object graph you didn't ask for. This tutorial walks through both halves, starting with the simplest API and ending with the zero-garbage pattern you want on any hot path (a request handler, a per-tick SEDA event, anything called in a loop). Every snippet is adapted from the demos shipped with the SDK (demo/sj/demo/JsonDemo.sj, demo/sj/demo/JsonBuilderDemo.sj), so it compiles and runs as written.
1. Parsing — the one-shot API
The simplest way to read a JSON document:
import sj.json.JsonParser;
import sj.json.JsonNode;
byte[] json = "{\"name\":\"SuperJ\",\"versions\":[1,2,3],\"fast\":true}".getBytes();
JsonNode doc = JsonParser.parse(json);
if (doc.isObject()) {
System.out.println("top-level object, " + doc.childCount() + " fields");
}
JsonNode is a flat "tape" view over the parsed document — isObject(), isArray(), isString(), isLong(), isDouble(), isBoolean(), isNull() tell you what a node is; asLong(), asDouble(), asBoolean(), asString() read its value.
Objects and arrays are walked as a linked sequence of children, not indexed:
JsonNode key = doc.firstChild();
while (key != null && key.isString()) {
JsonNode val = key.next();
System.out.println(key.asString() + " = " + describe(val));
key = val.next(); // advance past the value to the next key
}
For an array, firstChild()/nextSibling() walk the elements directly (no key in between).
Looking a key up, and duplicate keys
at(key) searches an object's members and returns the matching value, or null if there is none. It is a scan from the object's first member, not an index — an object is a sequence on the tape, and nothing hashes it.
If an object names the same key twice, the last one wins.
JsonNode n = JsonParser.parse("{\"k\":1,\"k\":2}".getBytes());
n.at("k").asLong(); // 2
n.childCount(); // 2 — both members are still on the tape
RFC 8259 §4 says names should be unique and leaves the meaning of a duplicate to the implementation. Last-wins is what a parser that builds a map gives you, because the later insert overwrites the earlier one, so it is what a caller arriving from Jackson, Gson or serde_json expects (#4325).
Nothing is discarded: the parse keeps every member, childCount() counts them all, and a firstChild()/next() walk sees both. Only at() picks a winner. The consequence is that at() cannot stop at the first match — ruling out a later duplicate means looking at every remaining key — so a lookup costs a walk of the whole object. If you are reading several keys out of a large object, walk it once yourself rather than calling at() per key; at() restarts from the beginning either way.
This API is fine for a one-off parse. JsonParser.parse(...) allocates a new JsonParser, and every firstChild()/next()/nextSibling()/at() call allocates a fresh JsonNode. In a hot loop that adds up.
2. Parsing — the zero-garbage pattern
sj.json's incremental design (see the design notes in doc/jsonDesign.md) means a JsonParser holds its tape/string/double buffers across calls — the only allocation left after that is the JsonNode objects returned to you. Those go away too if you supply your own and let the parser (and the node itself) rebind them in place instead of returning new ones:
JsonParser parser = new JsonParser(); // allocated once, outside the loop
JsonNode root = new JsonNode(); // unbound "out-parameter" node, reused every parse
JsonNode field = new JsonNode(); // unbound out-parameter, reused every navigation step
while (moreMessages()) {
parser.parseInto(root, nextMessage()); // rebinds `root`; allocates nothing
if (root.atInto(field, "name")) { // rebinds `field`; allocates nothing
boolean isBob = field.stringEquals("bob"); // byte-for-byte compare, no String
byte[] name = field.asStringBytes(); // only allocate when you need to keep it
}
}
new JsonNode()with no arguments produces an unbound node — every accessor throws until something binds it. It exists only to be passed as theoutparameter to a*Intomethod.parseInto(JsonNode out, byte[] buf, ...)isparseInstance's non-allocating twin: it reuses the parser's internal buffers and rebindsoutinstead of returning a new node.firstChildInto,nextSiblingInto,nextInto, andatIntoare the non-allocating twins offirstChild,nextSibling,next, andat. Pass the node you're walking with as its own out-parameter to advance it in place (cursor.nextInto(cursor)), or a second reused node when you need to hold two positions at once.stringEquals(string)andstringEquals(byte[], off, len)compare a JSON string's bytes directly against a key or expected value — noStringallocation, unlikeasString(). Reach forasString()/asStringBytes()only for the values you actually need to keep past the current iteration.
atInto still walks the object linearly from the start on each call — it's a lookup, not a persistent index — but the walk itself, and the per-key comparison, allocate nothing.
3. Building — chained object/array construction
JsonBuilder writes JSON directly into a byte buffer as you call it — there is no intermediate tree you build up and then serialize.
import sj.json.JsonBuilder;
import sj.util.ByteArray;
JsonBuilder jb = new JsonBuilder(new ByteArray(256));
ByteArray doc = jb.build(jb.object()
.add("name", "SuperJ")
.add("stars", 5)
.add(jb.object("author")
.add("org", "SuperJ")
.add("active", true))
.add(jb.array("tags")
.add("json")
.add("zero-garbage")));
System.out.println(doc.toString());
// {"name":"SuperJ","stars":5,"author":{"org":"SuperJ","active":true},"tags":["json","zero-garbage"]}
jb.object()/jb.object(name)starts an object;jb.array()/jb.array(name)starts an array. Both return a builder whoseadd(...)overloads chain.- To close a nested object/array back into its parent, pass the nested builder itself to
add(...):.add(jb.object("author").add(...))— the outeraddcall closes the object started by the innerjb.object("author"). build(...)closes the root container and flips the outputByteArrayfor reading. Mismatched nesting throws atbuild()rather than silently producing malformed JSON.
4. Building — the zero-garbage pattern
Allocate the JsonBuilder and its backing ByteArray once, outside the loop. build() clears and re-flips the same buffer on every call — nothing new is allocated per message:
ByteArray out = new ByteArray(256);
JsonBuilder jb = new JsonBuilder(out); // once, outside the loop
while (moreMessages()) {
ByteArray msg = jb.build(jb.object().add("seq", i).add("name", names[i]));
send(msg); // fully consume before the next build() call
}
Two rules keep this safe:
- Never
new JsonBuilder(...)per message. The constructor allocates two 64KB scratch buffers (buf,vbuf) used internally for name/value staging, on top of the output buffer.JsonBuilder implements Reusablefor exactly this reason — construct once, callbuild()many times. - The returned
ByteArrayis the builder's own output buffer, only valid until the nextbuild()call on the same builder. Write it out (to a socket, a log,toString()it for a caller who copies it) before building the next message — don't hold onto it across iterations.
If you're assembling several JSON fragments into one larger buffer yourself (rather than building one document per build() call), construct with new JsonBuilder(out, false) — flipping = false skips the automatic flip so you control when the shared buffer gets read.
5. Summary
- One-shot API (
JsonParser.parse,firstChild/next, plainnew JsonBuilder(...)per call) is the right default for a single parse or build — simple, and allocation cost doesn't matter once. - On a hot path, switch to the reuse pattern: one
JsonParser/JsonBuilder(plus its outputByteArray) allocated outside the loop,JsonNodecursors created once as unbound out-parameters, and the*Intomethod family (parseInto,firstChildInto,nextSiblingInto,nextInto,atInto) in place of their allocating counterparts. - Compare JSON string values with
stringEquals(...)instead of materializing aStringwithasString()just to call.equals()on it.
See Memory Management Best Practices for the general allocation-ladder discipline this pattern is an instance of, and Coding Guidelines for the GC-free review checklist.