Java Language
Java-Fallstricke - Leistungsprobleme
Suche…
Einführung
In diesem Thema werden einige "Fallstricke" (dh Fehler, die Java-Programmierer anfangen) gemacht, die sich auf die Java-Anwendungsleistung beziehen.
Bemerkungen
In diesem Thema werden einige "mikro" Java-Codierungspraktiken beschrieben, die ineffizient sind. In den meisten Fällen sind die Ineffizienzen relativ gering, aber es lohnt sich, sie zu vermeiden.
Pitfall - Der Aufwand für das Erstellen von Protokollnachrichten
TRACE
und DEBUG
Protokollebenen dienen dazu, zur Laufzeit sehr detaillierte Informationen über den Betrieb des angegebenen Codes zu vermitteln. Es wird normalerweise empfohlen, den Log-Level über diese Werte zu setzen. Es muss jedoch darauf geachtet werden, dass diese Aussagen die Leistung nicht beeinträchtigen, selbst wenn sie scheinbar "ausgeschaltet" sind.
Betrachten Sie diese Protokollanweisung:
// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString()
+ " parameters: " + Arrays.toString(veryLongParamArray));
Selbst wenn die Protokollebene auf INFO
, werden an debug()
Argumente bei jeder Ausführung der Zeile ausgewertet. Dies macht es in mehrfacher Hinsicht unnötig aufwendig:
-
String
Verkettung: Es werden mehrereString
Instanzen erstellt -
InetAddress
möglicherweise sogar eine DNS-Suche durch. - Der
veryLongParamArray
möglicherweise sehr lang - das Erstellen einesveryLongParamArray
daraus verbraucht Speicher und nimmt Zeit inveryLongParamArray
Lösung
Die meisten Protokollierungsframeworks bieten die Möglichkeit, Protokollnachrichten mithilfe von Fix-Strings und Objektreferenzen zu erstellen. Die Protokollnachricht wird nur ausgewertet, wenn die Nachricht tatsächlich protokolliert wird. Beispiel:
// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));
Dies funktioniert sehr gut, solange alle Parameter mit Hilfe von String.valueOf (Object) in Strings konvertiert werden können. Wenn die Protokollmeldungsberechnung komplexer ist, kann die Protokollebene vor der Protokollierung überprüft werden:
if (LOG.isDebugEnabled()) {
// Argument expression evaluated only when DEBUG is enabled
LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
Arrays.toString(veryLongParamArray);
}
Hier wird LOG.debug()
mit der kostspieligen Arrays.toString(Obect[])
nur verarbeitet, wenn DEBUG
tatsächlich aktiviert ist.
Pitfall - String-Verkettung in einer Schleife skaliert nicht
Betrachten Sie den folgenden Code als Illustration:
public String joinWords(List<String> words) {
String message = "";
for (String word : words) {
message = message + " " + word;
}
return message;
}
Unglücklicherweise ist dieser Code ineffizient, wenn die words
lang ist. Die Wurzel des Problems ist diese Aussage:
message = message + " " + word;
Für jede Schleifeniteration erstellt diese Anweisung eine neue message
die eine Kopie aller Zeichen in der ursprünglichen message
an die zusätzliche Zeichen angehängt werden. Dadurch werden viele temporäre Zeichenfolgen generiert und viel kopiert.
Wenn wir joinWords
analysieren, unter der Annahme, dass es N Wörter mit einer durchschnittlichen Länge von M gibt, stellen wir fest, dass temporäre O (N) joinWords
erstellt werden und O (MN 2 ) -Zeichen in den Prozess kopiert werden. Die N 2 -Komponente ist besonders beunruhigend.
Der empfohlene Ansatz für diese Art von Problem 1 ist die Verwendung eines StringBuilder
anstelle der String-Verkettung wie folgt:
public String joinWords2(List<String> words) {
StringBuilder message = new StringBuilder();
for (String word : words) {
message.append(" ").append(word);
}
return message.toString();
}
Bei der Analyse von joinWords2
muss der joinWords2
berücksichtigt werden, joinWords2
das StringBuilder
Backing-Array mit den Zeichen des joinWords2
"vergrößert" wird. Es stellt sich jedoch heraus, dass die Anzahl der neu erstellten Objekte O (logN) und die Anzahl der kopierten Zeichen O (MN) -Zeichen ist. Letzteres enthält Zeichen, die im letzten Aufruf von toString()
kopiert wurden.
(Möglicherweise können Sie dies weiter StringBuilder
, indem Sie den StringBuilder
mit der richtigen Kapazität erstellen, mit der Sie beginnen können. Die Gesamtkomplexität bleibt jedoch gleich.)
Bei der Rückkehr zur ursprünglichen joinWords
Methode stellt sich heraus, dass die kritische Anweisung von einem typischen Java-Compiler auf joinWords
optimiert wird:
StringBuilder tmp = new StringBuilder();
tmp.append(message).append(" ").append(word);
message = tmp.toString();
Der Java-Compiler "hebt" den StringBuilder
jedoch nicht aus der Schleife, wie wir es joinWords2
im Code für joinWords2
.
Referenz:
1 - In Java 8 und höher kann die Joiner
Klasse verwendet werden, um dieses bestimmte Problem zu lösen. Aber darum geht es in diesem Beispiel eigentlich nicht .
Fallstricke - Die Verwendung von "new" zum Erstellen von primitiven Wrapper-Instanzen ist ineffizient
Mit der Java-Sprache können Sie new
, um Instanzen wie Integer
, Boolean
usw. zu erstellen. Integer
ist jedoch im Allgemeinen eine schlechte Idee. Es ist besser, entweder Autoboxing (Java 5 und höher) oder die Methode valueOf
verwenden.
Integer i1 = new Integer(1); // BAD
Integer i2 = 2; // BEST (autoboxing)
Integer i3 = Integer.valueOf(3); // OK
Die Verwendung von new Integer(int)
ist eine schlechte Idee, weil sie ein neues Objekt erstellt (sofern nicht durch JIT-Compiler optimiert). Wenn dagegen valueOf
oder ein expliziter valueOf
Aufruf verwendet wird, versucht die Java-Laufzeitumgebung, ein Integer
Objekt aus einem Cache mit bereits vorhandenen Objekten zu verwenden. Jedes Mal, wenn die Laufzeit einen Cache-Treffer hat, wird die Erstellung eines Objekts vermieden. Dies spart auch Heap-Speicher und verringert die durch Objektabwanderung verursachten GC-Overheads.
Anmerkungen:
- In aktuellen Java-Implementierungen wird Autoboxing durch Aufrufen von
valueOf
implementiert, und es gibt Caches fürBoolean
,Byte
,Short
,Integer
,Long
undCharacter
. - Das Caching-Verhalten für die Integraltypen wird von der Java-Sprachspezifikation vorgegeben.
Pitfall - Das Aufrufen von 'new String (String)' ist ineffizient
Die Verwendung eines new String(String)
Strings new String(String)
zum Duplizieren eines Strings ist ineffizient und fast immer unnötig.
- String-Objekte sind unveränderlich, so dass sie zum Schutz vor Änderungen nicht kopiert werden müssen.
- In einigen älteren Java-Versionen können
String
Objekte Sicherungsarrays mit anderenString
Objekten gemeinsam nutzen. In diesen Versionen ist es möglich, Speicher zu verlieren, indem eine (kleine) Teilzeichenfolge einer (großen) Zeichenfolge erstellt und beibehalten wird.String
Backing-Arrays werden jedoch ab Java 7 nicht freigegeben.
Wenn kein nennenswerter Vorteil besteht, ist das Aufrufen von new String(String)
einfach verschwenderisch:
- Das Erstellen der Kopie erfordert CPU-Zeit.
- Die Kopie benötigt mehr Speicher, was den Speicherbedarf der Anwendung erhöht und / oder den GC-Aufwand erhöht.
- Operationen wie
equals(Object)
undhashCode()
können langsamer sein, wenn String-Objekte kopiert werden.
Pitfall - Das Aufrufen von System.gc () ist ineffizient
Es ist (fast immer) eine schlechte Idee, System.gc()
.
Der Javadoc für die Methode gc()
gibt Folgendes an:
Der Aufruf der
gc
Methode legt nahe, dass sich die Java Virtual Machine auf das Recycling nicht verwendeter Objekte konzentriert, um den aktuellgc
Speicher für eine schnelle Wiederverwendung verfügbar zu machen. Wenn die Kontrolle vom Methodenaufruf zurückkehrt, hat sich die Java Virtual Machine nach besten Kräften bemüht, diegc
Leerzeichen von allen ausrangierten Objekten. "
Es gibt einige wichtige Punkte, die daraus gezogen werden können:
Die Verwendung des Wortes "schlägt" anstelle von "sagen" bedeutet, dass die JVM den Vorschlag ignorieren kann. Das Standardverhalten der JVM (aktuelle Versionen) folgt dem Vorschlag. Dies kann jedoch durch Setzen von
-XX:+DisableExplicitGC
beim Starten der JVM überschrieben werden.Der Ausdruck "Ein bestmöglicher Versuch, Speicherplatz von allen verworfenen Objekten zurückzugewinnen", impliziert, dass der Aufruf von
gc
eine "vollständige" Garbage Collection auslöst.
Warum ist das Aufrufen von System.gc()
eine schlechte Idee?
Erstens ist das Ausführen einer vollständigen Speicherbereinigung teuer. Bei einer vollständigen GC werden alle noch erreichbaren Objekte besucht und "markiert". dh jedes Objekt, das kein Müll ist. Wenn Sie dies auslösen, wenn nicht viel Müll gesammelt werden muss, leistet der GC viel Arbeit für relativ wenig Nutzen.
Zweitens neigt eine vollständige Speicherbereinigung dazu, die "Lokalität" -Eigenschaften der Objekte zu stören, die nicht gesammelt werden. Objekte, die ungefähr zur gleichen Zeit von demselben Thread zugewiesen werden, neigen dazu, im Speicher nahe beieinander zu liegen. Das ist gut. Objekte, die gleichzeitig zugewiesen werden, sind wahrscheinlich miteinander verbunden. dh sich aufeinander beziehen. Wenn Ihre Anwendung diese Verweise verwendet, ist der Speicherzugriff möglicherweise aufgrund verschiedener Speicher- und Seiten-Caching-Effekte schneller. Leider neigt eine vollständige Speicherbereinigung dazu, Objekte zu verschieben, so dass Objekte, die sich einmal in der Nähe befanden, jetzt weiter voneinander entfernt sind.
Drittens kann die Ausführung einer vollständigen Speicherbereinigung dazu führen, dass Ihre Anwendung angehalten wird, bis die Sammlung abgeschlossen ist. Während dies geschieht, reagiert Ihre Anwendung nicht.
In der Tat ist es die beste Strategie, die JVM entscheiden zu lassen, wann der GC ausgeführt wird und welche Art von Sammlung ausgeführt werden soll. Wenn Sie sich nicht einmischen, wählt die JVM einen Zeit- und Erfassungstyp aus, der den Durchsatz optimiert oder die GC-Pausenzeiten minimiert.
Am Anfang haben wir gesagt "... (fast immer) eine schlechte Idee ...". In der Tat gibt es ein paar Szenarien , in denen es vielleicht eine gute Idee sein:
Wenn Sie einen
System.gc()
für einen Code implementieren, der dieSystem.gc()
z. B. Finalizer oder schwache / weiche /System.gc()
möglicherweise ein Aufruf vonSystem.gc()
erforderlich.In einigen interaktiven Anwendungen kann es bestimmte Zeitpunkte geben, an denen es dem Benutzer egal ist, ob eine Garbage Collection-Pause vorliegt. Ein Beispiel ist ein Spiel, bei dem im "Spiel" natürliche Pausen auftreten. zB beim Laden eines neuen Levels.
Fallstricke - Die übermäßige Verwendung von primitiven Wrapper-Typen ist ineffizient
Betrachten Sie diese beiden Teile des Codes:
int a = 1000;
int b = a + 1;
und
Integer a = 1000;
Integer b = a + 1;
Frage: Welche Version ist effizienter?
Antwort: Die beiden Versionen sehen fast identisch aus, aber die erste Version ist wesentlich effizienter als die zweite.
Die zweite Version verwendet eine Darstellung für die Nummern, die mehr Platz beansprucht, und setzt im Hintergrund auf das automatische Boxen und das automatische Ausblenden der Boxen. In der Tat entspricht die zweite Version direkt dem folgenden Code:
Integer a = Integer.valueOf(1000); // box 1000
Integer b = Integer.valueOf(a.intValue() + 1); // unbox 1000, add 1, box 1001
Vergleicht man dies mit der anderen Version, die int
, gibt es offensichtlich drei zusätzliche Methodenaufrufe, wenn Integer
verwendet wird. Im Falle von valueOf
erstellen und initialisieren die Aufrufe jeweils ein neues Integer
Objekt. All diese zusätzlichen Boxen und Unboxing-Arbeiten werden die zweite Version wahrscheinlich um eine Größenordnung langsamer machen als die erste.
Darüber hinaus valueOf
die zweite Version in jedem valueOf
Aufruf Objekte auf dem Heap zu. Während die Speicherplatznutzung plattformspezifisch ist, liegt sie wahrscheinlich für jedes Integer
Objekt im Bereich von 16 Byte. Im Gegensatz dazu benötigt die int
Version a
zusätzlichen Heap-Speicherplatz, vorausgesetzt, a
und b
sind lokale Variablen.
Ein weiterer wichtiger Grund dafür, dass Grundelemente schneller sind als ihre geschachtelten Entsprechungen, ist die Anordnung ihrer jeweiligen Array-Typen im Speicher.
Wenn Sie int[]
und Integer[]
als Beispiel nehmen, werden die int
Werte im Fall von int[]
zusammenhängend im Speicher abgelegt. Bei Integer[]
jedoch nicht die Werte festgelegt, sondern Verweise (Zeiger) auf Integer
Objekte, die wiederum die tatsächlichen int
Werte enthalten.
Abgesehen davon, dass es eine zusätzliche Ebene der Indirektion ist, kann dies ein großer Panzer sein, wenn es um die Cache-Lokalität geht, wenn die Werte durchlaufen werden. Bei einem int[]
die CPU alle Werte des Arrays auf einmal in den Cache abrufen, da sie im Speicher zusammenhängend sind. Bei einem Integer[]
die CPU jedoch möglicherweise für jedes Element einen zusätzlichen Speicherabruf ausführen, da das Array nur Verweise auf die tatsächlichen Werte enthält.
Kurz gesagt, die Verwendung von primitiven Wrapper-Typen ist sowohl für CPU- als auch für Speicherressourcen relativ teuer. Sie unnötig zu verwenden, ist effizient.
Fallstricke - Das Durchlaufen der Schlüssel einer Karte kann ineffizient sein
Der folgende Beispielcode ist langsamer als er sein muss:
Map<String, String> map = new HashMap<>();
for (String key : map.keySet()) {
String value = map.get(key);
// Do something with key and value
}
Dies liegt daran, dass für jeden Schlüssel in der Karte ein Map-Lookup (die Methode get()
) erforderlich ist. Diese Suche ist möglicherweise nicht effizient (in einer HashMap ist dies das Aufrufen von hashCode
für den Schlüssel, das Nachschlagen des richtigen Buckets in internen Datenstrukturen und manchmal sogar das Aufrufen von equals
). Auf einer großen Karte ist dies möglicherweise kein unbedeutender Aufwand.
Der richtige Weg, dies zu vermeiden, besteht darin, die Einträge der Karte zu iterieren, die im Thema Sammlungen detailliert beschrieben werden
Fallstricke - Die Verwendung von size () zum Testen, ob eine Sammlung leer ist, ist ineffizient.
Das Java Collections Framework bietet zwei verwandte Methoden für alle Collection
Objekte:
-
size()
gibt die Anzahl der Einträge in einerCollection
-
isEmpty()
MethodeisEmpty()
gibt true zurück, wenn (und nur wenn) dieCollection
leer ist.
Beide Methoden können verwendet werden, um die Leere der Sammlung zu testen. Zum Beispiel:
Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty(); // Best
Während diese Ansätze gleich aussehen, speichern einige Erfassungsimplementierungen die Größe nicht. Für eine solche Sammlung muss die Implementierung von size()
die Größe bei jedem Aufruf berechnen. Zum Beispiel:
- Möglicherweise muss eine einfache verknüpfte Listenklasse (aber nicht die
java.util.LinkedList
) die Liste durchlaufen, um die Elemente zu zählen. - Die
ConcurrentHashMap
Klasse muss die Einträge in allen "Segmenten" der Karte summieren. - Bei einer langsamen Implementierung einer Sammlung muss möglicherweise die gesamte Sammlung im Speicher implementiert werden, um die Elemente zählen zu können.
Im Gegensatz dazu muss eine isEmpty()
-Methode nur testen, ob mindestens ein Element in der Auflistung vorhanden ist. Dies beinhaltet nicht das Zählen der Elemente.
Während size() == 0
nicht immer weniger effizient ist als isEmpty()
, ist es nicht isEmpty()
, dass ein ordnungsgemäß implementiertes isEmpty()
weniger effizient ist als size() == 0
. Daher wird isEmpty()
bevorzugt.
Pitfall - Effizienzprobleme bei regulären Ausdrücken
Das Abgleichen von regulären Ausdrücken ist ein leistungsfähiges Werkzeug (in Java und in anderen Zusammenhängen), weist jedoch einige Nachteile auf. Einer davon, dass reguläre Ausdrücke eher teuer sind.
Muster- und Matcher-Instanzen sollten wiederverwendet werden
Betrachten Sie das folgende Beispiel:
/**
* 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;
}
Dieser Code ist korrekt, aber ineffizient. Das Problem liegt im matches(...)
. Unter der Haube entspricht s.matches("[A-Za-z0-9]*")
diesem:
Pattern.matches(s, "[A-Za-z0-9]*")
was wiederum entspricht
Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()
Der Pattern.compile("[A-Za-z0-9]*")
analysiert den regulären Ausdruck, analysiert ihn und erstellt ein Pattern
Objekt, das die Datenstruktur enthält, die von der Regex-Engine verwendet wird. Dies ist eine nicht triviale Berechnung. Dann wird ein Matcher
Objekt erstellt, um das Argument s
Matcher
. Schließlich rufen wir match()
auf, um den eigentlichen Musterabgleich durchzuführen.
Das Problem ist, dass diese Arbeit für jede Wiederholungsschleife wiederholt wird. Die Lösung besteht darin, den Code wie folgt umzustrukturieren:
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;
}
Beachten Sie, dass der Javadoc für Pattern
lautet:
Instanzen dieser Klasse sind unveränderlich und können von mehreren gleichzeitigen Threads verwendet werden. Instanzen der
Matcher
Klasse sind für eine solche Verwendung nicht sicher.
Verwenden Sie match () nicht, wenn Sie find () verwenden sollten.
Angenommen, Sie möchten testen, ob eine Zeichenfolge s
drei oder mehr Ziffern in einer Zeile enthält. Sie können dies auf verschiedene Weise ausdrücken, einschließlich:
if (s.matches(".*[0-9]{3}.*")) {
System.out.println("matches");
}
oder
if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
System.out.println("matches");
}
Die erste ist prägnanter, dürfte aber auch weniger effizient sein. Auf den ersten Blick versucht die erste Version, die gesamte Saite mit dem Muster abzugleichen. Da ". *" Ein "gieriges" Muster ist, wird der Mustervergleicher wahrscheinlich "eifrig" bis zum Ende der Zeichenfolge vorrücken und zurückgehen, bis er eine Übereinstimmung findet.
Im Gegensatz dazu sucht die zweite Version von links nach rechts und stoppt die Suche, sobald die 3 Ziffern in einer Reihe gefunden werden.
Verwenden Sie effizientere Alternativen zu regulären Ausdrücken
Reguläre Ausdrücke sind ein mächtiges Werkzeug, sie sollten jedoch nicht Ihr einziges Werkzeug sein. Viele Aufgaben können auf andere Weise effizienter erledigt werden. Zum Beispiel:
Pattern.compile("ABC").matcher(s).find()
macht das gleiche wie:
s.contains("ABC")
Abgesehen davon, dass letzteres viel effizienter ist. (Auch wenn Sie die Kosten für die Erstellung des regulären Ausdrucks amortisieren können.)
Oft ist die Nicht-Regex-Form komplizierter. Zum Beispiel kann der Test, der vom allAlplanumeric
matches()
Aufruf der früheren allAlplanumeric
Methode ausgeführt wird, wie allAlplanumeric
umgeschrieben werden:
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;
}
Das ist jetzt mehr Code als mit einem Matcher
, aber es wird auch wesentlich schneller sein.
Katastrophales Backtracking
(Dies ist möglicherweise ein Problem bei allen Implementierungen regulärer Ausdrücke, wir werden es hier jedoch erwähnen, da dies eine Gefahr für die Verwendung von Pattern
.)
Betrachten Sie dieses (erfundene) Beispiel:
Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());
Der erste Aufruf von println
wird schnell true
gedruckt. Die zweite wird false
gedruckt. Schließlich. Wenn Sie mit dem obigen Code experimentieren, werden Sie feststellen, dass sich die Zeit jedes Mal verdoppelt, wenn Sie vor dem C
ein A
hinzufügen.
Dieses Verhalten ist ein Beispiel für katastrophales Backtracking . Die Pattern - Matching - Engine, die die Regex Matching implementiert ist , alle möglichen Wege fruchtlos versucht , die das Muster könnte passen.
Schauen wir uns an, was (A+)+B
tatsächlich bedeutet. Oberflächlich betrachtet scheint es "ein oder mehrere A
Zeichen gefolgt von einem B
Wert" zu sein, doch in Wirklichkeit heißt es eine oder mehrere Gruppen, von denen jede aus einem oder mehreren A
Zeichen besteht. Also zum Beispiel:
- 'AB' gilt nur für eine Richtung: '(A) B'
- "AAB" bietet zwei Möglichkeiten: "(AA) B" oder "(A) (A) B"
- "AAAB" bietet vier Möglichkeiten: "(AAA) B" oder "(AA) (A) B
or '(A)(AA)B
oder "(A) (A) (A) B" - und so weiter
Mit anderen Worten ist die Anzahl möglicher Übereinstimmungen 2 N, wobei N die Anzahl von A
Zeichen ist.
Das obige Beispiel ist eindeutig durchdacht, aber Muster, die diese Art von Leistungsmerkmalen aufweisen (dh O(2^N)
oder O(N^K)
für ein großes K
), treten häufig auf, wenn unüberlegte reguläre Ausdrücke verwendet werden. Es gibt einige Standardmittel:
- Vermeiden Sie das Verschachteln von sich wiederholenden Mustern in anderen sich wiederholenden Mustern.
- Vermeiden Sie zu viele sich wiederholende Muster.
- Verwenden Sie gegebenenfalls keine Rückverfolgungswiederholung.
- Verwenden Sie keine regulären Ausdrücke für komplizierte Parsing-Aufgaben. (Schreiben Sie stattdessen einen richtigen Parser.)
Achten Sie schließlich auf Situationen, in denen ein Benutzer oder ein API-Client eine reguläre Ausdrücke mit pathologischen Merkmalen angeben kann. Dies kann zu einer versehentlichen oder vorsätzlichen "Dienstverweigerung" führen.
Verweise:
- Der Tag für reguläre Ausdrücke , insbesondere http://www.riptutorial.com/regex/topic/259/getting-started-with-regular-expressions/977/backtracking#t=201610010339131361163 und http://www.riptutorial.com/ Regex / Topic / 259 / Erste Schritte mit regulären Ausdrücken / 4527 / Wenn Sie nicht verwendet werden sollten reguläre Ausdrücke # t = 201610010339593564913
- "Regex Performance" von Jeff Atwood.
- "So töten Sie Java mit einem regulären Ausdruck" von Andreas Haufler.
Pitfall - Interning von Strings, damit Sie == verwenden können, ist eine schlechte Idee
Wenn einige Programmierer diesen Hinweis sehen:
"Das Testen von Strings mit
==
ist falsch (es sei denn, die Strings sind intern)."
Ihre erste Reaktion ist auf interne Zeichenfolgen, so dass sie ==
. (Schließlich ist ==
schneller als der Aufruf von String.equals(...)
, oder String.equals(...)
)
Dies ist der falsche Ansatz aus verschiedenen Perspektiven:
Zerbrechlichkeit
Zunächst können Sie ==
nur sicher verwenden, wenn Sie wissen, dass alle von Ihnen getesteten String
Objekte intern sind. Das JLS garantiert, dass String-Literale in Ihrem Quellcode intern sind. Abgesehen von String.intern(String)
selbst garantiert jedoch keine der Standard-Java SE-APIs die Rückgabe String.intern(String)
Zeichenfolgen. Wenn Sie nur eine Quelle von String
Objekten vermissen, die noch nicht intern sind, ist Ihre Anwendung unzuverlässig. Diese Unzuverlässigkeit manifestiert sich als falsche Negative und nicht als Ausnahmen, die die Erkennung erschweren könnten.
Kosten für die Verwendung von 'intern ()'
Unter der Haube arbeitet das Internieren, indem es eine Hashtabelle verwaltet, die zuvor internierte String
Objekte enthält. Eine Art schwacher Referenzmechanismus wird verwendet, damit die interne Hash-Tabelle nicht zu einem Speicherverlust wird. Während die Hashtabelle in nativem Code implementiert ist (im Gegensatz zu HashMap
, HashTable
usw.), sind die intern
Aufrufe immer noch relativ teuer in Bezug auf die verwendete CPU und den verwendeten Speicher.
Diese Kosten müssen mit den Einsparungen verglichen werden, die wir erhalten, wenn wir ==
anstelle von equals
. Tatsächlich werden wir nicht abbrechen, es sei denn, jeder internierte String wird "einige Male" mit anderen Strings verglichen.
(Abgesehen davon: In den wenigen Situationen, in denen ein Interning sinnvoll ist, geht es in der Regel darum, den Speicherbedarf einer Anwendung zu reduzieren, in der dieselben Zeichenfolgen häufig wiederkehren und diese Zeichenfolgen eine lange Lebensdauer haben.)
Die Auswirkungen auf die Müllsammlung
Zusätzlich zu den oben beschriebenen direkten CPU- und Speicherkosten wirken sich intern integrierte Strings auf die Leistung des Garbage Collectors aus.
Für Java-Versionen vor Java 7 werden internierte Zeichenfolgen im "PermGen" -Bereich gespeichert, der selten gesammelt wird. Wenn PermGen gesammelt werden muss, wird (in der Regel) eine vollständige Speicherbereinigung ausgelöst. Wenn der PermGen-Speicherplatz vollständig gefüllt ist, stürzt die JVM ab, auch wenn in den regulären Heap-Speicherbereichen Speicherplatz vorhanden ist.
In Java 7 wurde der String-Pool aus "PermGen" in den normalen Heap verschoben. Die Hash-Tabelle wird jedoch immer noch eine langlebige Datenstruktur sein, die dazu führt, dass alle internen Strings langlebig werden. (Selbst wenn die internierten String-Objekte im Eden-Space zugewiesen wurden, würden sie höchstwahrscheinlich befördert, bevor sie gesammelt wurden.)
In allen Fällen verlängert das Internieren einer Zeichenfolge ihre Lebensdauer im Vergleich zu einer normalen Zeichenfolge. Dies erhöht den Aufwand für die Speicherbereinigung über die Lebensdauer der JVM.
Das zweite Problem besteht darin, dass die Hashtabelle einen schwachen Referenzmechanismus verwenden muss, um zu verhindern, dass String interning Speicher verliert. Ein solcher Mechanismus ist jedoch mehr Arbeit für den Müllsammler.
Diese Müllsammel-Overheads sind schwer zu quantifizieren, aber es gibt wenig Zweifel, dass sie existieren. Wenn Sie intern
viel Gebrauch machen, können sie erheblich sein.
Die Hash-Tabellengröße des String-Pools
Gemäß dieser Quelle wird der String-Pool ab Java 6 als Hash-Tabelle mit fester Größe und Ketten implementiert, um Strings zu behandeln, die auf denselben Bucket-Hash zugreifen. In früheren Versionen von Java 6 hatte die Hashtabelle eine (fest verdrahtete) konstante Größe. Als -XX:StringTableSize
für Java 6 wurde ein -XX:StringTableSize
( -XX:StringTableSize
) hinzugefügt. Bei einer Aktualisierung auf Java 7 wurde die Standardgröße des Pools von 1009
auf 60013
.
Die Quintessenz ist, dass, wenn Sie intern
intensiv in Ihrem Code verwenden intern
, es ratsam ist , eine Java-Version auszuwählen, in der die Hashtable-Größe eingestellt werden kann, und stellen Sie sicher, dass Sie die Größe entsprechend anpassen. Andernfalls kann sich die Leistung des intern
verschlechtern, wenn der Pool größer wird.
Internierung als potenzieller Denial-of-Service-Vektor
Der Hashcode-Algorithmus für Strings ist allgemein bekannt. Wenn Sie interne Zeichenfolgen verwenden, die von böswilligen Benutzern oder Anwendungen bereitgestellt werden, kann dies als Teil eines DoS-Angriffs (Denial of Service) verwendet werden. Wenn der böswillige Agent veranlasst, dass alle von ihm bereitgestellten Zeichenfolgen denselben Hash-Code aufweisen, kann dies zu einer unausgeglichenen Hash-Tabelle und einer O(N)
-Leistung für intern
... führen, wobei N
die Anzahl der kollidierten Zeichenfolgen ist.
(Es gibt einfachere / effektivere Methoden, um einen DoS-Angriff auf einen Dienst zu starten. Dieser Vektor könnte jedoch verwendet werden, wenn das Ziel des DoS-Angriffs darin besteht, die Sicherheit zu brechen oder DoS-Abwehrlinien der ersten Wahl zu umgehen.)
Pitfall - Kleine Lese- / Schreibvorgänge für ungepufferte Streams sind ineffizient
Betrachten Sie den folgenden Code, um eine Datei in eine andere zu kopieren:
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);
}
}
}
}
(Wir haben weggelassen normales Argument Überprüfung, Fehlerberichterstattung beraten und so weiter , weil sie zu Punkt dieses Beispiels nicht relevant sind.)
Wenn Sie den obigen Code kompilieren und ihn zum Kopieren einer großen Datei verwenden, werden Sie feststellen, dass er sehr langsam ist. Tatsächlich ist es mindestens um einige Größenordnungen langsamer als die Standard-Dienstprogramme zum Kopieren von Dateien.
( Fügen Sie hier die tatsächlichen Leistungsmessungen hinzu! )
Der Hauptgrund dafür, dass das obige Beispiel langsam ist (im Fall der großen Datei), ist, dass Ein-Byte-Lesevorgänge und Ein-Byte-Schreibvorgänge für ungepufferte Byte-Streams ausgeführt werden. Die einfache Möglichkeit, die Leistung zu verbessern, besteht darin, die Streams mit gepufferten Streams zu umschließen. Zum Beispiel:
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);
}
}
}
}
Diese kleinen Änderungen verbessern die Datenkopierrate um mindestens einige Größenordnungen, abhängig von verschiedenen plattformbezogenen Faktoren. Die gepufferten Stream-Wrapper bewirken, dass die Daten gelesen und in größere Blöcke geschrieben werden. Beide Instanzen haben Puffer, die als Byte-Arrays implementiert sind.
Mit
is
, werden die Daten aus der Datei in den Puffer ein paar Kilobyte zu einem Zeitpunkt lesen. Wennread()
aufgerufen wird, gibt die Implementierung normalerweise ein Byte aus dem Puffer zurück. Es wird nur dann aus dem zugrunde liegenden Eingabestrom gelesen, wenn der Puffer geleert wurde.Das Verhalten für
os
ist analog. Aufrufe vonos.write(int)
schreiben einzelne Bytes in den Puffer. Daten werden nur in den Ausgabestrom geschrieben, wenn der Puffer voll ist oder wennos
geleert oder geschlossen wird.
Was ist mit zeichenbasierten Streams?
Wie Sie wissen sollten, bietet Java I / O verschiedene APIs zum Lesen und Schreiben von Binär- und Textdaten.
-
InputStream
undOutputStream
sind die Basis-APIs für Stream-basierte binäre E / A -
Reader
undWriter
sind die Basis-APIs für Stream-basierte Text-E / A.
Für Text-E / A sind BufferedReader
und BufferedWriter
die Entsprechungen für BufferedInputStream
und BufferedOutputStream
.
Warum machen gepufferte Streams so viel Unterschied?
Der wahre Grund, dass gepufferte Streams die Leistung verbessern, hängt damit zusammen, wie eine Anwendung mit dem Betriebssystem kommuniziert:
Die Java-Methode in einer Java-Anwendung oder native Prozeduraufrufe in den Laufzeitbibliotheken der JVM sind schnell. Sie nehmen in der Regel einige Maschinenanweisungen mit und haben nur minimale Auswirkungen auf die Leistung.
Im Gegensatz dazu sind JVM-Laufzeitaufrufe an das Betriebssystem nicht schnell. Sie beinhalten etwas, das als "Syscall" bekannt ist. Das typische Muster für einen Syscall lautet wie folgt:
- Legen Sie die Syscall-Argumente in Registern ab.
- Führen Sie eine SYSENTER-Trap-Anweisung aus.
- Der Trap-Handler hat in den privilegierten Zustand gewechselt und die Zuordnungen des virtuellen Speichers geändert. Dann sendet es an den Code, um den spezifischen Syscall zu behandeln.
- Der syscall-Handler überprüft die Argumente und achtet darauf, dass ihm nicht mitgeteilt wird, dass er auf den Speicher zugreifen soll, den der Benutzerprozess nicht sehen sollte.
- Die Syscall-spezifische Arbeit wird ausgeführt. Im Fall einer
read
syscall kann das bedeuten:- Überprüfen, ob Daten an der aktuellen Position des Dateideskriptors gelesen werden sollen
- den Dateisystem-Handler aufrufen, um die erforderlichen Daten von der Festplatte (oder wo auch immer sie gespeichert sind) in den Puffercache zu laden.
- Kopieren von Daten aus dem Puffercache an die von JVM bereitgestellte Adresse
- Anpassen der Position des punktweisen Dateideskriptors
- Rückkehr vom Syscall. Dies bedeutet, dass Sie die VM-Zuordnungen erneut ändern und den privilegierten Status ausschalten.
Wie Sie sich vorstellen können, kann ein einzelner Syscall Tausende von Maschinenanweisungen ausführen. Konservativ mindestens zwei Größenordnungen länger als ein normaler Methodenaufruf. (Wahrscheinlich drei oder mehr.)
Aus diesem Grund machen gepufferte Streams einen großen Unterschied, weil sie die Anzahl der Systemaufrufe drastisch reduzieren. Anstatt für jeden read()
Aufruf ein Syscall auszuführen, liest der gepufferte Eingabestrom bei Bedarf eine große Datenmenge in einen Puffer. Die meisten read()
Aufrufe im gepufferten Stream führen einige einfache Begrenzungen durch und geben ein byte
, das zuvor gelesen wurde. Ähnliches gilt für den Ausgabestromfall und auch für den Zeichenstromfall.
(Einige Leute denken, dass die gepufferte E / A-Leistung aus dem Missverhältnis zwischen der Größe der Leseanforderung und der Größe eines Festplattenblocks, der Rotationslatenz der Festplatte und dergleichen resultiert.) In der Tat verwendet ein modernes Betriebssystem eine Reihe von Strategien, um sicherzustellen, dass die Die Anwendung muss normalerweise nicht auf die Festplatte warten. Dies ist keine echte Erklärung.)
Sind gepufferte Streams immer ein Gewinn?
Nicht immer. Gepufferte Streams sind definitiv ein Gewinn, wenn Ihre Anwendung viele "kleine" Lese- oder Schreibvorgänge ausführt. Wenn Ihre Anwendung jedoch nur große Lese- oder Schreibvorgänge in / von einem großen byte[]
oder char[]
ausführen muss, bieten gepufferte Streams keine echten Vorteile. Es kann sogar eine (winzige) Leistungsstrafe geben.
Ist dies der schnellste Weg, eine Datei in Java zu kopieren?
Nein, ist es nicht. Wenn Sie Java-Stream-basierte APIs verwenden, um eine Datei zu kopieren, entstehen Ihnen mindestens eine zusätzliche Kopie der Daten von Arbeitsspeicher. Es ist möglich , diese Option, wenn Ihre Nutzung der NIO zu vermeiden ByteBuffer
und Channel
- APIs. ( Fügen Sie hier einen Link zu einem separaten Beispiel hinzu. )