Recherche…


Introduction

Cette rubrique décrit un certain nombre de "pièges" (c.-à-d. Les erreurs commises par les programmeurs novices java) liés aux performances des applications Java.

Remarques

Cette rubrique décrit quelques "micro" pratiques de codage Java inefficaces. Dans la plupart des cas, les inefficacités sont relativement faibles, mais il est toujours préférable de les éviter.

Pitfall - Les frais généraux de création de messages de journal

TRACE niveaux de journalisation TRACE et DEBUG sont là pour transmettre des informations détaillées sur le fonctionnement du code donné lors de l'exécution. La définition du niveau de journalisation au-dessus de ces paramètres est généralement recommandée. Toutefois, vous devez veiller à ce que ces instructions n’affectent pas les performances, même si elles sont apparemment désactivées.

Considérez cette déclaration de journal:

// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString() 
          + " parameters: " + Arrays.toString(veryLongParamArray));

Même lorsque le niveau de journalisation est défini sur INFO , les arguments transmis à debug() seront évalués à chaque exécution de la ligne. Cela le rend inutile inutilement à plusieurs égards:

  • Concaténation de String : plusieurs instances de String seront créées
  • InetAddress peut même faire une recherche DNS.
  • veryLongParamArray peut être très long - créer un String à partir de celui-ci consomme de la mémoire, prend du temps

Solution

La plupart des structures de journalisation permettent de créer des messages de journalisation à l'aide de chaînes de correctifs et de références d'objets. Le message de journal sera évalué uniquement si le message est réellement consigné. Exemple:

// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));

Cela fonctionne très bien tant que tous les paramètres peuvent être convertis en chaînes en utilisant String.valueOf (Object) . Si le calcul du message de journal est plus complexe, le niveau de journalisation peut être vérifié avant la journalisation:

if (LOG.isDebugEnabled()) {
    // Argument expression evaluated only when DEBUG is enabled
    LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
              Arrays.toString(veryLongParamArray);
}

Ici, LOG.debug() avec le Arrays.toString(Obect[]) coûteux Arrays.toString(Obect[]) est traité uniquement lorsque DEBUG est effectivement activé.

Pitfall - La concaténation de chaînes dans une boucle ne s'adapte pas

Considérez le code suivant comme illustration:

public String joinWords(List<String> words) {
    String message = "";
    for (String word : words) {
        message = message + " " + word;
    }
    return message;
}

Malheureusement, ce code est inefficace si la liste de words est longue. La racine du problème est cette déclaration:

message = message + " " + word;

Pour chaque itération de boucle, cette instruction crée une nouvelle chaîne de message contenant une copie de tous les caractères de la chaîne de message origine avec des caractères supplémentaires. Cela génère beaucoup de chaînes temporaires et fait beaucoup de copie.

Lorsque nous analysons joinWords , en supposant qu'il y ait N mots avec une longueur moyenne de M, nous trouvons que O (N) chaînes temporaires sont créées et que O (MN 2 ) caractères seront copiés dans le processus. La composante N 2 est particulièrement préoccupante.

L'approche recommandée pour ce type de problème 1 consiste à utiliser un StringBuilder au lieu de la concaténation de chaîne comme suit:

public String joinWords2(List<String> words) {
    StringBuilder message = new StringBuilder();
    for (String word : words) {
        message.append(" ").append(word);
    }
    return message.toString();
}

L'analyse de joinWords2 doit prendre en compte les frais généraux joinWords2 à la "croissance" du tableau de commandes StringBuilder qui contient les caractères du générateur. Cependant, il s'avère que le nombre de nouveaux objets créés est O (logN) et que le nombre de caractères copiés est O (MN). Ce dernier inclut les caractères copiés dans l’appel toString() final.

(Il est peut-être possible d’optimiser ce réglage en créant le StringBuilder avec la capacité correcte pour commencer. Cependant, la complexité globale reste la même.)

En revenant à la méthode joinWords origine, il s'avère que la déclaration critique sera optimisée par un compilateur Java typique en quelque chose comme ceci:

  StringBuilder tmp = new StringBuilder();
  tmp.append(message).append(" ").append(word);
  message = tmp.toString();

