Ricerca…


introduzione

Questo argomento descrive una serie di "insidie" (ad esempio errori commessi dai programmatori java principianti) che si riferiscono alle prestazioni dell'applicazione Java.

Osservazioni

Questo argomento descrive alcune pratiche di codifica Java "micro" che sono inefficienti. Nella maggior parte dei casi, le inefficienze sono relativamente piccole, ma vale comunque la pena di evitarle.

Pitfall - I costi generali di creazione dei messaggi di log

TRACE livelli di log TRACE e DEBUG sono lì per essere in grado di trasmettere dettagli elevati sul funzionamento del codice dato in fase di esecuzione. In genere, è consigliabile impostare il livello di registro sopra questi, tuttavia è necessario prestare attenzione affinché tali istruzioni non influiscano sulle prestazioni anche se apparentemente "disattivate".

Considera questa dichiarazione di registro:

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

Anche quando il livello di registro è impostato su INFO , gli argomenti passati a debug() verranno valutati in ogni esecuzione della riga. Questo rende inutilmente il consumo su diversi fronti:

  • String stringhe: verranno create più istanze String
  • InetAddress potrebbe persino eseguire una ricerca DNS.
  • veryLongParamArray potrebbe essere molto lungo: la creazione di una stringa che consuma memoria richiede tempo

Soluzione

La maggior parte della struttura di logging fornisce mezzi per creare messaggi di log usando stringhe fisse e riferimenti a oggetti. Il messaggio di registro verrà valutato solo se il messaggio viene effettivamente registrato. Esempio:

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

Funziona molto bene finché tutti i parametri possono essere convertiti in stringhe usando String.valueOf (Object) . Se il compendio dei messaggi di registro è più complesso, il livello di registro può essere controllato prima della registrazione:

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

Qui, LOG.debug() con il costoso Arrays.toString(Obect[]) viene elaborato solo quando DEBUG è effettivamente abilitato.

Pitfall - La concatenazione di stringhe in un loop non viene ridimensionata

Considera il seguente codice come illustrazione:

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

Sfortunatamente questo codice è inefficiente se l'elenco delle words è lungo. La radice del problema è questa affermazione:

message = message + " " + word;

Per ciascuna iterazione del ciclo, questa istruzione crea una nuova stringa di message contenente una copia di tutti i caratteri nella stringa del message originale con caratteri aggiuntivi aggiunti ad essa. Questo genera un sacco di stringhe temporanee e fa molte copie.

Quando analizziamo joinWords , supponendo che ci siano N parole con una lunghezza media di M, scopriamo che le stringhe temporanee di O (N) sono create e che i caratteri O (MN 2 ) verranno copiati nel processo. Il componente N 2 è particolarmente preoccupante.

L'approccio consigliato per questo tipo di problema 1 consiste nell'utilizzare un oggetto StringBuilder anziché una concatenazione di stringhe come segue:

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

L'analisi di joinWords2 deve tenere conto delle spese generali di "crescita" dell'array di supporto StringBuilder che contiene i caratteri del builder. Tuttavia, si scopre che il numero di nuovi oggetti creati è O (logN) e che il numero di caratteri copiati è O (MN) caratteri. Quest'ultimo include caratteri copiati nella chiamata finale a toString() .

(Potrebbe essere possibile ottimizzarlo ulteriormente, creando StringBuilder con la capacità corretta per iniziare. Tuttavia, la complessità complessiva rimane la stessa.)

Tornando al metodo joinWords originale, si scopre che la dichiarazione critica verrà ottimizzata da un tipico compilatore Java per qualcosa di simile a questo:

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

Tuttavia, il compilatore Java non "solleva" il StringBuilder dal ciclo, come abbiamo fatto a mano nel codice per joinWords2 .

Riferimento:


1 - In Java 8 e Joiner successive, la classe Joiner può essere utilizzata per risolvere questo particolare problema. Tuttavia, questo non è ciò di cui si suppone questo esempio.

Trappola - L'uso di "nuovo" per creare istanze di wrapper primitive è inefficiente

Il linguaggio Java ti permette di usare new per creare istanze Integer , Boolean e così via, ma generalmente è una cattiva idea. È preferibile utilizzare l'autoboxing (Java 5 e valueOf successive) o il metodo valueOf .

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

