Skip to content

Instantly share code, notes, and snippets.

@franz1981
Last active August 31, 2026 06:10
Show Gist options
  • Select an option

  • Save franz1981/6d303692cc24e430316f4c04c811c83a to your computer and use it in GitHub Desktop.

Select an option

Save franz1981/6d303692cc24e430316f4c04c811c83a to your computer and use it in GitHub Desktop.

Reflection-free Jackson in Quarkus REST: what we found, and what we propose

TL;DR

The shipped quarkus.rest.jackson.optimization.enable-reflection-free-serializers is slower than plain reflection on a REST endpoint that both deserializes and serializes JSON — mildly on the JVM, drastically in native image. We fixed every implementation defect we found and it still did not beat reflection, because the remaining cost comes from the shape of the generated code rather than from a bug in it. Two branches replace that shape with the Afterburner / Micronaut-Serde one and are ahead of reflection everywhere we measured.

Names used in this document

name what it is
released Quarkus 3.39.1 as shipped, enable-reflection-free-serializers=true
reflection the same application with the flag false (stock Jackson databind)
patched released + our fixes to the defects below, still one generated method per bean
accessor the proposed design (the two branches)

Throughput

One resource, one 9-property bean (6 strings, 1 int, 2 nested beans), two endpoints. Hyperfoil, 10 connections, application pinned to 2 cores, load generator on separate cores, 2 rounds per cell, every variant gated on returning byte-identical JSON before being timed.

mode endpoint released reflection accessor accessor vs reflection released vs reflection
JVM /echo-person — deser 1, ser 20 113.6k 119.0k 131.0k +10.1 % −4.5 %
JVM /echo-person/list — deser 20, ser 20 45.5k 79.9k 86.4k +8.1 % −43 %
native /echo-person — deser 1, ser 20 45.8k 51.0k 53.9k +5.6 % −10.2 %
native /echo-person/list — deser 20, ser 20 13.1k 36.0k 39.2k +8.9 % −64 %

req/s, mean of 2 rounds. The two endpoints differ only in how much deserialization they do: the first deserializes one object and serializes 20, the second does 20 both ways.

The second row is the honest one. The original fixture gave deserialization 1/20 of the weight, which hid the problem. Weighted equally, the shipped feature is 43 % slower than reflection on the JVM and 2.7× slower in native.

A 15-property variant of the bean (adds long, boolean, double, Integer, enum, List<String>) moves the serialization side further the wrong way — released 80.6k, reflection 88.8k, accessor 96.6k — so the problem grows with the number of properties rather than shrinking.

The defects we found

# side defect consequence
1 deser generated deserializer never overrides isCachable() every nested value misses DeserializerCache, takes the contended _incompleteDeserializersLock, and re-introspects the class on every request
2 deser tree-based: materialises a JsonNode, then re-reads each nested value via readTreeAsValue the document is effectively parsed twice
3 ser one generated serializeContent per bean writes every property inline C2 compiles it into a 20–50 KB nmethod holding a separate inlined copy of Jackson's string-copy loop per string property — see below
4 ser nested serializers looked up per call avoidable per-request lookup
5 ser property order differs from Jackson's own same content, different bytes — observable to clients

1 and 2 are what collapse the native numbers: with no JIT to amortise them, re-creating an uncachable deserializer per nested value dominates the request.

Why fixing the defects was not enough

We fixed 1, 2 and 4. Deserialization then beat reflection. Serialization still did not, and the assembly says why.

Jackson's ASCII copy loop in UTF8JsonGenerator._writeStringSegment is already optimally shaped — the fields are hoisted into locals by hand, so the loop touches only locals and two arrays. What changes is the compilation unit C2 inlines it into:

the same loop, compiled instructions per iteration stack operands per iteration
in its own nmethod (reflection, or with inlining disabled) 21 0
inlined into the big generated method 32 12

In the second case the loop counter is stored and reloaded twice within one iteration, and the loop invariants (output buffer, escape table, end index) are re-loaded from the stack on every iteration instead of staying in registers.

That is why removing reflection did not make it faster. Per operation, the generated shape executes fewer instructions than reflection but retires them at a much lower rate, so it ends up doing the same work per second:

instructions / op IPC cycles / op
reflection 94,648 5.11 18,509
released (one big method) 79,983 4.31 18,562
accessor shape (small per-property writers) 82,400 5.30 15,559

Verified three ways: disassembly of the application's own compiled methods; an isolated JMH benchmark that reproduces the spill and rules out the alternatives (String.charAt is inlined in both cases, and the generated code's extra live values are not the cause); and the same harness running the real decompiled Quarkus generated classes. Present on JDK 21 and JDK 25, Intel and AMD.

Why "just don't inline writeString" is not the answer

-XX:CompileCommand=dontinline,UTF8JsonGenerator::writeString restores the clean loop with no code change, and recovers most of the loss. It is still the wrong fix:

  • it is a global JVM flag — a framework cannot set it for applications, and it affects all Jackson use in the process, including paths where inlining that method is a win;
  • it only stops the feature being slower than reflection; it does not make it faster;
  • it does nothing in native image, which is where the feature is worst;
  • it treats a symptom of a code shape we control and can simply stop emitting.

