Java String Concatenation Performance: Under the Hood

Java provides various ways to combine two strings to form a new String such as String concatenation, StringBuilder and StringBuffer classes, MessageFormat API, Java 8 Stream, and even String templates, etc. This article delves into the differences between these ways and highlights how Java optimizes the bytecode to make the String concatenation faster.

1. Different Ways to Compose Strings

1.1. String Concatenation

In Java, String concatenation means combining multiple strings to form a new string. The most straightforward method is using the + operator. In this approach, everytime we concatenate two strings, Java internally creates a new literal in the string constant pool.

var name = "Alex";
var time = LocalTime.now();

var greeting = "Hello " + name + ", how are you?\nThe current time is " + time + " now!";

While this method is simple, it may not be the most efficient when dealing with a large number of concatenations or in performance-critical applications. That’s where StringBuilder and StringBuffer come into play.

1.2. StringBuilder and StringBuffer

The StringBuilder class is a more efficient way to manipulate strings when concatenating multiple values. Unlike the basic string concatenation using the + operator, StringBuilder modifies the content of the string without creating a new object each time.

This approach is more memory-efficient than using the + operator for concatenation when dealing with a large number of concatenations.

StringBuilder greetingBuilder = new StringBuilder();

greetingBuilder.append("Hello ").append(name).append(", how are you?\n");
greetingBuilder.append("The current time is ").append(time).append(" now!");

System.out.println( builder.toString() ); 

While StringBuilder is efficient, it is not thread-safe. In a multithreaded environment, if two or more threads attempt to modify the content of a StringBuilder simultaneously, unexpected results may occur. To address this, Java provides another class called StringBuffer.

StringBuffer sb = new StringBuffer();

sb.append("Hello ").append(name).append(", how are you?\n");
sb.append("The current time is ").append(time).append(" now!");

System.out.println( sb.toString() ); 

However, this synchronization comes at the cost of performance. For single-threaded applications, StringBuilder is generally preferred due to its higher performance.

1.3. String Templates

Since Java 21, we can create string templates containing the embedded expressions (evaluated at runtime). We embed the variables into a String, and the values of the variables are resolved in runtime. Thus template strings produce different results for different values of the variables.

String greeting = STR."Hello \{name}, how are you?\nThe current time is \{time} now!";

2. Compiler Optimization for String Concatenation

2.1. Till Java 8

In Java 8, the compiler performed certain optimizations when it came to string concatenation using the + operator. For simple operations where a fixed number of strings were concatenated, the compiler automatically transformed the code into the StringBuilder implementation for better performance.

Consider the following program:

import java.time.LocalTime;

public class MainClass {
    public static void main(String[] args) throws Exception {
        var name = "Alex";
        var time = LocalTime.now();

        String greeting = "Hello " + name + ", how are you?\nThe current time is " + time + " now!";
        System.out.println(greeting);
    }
}

When we compile this program, the JVM divides the concatenation process into multiple steps, logically into the following sequence:

String name = "Alex";
LocalTime time = LocalTime.now();

StringBuilder stringBuilder = new StringBuilder();

stringBuilder.append("Hello ")
	.append(name)
	.append(", how are you?\nThe current time is ")
	.append(time)
	.append(" now!");

String greeting = stringBuilder.toString();
System.out.println(greeting);

In other words, instead of creating multiple intermediate string objects, the compiler used a StringBuilder behind the scenes to efficiently concatenate the strings.

We can verify this by looking into the generated bytecode as well:

public static void main(java.lang.String[]) throws java.lang.Exception;
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=3, locals=4, args_size=1
       0: ldc           #2                  // String Alex
       2: astore_1
       3: invokestatic  #3                  // Method java/time/LocalTime.now:()Ljava/time/LocalTime;
       6: astore_2
       7: new           #4                  // class java/lang/StringBuilder
      10: dup
      11: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
      14: ldc           #6                  // String Hello
      16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: aload_1
      20: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: ldc           #8                  // String , how are you?
      25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: ldc           #9                  // String \nThe current time is
      30: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      33: aload_2
      34: invokevirtual #10                 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
      37: ldc           #11                 // String  now!
      39: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      42: invokevirtual #12                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      45: astore_3
      46: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
      49: aload_3
      50: invokevirtual #14                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      53: return
    Exception table:
       from    to  target type
           7    46    46   Class java/lang/Exception

2.2. Java 9 and Later

Since Java 9 [JEP-280], the compiler uses StringConcatFactory class for choosing the appropriate strategy for the string concatenation using an Invoke Dynamic (Also known as Indy) call [JSR-292].

Since Java 9, the ‘+‘ is no longer compiled to StringBuilder.

The invokedynamic call to java.lang.invoke.StringConcatFactory offers the facilities for a lazy linkage, by providing the means to bootstrap the call target once, during the initial invocation. At runtime, the bootstrap method (BSM) runs and links in the actual code doing the concatenation.