La ragione per cui l'utilizzo del new Integer(int) esplicito è una cattiva idea è che crea un nuovo oggetto (se non ottimizzato dal compilatore JIT). Al contrario, quando vengono utilizzati il ​​box automatico o una chiamata valueOf esplicita, il runtime Java tenterà di riutilizzare un oggetto Integer da una cache di oggetti preesistenti. Ogni volta che il runtime ha una cache "hit", evita di creare un oggetto. Ciò consente anche di risparmiare memoria heap e di ridurre i costi generali del GC causati dall'abbandono degli oggetti.

Gli appunti:

  1. Nelle recenti implementazioni Java, l'autoboxing è implementato chiamando valueOf e ci sono cache per Boolean , Byte , Short , Integer , Long e Character .
  2. Il comportamento di caching per i tipi integrali è richiesto dalla specifica del linguaggio Java.

Trappola: chiamare "nuova stringa (stringa)" è inefficiente

L'uso di una new String(String) per duplicare una stringa è inefficiente e quasi sempre non necessario.

  • Gli oggetti stringa sono immutabili, quindi non è necessario copiarli per proteggerli dalle modifiche.
  • In alcune versioni precedenti di Java, gli oggetti String possono condividere array di backup con altri oggetti String . In queste versioni, è possibile perdere memoria creando una sottostringa (piccola) di una stringa (grande) e conservandola. Tuttavia, da Java 7 in poi, gli array di backing delle String non sono condivisi.

In assenza di vantaggi tangibili, chiamare la new String(String) è semplicemente uno spreco:

  • Fare la copia richiede tempo CPU.
  • La copia utilizza più memoria che aumenta il footprint memorum dell'applicazione e / o aumenta i costi generali del GC.
  • Operazioni come equals(Object) e hashCode() possono essere più lente se gli oggetti String vengono copiati.

Pitfall - Calling System.gc () è inefficiente

È (quasi sempre) una cattiva idea chiamare System.gc() .

Javadoc per il metodo gc() specifica quanto segue:

"Chiamare il metodo gc suggerisce che la Java Virtual Machine spenda gli sforzi per riciclare oggetti inutilizzati al fine di rendere disponibile la memoria attualmente occupata per il riutilizzo rapido. Quando il controllo ritorna dalla chiamata al metodo, la Java Virtual Machine ha fatto il massimo sforzo per reclamare spazio da tutti gli oggetti scartati. "

Ci sono un paio di punti importanti che si possono trarre da questo:

  1. L'uso della parola "suggerisce" piuttosto che (dire) "dice" significa che la JVM è libera di ignorare il suggerimento. Il comportamento JVM predefinito (versioni recenti) deve seguire il suggerimento, ma questo può essere sovrascritto impostando -XX:+DisableExplicitGC quando si avvia JVM.

  2. L'espressione "il miglior tentativo di recuperare spazio da tutti gli oggetti scartati" implica che chiamare gc inneschi una garbage collection "completa".

Quindi, perché chiamare System.gc() una cattiva idea?

Innanzitutto, eseguire una garbage collection completa è costoso. Un GC completo comporta la visita e la "marcatura" di ogni oggetto che è ancora raggiungibile; vale a dire ogni oggetto che non è spazzatura. Se si attiva questo quando non c'è molta immondizia da raccogliere, allora il GC fa un sacco di lavoro con relativamente poco beneficio.

In secondo luogo, una garbage collection completa è suscettibile di disturbare le proprietà "locality" degli oggetti che non vengono raccolti. Gli oggetti allocati dallo stesso thread all'incirca nello stesso momento tendono ad essere allocati vicini in memoria. Questo è buono. È probabile che gli oggetti assegnati nello stesso momento siano correlati; vale a dire riferimento l'un l'altro. Se l'applicazione utilizza tali riferimenti, è probabile che l'accesso alla memoria risulti più veloce a causa di vari effetti di memorizzazione nella cache e nelle pagine. Sfortunatamente, una raccolta completa dei rifiuti tende a spostare gli oggetti in modo che gli oggetti che erano una volta vicini siano ora più distanti.

In terzo luogo, l'esecuzione di una garbage collection completa può mettere in pausa l'applicazione fino al completamento della raccolta. Mentre questo sta accadendo, la tua applicazione non risponderà.

