Suggerimenti sulle prestazioni delle corde

1. Introduzione

In questo tutorial, ci concentreremo sull'aspetto delle prestazioni dell'API Java String .

Scaveremo in stringa le operazioni di creazione, conversione e modifica di analizzare le opzioni disponibili e confrontare la loro efficienza.

I suggerimenti che daremo non saranno necessariamente adatti a ogni applicazione. Ma certamente, mostreremo come vincere in termini di prestazioni quando il tempo di esecuzione dell'applicazione è fondamentale.

2. Costruire una nuova stringa

Come sai, in Java le stringhe sono immutabili. Quindi ogni volta che costruiamo o concateniamo un oggetto String , Java crea una nuova String: questo potrebbe essere particolarmente costoso se eseguito in un ciclo.

2.1 . Utilizzando Constructor

Nella maggior parte dei casi, dovremmo evitare di creare stringhe utilizzando il costruttore a meno che non sappiamo cosa stiamo facendo .

Creiamo prima un oggetto newString all'interno del ciclo, utilizzando il costruttore new String () , quindi l' operatore = .

Per scrivere il nostro benchmark, utilizzeremo lo strumento JMH (Java Microbenchmark Harness).

La nostra configurazione:

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

Qui stiamo usando la modalità SingeShotTime , che esegue il metodo solo una volta. Poiché vogliamo misurare le prestazioni delle operazioni String all'interno del ciclo, è disponibile un'annotazione @Measurement per questo.

È importante sapere che i cicli di benchmarking direttamente nei nostri test possono distorcere i risultati a causa di varie ottimizzazioni applicate da JVM .

Quindi calcoliamo solo la singola operazione e lasciamo che JMH si occupi del loop. In breve, JMH esegue le iterazioni utilizzando il parametro batchSize .

Ora aggiungiamo il primo micro-benchmark:

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

Nel primo test, viene creato un nuovo oggetto in ogni iterazione. Nel secondo test, l'oggetto viene creato una sola volta. Per le iterazioni rimanenti, lo stesso oggetto viene restituito dal pool di costanti di String .

Eseguiamo i test con il conteggio delle iterazioni di loop = 1.000.000 e vediamo i risultati:

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

Dai valori del punteggio , possiamo vedere chiaramente che la differenza è significativa.

2.2. + Operatore

Diamo un'occhiata all'esempio di concatenazione di stringhe dinamiche :

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

Nei nostri risultati, vogliamo vedere il tempo medio di esecuzione. Il formato del numero di output è impostato su millisecondi:

Benchmark 1000 10,000 benchmarkStringDynamicConcat 47.331 4370.411

Ora analizziamo i risultati. Come si vede, l'aggiunta di 1000 elementi a state.result richiede 47,331 millisecondi. Di conseguenza, aumentando il numero di iterazioni di 10 volte, il tempo di esecuzione cresce fino a 4370,441 millisecondi.

In sintesi, il tempo di esecuzione cresce quadraticamente. Pertanto, la complessità della concatenazione dinamica in un ciclo di n iterazioni è O (n ^ 2) .

2.3. String.concat ()

Un altro modo per concatenare le stringhe è usare il metodo concat () :

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

L'unità di tempo di output è un millisecondo, il conteggio delle iterazioni è 100.000. La tabella dei risultati è simile a:

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

2.4. String.format ()

Un altro modo per creare stringhe è utilizzare il metodo String.format () . Dietro le quinte, utilizza espressioni regolari per analizzare l'input.

Scriviamo il test case JMH:

String formatString = "hello %s, nice to meet you"; @Benchmark public String benchmarkStringFormat_s() { return String.format(formatString, baeldung); }

Dopo, lo eseguiamo e vediamo i risultati:

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

Sebbene il codice con String.format () sembri più pulito e leggibile, non vinciamo qui in termini di prestazioni.

2.5. StringBuilder e StringBuffer

Abbiamo già un articolo che spiega StringBuffer e StringBuilder . Quindi qui, mostreremo solo informazioni extra sulle loro prestazioni. StringBuilder utilizza una matrice ridimensionabile e un indice che indica la posizione dell'ultima cella utilizzata nella matrice. Quando l'array è pieno, si espande il doppio della sua dimensione e copia tutti i caratteri nel nuovo array.

Tenendo conto che il ridimensionamento non si verifica molto spesso, possiamo considerare ogni operazione append () come tempo costante O (1) . Tenendo conto di ciò, l'intero processo ha una complessità O (n) .

Dopo aver modificato ed eseguito il test di concatenazione dinamica per StringBuffer e StringBuilder, otteniamo:

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

Sebbene la differenza di punteggio non sia molto, possiamo notare che StringBuilder funziona più velocemente .

Fortunatamente, in casi semplici, non abbiamo bisogno di StringBuilder per mettere una String con un'altra. A volte, la concatenazione statica con + può effettivamente sostituire StringBuilder . Dietro le quinte, gli ultimi compilatori Java chiameranno StringBuilder.append () per concatenare le stringhe .

Ciò significa vincere in modo significativo in termini di prestazioni.

3. Operazioni di utilità

3.1. StringUtils.replace () contro 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; } return stringSplit; }

Guava's Splitter :

@Benchmark public List 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.

Infine, come sempre, il codice utilizzato durante la discussione può essere trovato su GitHub.