Different implementations, one benchmark and several surprising JVM optimizations.

Reversing a string is one of those problems every Java developer has solved dozens of times. It’s common in coding interviews, utility methods, parsers and text-processing pipelines.
Java offers multiple ways to reverse a string, ranging from the built-in StringBuilder.reverse() to manual array manipulation and even streams.
While they all produce the same output, they certainly don't deliver the same performance and the implementation most developers assume is fastest (manual char[] swapping) only wins in one narrow case.
⚡ TL;DR (Quick Recap)
- StringBuilder.reverse() is the best default choice for almost every application.
- A manual char[] swap wins only on very short strings and it isn't Unicode-safe for surrogate pairs (e.g. many emoji).
- Streams, recursion, stacks and collection-based implementations are considerably slower and are better suited for educational purposes than production code.
Benchmark Setup
It measures the average execution time per operation in nanoseconds, running in a shared benchmark state, with 2 forks (each doing 1 warmup fork), warming up for 5 iterations of 1 second each and then measuring over 10 iterations of 1 second each.
Here’s every implementation tested, as a common interface:
public interface ReverseStrategy {
String reverse(String input);
}Test parameters:
- JMH 1.37 on Java 25
- JVM: -Xms1g -Xmx1g -XX:+UseG1GC
Why compare String reversal?
Most applications won’t spend measurable time reversing strings.
However, string manipulation appears everywhere — serialization, parsing, templating, log processing and data transformation. Understanding the trade-offs between different implementations is a useful exercise because it highlights the cost of allocations, boxing, recursion and abstraction.
StringBuilder.reverse()
The simplest and most idiomatic solution.
public class ReverseStringBuilder implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
return new StringBuilder(input).reverse().toString();
}
}Pros
- Extremely fast for medium/long inputs
- Handles Unicode surrogate pairs correctly (surrogate-aware)
- Readable and well-tested
Cons
- Allocates a StringBuilder (usually negligible)
Two-Pointer char[]
Convert the string into a character array and swap characters from both ends in place.
public class ReverseTwoPointer implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
char[] c = input.toCharArray();
for (int i = 0, j = c.length - 1; i < j; i++, j--) {
char t = c[i];
c[i] = c[j];
c[j] = t;
}
return new String(c);
}
}Pros
- Fastest option for very short strings
- Minimal overhead
Cons
- Operates on UTF-16 char units, not Unicode code points
- Breaks surrogate pairs — a string like "🙂🚀Java" can come out corrupted, since characters outside the Basic Multilingual Plane (many emoji) are represented as two chars in Java
Backwards Loop
Append characters in reverse order into a pre-sized StringBuilder.
public class ReverseLoop implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
StringBuilder sb = new StringBuilder(input.length());
for (int i = input.length() - 1; i >= 0; i--) {
sb.append(input.charAt(i));
}
return sb.toString();
}
}Pre-sizing the StringBuilder with input.length() avoids internal array resizing as characters are appended — a small but real win over letting it grow dynamically.
Recursive Solution
public class ReverseRecursive implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null || input.length() <= 1) {
return input;
}
return reverse(input.substring(1)) + input.charAt(0);
}
}Elegant from an academic perspective, but O(n²) — each recursive call allocates a new substring and string concatenation allocates again. It’s also susceptible to StackOverflowError on sufficiently large inputs, since each character adds a stack frame.
Streams
Two variants were benchmarked:
public class ReverseStreamCodePoints implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
return input.codePoints()
.mapToObj(Character::toString)
.reduce("", (a, b) -> b + a);
}
}
Unlike ReverseTwoPointer and ReverseStreamIndex, this operates on code points (via codePoints()), so it correctly preserves surrogate pairs — the only stream-based option that’s emoji-safe.
public class ReverseStreamIndex implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
return IntStream.range(0, input.length())
.mapToObj(i -> String.valueOf(input.charAt(input.length() - 1 - i)))
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
.toString();
}
}Although expressive, both introduce meaningful overhead: reduce with string concatenation reallocates a new String on every step (O(n²) total) and boxing each charcode point into an object adds GC pressure.
Stack / Deque
Push every character onto a stack, then pop them back out.
public class ReverseStack implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
Deque<Character> stack = new ArrayDeque<>();
for (char c : input.toCharArray()) {
stack.push(c);
}
StringBuilder sb = new StringBuilder(input.length());
while (!stack.isEmpty()) {
sb.append(stack.pop());
}
return sb.toString();
}
}Conceptually straightforward, but it performs unnecessary boxing (Character) and allocations.
Collections.reverse()
Convert every character into a boxed Character, reverse the list and rebuild the string.
public class ReverseCollections implements ReverseStrategy {
@Override
public String reverse(String input) {
if (input == null) return null;
List<Character> characters = new ArrayList<>(input.length());
for (char character : input.toCharArray()) {
characters.add(character);
}
Collections.reverse(characters);
StringBuilder result = new StringBuilder(input.length());
for (char character : characters) {
result.append(character);
}
return result.toString();
}
}Interesting as a demonstration of the collections framework — but the boxing overhead of List<Character> makes it one of the slowest options tested, and not something you'd choose for production.
Benchmark Results
The benchmark measured three input sizes using JMH: short, medium and long strings.
Short Strings For very small inputs, the differences are measured in only a few nanoseconds, but the manual two-pointer implementation comes out on top.
- 🥇 Two Pointer — 6.08 ns/op
- 🥈 StringBuilder — 7.22 ns/op
- 🥉 Backwards Loop — 8.05 ns/op
- Streams (Index) — 23.86 ns/op
- Recursion — 32.70 ns/op
- Stack / Deque — 39.40 ns/op
- Streams (Code Points) — 44.83 ns/op
- Collections.reverse() — 57.02 ns/op
Medium Strings As the input grows, StringBuilder.reverse() becomes the clear winner.
- 🥇 StringBuilder — 14.14 ns/op
- 🥈 Two Pointer — 17.84 ns/op
- 🥉 Backwards Loop — 63.39 ns/op
- Streams (Index) — 113.00 ns/op
- Collections.reverse() — 163.69 ns/op
- Streams (Code Points) — 338.67 ns/op
- Stack / Deque — 363.72 ns/op
- Recursion — 408.71 ns/op
Long Strings The performance gap widens even further for larger inputs.
- 🥇 StringBuilder — 27.48 ns/op
- 🥈 Two Pointer — 31.74 ns/op
- 🥉 Backwards Loop — 128.40 ns/op
- Streams (Index) — 272.31 ns/op
- Collections.reverse() — 450.99 ns/op
- Stack / Deque — 1011.62 ns/op
- Streams (Code Points) — 1172.19 ns/op
- Recursion — 1605.49 ns/op
Why Does StringBuilder Win?
Many developers assume that manually swapping a char[] should always be the fastest approach.
The benchmark shows otherwise.
StringBuilder.reverse() has been heavily optimized inside the JDK over many releases — it's surrogate-pair-aware since JDK 5 (this is what fixes the emoji-corruption bug that the manual swap still has today). It performs minimal allocations and benefits from JVM optimizations that a hand-written loop doesn't automatically get.
Unless you have a very specialized use case — extremely short, ASCII-only strings, in a hot loop where every nanosecond genuinely matters — it’s difficult to outperform the JDK implementation.
Final Takeaway
For most production code, the decision tree is short:
- Need maximum speed and know your input is BMP-only (no emoji)? Use the two-pointer swap.
- Need correctness and simplicity and a few nanoseconds don’t matter? Use StringBuilder.reverse(). This is the right default for the overwhelming majority of code.
- Avoid recursive reversal and stream-reduction-with-concatenation in anything performance-sensitive — both degrade badly as input grows.
Measure first. The fastest-looking code on paper isn’t always the code that actually runs fastest.
You can find all the code on GitHub.
Originally posted on marconak-matej.medium.com.