Skip to main content
fast by design

JSON Parsing Performance: Jackson Configuration That Matters

8 min read Chapter 41 of 90

JSON Parsing Performance: Jackson Configuration That Matters

The main chapter showed that ObjectMapper reuse is the highest-impact Jackson optimization. This section covers the next tier: configuration options and parsing strategies that compound into measurable throughput improvements.

The Afterburner and Blackbird Modules

Jackson uses reflection to access object fields and invoke getters/setters. Reflection is slower than direct field access because the JVM must perform access checks and cannot inline reflective calls as aggressively. The Afterburner module replaces reflection with runtime bytecode generation using ASM. Blackbird is its successor, using java.lang.invoke.MethodHandle instead of ASM-generated bytecode.

<!-- Afterburner (Java 8-16, uses ASM bytecode generation) -->
<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-afterburner</artifactId>
</dependency>

<!-- Blackbird (Java 11+, uses MethodHandles, recommended) -->
<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-blackbird</artifactId>
</dependency>
// SLOW: Default reflection-based access
ObjectMapper defaultMapper = new ObjectMapper();

// FAST: Blackbird replaces reflection with MethodHandles
ObjectMapper blackbirdMapper = new ObjectMapper()
    .registerModule(new BlackbirdModule());
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Benchmark)
public class AccessModuleBenchmark {

    private byte[] articleJson;
    private ObjectMapper defaultMapper;
    private ObjectMapper afterburnerMapper;
    private ObjectMapper blackbirdMapper;

    @Setup(Level.Trial)
    public void setup() throws Exception {
        defaultMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule());
        afterburnerMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .registerModule(new AfterburnerModule());
        blackbirdMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .registerModule(new BlackbirdModule());

        articleJson = defaultMapper.writeValueAsBytes(createArticle());
    }

    @Benchmark
    public Article defaultAccess() throws Exception {
        return defaultMapper.readValue(articleJson, Article.class);
    }

    @Benchmark
    public Article afterburnerAccess() throws Exception {
        return afterburnerMapper.readValue(articleJson, Article.class);
    }

    @Benchmark
    public Article blackbirdAccess() throws Exception {
        return blackbirdMapper.readValue(articleJson, Article.class);
    }
}
ModuleDeserialize (us)Serialize (us)Overhead
Default (reflection)1.822.14Baseline
Afterburner (ASM)1.311.52+12 MB at startup
Blackbird (MethodHandles)1.351.58+2 MB at startup

Afterburner and Blackbird yield a 25-30% speedup for deserialization and 26-29% for serialization. Blackbird is slightly slower than Afterburner on raw throughput but uses less memory and avoids the ASM dependency, which matters when running on modular JDK (Java 17+) where ASM-based bytecode generation triggers warnings.

For the content platform, Blackbird on the article API endpoint at 15,000 req/s saves 7.6 ms of CPU per second. Small per-request improvement, meaningful at volume.

ObjectReader and ObjectWriter: The Pre-Compiled Path

ObjectMapper’s readValue() and writeValue() methods resolve the serializer/deserializer chain on every call. The resolution is cached, but the cache lookup itself has overhead. ObjectReader and ObjectWriter pre-resolve the chain:

// SLOW: Resolves serializer chain per call (cache lookup overhead)
Article article = mapper.readValue(json, Article.class);

// FAST: Pre-resolved, no cache lookup
private static final ObjectReader ARTICLE_READER =
    mapper.readerFor(Article.class);
private static final ObjectWriter ARTICLE_WRITER =
    mapper.writerFor(Article.class);

Article article = ARTICLE_READER.readValue(json);
byte[] bytes = ARTICLE_WRITER.writeValueAsBytes(article);
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Benchmark)
public class ReaderWriterBenchmark {

    private byte[] smallJson;  // 200 bytes
    private byte[] largeJson;  // 50 KB
    private ObjectMapper mapper;
    private ObjectReader reader;
    private ObjectWriter writer;

    @Param({"small", "large"})
    String payloadSize;

    @Setup(Level.Trial)
    public void setup() throws Exception {
        mapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .registerModule(new BlackbirdModule());
        reader = mapper.readerFor(Article.class);
        writer = mapper.writerFor(Article.class);

        smallJson = mapper.writeValueAsBytes(createSmallArticle());
        largeJson = mapper.writeValueAsBytes(createLargeArticle());
    }

