Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Introduction

In this tutorial, we’re going to focus on the performance aspect of the Java String API.

We’ll dig into String creation, conversion and modification operations to analyze the available options and compare their efficiency.

The suggestions we’re going to make won’t be necessarily the right fit for every application. But certainly, we’re going to show how to win on performance when the application running time is critical.

2. Constructing a New String

As you know, in Java, Strings are immutable. So every time we construct or concatenate a String object, Java creates a new String – this might be especially costly if done in a loop.

2.1. Using Constructor

In most cases, we should avoid creating Strings using the constructor unless we know what are we doing.

Let’s create a newString object inside of the loop first, using the new String() constructor, then the = operator.

To write our benchmark, we’ll use the JMH (Java Microbenchmark Harness) tool.

Our configuration:

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

Here, we’re using the SingeShotTime mode, which runs the method only once. As we want to measure the performance of String operations inside of the loop, there’s a @Measurement annotation available for that.

Important to know, that benchmarking loops directly in our tests may skew the results because of various optimizations applied by JVM.

So we calculate only the single operation and let JMH take care for the looping. Briefly speaking, JMH performs the iterations by using the batchSize parameter.

Now, let’s add the first micro-benchmark:

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

@Benchmark
public String benchmarkStringLiteral() {
    return "baeldung";
}

In the first test, a new object is created in every iteration. In the second test, the object is created only once. For remaining iterations, the same object is returned from the String’s constant pool.

Let’s run the tests with the looping iterations count = 1,000,000 and see the results:

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

From the Score values, we can clearly see that the difference is significant.

2.2. + Operator

Let’s have a look at dynamic String concatenation example:

@State(Scope.Thread)
public static class StringPerformanceHints {
    String result = "";
    String baeldung = "baeldung";
}

@Benchmark
public String benchmarkStringDynamicConcat() {
    return result + baeldung;
}

In our results, we want to see the average execution time. The output number format is set to milliseconds:

Benchmark                       1000     10,000
benchmarkStringDynamicConcat    47.331   4370.411

Now, let’s analyze the results. As we see, adding 1000 items to state.result takes 47.331 milliseconds. Consequently, increasing the number of iterations in 10 times, the running time grows to 4370.441 milliseconds.

In summary, the time of execution grows quadratically. Therefore, the complexity of dynamic concatenation in a loop of n iterations is O(n^2).

2.3. String.concat()

One more way to concatenate Strings is by using the concat() method:

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

Output time unit is a millisecond, iterations count is 100,000. The result table looks like:

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

2.4. String.format()

Another way to create strings is by using String.format() method. Under the hood, it uses regular expressions to parse the input.

Let’s write the JMH test case:

String formatString = "hello %s, nice to meet you";

@Benchmark
public String benchmarkStringFormat_s() {
    return String.format(formatString, baeldung);
}

After, we run it and see the results:

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

Although the code with String.format() looks more clean and readable, we don’t win here in term of performance.

2.5. StringBuilder and StringBuffer

We already have a write-up explaining StringBuffer and StringBuilder. So here, we’ll show only extra information about their performance. StringBuilder uses a resizable array and an index that indicates the position of the last cell used in the array. When the array is full, it expands double of its size and copies all the characters into the new array.

Taking into account that resizing doesn’t occur very often, we can consider each append() operation as O(1) constant time. Taking this into account, the whole process has O(n) complexity.

After modifying and running the dynamic concatenation test for StringBuffer and StringBuilder, we get:

Benchmark               Mode  Cnt  Score   Error  Units
benchmarkStringBuffer   ss    10  1.409  ± 1.665  ms/op
benchmarkStringBuilder  ss    10  1.200  ± 0.648  ms/op

Although the score difference isn’t much, we can notice that StringBuilder works faster.

Fortunately, in simple cases, we don’t need StringBuilder to put one String with another. Sometimes, static concatenation with + can actually replace StringBuilder. Under the hood, the latest Java compilers will call the StringBuilder.append() to concatenate strings.

This means winning in performance significantly.

3. Utility Operations

3.1. StringUtils.replace() vs String.replace()

Interesting to know, that Apache Commons version for replacing the String does way better than the String’s own replace() method. The answer to this difference lays under their implementation. String.replace() uses a regex pattern to match the String.

In contrast, StringUtils.replace() is widely using indexOf(), which is faster.

Now, it’s time for the benchmark tests:

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

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

Setting the batchSize to 100,000, we present the results:

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

Although the difference between the numbers isn’t too big, the StringUtils.replace() has a better score. Of course, the numbers and the gap between them may vary depending on parameters like iterations count, string length and even JDK version.

With the latest JDK 9+ (our tests are running on JDK 10) versions both implementations have fairly equal results. Now, let’s downgrade the JDK version to 8 and the tests again:

Benchmark                     Mode  Cnt   Score    Error     Units
benchmarkStringReplace         ss   10    48.061   ± 17.157  ms/op
benchmarkStringUtilsReplace    ss   10    14.478   ±  5.752  ms/op

The performance difference is huge now and confirms the theory which we discussed in the beginning.

3.2. split()

Before we start, it’ll be useful to check out string splitting methods available in Java.

When there is a need to split a string with the delimiter, the first function that comes to our mind usually is String.split(regex). However, it brings some serious performance issues, as it accepts a regex argument. Alternatively, we can use the StringTokenizer class to break the string into tokens.

Another option is Guava’s Splitter API. Finally, the good old indexOf() is also available to boost our application’s performance if we don’t need the functionality of regular expressions.

