String Performance Hints

Here, we are going to discuss the performance aspect of the Java String API that includes String creation, conversion, and modification operations. Thus, analyzing the available options and comparing the efficiency to understand the way to win on performance when the application running time is critical.

Constructing a New String:

Strings are immutable in Java. Thus, a new String is created by Java every time a String object is constructed or concatenated. If done in a loop, it might be especially costly.

Using Constructor:

It is recommended to avoid creating Strings using the constructor without a proper understanding of the same. To create a string using the constructor, we are first creating an example string object inside a loop using the new String() constructor and the = operator. Here, we are using the JMH (Java Microbenchmark Harness) tool to write the benchmark.

Configuration:

@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Measurement(batchSize = 10000, iterations = 10)
@Warmup(batchSize = 10000, iterations = 10)
public class StringPerformance {
}

The SingeShotTime mode is used here to run the method only once. The @Measurement annotation is used here to measure the performance of String operations inside of the loop. Because of various optimizations applied by JVM, the benchmarking loops when used directly in the tests may skew the results. Thus, we are calculating only the single operation. The looping is taken care of by the JMH. By using the batchSize parameter, the JMH performs the iterations.

Adding the first micro-benchmark:

@Benchmark
public String benchmarkStringConstructor() {
    return new String("hello");
}
 
@Benchmark
public String benchmarkStringLiteral() {
    return "hello";
}

Instead of creating a new object in every iteration, as in the first test, the object is created only once in this test. The same object is returned from the String’s constant pool, for the other iterations.

Output:

Benchmark Mode Cnt Score Error Units
benchmarkStringConstructor ss 10 16.089 ± 3.355 ms/op
benchmarkStringLiteral ss 10 9.523 ± 3.331 ms/op

Explanation:
In the above example, the Score values makes it clear that the difference is significant. Here, we are running the test with the looping iterations count = 1,000,000.

+ Operator:

Example: Dynamic String concatenation:

@State(Scope.Thread)
public static class StringPerformanceHints {
    String result = "";
    String hello = "hello";
}
 
@Benchmark
public String benchmarkStringDynamicConcat() {
    return result + hello;
}

Output:

Benchmark 1000 10,000
benchmarkStringDynamicConcat 47.331 4370.411

Explanation:

In the above example, we are checking the average execution time. Here, the output number format is set to milliseconds. By analyzing the result we can say that it takes 47.331 milliseconds by adding 1000 items to state.result. Also, the running time grows to 4370.441 milliseconds, by increasing the number of iterations in 10 times. The time of execution thus grows quadratically, making the complexity of dynamic concatenation in a loop of n iterations to be O(n^2).

String.concat():

The concat() method is used to concatenate the Strings.

Example:

@Benchmark
public String benchmarkStringConcat() {
    return result.concat(hello);
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkStringConcat ss 10 3403.146 ± 852.520 ms/op

Explanation:

In the above example, the time unit is a millisecond. The iterations count here is 100,000.

String.format():

The String.format() method is used to create strings. The regular expressions is used by this method to parse the input. The String.format() is more clean and readable but is not so good performance wise.

JMH Test Case:

String formatString = "hello
@Benchmark
public String benchmarkStringFormat_s() {
    return String.format(formatString, hello);
}

Output:

Number of Iterations 10,000 100,000 1,000,000
benchmarkStringFormat_s 17.181 140.456 1636.279 ms/op

StringBuilder and StringBuffer:

A resizable array and an index (to indicate the position of the last cell used in the array) is used by the StringBuilder. The StringBuilder expands double of the size of the array and copies all the characters into the new array when an array is full. Each append() operation can be considered as O(1) constant time, as resizing doesn’t occur very often. Thus the complexity of the whole process can be considered as O(n).

On running the dynamic concatenation test after modifying it for StringBuffer and StringBuilder, the output will be:

Benchmark Mode Cnt Score Error Units

benchmarkStringBuffer ss 10 1.409 ± 1.665 ms/op

benchmarkStringBuilder ss 10 1.200 ± 0.648 ms/op

The score difference here is not much. But still it is clear that the StringBuilder works faster. The StringBuilder is not required in simple cases to put one String with another. The static concatenation with + can also replace the StringBuilder sometimes. To concatenate the strings, the latest Java compilers will actually call the StringBuilder.append(). Thus the performance can be improvised significantly.

Utility Operations:

StringUtils.replace() vs String.replace():

The Apache Commons version for replacing the String is better than the String replace() method. To match the String, a regex pattern is used by the String.replace(). On the other hand, the indexOf() is used by the StringUtils.replace() which is certainly faster.

Benchmark tests:

@Benchmark
public String benchmarkStringReplace() {
    return longString.replace("helloworld", " helloworld !!!");
}
 
@Benchmark
public String benchmarkStringUtilsReplace() {
    return StringUtils.replace(longString, "helloworld", " helloworld !!!");
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 6.233 ± 2.922 ms/op
benchmarkStringUtilsReplace ss 10 5.355 ± 2.497 ms/op

Explanation:

In the above example, the batchSize is 100,000. Here, the difference in the results is not too big. But still, the StringUtils.replace() has a better score. Depending on the parameters such as iterations count, string length and even JDK version, the numbers and the gap may vary.

The above result is with the latest JDK 9+ versions. Here, both the implementations have fairly equal results. When running the same tests after downgrading the JDK version to 8. The result will be:

Benchmark Mode Cnt Score Error Units

benchmarkStringReplace ss 10 48.061 ± 17.157 ms/op

benchmarkStringUtilsReplace ss 10 14.478 ± 5.752 ms/op

Explanation:

Here, the performance difference is huge.

split():

The String.split(regex) method is used to split a string with the delimiter. Because it accepts a regex argument, the performance is affected. The StringTokenizer class can be used in place of the String.split(regex) method to break the string into tokens. The Guava’s Splitter API can also be considered as an option. In case there is no need for the functionality of regular expressions, the good old indexOf() can be used to boost the performance of the application.

Benchmark tests for String.split() option:

String emptyString = " ";
 
@Benchmark
public String [] benchmarkStringSplit() {
    return longString.split(emptyString);
}

Pattern.split() :
@Benchmark
public String [] benchmarkStringSplitPattern() {
    return spacePattern.split(longString, 0);
}

StringTokenizer :

List stringTokenizer = new ArrayList<>();
 
@Benchmark
public List benchmarkStringTokenizer() {
    StringTokenizer str = new StringTokenizer(longString);
    while (str.hasMoreTokens()) {
        stringTokenizer.add(str.nextToken());
    }
    return stringTokenizer;
}

String.indexOf() :

List stringSplit = new ArrayList<>();
 
@Benchmark
public List benchmarkStringIndexOf() {
    int pos = 0, end;
    while ((end = longString.indexOf(' ', pos)) >= 0) {
        stringSplit.add(longString.substring(pos, end));
        pos = end + 1;
    }
    return stringSplit;
}

Guava’s Splitter :

@Benchmark
public List benchmarkGuavaSplitter() {
    return Splitter.on(" ").trimResults()
      .omitEmptyStrings()
      .splitToList(longString);
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkGuavaSplitter ss 10 4.008 ± 1.836 ms/op
benchmarkStringIndexOf ss 10 1.144 ± 0.322 ms/op
benchmarkStringSplit ss 10 1.983 ± 1.075 ms/op
benchmarkStringSplitPattern ss 10 14.891 ± 5.678 ms/op
benchmarkStringTokenizer ss 10 2.277 ± 0.448 ms/op

Explanation:

In the above example, we are running and comparing the results for the batchSize = 100,000. Here, the benchmarkStringSplitPattern method, where we use the Pattern class, has the worst performance. Thus, the performance loss is caused multiple times while using a regex class.

Converting to String:

int x = 100;
 
@Benchmark
public String benchmarkIntegerToString() {
    return Integer.toString(x);
}

String.valueOf() :

@Benchmark
public String benchmarkStringValueOf() {
    return String.valueOf(x);
}

[some integer value] + “” :

@Benchmark
public String benchmarkStringConvertPlus() {
    return x + "";
}

String.format() :

String formatDigit = 
 
@Benchmark
public String benchmarkStringFormat_d() {
    return String.format(formatDigit, x);
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkIntegerToString ss 10 0.953 ± 0.707 ms/op
benchmarkStringConvertPlus ss 10 1.464 ± 1.670 ms/op
benchmarkStringFormat_d ss 10 15.656 ± 8.896 ms/op
benchmarkStringValueOf ss 10 2.847 ± 11.153 ms/op

Explanation:

In the above example, we are measuring the runtime scores of string conversion. The Integer.toString() concatenation method is thus examined. Here, we are checking the output for the batchSize of 10,000. Here, the Integer.toString() has the best score of 0.953 milliseconds and the worst performance is for the conversion which involves String.format(�

Comparing Strings:

Benchmark tests for the String.equals() operation:

@Benchmark
public boolean benchmarkStringEquals() {
    return longString.equals(helloworld);
}

String.equalsIgnoreCase() :

@Benchmark
public boolean benchmarkStringEqualsIgnoreCase() {
    return longString.equalsIgnoreCase(helloworld);
}

String.matches() :

@Benchmark
public boolean benchmarkStringMatches() {
    return longString.matches(helloworld);
}

String.compareTo() :

@Benchmark
public int benchmarkStringCompareTo() {
    return longString.compareTo(helloworld);
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkStringCompareTo ss 10 2.561 ± 0.899 ms/op
benchmarkStringEquals ss 10 1.712 ± 0.839 ms/op
benchmarkStringEqualsIgnoreCase ss 10 2.081 ± 1.221 ms/op
benchmarkStringMatches ss 10 118.364 ± 43.203 ms/op

Explanation:

In the above example, we are evaluating the different ways of comparing Strings. Here, the iterations count is 100,000. The matches() use the regex to compare equality. Thus it takes the longest time. It is clear that the best choices are the equals() and equalsIgnoreCase().

String.matches() vs Precompiled Pattern:

The String.matches() takes a regexp as an argument. It compiles the regexp before executing. Thus, the String.matches() compiles the Pattern, whenever called.

@Benchmark
public boolean benchmarkStringMatches() {
    return longString.matches(helloworld);
}

By reusing the Pattern object:

Pattern longPattern = Pattern.compile(longString);
 
@Benchmark
public boolean benchmarkPrecompiledMatches() {
    return longPattern.matcher(helloworld).matches();
}

Output:

Benchmark Mode Cnt Score Error Units
benchmarkPrecompiledMatches ss 10 29.594 ± 12.784 ms/op
benchmarkStringMatches ss 10 106.821 ± 46.963 ms/op

Explanation:

In the above example, we can check that the working of the matching with the precompiled regexp is about three times faster.

Checking the Length:

String.isEmpty() method:

@Benchmark
public boolean benchmarkStringIsEmpty() {
    return longString.isEmpty();
}

String.length() method:

@Benchmark
public boolean benchmarkStringLengthZero() {
    return emptyString.length() == 0;
}

Output 1: When calling them over the longString = “Hello world, I am here to serve the purpose of a long string” String.

Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.295 ± 0.277 ms/op
benchmarkStringLengthZero ss 10 0.472 ± 0.840 ms/op

Output 2: When calling them over the longString = “ ” empty string:

Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.245 ± 0.362 ms/op
benchmarkStringLengthZero ss 10 0.351 ± 0.473 ms/op

Explanation:

In the above example, we are comparing the String.isEmpty() method and the String.length() method. Here, we are calling these methods first over the longString = “Hello world, I am here to serve the purpose of a long string” String, with the batchSize of 10,000, and then over the longString = “ ” empty string. From the result, it is clear that in both cases, the benchmarkStringLengthZero() and the benchmarkStringIsEmpty() have approximately the same score. The isEmpty() however, works faster than checking if the string’s length is zero when called.

String Deduplication:

To eliminate the memory consumption, the string deduplication feature is available since JDK 8. It is used to store one copy of each distinct string value into the String pool/pgx, for the strings with the same or duplicate contents. The String duplicates can be handled in two ways:

  • using the String.intern() manually
  • enabling string deduplication

String.intern():

The reference of the String object can be manually set inside of the global String pool by using the String.intern(). The JVM can then be used to return the reference if required. Based on the performance, reusing the string references from the constant pool can hugely benefit an application. The JVM String pool, however, is not local for the thread, i.e, every String added to the pool is also available to other threads.

Disadvantages:

  1. It may be required to set a -XX:StringTableSize JVM parameter to increase the pool size to maintain the application properly. Thus there will be a need to restart JVM to expand the pool size.
  2. Calling the String.intern() manually is time-consuming as it grows in a linear time algorithm with O(n) complexity.
  3. Memory problems can be caused by frequent calls on long String objects.

Benchmark test:

@Benchmark
public String benchmarkStringIntern() {
    return baeldung.intern();
}

Output:

Benchmark 1000 10,000 100,000 1,000,000
benchmarkStringIntern 0.433 2.243 19.996 204.373

Explanation:

In the above example, the column headers are describing different iterations counts from 1000 to 1,000,000. Here, the scores are in milliseconds. There is a test performance score, for each iteration number. Thus, we can check that the score increases dramatically along with the number of iterations.

Enable Deduplication Automatically:

Being a part of the G1 garbage collector, this feature is disabled, by default. The following command can be used to enable it:

-XX:+UseG1GC -XX:+UseStringDeduplication

Enabling the above option, however, does not guarantee String deduplication. Young Strings are also not processed by this option. The XX:StringDeduplicationAgeThreshold=3 JVM option can be used to manage the minimal age of processing Strings. In this option, 3 is the default parameter.

Summary:

  • To boost an application performance:
  • The StringBuilder is the best option for concatenating the strings. The + operation has almost the same performance, with the small strings. The StringBuilder class can be used by the Java compiler to reduce the number of string objects.
  • The [some type].toString() (For example, Integer.toString()) works faster in converting the value into the string then String.valueOf(). However, the difference is not significant. Thus, the String.valueOf() can be used to not have a dependency on the input value type.
  • String.equals() is best for string comparison.
  • The performance is improved in large, multi-threaded applications with the use of the String deduplication. The overuse of the String.intern() however can cause serious memory leaks. Thus, slowing down the application.
  • The use of the indexOf() is recommended for splitting the strings. The String.split() function can also be a good fit in some noncritical cases.
  • The string improves performance significantly with the use of the Pattern.match().
  • The String.length() ==0 is slower than the String.isEmpty().