    private byte[] currentJson() {
        return "small".equals(payloadSize) ? smallJson : largeJson;
    }

    @Benchmark
    public Article mapperRead() throws Exception {
        return mapper.readValue(currentJson(), Article.class);
    }

    @Benchmark
    public Article readerRead() throws Exception {
        return reader.readValue(currentJson());
    }
}
Payloadmapper.readValuereader.readValueSpeedup
200 bytes680 ns580 ns15%
50 KB18,200 ns17,800 ns2%

The ObjectReader advantage is proportionally larger for small payloads because the cache lookup overhead is a bigger fraction of total work. For large payloads, the parsing time dominates and the resolver overhead is noise. Use ObjectReader/ObjectWriter for high-frequency, small-payload endpoints. For large payloads, the benefit is negligible.

Streaming API Deep Dive

The main chapter introduced streaming vs data binding. Here, we build a production-grade streaming parser for the content platform’s article feed.

The feed endpoint returns a JSON array of articles. The API gateway needs to extract article IDs and scores for ranking, then fetch full articles selectively from cache. Parsing 50 full articles to extract 50 IDs is wasteful.

public class ArticleFeedStreamParser {

    private final JsonFactory jsonFactory;

    public ArticleFeedStreamParser(ObjectMapper mapper) {
        this.jsonFactory = mapper.getFactory();
    }

    /**
     * Extracts article summaries from a feed response using
     * streaming parsing. Skips body content entirely.
     */
    public List<ArticleSummary> extractSummaries(
            InputStream feedStream) throws IOException {

        List<ArticleSummary> summaries = new ArrayList<>(64);

        try (JsonParser parser = jsonFactory.createParser(feedStream)) {
            expectToken(parser, JsonToken.START_ARRAY);

            while (parser.nextToken() == JsonToken.START_OBJECT) {
                String id = null;
                String title = null;
                long viewCount = 0;
                int depth = 1;

                while (depth > 0) {
                    JsonToken token = parser.nextToken();
                    if (token == JsonToken.START_OBJECT
                            || token == JsonToken.START_ARRAY) {
                        if (depth == 1) {
                            parser.skipChildren();
                        } else {
                            depth++;
                        }
                        continue;
                    }
                    if (token == JsonToken.END_OBJECT
                            || token == JsonToken.END_ARRAY) {
                        depth--;
                        continue;
                    }
                    if (depth != 1 || token != JsonToken.FIELD_NAME) {
                        continue;
                    }

                    String field = parser.getCurrentName();
                    parser.nextToken();

                    switch (field) {
                        case "id" -> id = parser.getText();
                        case "title" -> title = parser.getText();
                        case "viewCount" -> viewCount = parser.getLongValue();
                        default -> parser.skipChildren();
                    }
                }

                if (id != null) {
                    summaries.add(new ArticleSummary(id, title, viewCount));
                }
            }
        }
        return summaries;
    }

    private void expectToken(
            JsonParser parser, JsonToken expected) throws IOException {
        JsonToken actual = parser.nextToken();
        if (actual != expected) {
            throw new IOException(
                "Expected " + expected + ", got " + actual);
        }
    }
}

Key implementation details:

Accept InputStream, not String or byte[]. The HTTP client provides a response body as a stream. Converting to String or byte[] first doubles memory usage and forces the entire payload into memory. Streaming parsing processes data as it arrives from the network.

Use skipChildren() aggressively. When the parser encounters a field you do not need, skipChildren() skips the entire subtree (nested objects, arrays) without creating tokens. For a 30 KB article body represented as a JSON string, this skips 30 KB of parsing work.

Pre-allocate the result list. new ArrayList<>(64) avoids 6 array copies that new ArrayList<>() would trigger when growing from 10 to 64 elements.

Custom Serializers: Eliminating Hot Path Allocation

Jackson’s default serialization creates intermediate JsonNode objects and performs type checks on every field. For a type serialized millions of times, a custom serializer eliminates this overhead:

// SLOW: Default Jackson serialization with reflection
// Jackson inspects every field, boxes primitives, checks annotations

