Skip to content

Share char array buffers in java.lang.String - #191

Merged
dlunch merged 4 commits into
mainfrom
agent/string-buffer-sharing
Jul 25, 2026
Merged

Share char array buffers in java.lang.String#191
dlunch merged 4 commits into
mainfrom
agent/string-buffer-sharing

Conversation

@dlunch

@dlunch dlunch commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Fixes the repeated char array copying in java.lang.String and removes the three TODOs it left behind (string.rs:221, :472, :501).

Buffer sharing

String now uses the JDK 1.x representation of value / offset / count, so substring shares its parent's char array instead of copying it. This also drops the lossy UTF-16 -> Rust String -> UTF-16 round trip the old substring went through.

  • Added a package-private String(int, int, char[]) that stores the array as given. Public entry points keep copying defensively.
  • toCharArray() returns a copy. It previously handed out the backing array, which let callers mutate a String in place.
  • charAt() / getChars() gained bounds checks. With a shared array, an out of range index would otherwise quietly read a neighbouring string.
  • equals() / endsWith() compare code units instead of lossy conversions, so distinct unpaired surrogates no longer compare equal.
  • String(String) shares only when the source spans its whole array, so new String(huge.substring(..)) still detaches the large buffer.
  • StringBuffer, StringTokenizer and Properties go through JavaLangString::to_utf16 rather than reading String.value directly.
Before After
substring O(n) copy + lossy round trip no copy (O(1))
String construction (from_rust_string path) 2 array copies 1
StringTokenizer, per token ~14 async calls, 2 copies ~8, 0
toCharArray no copy (leaked the backing array) 1 copy

Keeping the parent array alive behind a substring is the intended JDK 1.x semantics.

Corrupt ranges raise Java exceptions

The sharing constructor first took offset and count on trust. The runtime does not enforce access flags, so any bytecode can call it, and a negative offset widened into a huge usize and overflowed the addition in the array bounds check — killing the VM instead of raising a Java exception:

thread panicked at jvm/src/jvm.rs:440: attempt to add with overflow

The constructor now validates its range, and the two places that read the fields back (String::value_range, JavaLangString::to_utf16) reject a negative one, since a putfield can plant it just as easily. load_array and store_array saturate when computing the end of the requested region so no caller can overflow the check, which also covers the 32-bit targets where a valid-looking pair of i32 values can sum past usize.

Thanks to Copilot for spotting this on the first revision.

regionMatches with a non-positive len

regionMatches returned false whenever len was negative. The JDK doesn't check len at all: its bounds test widens to long (toffset > (long)length() - len), which a negative len passes, and then while (len-- > 0) never runs, so the call reports a match. "Hello".regionMatches(1, "World", 2, -1) is true on a real JVM but was false here. The bounds test now widens the same way, which also keeps a len near i32::MAX from overflowing the offset arithmetic.

One existing assertion in test_str_06_region_matches_without_ignore_case encoded the old behaviour and was flipped.

Leftover double copies in io

BufferedReader.readLine() (two sites) and DataInputStream.readUTF() allocated an exactly sized char array, filled it, and then passed it to a copying String constructor. Routing them through JavaLangString::from_utf16 drops one array copy per line and per UTF entry. CharArrayWriter.toString() keeps copying: its buffer is mutable and outlives the call.

Testing

cargo test --workspace passes 456 tests with 0 failures. cargo clippy --workspace --all-targets and cargo clippy --target wasm32-unknown-unknown are both clean.

Covered by tests: buffer sharing (the child's value is the same instance as its parent's), defensive copying, toCharArray copying, charAt/indexOf/getChars/hashCode/equals/intern/StringTokenizer/StringBuffer behaviour on top of a substring, unpaired surrogate preservation, bounds exceptions, reachability of the shared array after the parent is collected, every rejected constructor range, a corrupted offset/count field reaching each reader, load_array/store_array at an offset that would overflow, and regionMatches with a len of 0, -1 and i32::MIN.

Switch String to the JDK 1.x representation of value/offset/count so
substring can share its parent's char array instead of copying, which
also removes the lossy UTF-16 -> Rust String -> UTF-16 round trip the
old substring went through.

- add a package-private String(int, int, char[]) for trusted callers
  that pass a freshly allocated array; public entry points keep copying
  defensively
- toCharArray() now returns a copy; it previously handed out the backing
  array, which let callers mutate a String in place
- charAt()/getChars() gained bounds checks: with a shared array an out of
  range index would otherwise quietly read a neighbouring string
- equals()/endsWith() compare code units instead of lossy conversions, so
  distinct unpaired surrogates no longer compare equal
- String(String) shares only when the source spans its whole array, so
  new String(huge.substring(..)) still detaches the large buffer
- route StringBuffer, StringTokenizer and Properties through
  JavaLangString::to_utf16 rather than reading String.value directly

Cuts string construction from two array copies to one and makes
substring allocation free.
Copilot AI review requested due to automatic review settings July 25, 2026 06:59
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.49%. Comparing base (c4665b0) to head (13cc16f).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #191      +/-   ##
==========================================
+ Coverage   90.35%   90.49%   +0.14%     
==========================================
  Files         265      265              
  Lines       32303    32349      +46     
==========================================
+ Hits        29188    29275      +87     
+ Misses       3115     3074      -41     

