Home · ← SuperJ Manual EN|中文

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
    }
}

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"]}

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:

  1. 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 Reusable for exactly this reason — construct once, call build() many times.
  2. The returned ByteArray is the builder's own output buffer, only valid until the next build() 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

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.

SuperJ — manual · generated from json.md at pack time EN|中文