Cependant, le compilateur Java ne "retirera" pas le StringBuilder de la boucle, comme nous l'avons fait à la main dans le code de joinWords2 .

Référence:


1 - Dans Java 8 et Joiner ultérieures, la classe Joiner peut être utilisée pour résoudre ce problème particulier. Cependant, ce n'est pas ce que cet exemple est vraiment censé être .

Pitfall - Utiliser 'new' pour créer des instances d’emballages primitives est inefficace

Le langage Java vous permet d'utiliser new pour créer des instances Integer , Boolean , etc., mais c'est généralement une mauvaise idée. Il est préférable d’utiliser la méthode d’autoboxing (Java 5 et versions ultérieures) ou la méthode valueOf .

 Integer i1 = new Integer(1);      // BAD
 Integer i2 = 2;                   // BEST (autoboxing)
 Integer i3 = Integer.valueOf(3);  // OK

La raison pour laquelle l'utilisation de new Integer(int) explicitement est une mauvaise idée est qu'il crée un nouvel objet (à moins d'être optimisé par le compilateur JIT). En revanche, lors de l'utilisation de la mise en file d'attente automatique ou d'un appel valueOf explicite, le runtime Java tente de réutiliser un objet Integer partir d'un cache d'objets préexistants. Chaque fois que le runtime a un cache "hit", cela évite de créer un objet. Cela permet également d'économiser de la mémoire de tas et de réduire les frais généraux du GC causés par le roulement de l'objet.

Remarques:

  1. Dans les implémentations Java récentes, l'autoboxing est implémenté en appelant valueOf , et il existe des caches pour Boolean , Byte , Short , Integer , Long et Character .
  2. Le comportement de mise en cache pour les types intégraux est requis par la spécification de langage Java.

Piège - Appeler 'new String (String)' est inefficace

Utiliser new String(String) pour dupliquer une chaîne est inefficace et presque toujours inutile.

  • Les objets String sont immuables, il n'est donc pas nécessaire de les copier pour les protéger contre les modifications.
  • Dans certaines versions antérieures de Java, les objets String peuvent partager des tableaux de sauvegarde avec d'autres objets String . Dans ces versions, il est possible de fuir de la mémoire en créant une (petite) sous-chaîne d'une (grande) chaîne et en la conservant. Cependant, à partir de Java 7, les matrices de sauvegarde de String ne sont pas partagées.

En l’absence d’avantage tangible, l’appel de new String(String) est tout simplement inutile:

  • Faire la copie prend du temps CPU.
  • La copie utilise plus de mémoire, ce qui augmente l'encombrement de l'application et / ou augmente les frais généraux du GC.
  • Les opérations telles que equals(Object) et hashCode() peuvent être plus lentes si des objets String sont copiés.

Piège - L'appel de System.gc () est inefficace

C'est (presque toujours) une mauvaise idée d'appeler System.gc() .

Le javadoc pour la méthode gc() spécifie ce qui suit:

"L'appel de la méthode gc suggère que la machine virtuelle Java déploie des efforts pour recycler les objets inutilisés afin de pouvoir réutiliser rapidement la mémoire qu'elle occupe actuellement. Lorsque le contrôle revient de l'appel de méthode, la machine virtuelle Java s'efforce de récupérer espace de tous les objets jetés. "

On peut en tirer quelques points importants:

  1. L'utilisation du mot "suggère" plutôt que (dis) "raconte" signifie que la JVM est libre d'ignorer la suggestion. Le comportement par défaut de la JVM (versions récentes) doit suivre la suggestion, mais cela peut être remplacé par la définition de -XX:+DisableExplicitGC lors du lancement de la JVM.

  2. La phrase "un meilleur effort pour récupérer de l'espace à partir de tous les objets ignorés" implique que l'appel de gc déclenchera une récupération de gc "complète".

Alors pourquoi appeler System.gc() une mauvaise idée?

Tout d'abord, exécuter une collecte de place complète est coûteux. Un GC complet implique la visite et le "marquage" de chaque objet encore accessible; c'est-à-dire chaque objet qui n'est pas une poubelle. Si vous déclenchez cela alors qu'il n'y a pas beaucoup de déchets à collecter, le GC fait beaucoup de travail pour relativement peu d'avantages.

Deuxièmement, une récupération de place complète risque de perturber les propriétés de "localité" des objets non collectés. Les objets alloués par le même thread à peu près au même moment ont tendance à être alloués de manière rapprochée en mémoire. C'est bon. Les objets alloués en même temps sont susceptibles d'être liés; c'est-à-dire se référencer Si votre application utilise ces références, il est probable que l’accès à la mémoire sera plus rapide en raison des divers effets de mémoire et de mise en cache des pages. Malheureusement, une récupération de mémoire complète a tendance à déplacer des objets, de sorte que les objets qui étaient autrefois fermés sont désormais plus éloignés.

Troisièmement, l'exécution d'une récupération de place complète risque de mettre votre application en pause jusqu'à ce que la collecte soit terminée. Pendant que cela se produit, votre demande ne sera pas recevable.

En fait, la meilleure stratégie consiste à laisser la JVM décider du moment où exécuter le GC et du type de collection à exécuter. Si vous n'intervenez pas, la machine virtuelle Java choisira un type de temps et de collection qui optimise le débit ou minimise les temps de pause du GC.


Au début, nous avons dit "(presque toujours) une mauvaise idée ...". En fait, il existe quelques scénarios dans lesquels cela pourrait être une bonne idée:

  1. Si vous implémentez un test unitaire pour un code sensible au ramassage des ordures (par exemple, quelque chose impliquant des finaliseurs ou des références faibles / douces / fantômes), il peut être nécessaire d'appeler System.gc() .

  2. Dans certaines applications interactives, il peut y avoir des moments particuliers où l'utilisateur ne se soucie pas de savoir s'il y a une pause de récupération de place. Un exemple est un jeu où il y a des pauses naturelles dans le "jeu"; par exemple lors du chargement d'un nouveau niveau.

Piège - La surutilisation des types d’emballages primitifs est inefficace

Considérez ces deux morceaux de code:

int a = 1000;
int b = a + 1;

et

Integer a = 1000;
Integer b = a + 1;

Question: Quelle version est la plus efficace?

Réponse: Les deux versions sont presque identiques, mais la première version est beaucoup plus efficace que la deuxième.

La deuxième version utilise une représentation des nombres qui utilise plus d'espace, et s'appuie sur la mise en boîte automatique et le désencapsulation automatique en arrière-plan. En fait, la deuxième version est directement équivalente au code suivant:

Integer a = Integer.valueOf(1000);               // box 1000
Integer b = Integer.valueOf(a.intValue() + 1);   // unbox 1000, add 1, box 1001

En comparant ceci à l'autre version qui utilise int , il y a clairement trois appels de méthode supplémentaires quand Integer est utilisé. Dans le cas de valueOf , les appels vont chacun créer et initialiser un nouvel objet Integer . Tout ce travail supplémentaire de boxe et de déballage va probablement rendre la deuxième version plus lente que la première.

En plus de cela, la deuxième version alloue des objets sur le tas dans chaque appel valueOf . Bien que l'utilisation de l'espace soit spécifique à la plate-forme, il est probable qu'il soit de l'ordre de 16 octets pour chaque objet Integer . En revanche, la version int nécessite un espace de pile supplémentaire, en supposant que a et b sont des variables locales.


Une autre grande raison pour laquelle les primitives sont plus rapides que leur équivalent en boîte est la manière dont leurs types de tableau respectifs sont disposés en mémoire.

Si vous prenez int[] et Integer[] comme exemple, dans le cas d'un int[] les valeurs int sont contiguës en mémoire. Mais dans le cas d'un Integer[] ce ne sont pas les valeurs qui sont mises en page, mais les références (pointeurs) aux objets Integer , qui contiennent à leur tour les valeurs int réelles.

En plus d'être un niveau supplémentaire d'indirection, il peut s'agir d'un gros réservoir lorsqu'il s'agit de mettre en cache une localité lors d'une itération sur les valeurs. Dans le cas d'un int[] le processeur peut récupérer toutes les valeurs du tableau, dans son cache, car elles sont contiguës en mémoire. Mais dans le cas d'un Integer[] le processeur doit éventuellement effectuer une extraction de mémoire supplémentaire pour chaque élément, car le tableau contient uniquement des références aux valeurs réelles.


En bref, l'utilisation de types d'encapsuleurs primitifs est relativement coûteuse à la fois en termes de ressources processeur et mémoire. Les utiliser inutilement est efficace.

Piège - Itérer les clés d'une carte peut être inefficace

L'exemple de code suivant est plus lent que nécessaire:

Map<String, String> map = new HashMap<>(); 
for (String key : map.keySet()) {
    String value = map.get(key);
    // Do something with key and value
}

En effet, il nécessite une recherche de carte (la méthode get() ) pour chaque clé de la carte. Cette recherche peut ne pas être efficace (dans un HashMap, cela implique d'appeler hashCode sur la clé, puis de rechercher le bon compartiment dans les structures de données internes, et parfois même d'appeler des equals ). Sur une grande carte, ceci peut ne pas être une surcharge triviale.

La manière correcte d'éviter ceci est d'itérer sur les entrées de la carte, ce qui est détaillé dans la rubrique Collections.

Piège - L'utilisation de size () pour tester si une collection est vide est inefficace.

Java Collections Framework fournit deux méthodes connexes pour tous les objets Collection :

  • size() renvoie le nombre d'entrées dans une Collection et
  • isEmpty() méthode isEmpty() renvoie true si (et seulement si) la Collection est vide.

Les deux méthodes peuvent être utilisées pour tester la vacuité de la collecte. Par exemple:

Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty();         // Best

Bien que ces approches se ressemblent, certaines implémentations de collections ne stockent pas la taille. Pour une telle collection, l'implémentation de size() doit calculer la taille à chaque appel. Par exemple:

  • Une simple classe de liste liée (mais pas java.util.LinkedList ) peut avoir besoin de parcourir la liste pour compter les éléments.
  • La classe ConcurrentHashMap doit additionner les entrées de tous les "segments" de la carte.
  • Une implémentation paresseuse d'une collection peut nécessiter de réaliser toute la collection en mémoire afin de compter les éléments.

En revanche, une méthode isEmpty() doit uniquement tester s'il existe au moins un élément dans la collection. Cela ne nécessite pas de compter les éléments.

Alors que size() == 0 n'est pas toujours moins efficace que isEmpty() , il est inconcevable qu'un isEmpty() correctement implémenté soit moins efficace que size() == 0 . Par conséquent, isEmpty() est préféré.

Piège - Problèmes d’efficacité avec les expressions régulières

La correspondance d'expressions régulières est un outil puissant (en Java et dans d'autres contextes), mais elle présente certains inconvénients. Une de ces expressions que les expressions régulières ont tendance à être assez chère.