In effetti, la strategia migliore è lasciare che la JVM decida quando eseguire il GC e quale tipo di raccolta eseguire. Se non interferisci, la JVM sceglierà un tipo di tempo e di raccolta che ottimizzi il throughput o minimizzi i tempi di pausa del GC.


All'inizio abbiamo detto "... (quasi sempre) una cattiva idea ...". In effetti ci sono un paio di scenari in cui potrebbe essere una buona idea:

  1. Se si sta implementando un test unitario per un codice che è sensibile alla garbage collection (ad es. Qualcosa che riguarda i finalizzatori o i riferimenti debole / soft / phantom System.gc() potrebbe essere necessario chiamare System.gc() .

  2. In alcune applicazioni interattive, ci possono essere dei momenti particolari in cui l'utente non si cura se c'è una pausa nella raccolta dei dati inutili. Un esempio è un gioco in cui ci sono pause naturali nel "gioco"; ad esempio quando si carica un nuovo livello.

Trappola: l'uso eccessivo di tipi di wrapper primitivi è inefficiente

Considera questi due pezzi di codice:

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

e

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

Domanda: quale versione è più efficiente?

Risposta: Le due versioni sembrano quasi identiche, ma la prima versione è molto più efficiente della seconda.

La seconda versione usa una rappresentazione per i numeri che usano più spazio, e si basa sul box automatico e sull'automodifica dietro le quinte. In effetti la seconda versione è direttamente equivalente al seguente codice:

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

Confrontando questo con l'altra versione che usa int , ci sono chiaramente tre chiamate di metodo extra quando viene usato l' Integer . Nel caso di valueOf , le chiamate creeranno e inizializzeranno un nuovo oggetto Integer . È probabile che tutto questo lavoro di boxing e unboxing in più renderà la seconda versione un ordine di grandezza più lenta della prima.

Oltre a ciò, la seconda versione alloca gli oggetti nell'heap in ogni valueOf chiamata. Mentre l'utilizzo dello spazio è specifico della piattaforma, è probabile che si trovi nella regione di 16 byte per ogni oggetto Integer . Al contrario, la versione int bisogno di zero spazio su heap, assumendo che a e b siano variabili locali.


Un altro grande motivo per cui le primitive sono più veloci rispetto al loro equivalente in scatola è il modo in cui i rispettivi tipi di array sono disposti in memoria.

Se prendi int[] e Integer[] come esempio, nel caso di un int[] i valori int sono disposti in modo contiguo nella memoria. Ma nel caso di un Integer[] non sono i valori che sono disposti, ma i riferimenti (puntatori) agli oggetti Integer , che a loro volta contengono i valori int effettivi.

Oltre ad essere un ulteriore livello di riferimento, questo può essere un grande serbatoio quando si tratta di localizzare la cache quando si esegue un'iterazione sui valori. Nel caso di un int[] la CPU potrebbe recuperare tutti i valori dell'array, nella sua cache in una volta, perché sono contigui in memoria. Ma nel caso di un Integer[] la CPU deve potenzialmente eseguire un recupero di memoria aggiuntiva per ciascun elemento, poiché l'array contiene solo riferimenti ai valori effettivi.


In breve, l'uso di tipi di wrapper primitivi è relativamente costoso sia nella CPU che nelle risorse di memoria. Usarli inutilmente è efficiente.

Pitfall - Iterare le chiavi di una mappa può essere inefficiente

Il seguente codice di esempio è più lento di quanto deve essere:

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

Questo perché richiede una ricerca della mappa get() metodo get() ) per ogni chiave nella mappa. Questa ricerca potrebbe non essere efficiente (in una HashMap, implica chiamare hashCode sulla chiave, quindi cercare il bucket corretto nelle strutture di dati interne e talvolta persino chiamare equals ). Su una mappa di grandi dimensioni, questo potrebbe non essere un overhead banale.

Il modo corretto per evitare ciò è di ripetere le voci della mappa, che sono dettagliate nell'argomento Raccolte

Pitfall - Usare size () per verificare se una collezione è vuota è inefficiente.

Java Collections Framework fornisce due metodi correlati per tutti gli oggetti Collection :

  • size() restituisce il numero di voci in una Collection , e
  • isEmpty() metodo isEmpty() restituisce true se (e solo se) la Collection è vuota.

Entrambi i metodi possono essere utilizzati per testare il vuoto di raccolta. Per esempio:

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

