StdDateFormat's static initializer builds a SimpleDateFormat. That reaches
DecimalFormatSymbols, which calls String.charAt on the locale's per-mille sign "â°" --
a UTF-16 String.
String.charAt has a single process-wide MethodData. C2 uses the callee's MDO when it inlines,
with no per-call-site refinement, and prunes the isLatin1 branch only when the profiled UTF-16
count is exactly zero. That one call therefore leaves a cold StringUTF16.charAt call inside every
ASCII charAt loop compiled afterwards, which sets IdealLoopTree::_has_call and skips
iteration_split_impl -- so no unrolling and no range check elimination. One of the loops affected
is UTF8JsonGenerator._writeStringSegment, Jackson's own ASCII copy loop.
MapperBuilder's static initializer references StdDateFormat.instance, so simply building an
ObjectMapper is enough to trigger it.
Instrumenting String.charAt via --patch-module java.base to dump a stack trace on the UTF-16
branch shows exactly one such call in a whole run:
String.charAt("â°")
java.text.DecimalFormatSymbols.findNonFormatChar(:864)
java.text.DecimalFormatSymbols.<init> / NumberFormat.getIntegerInstance
java.text.SimpleDateFormat.<init>(:631)
tools.jackson.databind.util.StdDateFormat.<clinit>(:107)
tools.jackson.databind.cfg.MapperBuilder.<clinit>(:56)
JsonMapper.builder()
Suggested fix
DATE_FORMAT_RFC1123 is read only by parseAsRFC1123(), so moving it into a holder class defers
the SimpleDateFormat construction. With that, the instrumented count goes from 1 to 0 for
applications that never parse RFC1123 dates.
Measurements
JDK 25 / x86_64, one String property per object, 3 forks, 5x5 iterations, pinned:
|
before |
after |
| jackson 3.1.5 |
444.2 +/- 5.6 ns/op |
356.5 +/- 6.4 (-19.7%) |
| jackson 2.22.0 |
426.7 +/- 7.4 ns/op |
331.9 +/- 5.2 (-22.2%) |
The copy loop goes from 33 instructions with 13 stack operands per character, not unrolled, to
15.5 instructions with 2 stack operands, unrolled by 2.
A Quarkus REST endpoint serializing a list of beans gains 3.8% end to end (125 967 -> 130 768
req/s, 3 interleaved 30 s reps, ranges not overlapping).
Also worth noting: excluding writeString from inlining used to be worth ~22% on this benchmark.
After this change it is worth nothing -- the two variants are equal within error.
Caveat
This defers rather than removes the problem: an application that really does parse RFC1123 dates
still pollutes the profile, just later. The unconditional fix belongs in
java.text.DecimalFormatSymbols.findNonFormatChar, which should not use String.charAt; and
underneath that, one interpreter-era call arguably should not permanently de-optimize every
charAt loop in the process.
Reproducer
https://github.com/franz1981/c2-writestring-spill-jmh -- SingleBench.serialize vs
SingleBench.serializeWriteStringNotInlined. The README documents the mechanism, the perfasm
loop measurements and the before/after numbers. master is Jackson 2.22.0; the
jackson3-write-paths branch is the same benchmark on 3.1.5. The StdDateFormat static
initializer and the affected copy loop are identical in both lines.
StdDateFormat's static initializer builds aSimpleDateFormat. That reachesDecimalFormatSymbols, which callsString.charAton the locale's per-mille sign"â°"--a UTF-16 String.
String.charAthas a single process-wideMethodData. C2 uses the callee's MDO when it inlines,with no per-call-site refinement, and prunes the
isLatin1branch only when the profiled UTF-16count is exactly zero. That one call therefore leaves a cold
StringUTF16.charAtcall inside everyASCII
charAtloop compiled afterwards, which setsIdealLoopTree::_has_calland skipsiteration_split_impl-- so no unrolling and no range check elimination. One of the loops affectedis
UTF8JsonGenerator._writeStringSegment, Jackson's own ASCII copy loop.MapperBuilder's static initializer referencesStdDateFormat.instance, so simply building anObjectMapperis enough to trigger it.Instrumenting
String.charAtvia--patch-module java.baseto dump a stack trace on the UTF-16branch shows exactly one such call in a whole run:
Suggested fix
DATE_FORMAT_RFC1123is read only byparseAsRFC1123(), so moving it into a holder class defersthe
SimpleDateFormatconstruction. With that, the instrumented count goes from 1 to 0 forapplications that never parse RFC1123 dates.
Measurements
JDK 25 / x86_64, one String property per object, 3 forks, 5x5 iterations, pinned:
The copy loop goes from 33 instructions with 13 stack operands per character, not unrolled, to
15.5 instructions with 2 stack operands, unrolled by 2.
A Quarkus REST endpoint serializing a list of beans gains 3.8% end to end (125 967 -> 130 768
req/s, 3 interleaved 30 s reps, ranges not overlapping).
Also worth noting: excluding
writeStringfrom inlining used to be worth ~22% on this benchmark.After this change it is worth nothing -- the two variants are equal within error.
Caveat
This defers rather than removes the problem: an application that really does parse RFC1123 dates
still pollutes the profile, just later. The unconditional fix belongs in
java.text.DecimalFormatSymbols.findNonFormatChar, which should not useString.charAt; andunderneath that, one interpreter-era call arguably should not permanently de-optimize every
charAtloop in the process.Reproducer
https://github.com/franz1981/c2-writestring-spill-jmh --
SingleBench.serializevsSingleBench.serializeWriteStringNotInlined. The README documents the mechanism, theperfasmloop measurements and the before/after numbers.
masteris Jackson 2.22.0; thejackson3-write-pathsbranch is the same benchmark on 3.1.5. TheStdDateFormatstaticinitializer and the affected copy loop are identical in both lines.