☔ 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes java.lang.String’s internal representation to a shared value/offset/count slice model (JDK 1.x-style), allowing substring to be O(1) and removing several lossy UTF-16 ⇄ Rust String roundtrips across String operations. It also updates multiple library classes/tests to rely on UTF-16 code units and validates bounds-sensitive APIs under the new sharing semantics.

Changes:

  • Switch String to value + offset + count, make substring share the parent buffer, and add bounds checks for charAt/getChars.
  • Introduce JavaLangString::to_utf16 / from_utf16 and refactor equals/endsWith/hashCode and library consumers (Tokenizer/Properties/StringBuffer) to operate on code units.
  • Add extensive tests covering sharing, immutability, interning, unpaired surrogate preservation, and GC reachability.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
jvm/tests/test_string.rs Adds UTF-16 preservation + intern semantics tests (including substring cases).
jvm/tests/test_garbage_collection.rs Updates GC expectations and adds reachability test for shared substring backing array.
jvm/src/runtime/java_lang_string.rs Adds to_utf16 and makes from_utf16 use the sharing constructor for fewer copies.
java_runtime/tests/classes/java/util/test_string_tokenizer.rs Updates delimiter assertions and adds tokenizer tests for sharing + offset composition.
java_runtime/tests/classes/java/util/test_properties.rs Adds round-trip tests where keys/values come from substrings (including empty substring).
java_runtime/tests/classes/java/lang/test_string.rs Large test expansion for substring sharing, bounds, equality/hash, empty substrings, trimming, surrogates, etc.
java_runtime/tests/classes/java/lang/test_string_buffer.rs Adds tests ensuring StringBuffer APIs work with shared-substring-backed Strings and snapshots remain immutable.
java_runtime/tests/classes/java/lang/test_character.rs Stops peeking into String.value directly; uses charAt instead.
java_runtime/src/classes/java/util/string_tokenizer.rs Switches token construction to substring and uses JavaLangString::to_utf16 for delimiter scanning.
java_runtime/src/classes/java/util/properties.rs Uses JavaLangString::{to_utf16,from_utf16} instead of reading String.value directly.
java_runtime/src/classes/java/lang/string.rs Implements shared-slice String model, adds sharing constructor, updates core APIs to use offset/count and code-unit comparisons.
java_runtime/src/classes/java/lang/string_buffer.rs Refactors to use to_utf16; updates toString to snapshot via from_utf16 (avoids sharing mutable buffer).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread jvm/src/runtime/java_lang_string.rs
Comment thread jvm/src/runtime/java_lang_string.rs
Comment thread java_runtime/src/classes/java/lang/string.rs
regionMatches returned false for a negative len, but the JDK's bounds
test widens to long and its comparison loop simply never runs, so an
otherwise in-range region reports a match. Widening the check here too
keeps a len near i32::MAX from overflowing the offset arithmetic.

BufferedReader.readLine() and DataInputStream.readUTF() built an exactly
sized char array and then handed it to a copying constructor. They now go
through JavaLangString::from_utf16, which takes the sharing constructor,
so each line and each UTF entry costs one array copy instead of two.
The sharing constructor took offset and count on trust, but the runtime
does not enforce access flags, so any bytecode can call it. A negative
offset widened to a huge usize and overflowed the addition in the array
bounds check, killing the VM instead of raising a Java exception:

    thread panicked at jvm/src/jvm.rs:440: attempt to add with overflow

The constructor now validates its range, and the two places that read the
fields back reject a negative one, since a putfield can plant it just as
easily. load_array and store_array saturate when computing the end of the
requested region so no caller can overflow the check, which also covers
the 32-bit targets where a valid-looking pair of i32 values can sum past
usize.

Reported by Copilot on #191.
@dlunch

dlunch commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Addressed the review comments in e974800.

Negative offset/count reaching load_array (comments on java_lang_string.rs:14 and string.rs:245) — confirmed and reproducible. Calling the sharing constructor as new String(-1, 3, chars) and then any read panicked with attempt to add with overflow at jvm.rs:440, so the VM died instead of throwing. Fixed in three layers:

  • the constructor validates offset >= 0 && count >= 0 && offset + count <= value.length and throws StringIndexOutOfBoundsException (NullPointerException for a null array);
  • String::value_range and JavaLangString::to_utf16 reject a negative field, because a putfield can plant one without going through the constructor at all;
  • load_array and store_array saturate when computing the end of the region, so no caller of the core API can overflow the bounds check on any target width.

from_utf16 narrowing data.len() to i32 (comment on java_lang_string.rs:32) — a Vec that long would now be caught by the constructor's range check and surface as a Java exception rather than a silently wrapped negative count.

Immutability of a String built over a caller-held array — this one stands as designed. The runtime enforces no access flags anywhere, so bytecode can already putfield String.value directly; the constructor grants no capability it didn't have. What the design accepted was broken immutability, not a VM crash, which is why the panic above was worth fixing separately.

Regression tests cover every rejected constructor range, a corrupted offset/count field reaching each reader, and load_array/store_array at an offset that would overflow. 456 tests pass; clippy is clean for the workspace and for wasm32-unknown-unknown.

init_shared said nothing about what it takes, and the file names every
other constructor after its arguments.
@dlunch
dlunch merged commit 1d26040 into main Jul 25, 2026
10 checks passed
@dlunch
dlunch deleted the agent/string-buffer-sharing branch July 25, 2026 10:16
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