// FAST: Custom serializer writes directly to generator
public class ArticleSummarySerializer
        extends StdSerializer<ArticleSummary> {

    public ArticleSummarySerializer() {
        super(ArticleSummary.class);
    }

    @Override
    public void serialize(ArticleSummary value, JsonGenerator gen,
            SerializerProvider provider) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("id", value.id());
        gen.writeStringField("title", value.title());
        gen.writeNumberField("viewCount", value.viewCount());
        gen.writeEndObject();
    }
}
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Benchmark)
public class CustomSerializerBenchmark {

    private ObjectMapper defaultMapper;
    private ObjectMapper customMapper;
    private ArticleSummary summary;

    @Setup(Level.Trial)
    public void setup() {
        defaultMapper = new ObjectMapper();

        SimpleModule module = new SimpleModule();
        module.addSerializer(ArticleSummary.class,
            new ArticleSummarySerializer());
        customMapper = new ObjectMapper().registerModule(module);

        summary = new ArticleSummary(
            "perf-101", "Performance Engineering", 45000L);
    }

    @Benchmark
    public byte[] defaultSerialize() throws Exception {
        return defaultMapper.writeValueAsBytes(summary);
    }

    @Benchmark
    public byte[] customSerialize() throws Exception {
        return customMapper.writeValueAsBytes(summary);
    }
}
SerializerAvg TimeAlloc/op
Default (reflection)420 ns312 bytes
Custom (direct write)195 ns128 bytes

Custom serializers are 2.2x faster and allocate 2.4x less memory. The benefit is highest for small, frequently serialized types where the reflection overhead is a large fraction of total work. For complex types with 20+ fields, the reflection overhead is amortized and custom serializers add maintenance cost without proportional benefit.

Jackson Feature Flags That Matter

Jackson has dozens of configuration options. Most are irrelevant to performance. These are the ones that produce measurable differences:

ObjectMapper mapper = new ObjectMapper()
    // Performance: skip features you do not need
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .disable(MapperFeature.AUTO_DETECT_GETTERS)
    .disable(MapperFeature.AUTO_DETECT_IS_GETTERS)

    // Performance: use byte[] I/O, not String
    .enable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)

    // Correctness: handle Java 8+ time types
    .registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)

    // Performance: Blackbird for MethodHandle access
    .registerModule(new BlackbirdModule());

FAIL_ON_UNKNOWN_PROPERTIES: false: The default (true) requires Jackson to track all consumed fields and check for leftovers. Disabling this skips the tracking. Measurable only at high throughput.

AUTO_DETECT_GETTERS: false: Prevents Jackson from scanning all methods via reflection during serializer construction. Requires explicit @JsonProperty annotations but makes serializer construction deterministic.

Putting It Together: The Content Platform Configuration

The content platform’s Jackson configuration for the article API:

@Configuration
public class JacksonConfig {

    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .registerModule(new BlackbirdModule())
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL);
    }

    @Bean
    public ObjectReader articleReader(ObjectMapper mapper) {
        return mapper.readerFor(Article.class);
    }

    @Bean
    public ObjectWriter articleWriter(ObjectMapper mapper) {
        return mapper.writerFor(Article.class);
    }

    @Bean
    public ObjectReader articleListReader(ObjectMapper mapper) {
        return mapper.readerFor(
            mapper.getTypeFactory()
                .constructCollectionType(List.class, Article.class));
    }

    @Bean
    public ArticleFeedStreamParser feedStreamParser(
            ObjectMapper mapper) {
        return new ArticleFeedStreamParser(mapper);
    }
}

The cumulative impact of these Jackson optimizations:

OptimizationPer-request SavingsAt 15,000 req/s
ObjectMapper reuse83 us1,245 ms/s
Blackbird module0.5 us7.5 ms/s
ObjectReader0.1 us1.5 ms/s
Streaming (large feeds)7.3 ms109,500 ms/s
Custom serializers0.2 us3.0 ms/s

ObjectMapper reuse is the 47x win. Streaming for large payloads is the next biggest. Everything else is incremental. Apply optimizations in this order: fix the catastrophic mistake first, then measure whether the incremental improvements justify their complexity.