-
Notifications
You must be signed in to change notification settings - Fork 1.8k
[LANG-1805] Optimize StringBuilder usage in join() for primitives #1532
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
The previous implementation of join(boolean[], ...) calculated the initial StringBuilder capacity based on 'array.length' instead of the actual number of elements to be joined ('endIndex - startIndex').
This caused excessive memory allocation when joining a small range of a large array (e.g., joining 5 elements from an array of 10,000).
This commit fixes the calculation to use 'noOfItems', ensuring precise memory allocation.
Signed-off-by: jher235 <tim668666@gmail.com>
The previous implementation of join(char[], ...) calculated the initial StringBuilder capacity based on 'array.length' instead of the actual number of elements to be joined ('endIndex - startIndex').
This caused excessive memory allocation when joining a small range of a large array (e.g., joining 5 elements from an array of 10,000).
This commit fixes the calculation to use 'noOfItems', ensuring precise memory allocation.
Signed-off-by: jher235 <tim668666@gmail.com>
- Refactor `join` methods for primitive types (char, byte, short, int, long, float, double) to improve performance. - Pre-allocate `StringBuilder` capacity based on the actual number of elements (`noOfItems`) instead of the default 16 chars or `array.length`. - Fix inaccurate capacity calculation in existing methods (char, byte, short) that incorrectly used `array.length`, causing memory waste for sub-arrays. - Eliminate the final `substring()` call by appending delimiters conditionally within the loop, reducing unnecessary String object allocation. Signed-off-by: jher235 <tim668666@gmail.com>
|
Hello @jher235 This looks like a Clause Sonet bot at work, right? What I get is the same if I run Sonet, but it adds a comment. If this is supposed to be faster, then please provide a JMH benchmark to prove it. Otherwise, less code is easier to maintain IMO. |
|
Yes, I did use AI assistance. Additionally, I noticed that only the |
|
Hi @garydgregory , Thank you for your patience and for encouraging the benchmark. Transparency NoteAs mentioned in the checklist: I used AI assistance to help structure this PR and understand benchmarking best practices. However, the problem identification, benchmark execution, data analysis, and code implementation are my own work. Benchmark ResultsI've completed the JMH benchmarks, and the results reveal this PR should be viewed in two distinct parts: Part 1: Critical Bug Fix (
|
| Metric | Current (bug) | Fixed | Improvement |
|---|---|---|---|
| Time | 6,512 ns | 110 ns | 59x faster |
| Memory | 60,136 bytes | 200 bytes | 300x less allocation |
Test: Joining 10 elements from a 10,000-element boolean array
This isn't a theoretical edge case - it happens whenever the startIndex/endIndex parameters are used for their intended purpose. This is a correctness issue that needs fixing regardless of other optimizations.
Part 2: General Optimization (int and other numeric types)
For primitives that currently use the default StringBuilder constructor, I tested three array sizes with realistic integer values:
| Array Size | Time (ns/op) | Memory (B/op) | Observation |
|---|---|---|---|
| Small (10) | |||
| Current | 233 | 280 | Baseline |
| Optimized | 170 | 160 | 27% faster, 43% less memory |
| Medium (100) | |||
| Current | 1,821 | 1,800 | Baseline |
| Optimized | 1,714 | 1,808 | ~Same performance |
| Large (1000) | |||
| Current | 19,988 | 24,640 | Baseline |
| Optimized | 19,600 | 18,104 | 26% less memory |
Key findings:
- Small arrays: Clear wins (27% speed, 43% memory). The default 16-char buffer is immediately too small.
- Medium arrays: Neutral impact. Both approaches trigger ~1-2 resizes, resulting in comparable performance.
- Large arrays: Modest speed gain (2%) but significant memory reduction (26%). Multiple resizes in the old approach cause memory churn.
Bottom line: The optimization is "regression-free" - it provides meaningful gains in common cases (small and large arrays) and stays neutral in between.
Assessment
What we're fixing:
- Boolean/char bug: Dramatic over-allocation in subset scenarios (59x improvement).
- Numeric types: Situational improvements (Small/Large) with no downsides.
Trade-off:
- Gained: Bug fix + performance improvements where it matters.
- Cost: One extra variable (
noOfItems) per method + capacity calculations.
How Should We Proceed?
Respecting your "less code is easier to maintain" philosophy, here are two paths forward:
Option A (Recommended): Accept the full PR
- Fixes the boolean/char bug (critical).
- Applies consistent optimization pattern across all primitives.
- No performance regressions anywhere.
- Slightly more code, but uniform approach.
Option B (Minimal): Only fix boolean/char
- Just replace
array.lengthwithnoOfItemsin boolean/char methods. - Leave numeric types unchanged.
- Minimal code change.
- Accepts minor inefficiencies in numeric types.
I personally lean toward Option A for consistency and completeness, but I'm genuinely comfortable with Option B if you prefer to minimize changes.
What's your preference?
Environment: JMH 1.37, 3 warmup + 5 measurement iterations
|
Hello @jher235 src/test/java/org/apache/commons/lang3/StringUtilsGetDigitsBenchmark.java |
|
FTR, this is what Claude Sonet 4.5 says about this change when I generate it from Eclipse's Co-Pilot plugin using the prompt
|
Signed-off-by: jher235 <tim668666@gmail.com>
|
Hello @garydgregory, Sorry for the earlier misunderstanding — I initially thought that running the benchmarks locally and sharing the summary would be sufficient. I’ve now pushed the JMH benchmark source code ( Following the In this larger scenario, the execution time is slightly higher, while the allocation rate is still noticeably lower. This shows that the change does not provide a universal speedup, but it does consistently reduce memory allocation even at larger sizes. Thanks again for taking the time to review this in such detail — I really appreciate the guidance. |
Description
This pull request improves the internal implementation of the primitive
joinmethods by reducing unnecessaryStringBuilderresizing andaligning capacity calculations with the actual join range.
The changes are limited to implementation details and do not alter
public behavior or output.
Changes
Capacity Pre-sizing
StringBuilderwith an estimated capacity based on thenumber of elements being joined (
endIndex - startIndex).and reduces allocation and copy overhead.
Capacity Calculation Alignment
join(char[]),join(byte[]),and
join(short[])to consistently use the join range(
endIndex - startIndex) instead of the full array length.sub-ranges of large arrays.
Minor Loop Refactoring
final delimiter via
substring(), keeping the resulting behaviorunchanged while simplifying the control flow.
Jira Ticket
Checklist
mvn; that'smvnon the command line by itself.mvn clean verify).