Conseils de performance de chaîne

Conseils de performance de chaîne

**

1. introduction

Dans ce didacticiel,we’re going to focus on the performance aspect of the Java String API.

Nous approfondirons les opérations de création, de conversion et de modification deString pour analyser les options disponibles et comparer leur efficacité.

Les suggestions que nous allons faire ne conviendront pas nécessairement à chaque application. Mais nous allons certainement montrer comment gagner en performances lorsque le temps d’exécution de l’application est critique.

2. Construire une nouvelle chaîne

Comme vous le savez, en Java, les chaînes sont immuables. Ainsi, chaque fois que nous construisons ou concaténons un objetString, Java crée un nouveauString –, ce qui peut être particulièrement coûteux s'il est fait en boucle.

2.1. Utilisation du constructeur

Dans la plupart des cas,we should avoid creating Strings using the constructor unless we know what are we doing.

Créons d'abord un objetnewString à l'intérieur de la boucle, en utilisant le constructeurnew String(), puis l'opérateur=.

Pour rédiger notre benchmark, nous utiliserons l'outilJMH (Java Microbenchmark Harness).

Notre configuration:

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

Ici, nous utilisons le modeSingeShotTime, qui n'exécute la méthode qu'une seule fois. Comme nous voulons mesurer les performances des opérationsString à l'intérieur de la boucle, une annotation@Measurement est disponible pour cela.

Important à savoir, quebenchmarking loops directly in our tests may skew the results because of various optimizations applied by JVM.

Nous calculons donc uniquement la seule opération et laissons JMH s'occuper du bouclage. En bref, JMH effectue les itérations en utilisant le paramètrebatchSize.

Maintenant, ajoutons le premier micro-benchmark:

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

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

Lors du premier test, un nouvel objet est créé à chaque itération. Dans le deuxième test, l'objet est créé une seule fois. Pour les itérations restantes, le même objet est renvoyé à partir du pool de constantesString’s.

Exécutons les tests avec le nombre d'itérations en boucle= 1,000,000 et voyons les résultats:

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

A partir des valeurs deScore, nous pouvons clairement voir que la différence est significative.

2.2. + Opérateur

Jetons un coup d'œil à l'exemple de concaténation dynamiqueString:

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

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

Dans nos résultats, nous voulons voir le temps d'exécution moyen. Le format du numéro de sortie est défini sur millisecondes:

Benchmark                       1000     10,000
benchmarkStringDynamicConcat    47.331   4370.411

Maintenant, analysons les résultats. Comme nous le voyons, l'ajout d'éléments1000 àstate.result prend47.331 millisecondes. Par conséquent, en augmentant le nombre d'itérations en 10 fois, le temps d'exécution passe à4370.441 millisecondes.

En résumé, le temps d'exécution croît de manière quadratique. Par conséquent, la complexité de la concaténation dynamique dans une boucle de n itérations est deO(n^2).

2.3. String.concat()

Une autre façon de concaténerStrings consiste à utiliser la méthodeconcat():

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

L'unité de temps de sortie est une milliseconde, le nombre d'itérations est de 100 000. La table de résultat ressemble à:

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

2.4. String.format()

Une autre façon de créer des chaînes consiste à utiliser la méthodeString.format(). Under the hood, it uses regular expressions to parse the input.

Écrivons le cas de test JMH:

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

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

Après, nous l'exécutons et voyons les résultats:

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

Bien que le code avecString.format() semble plus propre et lisible, nous ne gagnons pas ici en termes de performances.

2.5. StringBuilder etStringBuffer

Nous avons déjà unwrite-up expliquantStringBuffer etStringBuilder. Donc, ici, nous afficherons uniquement des informations supplémentaires sur leurs performances. StringBuilder contient un tableau redimensionnable et un index qui indique la position de la dernière cellule utilisée dans le tableau. Lorsque le tableau est plein, il double le double de sa taille et copie tous les caractères dans le nouveau tableau.

En tenant compte du fait que le redimensionnement ne se produit pas très souvent,we can consider each append() operation as O(1) constant time. En tenant compte de cela, l'ensemble du processus a une complexité deO(n) .

Après avoir modifié et exécuté le test de concaténation dynamique pourStringBuffer etStringBuilder, we, obtenez:

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

Bien que la différence de score ne soit pas beaucoup, nous pouvons remarquerthat StringBuilder works faster.

Heureusement, dans les cas simples, nous n’avons pas besoin deStringBuilder pour mettre unString avec un autre. Parfois,static concatenation with + can actually replace StringBuilder. Under the hood, the latest Java compilers will call the StringBuilder.append() to concatenate strings.

Cela signifie gagner en performance de manière significative.

3. Opérations utilitaires

3.1. StringUtils.replace() contreString.replace()

Intéressant à savoir, queApache Commons version for replacing the String does way better than the String’s own replace() method. La réponse à cette différence réside dans leur mise en œuvre. String.replace() utilise un modèle regex pour correspondre auxString.

En revanche,StringUtils.replace() utilise largementindexOf(), ce qui est plus rapide.

Maintenant, il est temps pour les tests de référence:

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

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

En réglant lebatchSize à 100 000, nous présentons les résultats:

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

Bien que la différence entre les nombres ne soit pas trop grande, leStringUtils.replace() a un meilleur score. Bien entendu, les nombres et l'écart entre eux peuvent varier en fonction de paramètres tels que le nombre d'itérations, la longueur de la chaîne et même la version du JDK.

Avec la dernière version de JDK 9+ (nos tests s'exécutent sur JDK 10), les deux versions ont des résultats à peu près identiques. Maintenant, rétrogradons la version JDK à 8 et les tests à nouveau:

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

La différence de performance est énorme maintenant et confirme la théorie dont nous avons discuté au début.

3.2. split()

Avant de commencer, il sera utile de vérifier la chaînesplitting methods disponible en Java.

Lorsqu'il est nécessaire de diviser une chaîne avec le délimiteur, la première fonction qui nous vient à l'esprit est généralementString.split(regex). Cependant, cela pose de sérieux problèmes de performances, car il accepte un argument de regex. Alternativement, nous pouvons utiliser la classeStringTokenizer pour diviser la chaîne en jetons.

Une autre option est l’APISplitter de Guava. Enfin, le bon vieuxindexOf() est également disponible pour booster les performances de notre application si nous n’avons pas besoin de la fonctionnalité des expressions régulières.

Il est maintenant temps d’écrire les tests de référence pour l’optionString.split():

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;
}

Splitter de la goyave:

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

Enfin, nous exécutons et comparons les résultats pourbatchSize = 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

Comme nous le voyons, la pire performance a la méthodebenchmarkStringSplitPattern, où nous utilisons la classePattern. En conséquence, nous pouvons apprendre que l'utilisation d'une classe regex avec la méthodesplit() peut entraîner une perte de performances à plusieurs reprises.

De même,we notice that the fastest results are providing examples with the use of indexOf() and split().

3.3. Conversion enString

Dans cette section, nous allons mesurer les scores d'exécution de la conversion de chaîne. Pour être plus précis, nous allons examiner la méthode de concaténation deInteger.toString():

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);
}

Après avoir exécuté les tests, nous verrons la sortie pourbatchSize = 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

Après analyse des résultats, nous voyons quethe test for Integer.toString() has the best score of 0.953 milliseconds. En revanche, une conversion qui impliqueString.format(“%d”) a les pires performances.

C’est logique car l’analyse du formatString est une opération coûteuse.

3.4. Comparaison de chaînes

Évaluons différentes manières de comparerStrings. Le nombre d'itérations est de100,000.

Voici nos tests de référence pour le fonctionnementString.equals():

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

String.equalsIgnoreCase():

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

String.matches():

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

String.compareTo():

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

Ensuite, nous exécutons les tests et affichons les résultats:

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

Comme toujours, les chiffres parlent d’eux-mêmes. Lematches() prend le plus de temps car il utilise l'expression régulière pour comparer l'égalité.

En revanche,the equals() and equalsIgnoreCase() are the best choices.

3.5. String.matches() contrePrecompiled Pattern

Voyons maintenant séparément les spatternsString.matches() etMatcher.matches() . Le premier prend comme argument une expression rationnelle et le compile avant son exécution.

Donc chaque fois que nous appelonsString.matches(), il compile lesPattern:

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

La deuxième méthode réutilise l'objetPattern:

Pattern longPattern = Pattern.compile(longString);

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

Et maintenant les résultats:

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

Comme nous le voyons, l'appariement avec une expression rationnelle précompilée est environ trois fois plus rapide.

3.6. Vérification de la longueur

Enfin, comparons la méthodeString.isEmpty():

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

et la méthodeString.length():

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

Tout d'abord, nous les appelons sur leslongString = “Hello example, I am a bit longer than other Strings in average” String. LebatchSize est10,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

Ensuite, définissons la chaîne vide delongString = “” et réexécutons les tests:

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

Comme nous le remarquons, les méthodesbenchmarkStringLengthZero() etbenchmarkStringIsEmpty() dans les deux cas ont approximativement le même score. Cependant, appelerisEmpty() works faster than checking if the string’s length is zero.

4. Déduplication de chaîne

Depuis JDK 8, la fonctionnalité de déduplication de chaînes est disponible pour éliminer la consommation de mémoire. En termes simples,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.

Actuellement, il existe deux façons de gérer les doublons deString:

  • utilisation manuelle desString.intern()

  • activation de la déduplication de chaînes

Examinons de plus près chaque option.

4.1. String.intern()

Avant d'aller plus loin, il sera utile de lire sur les stages manuels dans noswrite-up. With String.intern() we can manually set the reference of the String object inside of the global String pool.

Ensuite, la machine virtuelle Java peut utiliser le renvoi de la référence si nécessaire. Du point de vue des performances, notre application peut grandement tirer profit de la réutilisation des références de chaîne du pool constant.

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

Cependant, il existe également de graves inconvénients:

  • pour maintenir correctement notre application, nous pouvons avoir besoin de définir un paramètre JVM-XX:StringTableSize pour augmenter la taille du pool. La machine virtuelle Java a besoin d'un redémarrage pour développer la taille du pool

  • calling String.intern() manually is time-consuming. Il se développe dans un algorithme de temps linéaire avec une complexité deO(n)

  • en plus,frequent calls on long String objects may cause memory problems

Pour avoir des chiffres prouvés, exécutons un test de référence:

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

De plus, les scores de sortie sont en millisecondes:

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

Les en-têtes de colonne représentent ici un nombre deiterations différent de1000 à1,000,000. Pour chaque numéro d'itération, nous avons le score de performance du test. Comme nous le remarquons, le score augmente considérablement en plus du nombre d'itérations.

4.2. Activer automatiquement la déduplication

Tout d'abord,this option is a part of the G1 garbage collector. Par défaut, cette fonction est désactivée. Nous devons donc l'activer avec la commande suivante:

 -XX:+UseG1GC -XX:+UseStringDeduplication

Il est important de noter queenabling this option doesn’t guarantee that String deduplication will happen. De plus, il ne traite pas les jeunesStrings.. Afin de gérer l’âge minimal de traitement, l’option JVM deStrings, XX:StringDeduplicationAgeThreshold=3 est disponible. Ici,3 est le paramètre par défaut.

5. Sommaire

Dans ce didacticiel, nous essayons de donner des conseils pour utiliser les chaînes plus efficacement dans notre vie de codage quotidienne.

En conséquence,we can highlight some suggestions in order to boost our application performance:

  • when concatenating strings, the StringBuilder is the most convenient option qui me vient à l'esprit. Cependant, avec les petites chaînes, l'opérationa presque les mêmes performances. Sous le capot, le compilateur Java peut utiliser la classeStringBuilder pour réduire le nombre d'objets chaîne

  • pour convertir la valeur en chaîne, le[some type].toString() (Integer.toString() par exemple) fonctionne plus vite queString.valueOf(). Comme cette différence n'est pas significative, nous pouvons utiliser librementString.valueOf() pour ne pas avoir de dépendance sur le type de valeur d'entrée

  • en ce qui concerne la comparaison de chaînes, rien ne vaut lesString.equals() jusqu'à présent

  • La déduplication deString améliore les performances des applications multithreads volumineuses. Mais une utilisation excessive deString.intern() peut provoquer de graves fuites de mémoire, ralentissant l'application

  • for splitting the strings we should use indexOf() to win in performance. Cependant, dans certains cas non critiques, la fonctionString.split() peut être un bon ajustement

  • En utilisantPattern.match(), la chaîne améliore considérablement les performances

  • String.isEmpty() est plus rapide que la chaîne.length() ==0

En outre,keep in mind that the numbers we present here are just JMH benchmark results - vous devez donc toujours tester dans le cadre de votre propre système et runtime pour déterminer l'impact de ces types d'optimisations.

Enfin, comme toujours, le code utilisé lors de la discussion se trouveover on GitHub.

**