From d950b0b16adfc63eebbaf0b91bbe1523e7cbc6d8 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts Date: Fri, 18 Sep 2026 16:23:13 +0200 Subject: [PATCH] perf(java): copy plain runs between escapes when decoding json strings Once a string contains its first escape, all three json readers decoded the rest of it one character at a time and never returned to the word scanner, so a string cost time proportional to the distance from its first escape to its end. The scanner that found the escape now finds the next stop character and the plain run between them is copied in one pass. clear() also replaced any decode buffer above the retained size with a fresh 8 KiB array after every parse, so a pooled reader re-grew it on every document whose string was larger. The readers now keep what the last document needed, bounded by a ceiling, and release it once a later document does not need it. Closes #4055 Co-Authored-By: Claude Opus 5 --- .../fory/json/reader/Latin1JsonReader.java | 55 ++++++++- .../fory/json/reader/Utf16JsonReader.java | 62 +++++++++- .../fory/json/reader/Utf8JsonReader.java | 56 ++++++++- .../org/apache/fory/json/JsonStringTest.java | 115 +++++++++++++++++- 4 files changed, 277 insertions(+), 11 deletions(-) diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java index 3bebb2c895..b26d420e62 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java @@ -53,7 +53,13 @@ public final class Latin1JsonReader extends JsonReader { private static final byte[] EMPTY_BYTES = new byte[0]; private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final int INT_BYTE_ONES = 0x01010101; @@ -80,6 +86,13 @@ public final class Latin1JsonReader extends JsonReader { // Latin1 string content and field-name hashing must keep unsigned byte conversion. private byte[] input; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -275,6 +288,7 @@ protected CharSequence decodeQuotedText(int start, int end) { outBytes = ensureStringDecodeCapacity(outBytes, out + 2); out = putUtf16Char(outBytes, out, ch); } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -396,8 +410,26 @@ public void clear() { reset(); input = EMPTY_BYTES; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -2759,6 +2791,24 @@ private String readStringLatin1Tail(byte[] bytes, int out, int ch) { bytes = ensureStringDecodeCapacity(bytes, out + 1); bytes[out++] = (byte) ch; } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + int runStart = position; + int wordEnd = input.length - Long.BYTES; + while (position <= wordEnd) { + long stopMask = stringStopMask(LittleEndian.getInt64(input, position)); + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 3; + break; + } + position += Long.BYTES; + } + int run = position - runStart; + if (run > 0) { + bytes = ensureStringDecodeCapacity(bytes, out + run); + System.arraycopy(input, runStart, bytes, out, run); + out += run; + } if (position >= input.length) { throw error("Unterminated string"); } @@ -2835,6 +2885,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] bytes, int length, boolean utf16) { + recordStringDecodeUse(length); // The decode buffer is reader-owned reusable storage; returned Strings must own exact bytes. byte[] result = new byte[length]; System.arraycopy(bytes, 0, result, 0, length); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java index 1827b3ef9a..d882826291 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java @@ -50,7 +50,13 @@ */ public final class Utf16JsonReader extends JsonReader { private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final long BYTE_HIGH_BITS = 0x8080808080808080L; @@ -76,6 +82,13 @@ public final class Utf16JsonReader extends JsonReader { private byte[] bytes; private int length; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -271,6 +284,7 @@ protected CharSequence decodeQuotedText(int start, int end) { outBytes = ensureStringDecodeCapacity(outBytes, out + 2); out = putUtf16Char(outBytes, out, ch); } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -417,8 +431,26 @@ public void clear() { bytes = null; length = 0; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -2111,6 +2143,31 @@ private String readStringLatin1Tail(byte[] outBytes, int out, int ch) { out = putUtf16Char(outBytes, out, (char) ch); return readStringUtf16Tail(outBytes, out, nextStringChar()); } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + if (LITTLE_ENDIAN && bytes != null) { + int runStart = position; + int wordEnd = length - 4; + while (position <= wordEnd) { + long word = LittleEndian.getInt64(bytes, position << 1); + long nonLatin = word & UTF16_NON_LATIN_BYTES; + long stopMask = utf16StringStopMask(word, nonLatin) | nonLatin; + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 4; + break; + } + position += 4; + } + int run = position - runStart; + if (run > 0) { + outBytes = ensureStringDecodeCapacity(outBytes, out + run); + byte[] localBytes = bytes; + for (int i = 0, offset = runStart << 1; i < run; i++, offset += 2) { + outBytes[out + i] = localBytes[offset]; + } + out += run; + } + } ch = nextStringChar(); } } @@ -2208,6 +2265,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] outBytes, int length, boolean utf16) { + recordStringDecodeUse(length); // The decode buffer is reader-owned reusable storage; returned Strings must own exact bytes. byte[] result = new byte[length]; System.arraycopy(outBytes, 0, result, 0, length); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java index f9672cd72e..2582c96b3c 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java @@ -80,7 +80,13 @@ public final class Utf8JsonReader extends JsonReader { 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000 }; private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final int INT_BYTE_ONES = 0x01010101; @@ -126,6 +132,13 @@ public final class Utf8JsonReader extends JsonReader { private byte[] input; private int inputLimit; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -379,6 +392,7 @@ protected CharSequence decodeQuotedText(int start, int end) { out = putUtf16Char(outBytes, out, Character.lowSurrogate(codePoint)); } } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -540,8 +554,26 @@ public void clear() { input = EMPTY_BYTES; inputLimit = 0; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -4417,6 +4449,25 @@ private String readStringLatin1Tail(byte[] bytes, int out, int b) { return readStringUtf16Tail(bytes, out); } } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + int runStart = position; + int wordEnd = inputLimit - Long.BYTES; + while (position <= wordEnd) { + long word = LittleEndian.getInt64(input, position); + long stopMask = stringStopMask(word); + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 3; + break; + } + position += Long.BYTES; + } + int run = position - runStart; + if (run > 0) { + bytes = ensureStringDecodeCapacity(bytes, out + run); + System.arraycopy(input, runStart, bytes, out, run); + out += run; + } if (position >= inputLimit) { throw error("Unterminated string"); } @@ -4687,6 +4738,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] bytes, int length, boolean utf16) { + recordStringDecodeUse(length); // Strings must not share the reader-owned decode buffer; the buffer is reused by later reads. byte[] result = new byte[length]; System.arraycopy(bytes, 0, result, 0, length); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java index b848a9d4ed..aab34999cd 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java @@ -573,26 +573,131 @@ public void readStringInputEscapes(boolean codegen) { assertThrows(ForyJsonException.class, () -> json.fromJson("\"\\uD83D\"", String.class)); } + /** + * A buffer the recent documents needed is kept, so a stream of large strings stops re-growing it, + * and it is released once a later document no longer needs it. + */ @Test - public void readerDecodeBufferShrinks() throws Exception { + public void readerDecodeBufferIsKeptWhileNeeded() throws Exception { String latin1Input = "\"" + repeat('a', 9000) + "\\n\""; + String smallInput = "\"" + repeat('a', 8) + "\\n\""; if (StringSerializer.isBytesBackedString() && StringSerializer.isLatin1Coder(StringSerializer.getStringCoder(latin1Input))) { Latin1JsonReader latin1Reader = newLatin1Reader(latin1Input); assertEquals(latin1Reader.readString(), repeat('a', 9000) + "\n"); - assertTrue(readerBufferLength(latin1Reader) > 8192); + int grown = readerBufferLength(latin1Reader); + assertTrue(grown > 8192); + latin1Reader.clear(); + assertEquals(readerBufferLength(latin1Reader), grown); + latin1Reader.reset(latin1Input); + assertEquals(latin1Reader.readString(), repeat('a', 9000) + "\n"); + assertEquals(readerBufferLength(latin1Reader), grown); + latin1Reader.clear(); + latin1Reader.reset(smallInput); + assertEquals(latin1Reader.readString(), repeat('a', 8) + "\n"); latin1Reader.clear(); assertEquals(readerBufferLength(latin1Reader), 8192); } - String utf16Input = "\"中文" + repeat('b', 9000) + "\\n\""; + // A buffer no document should pin: it is released whatever the shrink threshold says. + String hugeInput = "\"\\n" + repeat('a', 200000) + "\""; + Latin1JsonReader hugeReader = newLatin1Reader(hugeInput.getBytes(StandardCharsets.ISO_8859_1)); + assertEquals(hugeReader.readString(), "\n" + repeat('a', 200000)); + int hugeGrown = readerBufferLength(hugeReader); + assertTrue(hugeGrown > 131072 && hugeGrown < 262144); + hugeReader.clear(); + assertTrue(readerBufferLength(hugeReader) <= 131072); + + // The quoted text view shares the same buffer, so growing it there has to count the same way. + Utf8JsonReader quotedReader = newUtf8Reader(latin1Input.getBytes(StandardCharsets.UTF_8)); + assertEquals(quotedReader.readQuotedText().toString(), repeat('a', 9000) + "\n"); + int quotedGrown = readerBufferLength(quotedReader); + assertTrue(quotedGrown > 8192); + quotedReader.clear(); + assertEquals(readerBufferLength(quotedReader), quotedGrown); + + Utf8JsonReader utf8Reader = newUtf8Reader(latin1Input.getBytes(StandardCharsets.UTF_8)); + assertEquals(utf8Reader.readString(), repeat('a', 9000) + "\n"); + int utf8Grown = readerBufferLength(utf8Reader); + assertTrue(utf8Grown > 8192); + utf8Reader.clear(); + assertEquals(readerBufferLength(utf8Reader), utf8Grown); + utf8Reader.reset(smallInput.getBytes(StandardCharsets.UTF_8)); + assertEquals(utf8Reader.readString(), repeat('a', 8) + "\n"); + utf8Reader.clear(); + assertEquals(readerBufferLength(utf8Reader), 8192); + + String utf16Input = "\"\u4e2d\u6587" + repeat('b', 9000) + "\\n\""; Utf16JsonReader utf16Reader = newUtf16Reader(utf16Input); - assertEquals(utf16Reader.readString(), "中文" + repeat('b', 9000) + "\n"); - assertTrue(readerBufferLength(utf16Reader) > 8192); + assertEquals(utf16Reader.readString(), "\u4e2d\u6587" + repeat('b', 9000) + "\n"); + int grown = readerBufferLength(utf16Reader); + assertTrue(grown > 8192); + utf16Reader.clear(); + assertEquals(readerBufferLength(utf16Reader), grown); + String smallUtf16 = "\"\u4e2d" + repeat('b', 8) + "\\n\""; + byte[] smallUtf16Bytes = new byte[smallUtf16.length() << 1]; + StringSerializer.copyStringCharsToBytes(smallUtf16, smallUtf16Bytes); + utf16Reader.reset(smallUtf16, smallUtf16Bytes); + assertEquals(utf16Reader.readString(), "\u4e2d" + repeat('b', 8) + "\n"); utf16Reader.clear(); assertEquals(readerBufferLength(utf16Reader), 8192); } + /** + * The run scan sits between the escape handling and these checks, so both must still fire after + * one. + */ + @Test + public void rejectMalformedInputAfterAnEscape() { + ForyJson json = newJson(true); + String plain = repeat('a', 24); + // The bad character is followed by more plain text so it falls inside a scanned word rather + // than + // the scalar remainder the scan leaves at the end of the buffer. + for (String document : + new String[] { + "\"x\\n" + plain, + "\"x\\n" + plain + "\u0001" + plain + "\"", + "\"x\\n" + plain + "\\x" + plain + "\"", + "\"x\\n" + plain + "\\uD83D" + plain + "\"", + "\"x\\n" + plain + "\u0001\"", + "\"x\\n" + plain + "\\x\"" + }) { + assertThrows(ForyJsonException.class, () -> json.fromJson(document, String.class)); + assertThrows(ForyJsonException.class, () -> readUtf8String(json, document)); + } + } + + /** + * Plain text after an escape is copied in bulk, and that copy has to stop wherever the + * character-at-a-time path would have handled the character itself - a latin1 byte above 0x7F, + * text outside latin1, and a surrogate pair. + */ + @Test + public void readEscapeFollowedByNonAsciiText() { + ForyJson json = newJson(true); + String[] tails = { + "a", + "\u00e9", + "\u00ff", + "\u4e2d\u6587", + "\uD83D\uDE00", + "a\u00e9\u4e2d\u6587b", + "\u00e9" + repeat('c', 40) + }; + for (String tail : tails) { + for (int lead = 0; lead <= 20; lead++) { + for (int gap = 0; gap <= 8; gap++) { + String value = repeat('a', lead) + "\n" + repeat('c', gap) + tail + repeat('b', 7); + String document = + "\"" + repeat('a', lead) + "\\n" + repeat('c', gap) + tail + repeat('b', 7) + "\""; + assertEquals(json.fromJson(document, String.class), value); + assertEquals(readUtf8String(json, document), value); + } + } + } + } + @Test(dataProvider = "enableCodegen") public void readUnicodeFieldNames(boolean codegen) { ForyJson json = newJson(codegen);