Skip to content

Optimize substring copies in the Message.extractField hot path - #1340

Open
leonchen83 wants to merge 3 commits into
quickfix-j:masterfrom
leonchen83:master
Open

leonchen83 wants to merge 3 commits into
quickfix-j:masterfrom
leonchen83:master

Conversation

@leonchen83

@leonchen83 leonchen83 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

While profiling temporary object allocations with async-profiler, we found that
Message.extractField is one of the hottest allocation paths. Parsing a single
message copies the raw message data several times via String.substring,
String.getBytes and Arrays.copyOf:

  • one substring of the tag per field, for IntConverter.convert
  • one substring of the value per field, for every StringField
  • for data fields (RawData etc.), substring().getBytes() on every scan
    iteration
    to measure the encoded length, copying the whole span repeatedly
    (O(n²) for large payloads)
image

hedger1-alloc-20260930-151550.html

Changes

This PR optimizes the substring allocations inside the hot method
extractField.

Core idea: instead of copying the value eagerly, we keep the original String
plus the value's offset/length, and only perform the substring when the value
is actually needed.

  • StringField — keeps the raw message String together with offset and
    length, and only does the substring when getValue() is called, releasing
    the reference to the raw message afterwards. equals/hashCode compare and
    hash the value range directly, without triggering a substring.

  • All converters gained convert(String, offset, length) — type conversion
    reads the given range of the original String directly, so no substring is
    needed before converting. The typed getters of FieldMap
    (getInt/getDouble/getDecimal, ...) go through this path and are
    allocation-free.

  • Message.extractField — builds StringField instances from the value
    range instead of substringing every field. The length measurement for data
    fields (RawData etc.) no longer uses substring().getBytes() either; the
    encoded byte length is computed directly over the original String.

Compatibility

Field values, error messages and parse positions are byte-for-byte identical to
the previous implementation, including the null-value contract covered by
MessageTest/FieldMapTest. Non-String fields and unknown charsets fall back to
the original code paths.

Result

async-profiler allocation profile after this change — the substring,
String.getBytes and Arrays.copyOf frames in extractField are gone:

image

hedger1-alloc-20260930-153207.html

chenby added 3 commits October 10, 2026 18:34
Parsing allocated several Strings and arrays per field: a substring
for tag conversion, a substring for the field value, and - for raw
data fields - substring().getBytes() on every scan iteration to
measure the encoded length.

Fields are now parsed as a range (offset/length) of the raw message:

- IntConverter.convert(String, offset, length) parses tags without
  allocating; convert(String) delegates to it. Error messages are
  unchanged.
- The data field length scan uses the char count for string-equivalent
  charsets and a char-wise UTF-8 count otherwise, falling back to the
  original measurement for any other charset.
- StringField materializes the value lazily on first access and then
  collapses to the value, releasing the raw message. equals/hashCode/
  valueEquals work directly on the range using the String formulas.
FieldMap's typed getters (getBoolean/getChar/getChars/getInt/getDouble/
getDecimal) converted the materialized value String, paying a substring
copy per call. The converters now accept a (String, offset, length)
range and parse it in a single character scan:

- IntConverter, CharConverter, BooleanConverter, CharArrayConverter:
  pure character checks, zero allocation (CharArrayConverter also drops
  the per-call regex compilation and the split copies).
- DoubleConverter, DecimalConverter: fast path for plain numbers
  (single correctly-rounded division / BigDecimal.valueOf(unscaled,
  scale)); exponents, oversized values and invalid input fall back to
  the original implementation.

convert(String) delegates to the range-based implementation, so
results and error messages are unchanged. StringField exposes the
conversion as to* methods, and FieldMap dispatches to them for string
fields instead of calling getString().
hasValue() now returns true when the field carries a value.
@chrjohn

chrjohn commented Oct 10, 2026

Copy link
Copy Markdown
Member

@leonchen83 thanks for the PR. 👍
Could you please remove the unrelated commits? Thank you

@codecov

codecov Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.25225% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.19%. Comparing base (db43e4d) to head (75fcdd3).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
...kfixj-base/src/main/java/quickfix/StringField.java 54.09% 18 Missing and 10 partials ⚠️
...ava/quickfix/field/converter/DecimalConverter.java 43.90% 12 Missing and 11 partials ⚠️
quickfixj-base/src/main/java/quickfix/Message.java 24.00% 17 Missing and 2 partials ⚠️
...a/quickfix/field/converter/CharArrayConverter.java 50.00% 4 Missing and 5 partials ⚠️
...java/quickfix/field/converter/DoubleConverter.java 58.82% 3 Missing and 4 partials ⚠️
...n/java/quickfix/field/converter/CharConverter.java 33.33% 3 Missing and 3 partials ⚠️
...uickfixj-base/src/main/java/quickfix/FieldMap.java 70.58% 5 Missing ⚠️
...ava/quickfix/field/converter/BooleanConverter.java 50.00% 2 Missing and 3 partials ⚠️
...in/java/quickfix/field/converter/IntConverter.java 83.33% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1340      +/-   ##
============================================
- Coverage     70.80%   70.19%   -0.61%     
- Complexity     2271     2306      +35     
============================================
  Files           161      161              
  Lines          9077     9241     +164     
  Branches       1192     1239      +47     
============================================
+ Hits           6427     6487      +60     
- Misses         2197     2265      +68     
- Partials        453      489      +36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@leonchen83

Copy link
Copy Markdown
Contributor Author

Already done — I force-pushed the branch and the PR now contains only the 3
relevant commits. Please refresh and take another look. Let me know if you'd
prefer them squashed into a single commit.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants