This is an automated email from the ASF dual-hosted git repository.
benweidig pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
The following commit(s) were added to refs/heads/master by this push:
new bb9b096aa TAP5-2837: Improve JSON serialization performance (#67)
bb9b096aa is described below
commit bb9b096aa87a59a7961beaa5f2dcd597e4fada60
Author: Ben Weidig <[email protected]>
AuthorDate: Sat Oct 10 12:31:27 2026 +0200
TAP5-2837: Improve JSON serialization performance (#67)
---
.../org/apache/tapestry5/json/CompactSession.java | 23 ++-
.../org/apache/tapestry5/json/JSONCollection.java | 36 ++--
.../java/org/apache/tapestry5/json/JSONObject.java | 200 ++++++++++++++++++---
.../org/apache/tapestry5/json/JSONPrintSink.java | 92 ++++++++++
.../org/apache/tapestry5/json/JSONStringer.java | 3 +
.../org/apache/tapestry5/json/JSONTokener.java | 4 +-
.../apache/tapestry5/json/PrettyPrintSession.java | 41 +++--
.../org/apache/tapestry5/json/package-info.java | 4 +-
.../org/apache/tapestry5/json/JSONObjectTest.java | 15 +-
9 files changed, 355 insertions(+), 63 deletions(-)
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
index 8eced6925..55127a23a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -18,16 +18,21 @@ import java.io.PrintWriter;
/**
* Prints the JSON content compactly, with no indentation or extra whitespace.
- *
+ *
* @since 5.2.0
*/
class CompactSession implements JSONPrintSession
{
- private final PrintWriter writer;
+ private final JSONPrintSink out;
+
+ CompactSession(StringBuilder out)
+ {
+ this.out = new StringBuilderPrintSink(out);
+ }
public CompactSession(PrintWriter writer)
{
- this.writer = writer;
+ this.out = new WriterPrintSink(writer);
}
@Override
@@ -51,7 +56,7 @@ class CompactSession implements JSONPrintSession
@Override
public JSONPrintSession print(String value)
{
- writer.print(value);
+ out.append(value);
return this;
}
@@ -59,13 +64,17 @@ class CompactSession implements JSONPrintSession
@Override
public JSONPrintSession printQuoted(String value)
{
- return print(JSONObject.quote(value));
+ out.append('"');
+ JSONObject.escapeInto(out, value);
+ out.append('"');
+
+ return this;
}
@Override
public JSONPrintSession printSymbol(char symbol)
{
- writer.print(symbol);
+ out.append(symbol);
return this;
}
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
index c5ee1082c..0615bc47a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
@@ -12,7 +12,6 @@
package org.apache.tapestry5.json;
-import java.io.CharArrayWriter;
import java.io.PrintWriter;
import java.io.Serializable;
@@ -38,16 +37,13 @@ public abstract class JSONCollection implements Serializable
@Override
public String toString()
{
- CharArrayWriter caw = new CharArrayWriter();
- PrintWriter pw = new PrintWriter(caw);
+ StringBuilder out = new StringBuilder(estimateCapacity());
- JSONPrintSession session = new PrettyPrintSession(pw);
+ JSONPrintSession session = new PrettyPrintSession(out);
print(session);
- pw.close();
-
- return caw.toString();
+ return out.toString();
}
/**
@@ -68,16 +64,32 @@ public abstract class JSONCollection implements Serializable
*/
public String toCompactString()
{
- CharArrayWriter caw = new CharArrayWriter();
- PrintWriter pw = new PrintWriter(caw);
+ StringBuilder out = new StringBuilder(estimateCapacity());
- print(pw);
+ JSONPrintSession session = new CompactSession(out);
- pw.close();
+ print(session);
- return caw.toString();
+ return out.toString();
+ }
+
+ /**
+ * A cheap starting capacity for the {@link StringBuilder} backing {@link
#toString()} /
+ * {@link #toCompactString()}, to avoid repeated grow-and-copy cycles for
non-trivial trees.
+ * Scales with the top-level collection's element count only; recursing
into nested
+ * collections isn't worth the extra bookkeeping for a rough estimate.
+ */
+ private int estimateCapacity()
+ {
+ return Math.max(256, size() * 32);
}
+ /**
+ * The number of top-level entries (name/value pairs for {@link
JSONObject}, elements for
+ * {@link JSONArray}), used only to estimate a starting buffer capacity.
+ */
+ abstract int size();
+
/**
* Prints the JSONObject to the write (compactly or not).
*
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
index 321085ed5..b7f034549 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
@@ -775,14 +775,154 @@ public final class JSONObject extends JSONCollection
implements Map<String, Obje
if (data == null) {
return "\"\"";
}
- try {
- JSONStringer stringer = new JSONStringer();
- stringer.open(JSONStringer.Scope.NULL, "");
- stringer.string(data);
- stringer.close(JSONStringer.Scope.NULL, JSONStringer.Scope.NULL,
"");
- return stringer.toString();
- } catch (RuntimeException e) {
- throw new AssertionError();
+
+ StringBuilder out = new StringBuilder(data.length() + 8);
+ out.append('"');
+ escapeInto(out, data);
+ out.append('"');
+ return out.toString();
+ }
+
+ private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
+
+ /**
+ * Appends the escaped (but not quoted) form of {@code value} to {@code
out}: the same
+ * character escaping rules as {@link #quote(String)}, without allocating
an intermediate
+ * String or wrapping quotes.
+ *
+ * <p>This is the direct-to-{@link StringBuilder} fast path used by {@code
quote()} itself,
+ * which always targets a StringBuilder and is called often enough (once
per printed string/key)
+ * that the {@link JSONPrintSink} indirection used by {@link
#escapeInto(JSONPrintSink, String)}
+ * measurably costs more than it's worth here. See {@link
#escapeInto(JSONPrintSink, String)}
+ * for the print-session variant.
+ */
+ static void escapeInto(StringBuilder out, String value) {
+ char currentChar = 0;
+
+ for (int i = 0, length = value.length(); i < length; i++) {
+ char previousChar = currentChar;
+ currentChar = value.charAt(i);
+
+ /*
+ * From RFC 4627, "All Unicode characters may be placed within the
+ * quotation marks except for the characters that must be escaped:
+ * quotation mark, reverse solidus, and the control characters
+ * (U+0000 through U+001F)."
+ */
+ switch (currentChar) {
+ case '"':
+ case '\\':
+ out.append('\\').append(currentChar);
+ break;
+
+ case '/':
+ // it makes life easier for HTML embedding of javascript
if we escape </ sequences
+ if (previousChar == '<') {
+ out.append('\\');
+ }
+ out.append(currentChar);
+ break;
+
+ case '\t':
+ out.append("\\t");
+ break;
+
+ case '\b':
+ out.append("\\b");
+ break;
+
+ case '\n':
+ out.append("\\n");
+ break;
+
+ case '\r':
+ out.append("\\r");
+ break;
+
+ case '\f':
+ out.append("\\f");
+ break;
+
+ default:
+ if (currentChar <= 0x1F || (currentChar >= 0x0080 &&
currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+ out.append('\\').append('u')
+ .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+ .append(HEX_DIGITS[currentChar & 0xF]);
+ } else {
+ out.append(currentChar);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Same escaping rules as {@link #escapeInto(StringBuilder, String)},
targeting a
+ * {@link JSONPrintSink} instead.
+ *
+ * <p>Used by the print sessions, whose destination varies between an
in-memory
+ * {@link StringBuilder} ({@code toString()}/{@code toCompactString()})
and a
+ * caller-supplied {@link java.io.PrintWriter} ({@code print(PrintWriter,
boolean)}),
+ * so they can escape directly into whichever one they were constructed
with.
+ *
+ * <p>Note: Having dedicated methods for the types instead of a
classifying mechanism
+ * keeps the call site monomorphic for the JVM, allowing for greater
optimization
+ * potential.
+ */
+ static void escapeInto(JSONPrintSink out, String value) {
+ char currentChar = 0;
+
+ for (int i = 0, length = value.length(); i < length; i++) {
+ char previousChar = currentChar;
+ currentChar = value.charAt(i);
+
+ switch (currentChar) {
+ case '"':
+ case '\\':
+ out.append('\\').append(currentChar);
+ break;
+
+ case '/':
+ if (previousChar == '<') {
+ out.append('\\');
+ }
+ out.append(currentChar);
+ break;
+
+ case '\t':
+ out.append("\\t");
+ break;
+
+ case '\b':
+ out.append("\\b");
+ break;
+
+ case '\n':
+ out.append("\\n");
+ break;
+
+ case '\r':
+ out.append("\\r");
+ break;
+
+ case '\f':
+ out.append("\\f");
+ break;
+
+ default:
+ if (currentChar <= 0x1F || (currentChar >= 0x0080 &&
currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+ out.append('\\').append('u')
+ .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+ .append(HEX_DIGITS[currentChar & 0xF]);
+ } else {
+ out.append(currentChar);
+ }
+ break;
+ }
}
}
@@ -826,7 +966,6 @@ public final class JSONObject extends JSONCollection
implements Map<String, Obje
session.printSymbol('}');
}
-
/**
* Prints a value (a JSONArray or JSONObject, or a value stored in an
array or object) using
* the session.
@@ -835,48 +974,61 @@ public final class JSONObject extends JSONCollection
implements Map<String, Obje
*/
static void printValue(JSONPrintSession session, Object value)
{
+ // Orderd by (assumed) most common frequency in JSON
+ // Null Check: Must be first.
if (value == null || value == NULL)
{
session.print("null");
return;
}
- if (value instanceof JSONObject)
- {
- ((JSONObject) value).print(session);
- return;
- }
- if (value instanceof JSONArray)
+ // String: Explicitly checking here prevents the most common
+ // JSON type from falling through all other checks.
+ if (value instanceof String)
{
- ((JSONArray) value).print(session);
+ session.printQuoted((String) value);
return;
}
- if (value instanceof JSONString)
+ // Number: Highly common in JSON payloads.
+ if (value instanceof Number)
{
- String printValue = ((JSONString) value).toJSONString();
-
+ String printValue = numberToString((Number) value);
session.print(printValue);
-
return;
}
- if (value instanceof Number)
+ // JSONObject: The primary structural container.
+ if (value instanceof JSONObject)
{
- String printValue = numberToString((Number) value);
- session.print(printValue);
+ ((JSONObject) value).print(session);
return;
}
+ // Boolean: Common, but usually fewer instances per payload than
numbers.
if (value instanceof Boolean)
{
session.print(value.toString());
+ return;
+ }
+ // JSONArray: Generally fewer arrays than objects in nested structures.
+ if (value instanceof JSONArray)
+ {
+ ((JSONArray) value).print(session);
+ return;
+ }
+
+ // JSONString: Kept near the bottom assuming it is a custom wrapper.
+ if (value instanceof JSONString)
+ {
+ String printValue = ((JSONString) value).toJSONString();
+ session.print(printValue);
return;
}
- // Otherwise it really should just be a string. Nothing else can go in.
+ // Fallback for any other unexpected objects
session.printQuoted(value.toString());
}
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
new file mode 100644
index 000000000..ccd843af9
--- /dev/null
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.json;
+
+import java.io.PrintWriter;
+
+/**
+ * The destination a {@link JSONPrintSession} (and {@link
JSONObject#escapeInto}) writes
+ * characters to: either an in-memory buffer (for {@code toString()}/{@code
toCompactString()})
+ * or a caller-supplied {@link PrintWriter} (for {@code print(PrintWriter,
boolean)}).
+ *
+ * <p>Deliberately narrower than {@link Appendable}: neither implementation
below can actually throw
+ * {@link java.io.IOException}, so this avoids forcing checked-exception
handling onto every
+ * append call in the hot print path.
+ */
+interface JSONPrintSink
+{
+ JSONPrintSink append(String value);
+
+ JSONPrintSink append(char value);
+}
+
+/**
+ * Sink backed by an in-memory {@link StringBuilder}, used to build a {@code
String} directly
+ * without an intermediate {@code CharArrayWriter}/{@code PrintWriter} pair.
+ */
+class StringBuilderPrintSink implements JSONPrintSink
+{
+ private final StringBuilder out;
+
+ StringBuilderPrintSink(StringBuilder out)
+ {
+ this.out = out;
+ }
+
+ @Override
+ public JSONPrintSink append(String value)
+ {
+ out.append(value);
+
+ return this;
+ }
+
+ @Override
+ public JSONPrintSink append(char value)
+ {
+ out.append(value);
+
+ return this;
+ }
+}
+
+/**
+ * Sink backed by a caller-supplied {@link PrintWriter}, used by {@code
print(PrintWriter, boolean)}.
+ */
+class WriterPrintSink implements JSONPrintSink
+{
+ private final PrintWriter writer;
+
+ WriterPrintSink(PrintWriter writer)
+ {
+ this.writer = writer;
+ }
+
+ @Override
+ public JSONPrintSink append(String value)
+ {
+ writer.print(value);
+
+ return this;
+ }
+
+ @Override
+ public JSONPrintSink append(char value)
+ {
+ writer.write(value);
+
+ return this;
+ }
+}
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
index 861263574..d49b6c3a1 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
@@ -57,7 +57,10 @@ import java.util.List;
* self-use by overrideable methods is not specified. See <i>Effective Java</i>
* Item 17, "Design and Document or inheritance or else prohibit it" for
further
* information.
+ *
+ * @deprecated in 5.10.0 without a replacement.
*/
+@Deprecated
class JSONStringer {
/**
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
index e0c4dac3c..225c8c26c 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
@@ -476,14 +476,14 @@ class JSONTokener {
case ',':
case ';':
/* A separator without a value first means "null". */
- result.put(JSONObject.NULL);
+ result.add(JSONObject.NULL);
hasTrailingSeparator = true;
continue;
default:
pos--;
}
- result.put(nextValue(null));
+ result.add(nextValue(null));
switch (nextCleanInternal()) {
case ']':
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
index 8f0c5f31b..729f0c39b 100644
---
a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
+++
b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -18,12 +18,12 @@ import java.io.PrintWriter;
/**
* Used to pretty-print JSON content, with a customizable indentation.
- *
+ *
* @since 5.2.0
*/
class PrettyPrintSession implements JSONPrintSession
{
- private final PrintWriter writer;
+ private final JSONPrintSink out;
private final String indentString;
@@ -36,10 +36,16 @@ class PrettyPrintSession implements JSONPrintSession
private Position position = Position.MARGIN;
+ /** Defaults the indentation to be two spaces per indentation level. */
+ PrettyPrintSession(StringBuilder out)
+ {
+ this(new StringBuilderPrintSink(out), " ");
+ }
+
/** Defaults the indentation to be two spaces per indentation level. */
public PrettyPrintSession(PrintWriter writer)
{
- this(writer, " ");
+ this(new WriterPrintSink(writer), " ");
}
/**
@@ -50,7 +56,12 @@ class PrettyPrintSession implements JSONPrintSession
*/
public PrettyPrintSession(PrintWriter writer, String indentString)
{
- this.writer = writer;
+ this(new WriterPrintSink(writer), indentString);
+ }
+
+ private PrettyPrintSession(JSONPrintSink out, String indentString)
+ {
+ this.out = out;
this.indentString = indentString;
}
@@ -67,7 +78,7 @@ class PrettyPrintSession implements JSONPrintSession
{
if (position != Position.MARGIN)
{
- writer.write('\n');
+ out.append('\n');
position = Position.MARGIN;
}
@@ -87,7 +98,7 @@ class PrettyPrintSession implements JSONPrintSession
if (position == Position.MARGIN)
{
for (int i = 0; i < indentLevel; i++)
- writer.print(indentString);
+ out.append(indentString);
position = Position.INDENTED;
}
@@ -97,7 +108,7 @@ class PrettyPrintSession implements JSONPrintSession
{
if (position == Position.CONTENT)
{
- writer.print(' ');
+ out.append(' ');
}
}
@@ -113,7 +124,7 @@ class PrettyPrintSession implements JSONPrintSession
{
prepareToPrint();
- writer.print(value);
+ out.append(value);
position = Position.CONTENT;
@@ -123,7 +134,15 @@ class PrettyPrintSession implements JSONPrintSession
@Override
public JSONPrintSession printQuoted(String value)
{
- return print(JSONObject.quote(value));
+ prepareToPrint();
+
+ out.append('"');
+ JSONObject.escapeInto(out, value);
+ out.append('"');
+
+ position = Position.CONTENT;
+
+ return this;
}
@Override
@@ -134,7 +153,7 @@ class PrettyPrintSession implements JSONPrintSession
if (symbol != ',')
addSep();
- writer.print(symbol);
+ out.append(symbol);
return this;
}
diff --git
a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
index 464082c05..2d5a6715a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
@@ -13,7 +13,7 @@
/**
* Repackaged, improved (and tested) version of code originally
https://github.com/tdunning/open-json
*
- * Starting in release 5.4, {@link org.apache.tapestry5.json.JSONObject} and
{@link org.apache.tapestry5.json.JSONArray}
+ * Starting in release 5.4, {@link
org.apache.tapestry5.json.improved.JSONObject} and {@link
org.apache.tapestry5.json.improved.JSONArray}
* are serializable.
*/
-package org.apache.tapestry5.json;
+package org.apache.tapestry5.json.improved;
diff --git
a/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
index 0b4167a80..64689de9d 100644
--- a/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
+++ b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
@@ -1,4 +1,4 @@
-// Copyright 2025 The Apache Software Foundation
+// Copyright 2025, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -320,8 +320,8 @@ public class JSONObjectTest {
JSONObject object = new JSONObject("{fred: 'flintstone'}");
- assertTrue(object.has("fred"));
- assertFalse(object.has("barney"));
+ assertTrue(object.containsKey("fred"));
+ assertFalse(object.containsKey("barney"));
}
@Test
@@ -456,7 +456,12 @@ public class JSONObjectTest {
return Stream.of(
Arguments.of(null, "\"\"", "null is empty string"),
Arguments.of("", "\"\"", "empty string is empty string"),
- Arguments.of("\"/\b\t\n\f\r\u2001/a</",
"\"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\"", "special characters")
+ Arguments.of("\"/\b\t\n\f\r\u2001/a</",
"\"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\"", "special characters"),
+ Arguments.of(
+ " \u0080\u009f\u00a0\u1fff\u2000\u20ff\u2100",
+ "\"\\u001f \\u0080\\u009f\u00a0\u1fff\\u2000\\u20ff\u2100\"",
+ "escape range boundaries: 0x1F/0x20, 0x7F/0x80/0x9F/0xA0,
0x1FFF/0x2000/0x20FF/0x2100"
+ )
);
}
@@ -888,4 +893,4 @@ public class JSONObjectTest {
assertEquals(defaultValue, value);
}
-}
\ No newline at end of file
+}