Les instances Pattern et Matcher doivent être réutilisées

Prenons l'exemple suivant:

/**
 * Test if all strings in a list consist of English letters and numbers.
 * @param strings the list to be checked
 * @return 'true' if an only if all strings satisfy the criteria
 * @throws NullPointerException if 'strings' is 'null' or a 'null' element.
 */
public boolean allAlphanumeric(List<String> strings) {
    for (String s : strings) {
        if (!s.matches("[A-Za-z0-9]*")) {
            return false;
        }  
    }
    return true;
}

Ce code est correct, mais il est inefficace. Le problème réside dans l'appel de matches(...) . Sous le capot, s.matches("[A-Za-z0-9]*") est équivalent à ceci:

Pattern.matches(s, "[A-Za-z0-9]*")

ce qui équivaut à son tour à

Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()

L' Pattern.compile("[A-Za-z0-9]*") analyse l'expression régulière, l'analyse et construit un objet Pattern contenant la structure de données qui sera utilisée par le moteur regex. C'est un calcul non trivial. Ensuite, un objet Matcher est créé pour envelopper l'argument s . Enfin, nous appelons match() pour faire la correspondance de motif réelle.

Le problème est que ce travail est répété pour chaque itération de boucle. La solution consiste à restructurer le code comme suit:

private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");