Since Java 9, String concatenation is a runtime decision, not a compile time one anymore. It means that after you the code Java 9 (or later) and it can change the underlying implementation however it pleases, without the need to re-compile.

Consider the following Java program:

public class StringConcatenationBenchmark {  
  public static void main(String[] args) throws Exception {

    String string1 = "Hello ";
    String string2 = "World!";

    String concatResult = string1 + string2;
    System.out.println(concatResult);
  }
}

When we compile and see the generated bytecode in Java 21, we can see the usage of invokedynamic. Look how the code is minimal and short.

  public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: ldc           #7                  // String Alex
         2: astore_1
         3: invokestatic  #9                  // Method java/time/LocalTime.now:()Ljava/time/LocalTime;
         6: astore_2
         7: aload_1
         8: aload_2
         9: invokestatic  #15                 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
        12: invokedynamic #21,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
        17: astore_3
        18: getstatic     #25                 // Field java/lang/System.out:Ljava/io/PrintStream;
        21: aload_3
        22: invokevirtual #31                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        25: return
      LineNumberTable:
        line 69: 0
        line 70: 3
        line 72: 7
        line 73: 18
        line 79: 25
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      26     0  args   [Ljava/lang/String;
            3      23     1  name   Ljava/lang/String;
            7      19     2  time   Ljava/time/LocalTime;
           18       8     3 greeting   Ljava/lang/String;
    Exceptions:
      throws java.lang.Exception
}

Now based on the string variables and constants to append, the invokedynamic call can select one of the  6 possible strategies that point to the actual concatenation logic.

private enum Strategy {

  /**
   * Bytecode generator, calling into {@link java.lang.StringBuilder}.
   */
  BC_SB,

  /**
   * Bytecode generator, calling into {@link java.lang.StringBuilder};
   * but trying to estimate the required storage.
   */
  BC_SB_SIZED,

  /**
   * Bytecode generator, calling into {@link java.lang.StringBuilder};
   * but computing the required storage exactly.
   */
  BC_SB_SIZED_EXACT,

  /**
   * MethodHandle-based generator, that in the end calls into {@link java.lang.StringBuilder}.
   * This strategy also tries to estimate the required storage.
   */
  MH_SB_SIZED,

  /**
   * MethodHandle-based generator, that in the end calls into {@link java.lang.StringBuilder}.
   * This strategy also estimate the required storage exactly.
   */
  MH_SB_SIZED_EXACT,

  /**
   * MethodHandle-based generator, that constructs its own byte[] array from
   * the arguments. It computes the required storage exactly.
   */
  MH_INLINE_SIZED_EXACT
}

The default strategy is MH_INLINE_SIZE_EXACT. However, we can change this strategy using the -Djava.lang.invoke.stringConcat=<strategyName> system property. 

3. String Concatenation vs String Templates

Since Java 21, String templates provide the interpolation-like capabilities to Java strings. We can place variables and expressions into Strings that are evaluated and replaced in runtime.

In runtime, String concatenation and Sprint templates generate mostly the similar bytecode.

It turns out that String templates are more of syntax sugar and, under the hood, they are similar to string concatenations only.

public static void main(String[] args) throws Exception {

	var name = "Alex";
	var time = LocalTime.now();

	// 1- String concatenation
	String greeting = "Hello " + name + ", how are you?\nThe current time is " + time + " now!";
	System.out.println(greeting);


	// 2- String Template
	String greetingTemplate = STR."Hello \{name}, how are you?\nThe current time is \{time} now!";
	System.out.println(greetingTemplate);
}

Let us check the generated bytecode of both techniques using the javap -c -v command. Notice how the commands generated for both set of methods are almost the same.

public static void main(java.lang.String[]) throws java.lang.Exception;
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=5, args_size=1
       0: ldc           #7                  // String Alex
       2: astore_1
       3: invokestatic  #9                  // Method java/time/LocalTime.now:()Ljava/time/LocalTime;
       6: astore_2
       7: aload_1
       8: aload_2
       9: invokestatic  #15                 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
      12: invokedynamic #21,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
      17: astore_3
      18: getstatic     #25                 // Field java/lang/System.out:Ljava/io/PrintStream;
      21: aload_3
      22: invokevirtual #31                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      25: aload_1
      26: aload_2
      27: invokestatic  #15                 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
      30: invokedynamic #21,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
      35: astore        4
      37: getstatic     #25                 // Field java/lang/System.out:Ljava/io/PrintStream;
      40: aload         4
      42: invokevirtual #31                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      45: return

4. Conclusion

In this Java article, we explored different ways to compose Java strings till Java 21 and checked their bytecodes. We looked into how Java runtime applies performance optimizations to make code run faster for different inputs.

Happy Learning !!

Source Code on Github

Comments

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.

Our Blogs

REST API Tutorial

Dark Mode

Dark Mode