
Converting an int to a String – Java gives you four ways to do it. Which one is the fastest?
I measured all four with JMH microbenchmarks: on Java 8 to 25, on an x86 and an arm64 machine. The result is clear – and it is a different one than the first version of this article claimed in 2019. I’ll show you why the old answer was wrong, too.
If you only want the recommendation: Jump straight to the summary.
Four ways to convert an int to a String
Leaving aside deliberately convoluted constructions, these are the four options:
- Option 1:
Integer.toString(i) - Option 2:
String.valueOf(i) - Option 3:
String.format("%d", i) - Option 4:
"" + i
String.valueOf(i) calls Integer.toString(i) internally. String.format() sends the number through the Formatter. And "" + i is the variant where it gets interesting: since Java 9, it is no longer the compiler that decides what this becomes, but the JVM. More on that later.
The benchmark setup
For the measurements, I use the Java Microbenchmark Harness, JMH for short – a framework that runs short pieces of code millions of times, starts measuring only after a warm-up phase for the JIT compiler, and finally reports throughput along with an error interval.
You can find a good beginner’s tutorial by Jakob Jenkov. For IntelliJ, there is a JMH plugin that lets you start benchmarks straight from the IDE.
The benchmark
You can find the complete source code in my GitHub repository. The benchmark itself is short:
public class IntToStringBenchmark {
@Benchmark
public void option1(RandomInts state, Blackhole blackhole) {
String s = Integer.toString(state.next());
blackhole.consume(s);
}
@Benchmark
public void option2(RandomInts state, Blackhole blackhole) {
String s = String.valueOf(state.next());
blackhole.consume(s);
}
@Benchmark
public void option3(RandomInts state, Blackhole blackhole) {
String s = String.format("%d", state.next());
blackhole.consume(s);
}
@Benchmark
public void option4(RandomInts state, Blackhole blackhole) {
String s = "" + state.next();
blackhole.consume(s);
}
}
The numbers come from a JMH state. It initializes an array of 1,024 random numbers once, up front, and every call of the benchmark method picks one of them through the next() method.
@State(Scope.Thread)
public class RandomInts {
private static final int SIZE = 1024;
private final int[] values = new int[SIZE];
private int index;
@Setup(Level.Trial)
public void doSetup() {
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int k = 0; k < SIZE; k++) {
values[k] = 1_000_000 + random.nextInt(9_000_000);
}
}
public int next() {
return values[index++ & (SIZE - 1)];
}
}
Three things about this are deliberate:
- The numbers are random, so that the JIT compiler cannot optimize the conversion away – converting a constant to a String, it would simply replace with the result of that conversion.
- All numbers have seven digits, so that every String has the same length.
- The result goes into a
Blackhole– for the same reason as the first point.
And a fourth detail is the reason for the new version of this article: the 1,024 numbers are drawn once per trial (Level.Trial), not before every single invocation.
The test environment
I measured on two machines:
- a Dell XPS 17 with an Intel Core i7-12700H (six performance and eight efficiency cores), under WSL2, and
- a Mac with an Apple M5 Pro (18 cores).
The Java versions used were 8, 11, 17, 21, 22, 23, 24, and 25, each a current OpenJDK build. JMH ran with three forks of five warm-up and five measurement iterations of five seconds each – and with the GC profiler, which reports the bytes allocated per call for every variant.
One more detail: the bytecode for "" + i depends on the compiler. That is why I built a separate JAR for each Java version, with that version’s javac compiler. So every column of the tables shows you what you get when you compile and run your code with exactly that Java version. What happens when bytecode compiled with Java 8 runs on a new JVM, I’ll show you in the section “Under the hood”.
The results
The tables show how long one conversion takes, in nanoseconds – the fewer, the better. The error intervals (99.9%) are below 2% almost everywhere and are left out for readability; you can find the raw data with all iterations, error intervals, and bytes per call as JMH JSON in the results/ directory of the benchmark repository.
The last row of each table – new StringBuilder().append("").append(i).toString() – is not one of the four variants but a fifth measurement. This is exactly the code the compiler turns "" + i into up to Java 8. I measured it because it explains why "" + i is the odd one out on Java 8: you can see it in the Java 8 numbers – identical.
Intel Core i7-12700H (x86)
| Java 8 | Java 11 | Java 17 | Java 21 | Java 22 | Java 23 | Java 24 | Java 25 | |
|---|---|---|---|---|---|---|---|---|
Integer.toString(i) | 14.5 | 9.0 | 9.4 | 8.6 | 8.1 | 8.1 | 8.0 | 7.1 |
String.valueOf(i) | 15.0 | 8.7 | 9.2 | 8.6 | 8.1 | 8.1 | 8.0 | 7.1 |
"" + i | 19.2 | 8.3 | 9.2 | 8.6 | 8.2 | 8.1 | 8.0 | 7.1 |
String.format("%d", i) | 183.0 | 157.6 | 54.3 | 58.8 | 57.7 | 54.6 | 54.9 | 54.7 |
explicit StringBuilder | 19.3 | 15.6 | 15.7 | 14.8 | 11.2 | 13.3 | 11.1 | 9.0 |
The chart for it leaves out String.format(): at 183 nanoseconds, that bar would squeeze the other four into stripes. This variant gets a chart of its own further down. And the chart shows only the LTS line plus the current GA release – Java 8, 11, 17, 21, and 25; the in-between versions 22 to 24 are in the table above.
Apple M5 Pro (arm64)
| Java 8 | Java 11 | Java 17 | Java 21 | Java 22 | Java 23 | Java 24 | Java 25 | |
|---|---|---|---|---|---|---|---|---|
Integer.toString(i) | 9.6 | 6.8 | 6.7 | 7.2 | 6.4 | 6.3 | 6.3 | 6.3 |
String.valueOf(i) | 9.6 | 6.8 | 6.7 | 7.1 | 6.4 | 6.2 | 6.3 | 6.3 |
"" + i | 14.7 | 6.5 | 6.8 | 7.2 | 6.4 | 6.3 | 6.2 | 6.2 |
String.format("%d", i) | 147.6 | 150.3 | 154.3 | 44.7 | 43.8 | 43.3 | 42.4 | 41.8 |
explicit StringBuilder | 14.7 | 9.2 | 9.3 | 8.5 | 7.8 | 12.4 | 7.6 | 7.4 |
Here, too, the chart is without String.format() and without the versions 22 to 24, for the same reasons.
What the numbers say
Three variants, one operation
Since Java 17, Integer.toString(i), String.valueOf(i), and "" + i are on par – on both machines, in every version. The largest difference is two percent, on Java 17 under x86; from Java 21 on it is less than one percent. The GC profiler confirms that this is no coincidence: all three allocate 48 bytes per conversion, namely exactly one String with a byte array for seven digits.
It is the same work. Integer.toString() computes the number of digits first, allocates an array of exactly that length, and writes the digits into it. And since Java 9, "" + i does the same – by way of a detour I’ll show you in a moment.
On Java 11, "" + i is even 4% ahead. On Java 8, however, it is clearly behind: 28% slower on x86, 53% slower on the M5 Pro. The reason is the StringBuilder chain that javac 8 turns it into – with a buffer for 16 characters that is copied into an array of the right length at the end. The last table row shows that this chain remains the slower option on new JVMs too, and it allocates 80 instead of 48 bytes.
String.format() is the outlier
On Java 25, String.format("%d", i) takes about 55 nanoseconds on the i7 and 42 on the M5 Pro – about seven times as long as the other variants – and allocates 360 bytes per call: a Formatter, a StringBuilder, the parsed format specification, and the result String.
Up to Java 11, String.format() was even slower: 150 to 180 ns, 13 to 22 times the other variants. Since Java 17, the Formatter has a fast path for simple specifications like %d and %s, which are parsed without a regular expression (JDK-8263038). On x86, you can see the jump exactly there. On the M5 Pro, however, only from Java 21 on: there, Java 17 formats in 154 ns even with the current build 17.0.20 and allocates 1,152 bytes per call, three times as much as on x86. The fast path is the same Java code on both platforms – so the cause lies in the JIT compiler for aarch64 in Java 17. Which optimization is missing there, I did not investigate.
My recommendation: use String.format() only when you actually format – zero padding, thousands separators, fixed width. For a plain conversion, it is the wrong method.
The outlier on Java 23
A curiosity on the side: on Java 23, the explicit StringBuilder is slower on both machines than on 22 and 24, and it allocates 104 instead of 80 bytes. The three main variants (Integer.toString(i), String.valueOf(i), and "" + i) are unaffected. I did not investigate the cause – Java 23 has been out of support since March 2025, and from 24 on, time and memory consumption are back where they were on Java 22.
Under the hood: what "" + i becomes
To understand why "" + i has been just as fast as Integer.toString() since Java 9, let’s look at the bytecode. For that, we need a file IntToStringFast.java with the following content:
import java.util.Random;
public class IntToStringFast {
public static void main(String[] args) {
int i = new Random().nextInt();
System.out.println("" + i);
}
}
Compile it:
javac IntToStringFast.java
And display the bytecode:
javap -c IntToStringFast.class
Java 8: a StringBuilder chain
With javac 8, the relevant excerpt looks like this:
14: new #6 // class java/lang/StringBuilder
17: dup
18: invokespecial #7 // Method java/lang/StringBuilder."<init>":()V
21: ldc #8 // String
23: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
26: iload_1
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
30: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
That corresponds to this Java code:
new StringBuilder().append("").append(i).toString();
The compiler did not even optimize the append("") away. The StringBuilder allocates a char buffer for 16 characters, appends an empty String, writes the digits into it, and toString() copies them into a new array of the exact length. Two allocations, one copy – that is how 48 bytes become 80.
Since Java 9: invokedynamic
From javac 9 on, the same line of Java code becomes a single bytecode instruction:
15: invokedynamic #6, 0 // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
The compiler no longer commits to how the concatenation is done. It only leaves behind a “recipe” – here: a constant followed by an int – and leaves the rest to the JVM. On the first call, StringConcatFactory builds a concatenation method from it, and that method goes about it differently than the StringBuilder: it first computes the length of the result – zero characters for the empty constant plus the number of digits – allocates the target array in exactly that size, and writes the digits straight into it. No buffer, no copy. That is the same work Integer.toString() does, which is why it is the same 48 bytes and the same nanoseconds.
This mechanism is called indified string concatenation and arrived with JEP 280 in Java 9. The advantage of the detour: the JVM can improve the strategy without you having to recompile your code.
The proof: old bytecode on new JVMs
If the explanation is right, "" + i must be slower than Integer.toString() on a current JVM too – provided it is compiled as a StringBuilder chain. That can be checked: I additionally compiled the benchmark with --release 8. Then even a current javac emits the old StringBuilder chain. I ran this one JAR unchanged on all JVMs. "" + i is then considerably slower than Integer.toString():
| Java 11 | Java 17 | Java 21 | Java 22 | Java 23 | Java 24 | Java 25 | |
|---|---|---|---|---|---|---|---|
| x86 | +82% | +73% | +69% | +37% | +64% | +35% | +26% |
| arm64 | +35% | +36% | +20% | +25% | +98% | +21% | +20% |
The spike on Java 23 under arm64 is the curiosity from above: the same effect the explicit StringBuilder shows there.
If your project still builds with --release 8 or -target 8, or even still runs on Java 8, String.valueOf(i) is the faster choice.
I did the cross-check as well: a JAR built with --release 11, run on all JVMs from Java 17 on. On every JVM version, it delivers the same numbers as the JAR built with the corresponding version’s javac. The compiler has not changed anything about concatenation since Java 11 – the progress came from the JVM.
Summary
Since Java 9, Integer.toString(i), String.valueOf(i), and "" + i are the same operation: they are equally fast, and all three allocate the same 48 bytes per conversion – exactly one String, no intermediate buffer – on x86 as on arm64. Which one you pick is a matter of readability, not speed.
I recommend String.valueOf(i): you can see at first glance that a conversion happens here, and it is fast no matter which compiler produced your bytecode – unlike "" + i, which falls behind clearly on Java 8 and with --release 8. "" + i is equivalent as soon as your bytecode comes from a javac of version 9 or later; some read it as a trick, so it is a matter of taste within your team.
Use String.format("%d", i) only when you actually format. For a plain conversion, it costs seven times as much.
And the 2019 result, according to which "" + i was the fastest variant? It was an artifact of the benchmark, not a property of Java. The info box in the section “The benchmark” explains what went wrong – if you write microbenchmarks yourself, that is the part of this article that will save you the most time.
The next article goes the other way: what you need to watch out for when parsing Strings into ints – and what boxing costs you there.
Did this article save you time? Then I’d be happy if you invested a minute of it in a review on my ProvenExpert profile. Your feedback shows me that the work on these articles pays off.
Would you like to be notified when I take a close look at the next Java release? Then click here to sign up for the HappyCoders newsletter.