Sebbene questi approcci abbiano lo stesso aspetto, alcune implementazioni di raccolta non memorizzano le dimensioni. Per una tale raccolta, l'implementazione di size() deve calcolare la dimensione ogni volta che viene chiamata. Per esempio:

  • Una semplice lista di classi collegate (ma non java.util.LinkedList ) potrebbe dover attraversare la lista per contare gli elementi.
  • La classe ConcurrentHashMap deve sommare le voci in tutti i "segmenti" della mappa.
  • Un'implementazione lenta di una raccolta potrebbe dover realizzare l'intera collezione in memoria per contare gli elementi.

Al contrario, un metodo isEmpty() deve solo verificare se c'è almeno un elemento nella collezione. Questo non implica il conteggio degli elementi.

Mentre size() == 0 non è sempre meno efficiente che isEmpty() , è concepibile che un correttamente attuato isEmpty() per essere meno efficiente di size() == 0 . Quindi isEmpty() è preferito.

Trappola: problemi di efficienza con espressioni regolari

La corrispondenza delle espressioni regolari è uno strumento potente (in Java e in altri contesti) ma presenta alcuni inconvenienti. Una di queste espressioni regolari tende ad essere piuttosto costosa.

Le istanze Pattern and Matcher devono essere riutilizzate

Considera il seguente esempio:

/**
 * 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;
}

Questo codice è corretto, ma è inefficiente. Il problema è nella chiamata alle matches(...) . Sotto il cofano, s.matches("[A-Za-z0-9]*") è equivalente a questo:

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

che è a sua volta equivalente a

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

La Pattern.compile("[A-Za-z0-9]*") analizza l'espressione regolare, la analizza e costruisce un oggetto Pattern che contiene la struttura dati che verrà utilizzata dal motore regex. Questo è un calcolo non banale. Quindi viene creato un oggetto Matcher per racchiudere l'argomento s . Infine chiamiamo match() per fare la corrispondenza del pattern attuale.

Il problema è che questo lavoro viene ripetuto per ogni iterazione del ciclo. La soluzione è ristrutturare il codice come segue:

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;
}

Si noti che javadoc per gli stati Pattern :

Le istanze di questa classe sono immutabili e sono sicure per l'utilizzo da più thread simultanei. Le istanze della classe Matcher non sono sicure per tale uso.

Non usare match () quando dovresti usare find ()

Supponiamo di voler verificare se una stringa s contiene tre o più cifre in una riga. Puoi esprimerlo in vari modi, tra cui:

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

o

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

Il primo è più conciso, ma è anche probabile che sia meno efficiente. A prima vista, la prima versione cercherà di abbinare l'intera stringa al modello. Inoltre, dato che ". *" È un pattern "goloso", è probabile che il pattern matcher faccia avanzare "impazientemente" la fine della stringa, e backtrack fino a quando non trova una corrispondenza.

Al contrario, la seconda versione cercherà da sinistra a destra e interromperà la ricerca non appena trova le 3 cifre di seguito.

Utilizzare alternative più efficienti alle espressioni regolari

Le espressioni regolari sono uno strumento potente, ma non dovrebbero essere il tuo unico strumento. Molte attività possono essere svolte in modo più efficiente in altri modi. Per esempio:

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

fa la stessa cosa di:

 s.contains("ABC")

tranne che quest'ultimo è molto più efficiente. (Anche se è possibile ammortizzare il costo della compilazione dell'espressione regolare).

Spesso, la forma non regex è più complicata. Ad esempio, il test eseguito da matches() chiama il metodo allAlplanumeric precedente può essere riscritto come:

 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;
 }

Ora questo è più codice rispetto all'utilizzo di un Matcher , ma sarà anche molto più veloce.

Catastrophic Backtracking

(Questo è potenzialmente un problema con tutte le implementazioni delle espressioni regolari, ma lo menzioneremo qui perché è un trabocchetto per l'utilizzo di Pattern .)

Considera questo esempio (forzato):

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

La prima chiamata println verrà stampata rapidamente true . Il secondo stamperà false . Infine. Infatti, se sperimentate il codice sopra, vedrete che ogni volta che aggiungete un A prima del C , il tempo impiegato raddoppierà.

Questo comportamento è un esempio di backtracking catastrofico . Il motore di corrispondenza dei modelli che implementa la corrispondenza delle espressioni regolari sta tentando inutilmente tutti i possibili modi in cui il modello potrebbe corrispondere.

Vediamo cosa significa in realtà (A+)+B Superficialmente, sembra dire "uno o più personaggi A seguiti da un valore B ", ma in realtà dice uno o più gruppi, ognuno dei quali è costituito da uno o più caratteri A Quindi, ad esempio:

  • 'AB' corrisponde solo in un modo: '(A) B'
  • 'AAB' corrisponde a due modi: '(AA) B' o '(A) (A) B`
  • 'AAAB' corrisponde a quattro modi: '(AAA) B' o '(AA) (A) B or '(A)(AA)B o '(A) (A) (A) B`
  • e così via

In altre parole, il numero di corrispondenze possibili è 2 N dove N è il numero di caratteri A

L'esempio sopra è chiaramente artificioso, ma i modelli che esibiscono questo tipo di caratteristiche di performance (cioè O(2^N) o O(N^K) per un K grande) sorgono frequentemente quando si usano espressioni regolari sconsiderate. Ci sono alcuni rimedi standard:

  • Evitare di annidare i pattern ripetuti all'interno di altri pattern ripetitivi.
  • Evita di usare troppi pattern ripetuti.
  • Utilizzare la ripetizione senza retromarcia come appropriato.
  • Non utilizzare espressioni regex per attività di analisi complicate. (Scrivi invece un parser adeguato).

Infine, fai attenzione alle situazioni in cui un utente o un client API può fornire una stringa regex con caratteristiche patologiche. Ciò può portare a "denial of service" accidentale o intenzionale.

Riferimenti:

Pitfall - Le stringhe Internazionali in modo che tu possa usare == è una cattiva idea

Quando alcuni programmatori vedono questo consiglio:

"Testare le stringhe usando == non è corretto (a meno che le stringhe non siano internate)"

la loro reazione iniziale è alle stringhe interne in modo che possano usare == . (Dopotutto == è più veloce di chiamare String.equals(...) , non è vero).

Questo è l'approccio sbagliato, da un certo numero di prospettive:

Fragilità

Prima di tutto, puoi usare tranquillamente == se sai che tutti gli oggetti String che stai testando sono stati internati. Il JLS garantisce che i valori letterali stringa nel codice sorgente siano stati internati. Tuttavia, nessuna delle API Java SE standard garantisce di restituire stringhe internate, a parte String.intern(String) stesso. Se manchi solo una fonte di oggetti String che non sono stati internati, la tua applicazione sarà inaffidabile. Questa inaffidabilità si manifesterà come falsi negativi piuttosto che come eccezioni che potrebbero renderlo più difficile da rilevare.

Costi dell'utilizzo di 'intern ()'

Sotto il cofano, interning funziona mantenendo una tabella hash che contiene oggetti String precedentemente internati. Viene utilizzato un tipo di meccanismo di riferimento debole in modo che la tabella hash interna non diventi una perdita di archiviazione. Mentre la tabella hash è implementato in codice nativo (a differenza HashMap , HashTable e così via), i intern chiamate sono ancora relativamente costosi in termini di CPU e memoria utilizzata.

Questo costo deve essere confrontato con il risparmio che otterremo usando == invece di equals . In realtà, non stiamo andando in pareggio, a meno che ogni stringa internata venga confrontata con altre stringhe "un paio di volte".

(A parte: le poche situazioni in cui vale la pena internazionalizzare tendono a ridurre la memoria del footprint di un'applicazione in cui le stesse stringhe ricorrono molte volte e quelle stringhe hanno una lunga durata.)

L'impatto sulla raccolta dei rifiuti

Oltre ai costi diretti della CPU e della memoria sopra descritti, le stringhe interne influiscono sulle prestazioni del garbage collector.

Per le versioni di Java precedenti a Java 7, le stringhe internate vengono conservate nello spazio "PermGen" che viene raccolto di rado. Se è necessario raccogliere PermGen, questo (in genere) attiva una garbage collection completa. Se lo spazio PermGen si riempie completamente, la JVM si arresta in modo anomalo, anche se c'era spazio libero negli spazi heap normali.

In Java 7, il pool di stringhe è stato spostato da "PermGen" nell'heap normale. Tuttavia, la tabella hash sarà ancora una struttura di dati di lunga durata, che farà sì che le stringhe internamente siano di lunga durata. (Anche se gli oggetti stringa interni sono stati allocati nello spazio Eden, molto probabilmente verrebbero promossi prima di essere raccolti).

Pertanto, in tutti i casi, l'internatura di una stringa prolungherà la sua durata rispetto a una stringa ordinaria. Ciò aumenterà i costi generali di raccolta dei dati obsoleti nel corso della durata della JVM.

Il secondo problema è che la tabella hash deve utilizzare un meccanismo di riferimento debole di qualche tipo per impedire che la stringa internamente perdi memoria. Ma un tale meccanismo è più lavoro per il garbage collector.

Queste spese generali di raccolta dei dati inutili sono difficili da quantificare, ma non c'è dubbio che esistano. Se usi intern , potrebbero essere significativi.

La dimensione hashtable del pool di stringhe

Secondo questa fonte , da Java 6 in poi, il pool di stringhe viene implementato come tabella hash di dimensioni fisse con catene per gestire stringhe che eseguono lo hash nello stesso bucket. Nelle prime versioni di Java 6, la tabella hash aveva una dimensione costante (cablata). Un parametro di ottimizzazione ( -XX:StringTableSize ) è stato aggiunto come aggiornamento mid-life a Java 6. Quindi, in un aggiornamento di metà vita di Java 7, la dimensione predefinita del pool è stata modificata da 1009 a 60013 .

La linea di fondo è che se si intende utilizzare intern internamente nel proprio codice, è consigliabile scegliere una versione di Java in cui la dimensione della tabella hash può essere regolata e assicurarsi di regolarne la dimensione in modo appropriato. In caso contrario, le prestazioni di intern rischiano di peggiorare man mano che la piscina si ingrandisce.

Interning come potenziale negazione del vettore di servizio

L'algoritmo di hashcode per le stringhe è ben noto. Se le stringhe interne fornite da utenti malintenzionati o applicazioni, questo potrebbe essere utilizzato come parte di un attacco DoS (denial of service). Se l'agente malintenzionato dispone che tutte le stringhe che fornisce abbiano lo stesso codice hash, ciò potrebbe comportare una tabella hash sbilanciata e prestazioni O(N) per intern ... dove N è il numero di stringhe collise.

(Esistono metodi più semplici / più efficaci per lanciare un attacco DoS contro un servizio, tuttavia questo vettore potrebbe essere utilizzato se l'obiettivo dell'attacco DoS è quello di violare la sicurezza o di eludere le difese DoS di prima linea.)

Trappola - Le piccole letture / scritture sui flussi non bufferizzati sono inefficienti

Considera il seguente codice per copiare un file in un altro:

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);
           }
        }
    }
}

(Abbiamo deliberato di omettere il normale controllo degli argomenti, la segnalazione degli errori e così via perché non sono pertinenti al punto di questo esempio).

Se compili il codice sopra e lo usi per copiare un file enorme, noterai che è molto lento. In effetti, sarà inferiore di almeno un paio di ordini di grandezza rispetto alle utility di copia di file OS standard.

( Aggiungi misurazioni di prestazioni effettive qui! )

Il motivo principale per cui l'esempio precedente è lento (nel caso di file di grandi dimensioni) è che sta eseguendo letture a un byte e scritture a byte singolo su flussi di byte senza buffer. Il modo semplice per migliorare le prestazioni è quello di avvolgere gli stream con flussi bufferizzati. Per esempio:

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);
           }
        }
    }
}

Queste piccole modifiche miglioreranno la velocità di copia dei dati di almeno un paio di ordini di grandezza, a seconda dei vari fattori legati alla piattaforma. I wrapper di flusso bufferizzati causano la lettura e la scrittura dei dati in blocchi più grandi. Le istanze hanno entrambi buffer implementati come array di byte.

  • Con is , i dati vengono letti dal file nel buffer pochi kilobyte alla volta. Quando viene chiamato read() , l'implementazione tipicamente restituisce un byte dal buffer. Legge solo dal flusso di input sottostante se il buffer è stato svuotato.

  • Il comportamento per os è analogo. Chiama su os.write(int) scrivere byte singoli nel buffer. I dati vengono scritti nel flusso di output solo quando il buffer è pieno o quando l' os viene svuotato o chiuso.

Che dire dei flussi basati sui personaggi?

Come dovresti sapere, Java I / O fornisce diverse API per leggere e scrivere dati binari e di testo.

  • InputStream e OutputStream sono le API di base per I / O binari basati sul flusso
  • Reader e Writer sono le API di base per l'I / O di testo basato sul flusso.

Per l'I / O di testo, BufferedReader e BufferedWriter sono gli equivalenti per BufferedInputStream e BufferedOutputStream .

Perché i flussi bufferizzati fanno la differenza?

La vera ragione per cui gli stream bufferizzati aiutano le prestazioni è il modo in cui un'applicazione comunica con il sistema operativo:

  • Il metodo Java in un'applicazione Java o le chiamate di procedure native nelle librerie di runtime native della JVM sono veloci. Solitamente richiedono un paio di istruzioni della macchina e hanno un impatto minimo sulle prestazioni.

  • Al contrario, le chiamate di runtime JVM al sistema operativo non sono veloci. Coinvolgono qualcosa conosciuto come "syscall". Il modello tipico per un syscall è il seguente:

    1. Metti gli argomenti di syscall in registri.
    2. Esegui un'istruzione trap SYSENTER.
    3. Il gestore trap passa allo stato privilegiato e modifica i mapping della memoria virtuale. Quindi invia al codice per gestire lo specifico syscall.
    4. Il gestore syscall controlla gli argomenti, facendo attenzione che non gli venga detto di accedere alla memoria che il processo utente non dovrebbe vedere.
    5. Il lavoro specifico di syscall viene eseguito. Nel caso di una read syscall, ciò potrebbe comportare:
      1. controllando che ci siano dati da leggere nella posizione corrente del descrittore del file
      2. chiamando il gestore del file system per recuperare i dati richiesti dal disco (o ovunque sia archiviato) nella cache del buffer,
      3. copia dei dati dalla cache del buffer all'indirizzo fornito da JVM
      4. regolazione della posizione del descrittore del file pointstream
    6. Ritorna da syscall. Ciò comporta di nuovo la modifica dei mapping delle macchine virtuali e il passaggio dallo stato privilegiato.

Come puoi immaginare, l'esecuzione di un singolo syscall può contenere migliaia di istruzioni. Conservativamente, almeno due ordini di grandezza più lunghi di una normale chiamata di metodo. (Probabilmente tre o più.)

Detto questo, la ragione per cui i flussi bufferizzati fanno una grande differenza è che riducono drasticamente il numero di syscall. Invece di eseguire una syscall per ogni chiamata read() , il flusso di input memorizzato nel buffer legge una grande quantità di dati in un buffer come richiesto. La maggior parte delle chiamate read() sul flusso bufferizzato esegue alcuni semplici controlli e restituisce un byte letto in precedenza. Ragionamento analogo si applica al caso del flusso di output e anche ai casi del flusso di caratteri.

(Alcune persone pensano che le prestazioni di I / O bufferizzate derivino dalla mancata corrispondenza tra la dimensione della richiesta di lettura e le dimensioni di un blocco del disco, la latenza di rotazione del disco e cose del genere.In realtà, un sistema operativo moderno utilizza una serie di strategie per garantire che il in genere l' applicazione non ha bisogno di attendere il disco. Questa non è la vera spiegazione.)

I flussi bufferizzati sono sempre una vittoria?

Non sempre. I flussi bufferizzati sono sicuramente una vittoria se la tua applicazione farà molte letture o scritture "piccole". Tuttavia, se l'applicazione deve eseguire solo letture o scritture di grandi dimensioni su / da un byte[] grande byte[] o char[] , i flussi memorizzati nel buffer non offrono vantaggi reali. In effetti potrebbe anche esserci una (piccola) penalità per le prestazioni.

È questo il modo più veloce per copiare un file in Java?

No, non lo è. Quando si usano le API basate sul flusso di Java per copiare un file, si incorre nel costo di almeno una copia extra dei dati da memoria a memoria. È possibile evitare questo se si utilizzano NIO ByteBuffer e le API del Channel . ( Aggiungi un link ad un esempio separato qui. )



Modified text is an extract of the original Stack Overflow Documentation
Autorizzato sotto CC BY-SA 3.0
Non affiliato con Stack Overflow