public boolean allAlphanumeric(List<String> strings) {
    Matcher matcher = ALPHA_NUMERIC.matcher("");
    for (String s : strings) {
        matcher.reset(s);
        if (!matcher.matches()) {
            return false;
        }  
    }
    return true;
}

Notez que le javadoc pour Pattern indique:

Les instances de cette classe sont immuables et peuvent être utilisées par plusieurs threads simultanés. Les instances de la classe Matcher ne sont pas sûres pour une telle utilisation.

N'utilisez pas match () quand vous devriez utiliser find ()

Supposons que vous voulez tester si une chaîne s contient trois chiffres ou plus dans une rangée. Vous pouvez l'exprimer de différentes manières, notamment:

  if (s.matches(".*[0-9]{3}.*")) {
      System.out.println("matches");
  }

ou

  if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
      System.out.println("matches");
  }

Le premier est plus concis, mais il est également susceptible d'être moins efficace. À première vue, la première version va essayer de faire correspondre la chaîne entière au motif. De plus, puisque ". *" Est un modèle "gourmand", le gestionnaire de motifs est susceptible de faire avancer "avidement" la fin de la chaîne et de revenir en arrière jusqu'à ce qu'il trouve une correspondance.

En revanche, la deuxième version recherchera de gauche à droite et cessera de chercher dès qu'elle trouvera les 3 chiffres à la suite.

