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.
| 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) |
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.
| # | 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.
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.
-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.
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.
- Only the
rest-jacksonmodule 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/listrow, 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.
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.