Java Language
Pièges Java - Problèmes de performances
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 deString
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:
- Dans les implémentations Java récentes, l'autoboxing est implémenté en appelant
valueOf
, et il existe des caches pourBoolean
,Byte
,Short
,Integer
,Long
etCharacter
. - 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 objetsString
. 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 deString
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)
ethashCode()
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:
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.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 degc
"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:
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()
.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 uneCollection
et -
isEmpty()
méthodeisEmpty()
renvoie true si (et seulement si) laCollection
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:
- Le tag Expressions régulières , en particulier http://www.riptutorial.com/regex/topic/259/getting-started-with-regular-expressions/977/backtracking#t=201610010339131361163 et http://www.riptutorial.com/ regex / topic / 259 / getting-started-with-regular-expressions / 4527 / lorsque-you-should-not-use-regular-expressions # t = 201610010339593564913
- "Regex Performance" par Jeff Atwood.
- "Comment tuer Java avec une expression régulière" par Andreas Haufler.
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. Lorsqueread()
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 lorsqueos
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
etOutputStream
sont les API de base pour les E / S binaires basées sur les flux -
Reader
etWriter
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:
- Placez les arguments syscall dans des registres.
- Exécutez une instruction d'interruption SYSENTER.
- 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.
- 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.
- Le travail spécifique à l'appel système est effectué. Dans le cas d'un appel système en
read
, cela peut impliquer:- vérifier qu'il y a des données à lire à la position actuelle du descripteur de fichier
- 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,
- copier des données du cache tampon vers l'adresse fournie par la JVM
- ajuster la position du descripteur de fichier pointé thstream
- 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. )