Now, it’s time to write the 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 st = new StringTokenizer(longString);
    while (st.hasMoreTokens()) {
        stringTokenizer.add(st.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;
    }
    stringSplit.add(longString.substring(pos));
    return stringSplit;
}

Guava’s Splitter :

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

Finally, we run and compare results for batchSize = 100,000:

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

As we see, the worst performance has the benchmarkStringSplitPattern method, where we use the Pattern class. As a result, we can learn that using a regex class with the split() method may cause performance loss in multiple times.

Likewise, we notice that the fastest results are providing examples with the use of indexOf() and split().

3.3. Converting to String

In this section, we’re going to measure the runtime scores of string conversion. To be more specific, we’ll examine Integer.toString() concatenation method:

int sampleNumber = 100;

@Benchmark
public String benchmarkIntegerToString() {
    return Integer.toString(sampleNumber);
}

String.valueOf() :

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

[some integer value] + “” :

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

String.format() :

String formatDigit = "%d";

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

After running the tests, we’ll see the output for batchSize = 10,000:

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

After analyzing the results, we see that the test for Integer.toString() has the best score of 0.953 milliseconds. In contrast, a conversion which involves String.format(“%d”) has the worst performance.

That’s logical because parsing the format String is an expensive operation.

3.4. Comparing Strings

Let’s evaluate different ways of comparing Strings. The iterations count is 100,000.

Here are our benchmark tests for the String.equals() operation:

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

String.equalsIgnoreCase() :

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

String.matches() :

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

String.compareTo() :

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

After, we run the tests and display the results:

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

As always, the numbers speak for themselves. The matches() takes the longest time as it uses the regex to compare the equality.

In contrast, the equals() and equalsIgnoreCase() are the best choices.

3.5. String.matches() vs Precompiled Pattern

Now, let’s have a separate look at String.matches() and Matcher.matches() patterns. The first one takes a regexp as an argument and compiles it before executing.

So every time we call String.matches(), it compiles the Pattern:

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

The second method reuses the Pattern object:

Pattern longPattern = Pattern.compile(longString);

@Benchmark
public boolean benchmarkPrecompiledMatches() {
    return longPattern.matcher(baeldung).matches();
}

And now the results:

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

As we see, matching with precompiled regexp works about three times faster.

3.6. Checking the Length

Finally, let’s compare the String.isEmpty() method:

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

and the String.length() method:

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

First, we call them over the longString = “Hello baeldung, I am a bit longer than other Strings in average” String. The batchSize is 10,000:

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

After, let’s set the longString = “” empty string and run the tests again:

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

As we notice, benchmarkStringLengthZero() and benchmarkStringIsEmpty() methods in both cases have approximately the same score. However, calling isEmpty() works faster than checking if the string’s length is zero.

4. String Deduplication

Since JDK 8, string deduplication feature is available to eliminate memory consumption. Simply put, this tool is looking for the strings with the same or duplicate contents to store one copy of each distinct string value into the String pool.

Currently, there are two ways to handle String duplicates:

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

Let’s have a closer look at each option.

4.1. String.intern()

Before jumping ahead, it will be useful to read about manual interning in our write-up. With String.intern() we can manually set the reference of the String object inside of the global String pool.

Then, JVM can use return the reference when needed. From the point of view of performance, our application can hugely benefit by reusing the string references from the constant pool.

Important to know, that JVM String pool isn’t local for the thread. Each String that we add to the pool, is available to other threads as well.

However, there are serious disadvantages as well:

  • to maintain our application properly, we may need to set a -XX:StringTableSize JVM parameter to increase the pool size. JVM needs a restart to expand the pool size
  • calling String.intern() manually is time-consuming. It grows in a linear time algorithm with O(n) complexity
  • additionally, frequent calls on long String objects may cause memory problems

To have some proven numbers, let’s run a benchmark test:

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

Additionally, the output scores are in milliseconds:

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

The column headers here represent a different iterations counts from 1000 to 1,000,000. For each iteration number, we have the test performance score. As we notice, the score increases dramatically in addition to the number of iterations.

4.2. Enable Deduplication Automatically

First of all, this option is a part of the G1 garbage collector. By default, this feature is disabled. So we need to enable it with the following command:

 -XX:+UseG1GC -XX:+UseStringDeduplication

Important to note, that enabling this option doesn’t guarantee that String deduplication will happen. Also, it doesn’t process young Strings. In order to manage the minimal age of processing Strings, XX:StringDeduplicationAgeThreshold=3 JVM option is available. Here, 3 is the default parameter.

5. Summary

In this tutorial, we’re trying to give some hints to use strings more efficiently in our daily coding life.

As a result, we can highlight some suggestions in order to boost our application performance:

  • when concatenating strings, the StringBuilder is the most convenient option that comes to mind. However, with the small strings, the operation has almost the same performance. Under the hood, the Java compiler may use the StringBuilder class to reduce the number of string objects
  • to convert the value into the string, the [some type].toString() (Integer.toString() for example) works faster then String.valueOf(). Because that difference isn’t significant, we can freely use String.valueOf() to not have a dependency on the input value type
  • when it comes to string comparison, nothing beats the String.equals() so far
  • String deduplication improves performance in large, multi-threaded applications. But overusing String.intern() may cause serious memory leaks, slowing down the application
  • for splitting the strings we should use indexOf() to win in performance. However, in some noncritical cases String.split() function might be a good fit
  • Using Pattern.match() the string improves performance significantly
  • String.isEmpty() is faster than String.length() ==0

Also, keep in mind that the numbers we present here are just JMH benchmark results – so you should always test in the scope of your own system and runtime to determine the impact of these kinds of optimizations.

Finally, as always, the code used during the discussion can be found over on GitHub.

 

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.