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 mehrere String Instanzen erstellt
  • InetAddress möglicherweise sogar eine DNS-Suche durch.
  • Der veryLongParamArray möglicherweise sehr lang - das Erstellen eines veryLongParamArray daraus verbraucht Speicher und nimmt Zeit in veryLongParamArray

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:

  1. In aktuellen Java-Implementierungen wird Autoboxing durch Aufrufen von valueOf implementiert, und es gibt Caches für Boolean , Byte , Short , Integer , Long und Character .
  2. 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 anderen String 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) und hashCode() 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 aktuell gc 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, die gc Leerzeichen von allen ausrangierten Objekten. "

Es gibt einige wichtige Punkte, die daraus gezogen werden können:

  1. 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.

  2. 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:

  1. Wenn Sie einen System.gc() für einen Code implementieren, der die System.gc() z. B. Finalizer oder schwache / weiche / System.gc() möglicherweise ein Aufruf von System.gc() erforderlich.

  2. 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 einer Collection
  • isEmpty() Methode isEmpty() gibt true zurück, wenn (und nur wenn) die Collection 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:

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. Wenn read() 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 von os.write(int) schreiben einzelne Bytes in den Puffer. Daten werden nur in den Ausgabestrom geschrieben, wenn der Puffer voll ist oder wenn os 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 und OutputStream sind die Basis-APIs für Stream-basierte binäre E / A
  • Reader und Writer 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:

    1. Legen Sie die Syscall-Argumente in Registern ab.
    2. Führen Sie eine SYSENTER-Trap-Anweisung aus.
    3. 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.
    4. 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.
    5. Die Syscall-spezifische Arbeit wird ausgeführt. Im Fall einer read syscall kann das bedeuten:
      1. Überprüfen, ob Daten an der aktuellen Position des Dateideskriptors gelesen werden sollen
      2. den Dateisystem-Handler aufrufen, um die erforderlichen Daten von der Festplatte (oder wo auch immer sie gespeichert sind) in den Puffercache zu laden.
      3. Kopieren von Daten aus dem Puffercache an die von JVM bereitgestellte Adresse
      4. Anpassen der Position des punktweisen Dateideskriptors
    6. 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. )



Modified text is an extract of the original Stack Overflow Documentation
Lizenziert unter CC BY-SA 3.0
Nicht angeschlossen an Stack Overflow