Utiliser des alternatives plus efficaces aux expressions régulières

Les expressions régulières sont un outil puissant, mais elles ne doivent pas être votre seul outil. Beaucoup de tâches peuvent être effectuées de manière plus efficace par d'autres moyens. Par exemple:

 Pattern.compile("ABC").matcher(s).find()

fait la même chose que:

 s.contains("ABC")

sauf que ce dernier est beaucoup plus efficace. (Même si vous pouvez amortir le coût de compilation de l'expression régulière)

Souvent, la forme non-regex est plus compliquée. Par exemple, le test effectué par l'appel matches() la méthode allAlplanumeric antérieure peut être réécrit comme allAlplanumeric :

 public boolean matches(String s) {
     for (char c : s) {
         if ((c >= 'A' && c <= 'Z') ||
             (c >= 'a' && c <= 'z') ||
             (c >= '0' && c <= '9')) {
              return false;
         }
     }
     return true;
 }

Maintenant, c'est plus de code que d'utiliser un Matcher , mais cela va être beaucoup plus rapide.

Retournement catastrophique

(Ceci est potentiellement un problème avec toutes les implémentations d'expressions régulières, mais nous allons le mentionner ici car c'est un piège pour l'utilisation de Pattern .)

Considérons cet exemple (artificiel):

Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());

Le premier println appel va rapidement imprimer true . Le second imprimera false . Finalement. En effet, si vous testez le code ci-dessus, vous verrez que chaque fois que vous ajoutez un A avant le C , le temps nécessaire doublera.

Ce comportement est un exemple de retour en arrière catastrophique . Le moteur correspondant de modèle qui met en oeuvre la mise en correspondance regex tente infructueusement toutes les façons possibles que le modèle pourrait correspondre.

Regardons ce que (A+)+B signifie réellement. Superficiellement, il semble dire "un ou plusieurs caractères A suivis d'une valeur B ", mais en réalité, il s'agit d'un ou de plusieurs groupes, chacun composé d'un ou plusieurs caractères A Donc, par exemple:

  • "AB" correspond à un sens seulement: "(A) B"
  • "AAB" correspond à deux manières: "(AA) B" ou "(A) (A) B"
  • "AAAB" correspond à quatre méthodes: "(AAA) B" ou "(AA) (A) B or '(A)(AA)B ou "(A) (A) (A) B"
  • etc

En d'autres termes, le nombre de correspondances possibles est 2 N où N est le nombre de caractères A

L'exemple ci-dessus est clairement inventé, mais les modèles qui présentent ce type de caractéristiques de performance (c'est-à-dire O(2^N) ou O(N^K) pour un grand K ) apparaissent fréquemment lorsque des expressions régulières mal considérées sont utilisées. Il existe des remèdes standard:

  • Évitez d'imbriquer des motifs répétés dans d'autres motifs répétés.
  • Évitez d'utiliser trop de motifs répétés.
  • Utilisez la répétition sans retour en arrière si nécessaire.
  • N'utilisez pas les expressions rationnelles pour les tâches d'analyse complexes. (Écrivez un analyseur approprié à la place.)

Enfin, méfiez-vous des situations où un utilisateur ou un client API peut fournir une chaîne d'expression régulière présentant des caractéristiques pathologiques. Cela peut entraîner un "déni de service" accidentel ou délibéré.

Les références:

Pitfall - Interner des chaînes pour que vous puissiez utiliser == est une mauvaise idée

Quand certains programmeurs voient ce conseil:

"Tester des chaînes avec == est incorrect (à moins que les chaînes ne soient internées)"

leur première réaction consiste à utiliser des chaînes internes pour pouvoir utiliser == . (Après tout, == est plus rapide que d'appeler String.equals(...) , n'est-ce pas?)

C'est la mauvaise approche, sous plusieurs angles:

Fragilité

Tout d'abord, vous ne pouvez utiliser qu'en toute sécurité == si vous savez que tous les objets String vous testez ont été internés. Le JLS garantit que les littéraux String de votre code source auront été internés. Cependant, aucune des API Java SE standard ne garantit de renvoyer des chaînes internes, à l'exception de String.intern(String) elle-même. Si vous ne manquez qu'une seule source d'objets String qui n'ont pas été internés, votre application ne sera pas fiable. Ce manque de fiabilité se traduira par de faux négatifs plutôt que des exceptions susceptibles de rendre la détection plus difficile.

Coûts d'utilisation de 'intern ()'

Sous le capot, l'internement fonctionne en maintenant une table de hachage qui contient des objets String précédemment internés. Une sorte de mécanisme de référence faible est utilisé pour que la table de hachage interne ne devienne pas une fuite de stockage. Alors que la table de hachage est implémentée en code natif (contrairement à HashMap , HashTable , etc.), les appels intern sont encore relativement coûteux en termes de CPU et de mémoire.

Ce coût doit être comparé à celui que nous allons obtenir en utilisant == au lieu d’ equals . En fait, nous n'allons pas à la rupture à moins que chaque chaîne interne soit comparée à d'autres chaînes "quelques fois".

(Mis à part: les quelques situations où l’internat est utile ont tendance à réduire l’empreinte mémoire d’une application où les mêmes chaînes se répètent plusieurs fois, et ces chaînes ont une longue durée de vie.)

L'impact sur la collecte des ordures

Outre les coûts directs de processeur et de mémoire décrits ci-dessus, les chaînes internes affectent les performances du ramasse-miettes.

Pour les versions de Java antérieures à Java 7, les chaînes internes sont conservées dans l'espace "PermGen", qui est rarement collecté. Si PermGen doit être collecté, cela déclenche généralement une récupération de place complète. Si l'espace PermGen se remplit complètement, la machine virtuelle Java se bloque, même s'il y avait de l'espace libre dans les espaces de pile standard.

Dans Java 7, le pool de chaînes a été déplacé de "PermGen" dans le tas normal. Cependant, la table de hachage sera toujours une structure de données à long terme, ce qui entraînera une longue durée de vie des chaînes internes. (Même si les objets de chaîne internes étaient alloués dans l'espace Eden, ils seraient très probablement promus avant d'être collectés.)

Ainsi, dans tous les cas, l’installation d’une ficelle va prolonger sa durée de vie par rapport à une ficelle ordinaire. Cela augmentera les frais généraux de la récupération de place pendant la durée de vie de la machine virtuelle Java.

Le deuxième problème est que la table de hachage doit utiliser un mécanisme de référence faible afin d'empêcher que la chaîne ne contienne de la mémoire. Mais un tel mécanisme est plus utile pour le ramasse-miettes.

Il est difficile de quantifier ces frais généraux de récupération de place, mais il ne fait aucun doute qu'ils existent. Si vous utilisez beaucoup de intern , ils pourraient être importants.

La taille de la table de hachage

Selon cette source , à partir de Java 6, le pool de chaînes de caractères est implémenté sous la forme d'une table de hachage de taille fixe avec des chaînes pour gérer les chaînes qui hachent le même compartiment. Dans les premières versions de Java 6, la table de hachage avait une taille constante (câblée). Un paramètre de réglage ( -XX:StringTableSize ) a été ajouté en tant que mise à jour à mi-vie à Java 6. Dans une mise à jour à mi-vie de Java 7, la taille par défaut du pool est passée de 1009 à 60013 .

L'essentiel est que si vous avez l'intention d'utiliser intensivement intern dans votre code, il est conseillé de choisir une version de Java où la taille hashtable est réglable et assurez-vous de régler la taille de manière appropriée. Sinon, les performances du intern risquent de se dégrader à mesure que le pool augmente.

Interning en tant que vecteur potentiel de déni de service

L'algorithme de hachage pour les chaînes est bien connu. Si vous stockez des chaînes fournies par des utilisateurs ou des applications malveillants, cela peut être utilisé dans le cadre d'une attaque par déni de service (DoS). Si l'agent malveillant organise le même code de hachage pour toutes les chaînes qu'il fournit, cela peut entraîner une table de hachage non équilibrée et des performances O(N) pour intern ... où N est le nombre de chaînes en collision.

(Il existe des moyens plus simples et plus efficaces pour lancer une attaque DoS contre un service. Toutefois, ce vecteur pourrait être utilisé si l’objectif de l’attaque DoS était de briser la sécurité ou d’éviter les défenses DoS de première ligne.)

Piège - Les petites lectures / écritures sur les flux non tamponnés sont inefficaces

Considérez le code suivant pour copier un fichier vers un autre:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new FileInputStream(args[0]);
             OutputStream os = new FileOutputStream(args[1])) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

(Nous avons délibérément omis de vérifier les arguments normaux, de signaler les erreurs, etc., car ils ne sont pas pertinents pour le point de cet exemple.)

Si vous compilez le code ci-dessus et l'utilisez pour copier un fichier volumineux, vous remarquerez qu'il est très lent. En fait, il sera au moins deux fois plus lent que les utilitaires de copie de fichiers standard.

( Ajouter des mesures de performances réelles ici! )

La principale raison pour laquelle l'exemple ci-dessus est lent (dans le cas des fichiers volumineux) est qu'il effectue des lectures d'un octet et des écritures d'un octet sur les flux d'octets sans tampon. La manière simple d'améliorer les performances consiste à envelopper les flux avec des flux tamponnés. Par exemple:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new BufferedInputStream(
                     new FileInputStream(args[0]));
             OutputStream os = new BufferedOutputStream(
                     new FileOutputStream(args[1]))) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

Ces petits changements amélioreront le taux de copie des données d’ au moins deux ordres de grandeur, en fonction de divers facteurs liés à la plate-forme. Les wrappers de flux en mémoire tampon entraînent la lecture et l'écriture des données en gros morceaux. Les instances ont toutes deux des tampons implémentés en tant que tableaux d'octets.

  • Avec is , les données sont lues quelques kilo - octets à la fois du fichier dans la mémoire tampon. Lorsque read() est appelée, l'implémentation retourne généralement un octet du tampon. Il ne lira que dans le flux d'entrée sous-jacent si le tampon a été vidé.

  • Le comportement de os est analogue. Les appels à os.write(int) écrivent des octets simples dans le tampon. Les données ne sont écrites dans le flux de sortie que lorsque le tampon est plein ou lorsque os est vidé ou fermé.

Qu'en est-il des flux basés sur des caractères?

Comme vous devez le savoir, Java I / O fournit différentes API pour lire et écrire des données binaires et textuelles.

  • InputStream et OutputStream sont les API de base pour les E / S binaires basées sur les flux
  • Reader et Writer sont les API de base pour les E / S de texte basées sur les flux.

Pour le texte I / O, BufferedReader et BufferedWriter sont les équivalents de BufferedInputStream et BufferedOutputStream .

Pourquoi les flux tamponnés font-ils autant de différence?

La véritable raison pour laquelle les flux mis en mémoire tampon aident les performances est liée à la manière dont une application communique avec le système d'exploitation:

  • La méthode Java dans une application Java ou les appels de procédure natifs dans les bibliothèques d'exécution natives de la JVM sont rapides. Ils prennent généralement quelques instructions de la machine et ont un impact minimal sur les performances.

  • En revanche, les appels d'exécution JVM au système d'exploitation ne sont pas rapides. Ils impliquent quelque chose appelé un "syscall". Le schéma type d'un appel système est le suivant:

    1. Placez les arguments syscall dans des registres.
    2. Exécutez une instruction d'interruption SYSENTER.
    3. Le gestionnaire d'interruptions passe à l'état privilégié et modifie les mappages de mémoire virtuelle. Ensuite, il envoie au code pour gérer l'appel système spécifique.
    4. Le gestionnaire syscall vérifie les arguments en veillant à ne pas avoir accès à la mémoire que le processus utilisateur ne doit pas voir.
    5. Le travail spécifique à l'appel système est effectué. Dans le cas d'un appel système en read , cela peut impliquer:
      1. vérifier qu'il y a des données à lire à la position actuelle du descripteur de fichier
      2. appeler le gestionnaire de système de fichiers pour qu'il récupère les données requises sur le disque (ou partout où il est stocké) dans le cache tampon,
      3. copier des données du cache tampon vers l'adresse fournie par la JVM
      4. ajuster la position du descripteur de fichier pointé thstream
    6. Revenez de l'appel système. Cela implique de modifier à nouveau les mappages de VM et de sortir de l'état privilégié.

Comme vous pouvez l'imaginer, exécuter un seul appel système peut contenir des milliers d'instructions de machine. De manière conservatrice, au moins deux ordres de grandeur plus longs qu'un appel de méthode régulier. (Probablement trois ou plus.)

Compte tenu de cela, la raison pour laquelle les flux en mémoire tampon font une grande différence est qu'ils réduisent considérablement le nombre d'appels système. Au lieu de faire un appel système pour chaque appel read() , le flux d'entrée en mémoire tampon lit une grande quantité de données dans un tampon, selon les besoins. La plupart des appels read() sur le flux en mémoire tampon effectuent des vérifications simples et renvoient un byte lu précédemment. Un raisonnement similaire s'applique dans le cas du flux de sortie, ainsi que dans les cas de flux de caractères.

(Certaines personnes pensent que les performances d'E / S mises en mémoire tampon proviennent de l'incompatibilité entre la taille de la requête de lecture et la taille d'un bloc de disque, la latence de rotation des disques et d'autres facteurs. l'application n'a généralement pas besoin d'attendre le disque, ce n'est pas la vraie explication.

Les flux tamponnés sont-ils toujours une victoire?

Pas toujours. Les flux en mémoire tampon sont certainement une victoire si votre application va faire beaucoup de "petites" lectures ou écritures. Cependant, si votre application n'a besoin que d'effectuer des lectures ou des écritures importantes sur / à partir d'un grand byte[] ou char[] , alors les flux mis en mémoire tampon ne vous apporteront aucun avantage réel. En effet, il pourrait même y avoir une pénalité de performance (minuscule).

Est-ce le moyen le plus rapide de copier un fichier en Java?

Non ce n'est pas Lorsque vous utilisez les API basées sur les flux Java pour copier un fichier, vous devez assumer le coût d'au moins une copie de la mémoire vers la mémoire supplémentaire des données. Il est possible d'éviter cela si vous utilisez les ByteBuffer NIO ByteBuffer et Channel . ( Ajouter un lien vers un exemple séparé ici. )



Modified text is an extract of the original Stack Overflow Documentation
Sous licence CC BY-SA 3.0
Non affilié à Stack Overflow