We are separately preparing a minimal reproducer for the OpenJDK compiler team, since a 21-instruction loop that compiles cleanly on its own and spills its induction variable when inlined deeper is worth their attention independently of Quarkus.

What we propose

Stop generating serializers and deserializers. Generate the one thing databind cannot do without reflection — direct property access — and let Jackson drive everything else. This is the Afterburner and Micronaut-Serde shape.

One class is generated per application, holding a typed getter and setter per bean property, reached by (classId, propertyIndex). Two databind hooks swap only the reflective access: changeProperties replaces each plain BeanPropertyWriter, updateBuilder replaces each SettableBeanProperty. Anything the accessor does not cover — unwrapping, any-getters, custom serializers, views, merges, creators — keeps Jackson's own implementation, so behaviour is unchanged by construction and the fallbacks are databind's own. Output is byte-identical to reflection, including property order.

It is also a large net deletion: ~2,100 lines of generator that re-implemented databind (inclusion rules, naming strategies, views, unwrapping, date formats, self-references, any-getters) go away.

branch base diff module tests
perf/jackson-unified-property-accessor Quarkus main (Jackson 3) 25 files, +1,887 / −3,123 438 / 438
perf/jackson-unified-property-accessor-3.39.1 tag 3.39.1 (Jackson 2) 24 files, +2,075 / −3,049 433 / 433

Each is a single commit, and each commit message carries its own known-limitations section. Both add tests that assert the accessor is actually used (with verified negative controls), that @JsonSetter(nulls = Nulls.SKIP) and application-registered deserializers still work, and that a dev-mode live reload does not detach the beans from their accessor.

Caveats

  • Only the rest-jackson module test suites were run. The 63 integration-test modules that depend on this extension, and the native ITs, were not.
  • In the JVM /echo-person/list row, one of the two reflection rounds was disturbed (79.9k then 60.8k, against ≤2 % spread everywhere else); the table uses the clean round, which also matches an earlier independent run.
  • Everything is generated into one class, so there is a property-count ceiling; above it the accessor is not generated and Jackson keeps using reflection, with a warning. Sharding would remove the ceiling rather than degrade at it.
  • The classId and property-index dispatches are chains of integer comparisons, because Gizmo can only emit a switch over strings and enums. An int switch in Gizmo would make both constant-time.
@franz1981

franz1981 commented Aug 28, 2026 •

Copy link
Copy Markdown
Author

The benchmark used was https://github.com/mariofusco/quarkus-metaprogramming-advantage at mariofusco/quarkus-metaprogramming-advantage@8dfe992

modified in one case to inject more instances to deserialize (still the same type).

The 2 branches with the fix are:
https://github.com/franz1981/quarkus/tree/perf/jackson-unified-property-accessor-3.39.1
https://github.com/franz1981/quarkus/tree/perf/jackson-unified-property-accessor

On each commit msg you can find the known limitations which include the lack of a switch(int) in Gizmo.

@franz1981

franz1981 commented Aug 28, 2026 •

Copy link
Copy Markdown
Author

This is the reproducer I shared with our IBM OpenJDK team: https://github.com/franz1981/c2-writestring-spill-jmh

And this is a possible fix (openJDK only) https://github.com/franz1981/jackson-core/tree/perf/ascii-copy-loop-no-escape-table at least for the JMH reproducer.
Sadly this change, along with the others listed in the doc (still not pushed in a quarkus branch) won't help to get the performance as good as the accessor generated one, not to mention native-image which is immune to my cheap trick to speedup the jackson method - making such option brittle at best.
TLDR the OpenJDK reproducer stand as a valuable reproducer for a JIT inefficiency but is not enough to fix our quarkus issue.

The table of comparison is

┌─────────────────────────────────────┬───────────────────┬─────────┐
│            Quarkus side             │ jackson-core side │ single  │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ pristine 3.39.1, reflection-free ON │ stock 2.22.0      │ 113.02k │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ pristine 3.39.1, reflection-free ON │ + escape fix      │ 117.00k │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ reflection (reflection-free OFF)    │ stock 2.22.0      │ 119.45k │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ fixes branch 4c914eb508f            │ stock 2.22.0      │ 122.38k │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ fixes branch 4c914eb508f            │ + escape fix      │ 125.44k │
├─────────────────────────────────────┼───────────────────┼─────────┤
│ unified accessor                    │ stock 2.22.0      │ 131.19k │
└─────────────────────────────────────┴───────────────────┴─────────┘

whcih shows that the "escape fix" (jackson change) help moving the "fixed" reflection off code to the unified accessor, still not reaching it.
On native image instead, it doesn't deliver anything, as said.

@franz1981

franz1981 commented Aug 31, 2026 •

Copy link
Copy Markdown
Author

@mariofusco I've created other 2 versions of the reflection-free feature:

These implement the same feature with a per-bean approach instead.
perf-wise in the original benchmark, it perform within noise vs the unified accessor approach, but in the real world, with many bean type, there should change due to the mega-morphism of each call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment