De l’utilité du constructeur String(String)

Rien n’est complètement inutile, malgré des apparences qui pourraient laisser présager le contraire…

L’API Java propose un constructeur de copie String(String) pour la classe String. Pour rappel le constructeur de copie est une notion très importante en C++ où des copies d’objets sont obligatoires afin de gérer proprement la mémoire (lorsqu’on n’utilise pas de GC), sinon on ne saurait plus si l’objet est libérable ou pas. A l’inverse, en Java ce concept est rarement utilisé puisque le GC couplé à la notion d’immuabilité des classes permet de partager des instances entre plusieurs classes sans que cela ne cause de problème. Cela permet d’éviter d’avoir à faire de multiples copies de protection…

La classe String étant immuable, la création de copie de protection est donc inutile. De ce fait pendant longtemps j’ai pensé que la présence de ce constructeur de copie String(String) était une bizarrerie de l’API, que l’on se traine pour des raisons de compatibilité ascendante.

D’ailleurs, même la documentation officiel met en avant le peu d’intérêt de ce constructeur :

Unless an explicit copy of original is needed, use of this constructor is unnecessary since Strings are immutable.

Il existe pourtant un cas où cela peut se reveler utile, et c’est justement lié à l’implémentation de la classe String en Java. Comme vous le savez surement, et puisque la classe String est immuable, la plupart des méthodes « modifiant » la chaine de caractères renvoyent en fait une nouvelle instance de la classe, ceci afin de préserver l’immuabilité de la classe. Ces méthodes sont toutefois optimisés afin de ne pas utiliser trop de mémoire, en partageant un maximum d’information.

Ainsi, en interne, une String est représentée par trois attributs :

  • char value[], un tableau stockant les caractères de la chaîne.
  • int offset, qui indique l’index du premier caractère de la chaîne dans le tableau.
  • int count, qui indique le nombre de caractère de la chaîne.

L’astuce vient du fait que le tableau de caractère value peut être partagé par plusieurs String différentes afin d’éviter de dupliquer du code. Ainsi, si on prend le code suivant :


  String helloWorld = "Hello World !";
  String world = helloWorld.substring(6, 11);

Nous avons bien deux objets de type String, mais ils utilisent tous les deux le même tableau value avec des positions différentes :

  • offset=0 et count=13 pour la première, ce qui fait que la totalité du tableau est utilisé.
  • offset=6 et count=5 pour la seconde, ce qui fait que seul le mot « World » est utilisé.

Cela permet d’éviter de dupliquer inutilement les tableaux et donc d’économiser la mémoire. La plupart des temps cette implémentation est bénéfique, mais il arrive des cas où cela peut poser problème. Et c’est justement dans ces cas là que le constructeur par copie prend de l’intérêt.

Pour bien mettre en évidence cette problématique, je vais utiliser deux méthodes toutes simples.

  • La première permet de générer aléatoirement une chaine de caractère de taille quelconque :
    
    
      /**
       * Crée une chaine aléatoire de la taille indiqué en paramètre
       * @param size Taille de la chaine à créer
       * @return Une chaine aléatoire.
       */
      public static String createString(int size) {
        final String str = "abcdefghijklmnopqrstuvwxyz0123456789";
        final int length = str.length();
        final Random random = new Random();
       
        final StringBuilder sb = new StringBuilder();
        for (int i=0; i<size; i++) {
          sb.append( str.charAt(random.nextInt(length)) );
        }
        return sb.toString();
      }
  • La seconde « force » la collecte des objets par le GC, et affiche les informations sur la mémoire utilisée par l’application :
    
    
      /**
       * "Force" l'exécution du GC et affiche la taille du heap.
       */
      public static void tryToForceGC() {
        // On fait le bourrin en appelant le GC 5 fois au cas où
        for (int i=0; i<5; i++) {
          System.gc();
          Thread.yield();
        }
        System.out.println(ManagementFactory.getMemoryMXBean().getHeapMemoryUsage());
      }

    Attention : le GC ne devrait pas être appelé explicitement dans une application normale. Si je le fais ici c’est uniquement pour mettre en évidence un comportement. Dans une vrai application l’appel explicite au GC peut poser des problèmes de performances car il y a de forte chance que cela génère un cycle complet inutilement…

Mon premier test se contentera de mettre en évidence les optimisations de substring(), en créant une chaîne de très grande taille (10 millions d’éléments), puis en la découpant en deux sous-chaines :


  tryToForceGC();
 
  String str = createString(10000000);
 
  tryToForceGC();
 
  String str1 = str.substring(0, 5000000);
  String str2 = str.substring(5000001);
 
  tryToForceGC();

Cela nous génère le résultat suivant :


init = 0(0K) used = 132944(129K) committed = 5177344(5056K) max = 66650112(65088K)
init = 0(0K) used = 20116464(19644K) committed = 66650112(65088K) max = 66650112(65088K)
init = 0(0K) used = 20116512(19645K) committed = 66650112(65088K) max = 66650112(65088K)

Avec la création de notre chaîne d’un millions d’éléments, la mémoire utilisée passe logiquement de 129K à près de 20 Mo (pour rappel les char en Java sont stocké en UTF-16 et occupe ainsi 2 octets). Mais on voit également que la création des deux sous-chaines via subString() n’entraine quasiment aucune consommation mémoire, puisqu’on utilise en fait les mêmes données…

On voit bien là tout l’intérêt de cette optimisation : théoriquement l’occupation mémoire aurait dû doubler entre le deuxième et le troisième affichage de la mémoire. Or il n’en ait rien car ici les trois instances de la classe String utilisent toutes le même espace mémoire.

A l’inverse prennons maintenant le code suivant :


  tryToForceGC();  
       
  String str = createString(10000000);  
       
  tryToForceGC();  
     
  str = str.substring(0, 5);
       
  tryToForceGC();

De notre chaine de 20 Mo, nous ne conservons désormais que les 5 premiers caractères, ce qui devrait représenter 10 petits octets (si on prend uniquement en compte les caractères).

Pourtant la sortie du programme nous donne une toute autre information :


init = 0(0K) used = 132832(129K) committed = 5177344(5056K) max = 66650112(65088K)
init = 0(0K) used = 20116496(19645K) committed = 66650112(65088K) max = 66650112(65088K)
init = 0(0K) used = 20116496(19645K) committed = 66650112(65088K) max = 66650112(65088K)

Nous utilisons toujours 20 Mo de mémoire, alors que notre programme ne référence plus qu’une toute petite chaine de 10 octets !

Le GC aurait-il du mal à faire son travail ?
Pas du tout ! La référence vers l’instance de String de 10 millions de lettres a bien été nettoyé, mais pas son tableau interne qui est désormais également référencé par la nouvelle instance renvoyée par le substring(), malgré le fait qu’elle n’en utilise qu’une infime partie…

On est typiquement dans un cas (extrême) où le partage des données engendre une utilisation mémoire inutile.

C’est là que survient l’intérêt du constructeur String(String) :


  tryToForceGC();  
       
  String str = createString(10000000);  
       
  tryToForceGC();  
     
  str = new String( str.substring(0, 5) );
       
  tryToForceGC();

En effet, ce constructeur va créer une nouvelle instance de la même valeur, mais sans utiliser le mécanisme de partage des données : elle utilisera son propre espace mémoire. Ainsi le tableau interne de 10 millions d’éléments est désormais libérable par le GC, puisqu’il n’est plus référencé nulle-part, et on se retrouve bien à la fin avec une utilisation mémoire « normale » :


init = 0(0K) used = 132832(129K) committed = 5177344(5056K) max = 66650112(65088K)
init = 0(0K) used = 20116496(19645K) committed = 66650112(65088K) max = 66650112(65088K)
init = 0(0K) used = 116504(113K) committed = 5181440(5060K) max = 66650112(65088K)

Bref : le constructeur String(String) n’est pas si inutile que cela, puisqu’il permet de passer outre le partage des données entre différentes instances de String. Si ces dernières sont très utile dans 95% des cas, il reste quelque cas particulier où cela peut être problématique…

Faut-il l’utiliser massivement pour autant ? Non puisque cela ne représente que quelques cas particuliers, mais il peut être important de bien comprendre ce comportement, en particulier lorsque on crée des sous-chaines qui auront une durée de vie plus importante que la chaine originale…

15 réflexions au sujet de « De l’utilité du constructeur String(String) »

    1. adiGuba Auteur de l’article

      @Nemek : tout à fait !

      Désormais chaque String possède son propre tableau interne.
      Du coup substring() ne s’exécute plus en temps constant (puisqu’il faut allouer un nouveau tableau), mais on évite ainsi des risques de fuite de mémoire…

      A noter en contrepartie qu’il y a une JEP dont l’objectif sera à terme de dé-dupliquer les String identiques : http://openjdk.java.net/jeps/192

      a++

  1. Avatar de julurejulure

    Voici l’implémentation du J2RE 1.5.0 de la JVM J9 d’IBM
    du constructeur de copie de la classe String.
    L’optimisation de la mémoire ne fonctionne donc pas chez IBM.

    /**
     * Creates a string that is a copy of another string
     *
     * @param    string  the String to copy
     */
    public String (String string)
      value = string.value;
      offset = string.offset;
      count = string.count;
    }
  2. Avatar de lunatixlunatix

    Je me rappelle d’un post des développeurs d’Eclipse qui s’étaient rendu compte que de changer les retours de méthodes de

    return stringBuffer.toString() a
    return new String(stringBuffer.toString())

    leur avaient fait gagner pas mal de mémoire.

  3. adiguba Auteur de l’article

    Je n’avais jamais lu ce billet. Et j’ai une preuve de ma bonne foi :D

    Ce billet a été publié en septembre 2008.

    Si finalement mon billet n’a été publié qu’hier, cela fait longtemps que j’en avais écrit les grandes lignes (cela m’arrive parfois et encore maintenant j’ai 2 billets en brouillon :aie:)

    Cela peut se vérifier par le numéro d’identifiant du billet : http://blog.developpez.com/?p=6053

    Les billets avec les numéros suivants ont été publié en juillet… ce qui fait que j’ai commencé à écrire ce billet en Juillet, pour le finaliser hier :aie:

    a++

  4. adiguba Auteur de l’article

    fmarot> Je ne me suis pas inspiré d’un blog anglais pour ce billet…

    Cela fait pas mal de temps que j’ai commencé à écrire ce billet, mais le temps me manquant je ne l’avais jamais finalisé. Aujourd’hui j’ai pris le temps de le finir.

    a++

    PS : Mais si tu as le lien vers ce billet n’hésite pas ;)

  5. fmarot

    La source de l’article aurait put etre citée, quand meme…
    Effectivement c’est interressant, mais j’ai lu récemment un blog en anglais (linké sur dzone.com) qui évoquait ce meme point… L’auteur aurait donc put dire où il a trouvé son inspiration. Dommage !
    Mais ca reste intéressant ;)

  6. Avatar de gifffftanegifffftane

    Cet usage est explicitement prévu dans les commentaires du source ; on trouve, en effet :

    public String(String original) {
    int size = original.count;
    char[] originalValue = original.value;
    char[] v;
    if (originalValue.length > size) {
    // The array representing the String is bigger than the new
    // String itself. Perhaps this constructor is being called
    // in order to trim the baggage, so make a copy of the array.

    int off = original.offset;
    v = Arrays.copyOfRange(originalValue, off, off+size);
    } else {
    // The array representing the String is the same
    // size as the String, so no point in making a copy.
    v = originalValue;
    }
    this.offset = 0;
    this.count = size;
    this.value = v;
    }

    Ils appellent ça « trim the baggage » :-)

Laisser un commentaire