Qual è la capacità e il fattore di carico ottimali per una HashMap di dimensioni fisse?


86

Sto cercando di capire la capacità e il fattore di carico ottimali per un caso specifico. Penso di aver capito il succo, ma sarei comunque grato per una conferma da parte di qualcuno più informato di me. :)

Se so che la mia HashMap si riempirà per contenere, diciamo, 100 oggetti e trascorrerà la maggior parte del tempo con 100 oggetti, immagino che i valori ottimali siano la capacità iniziale 100 e il fattore di carico 1? O ho bisogno della capacità 101 o ci sono altri trucchi?

EDIT: OK, ho messo da parte qualche ora e ho fatto dei test. Ecco i risultati:

  • Curiosamente, capacità, capacità + 1, capacità + 2, capacità-1 e persino capacità-10 producono tutti esattamente gli stessi risultati. Mi aspetto che almeno capacità 1 e capacità 10 diano risultati peggiori.
  • L'uso della capacità iniziale (invece di utilizzare il valore predefinito di 16) offre un notevole miglioramento di put (), fino al 30% più veloce.
  • L'utilizzo del fattore di carico 1 fornisce prestazioni uguali per un numero ridotto di oggetti e prestazioni migliori per un numero maggiore di oggetti (> 100000). Tuttavia, ciò non migliora proporzionalmente al numero di oggetti; Sospetto che ci sia un fattore aggiuntivo che influisce sui risultati.
  • Le prestazioni di get () sono leggermente diverse a seconda del numero di oggetti / capacità, ma sebbene possano variare leggermente da caso a caso, generalmente non sono influenzate dalla capacità iniziale o dal fattore di carico.

EDIT2: Aggiungendo anche alcuni grafici da parte mia. Ecco quello che illustra la differenza tra il fattore di carico 0,75 e 1, nei casi in cui inizializzo HashMap e lo riempio fino alla piena capacità. Sulla scala y è il tempo in ms (inferiore è meglio) e la scala x è la dimensione (numero di oggetti). Poiché le dimensioni cambiano linearmente, anche il tempo richiesto cresce linearmente.

Quindi, vediamo cosa ho ottenuto. I due grafici seguenti mostrano la differenza nei fattori di carico. Il primo grafico mostra cosa succede quando HashMap è pieno di capacità; il fattore di carico 0,75 ha prestazioni peggiori a causa del ridimensionamento. Tuttavia, non è costantemente peggiore e ci sono tutti i tipi di salti e salti - immagino che GC abbia un ruolo importante in questo. Il fattore di carico 1,25 ha la stessa funzione di 1, quindi non è incluso nel grafico.

completamente riempito

Questo grafico dimostra che 0,75 era peggiore a causa del ridimensionamento; se riempiamo l'HashMap a metà della capacità, 0,75 non è peggio, solo ... diverso (e dovrebbe usare meno memoria e avere prestazioni di iterazione incredibilmente migliori).

mezzo pieno

Un'altra cosa che voglio mostrare. Questo è ottenere prestazioni per tutti e tre i fattori di carico e le diverse dimensioni di HashMap. Costantemente costante con una piccola variazione, ad eccezione di un picco per il fattore di carico 1. Vorrei davvero sapere di cosa si tratta (probabilmente GC, ma chi lo sa).

vai spike

Ed ecco il codice per chi è interessato:

import java.util.HashMap;
import java.util.Map;

public class HashMapTest {

  // capacity - numbers high as 10000000 require -mx1536m -ms1536m JVM parameters
  public static final int CAPACITY = 10000000;
  public static final int ITERATIONS = 10000;

  // set to false to print put performance, or to true to print get performance
  boolean doIterations = false;

  private Map<Integer, String> cache;

  public void fillCache(int capacity) {
    long t = System.currentTimeMillis();
    for (int i = 0; i <= capacity; i++)
      cache.put(i, "Value number " + i);

    if (!doIterations) {
      System.out.print(System.currentTimeMillis() - t);
      System.out.print("\t");
    }
  }

  public void iterate(int capacity) {
    long t = System.currentTimeMillis();

    for (int i = 0; i <= ITERATIONS; i++) {
      long x = Math.round(Math.random() * capacity);
      String result = cache.get((int) x);
    }

    if (doIterations) {
      System.out.print(System.currentTimeMillis() - t);
      System.out.print("\t");
    }
  }

  public void test(float loadFactor, int divider) {
    for (int i = 10000; i <= CAPACITY; i+= 10000) {
      cache = new HashMap<Integer, String>(i, loadFactor);
      fillCache(i / divider);
      if (doIterations)
        iterate(i / divider);
    }
    System.out.println();
  }

  public static void main(String[] args) {
    HashMapTest test = new HashMapTest();

    // fill to capacity
    test.test(0.75f, 1);
    test.test(1, 1);
    test.test(1.25f, 1);

    // fill to half capacity
    test.test(0.75f, 2);
    test.test(1, 2);
    test.test(1.25f, 2);
  }

}

1
Ottimale nel senso che la modifica dei valori predefiniti offre prestazioni migliori (esecuzione put () più veloce) per questo caso.
Domchi

2
@Peter GC = Garbage Collection.
Domchi

2
Quei grafici sono puliti ... Cosa hai usato per generarli / renderli?
G_H

1
@ G_H Niente di speciale - output del programma precedente ed Excel. :)
Domchi

2
La prossima volta, usa i punti invece delle linee. Renderà il confronto più facile visivamente.
Paul Draper

Risposte:


74

Bene, per mettere a tacere questa cosa, ho creato un'app di prova per eseguire un paio di scenari e ottenere alcune visualizzazioni dei risultati. Ecco come vengono eseguiti i test:

  • Sono state provate raccolte di diverse dimensioni: cento, mille e centomila voci.
  • Le chiavi utilizzate sono istanze di una classe identificate in modo univoco da un ID. Ogni test utilizza chiavi univoche, con numeri interi incrementali come ID. Il equalsmetodo utilizza solo l'ID, quindi nessuna mappatura dei tasti ne sovrascrive un altro.
  • Le chiavi ottengono un codice hash costituito dal resto del modulo del loro ID rispetto a un numero preimpostato. Chiameremo quel numero limite di hash . Questo mi ha permesso di controllare il numero di collisioni di hash previste. Ad esempio, se la dimensione della nostra raccolta è 100, avremo chiavi con ID compresi tra 0 e 99. Se il limite hash è 100, ogni chiave avrà un codice hash univoco. Se il limite hash è 50, la chiave 0 avrà lo stesso codice hash della chiave 50, 1 avrà lo stesso codice hash di 51 ecc. In altre parole, il numero previsto di collisioni hash per chiave è la dimensione della raccolta divisa per l'hash limite.
  • Per ogni combinazione di dimensione della raccolta e limite hash, ho eseguito il test utilizzando mappe hash inizializzate con impostazioni diverse. Queste impostazioni sono il fattore di carico e una capacità iniziale espressa come fattore dell'impostazione di raccolta. Ad esempio, un test con una dimensione di raccolta di 100 e un fattore di capacità iniziale di 1,25 inizializzerà una mappa hash con una capacità iniziale di 125.
  • Il valore per ogni chiave è semplicemente nuovo Object.
  • Ogni risultato del test è incapsulato in un'istanza di una classe Result. Alla fine di tutti i test, i risultati vengono ordinati dalla peggiore prestazione complessiva alla migliore.
  • Il tempo medio per put e get è calcolato per 10 put / get.
  • Tutte le combinazioni di test vengono eseguite una volta per eliminare l'influenza della compilazione JIT. Successivamente, vengono eseguiti i test per i risultati effettivi.

Ecco la classe:

package hashmaptest;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;

public class HashMapTest {

    private static final List<Result> results = new ArrayList<Result>();

    public static void main(String[] args) throws IOException {

        //First entry of each array is the sample collection size, subsequent entries
        //are the hash limits
        final int[][] sampleSizesAndHashLimits = new int[][] {
            {100, 50, 90, 100},
            {1000, 500, 900, 990, 1000},
            {100000, 10000, 90000, 99000, 100000}
        };
        final double[] initialCapacityFactors = new double[] {0.5, 0.75, 1.0, 1.25, 1.5, 2.0};
        final float[] loadFactors = new float[] {0.5f, 0.75f, 1.0f, 1.25f};

        //Doing a warmup run to eliminate JIT influence
        for(int[] sizeAndLimits : sampleSizesAndHashLimits) {
            int size = sizeAndLimits[0];
            for(int i = 1; i < sizeAndLimits.length; ++i) {
                int limit = sizeAndLimits[i];
                for(double initCapacityFactor : initialCapacityFactors) {
                    for(float loadFactor : loadFactors) {
                        runTest(limit, size, initCapacityFactor, loadFactor);
                    }
                }
            }

        }

        results.clear();

        //Now for the real thing...
        for(int[] sizeAndLimits : sampleSizesAndHashLimits) {
            int size = sizeAndLimits[0];
            for(int i = 1; i < sizeAndLimits.length; ++i) {
                int limit = sizeAndLimits[i];
                for(double initCapacityFactor : initialCapacityFactors) {
                    for(float loadFactor : loadFactors) {
                        runTest(limit, size, initCapacityFactor, loadFactor);
                    }
                }
            }

        }

        Collections.sort(results);

        for(final Result result : results) {
            result.printSummary();
        }

//      ResultVisualizer.visualizeResults(results);

    }

    private static void runTest(final int hashLimit, final int sampleSize,
            final double initCapacityFactor, final float loadFactor) {

        final int initialCapacity = (int)(sampleSize * initCapacityFactor);

        System.out.println("Running test for a sample collection of size " + sampleSize 
            + ", an initial capacity of " + initialCapacity + ", a load factor of "
            + loadFactor + " and keys with a hash code limited to " + hashLimit);
        System.out.println("====================");

        double hashOverload = (((double)sampleSize/hashLimit) - 1.0) * 100.0;

        System.out.println("Hash code overload: " + hashOverload + "%");

        //Generating our sample key collection.
        final List<Key> keys = generateSamples(hashLimit, sampleSize);

        //Generating our value collection
        final List<Object> values = generateValues(sampleSize);

        final HashMap<Key, Object> map = new HashMap<Key, Object>(initialCapacity, loadFactor);

        final long startPut = System.nanoTime();

        for(int i = 0; i < sampleSize; ++i) {
            map.put(keys.get(i), values.get(i));
        }

        final long endPut = System.nanoTime();

        final long putTime = endPut - startPut;
        final long averagePutTime = putTime/(sampleSize/10);

        System.out.println("Time to map all keys to their values: " + putTime + " ns");
        System.out.println("Average put time per 10 entries: " + averagePutTime + " ns");

        final long startGet = System.nanoTime();

        for(int i = 0; i < sampleSize; ++i) {
            map.get(keys.get(i));
        }

        final long endGet = System.nanoTime();

        final long getTime = endGet - startGet;
        final long averageGetTime = getTime/(sampleSize/10);

        System.out.println("Time to get the value for every key: " + getTime + " ns");
        System.out.println("Average get time per 10 entries: " + averageGetTime + " ns");

        System.out.println("");

        final Result result = 
            new Result(sampleSize, initialCapacity, loadFactor, hashOverload, averagePutTime, averageGetTime, hashLimit);

        results.add(result);

        //Haha, what kind of noob explicitly calls for garbage collection?
        System.gc();

        try {
            Thread.sleep(200);
        } catch(final InterruptedException e) {}

    }

    private static List<Key> generateSamples(final int hashLimit, final int sampleSize) {

        final ArrayList<Key> result = new ArrayList<Key>(sampleSize);

        for(int i = 0; i < sampleSize; ++i) {
            result.add(new Key(i, hashLimit));
        }

        return result;

    }

    private static List<Object> generateValues(final int sampleSize) {

        final ArrayList<Object> result = new ArrayList<Object>(sampleSize);

        for(int i = 0; i < sampleSize; ++i) {
            result.add(new Object());
        }

        return result;

    }

    private static class Key {

        private final int hashCode;
        private final int id;

        Key(final int id, final int hashLimit) {

            //Equals implies same hashCode if limit is the same
            //Same hashCode doesn't necessarily implies equals

            this.id = id;
            this.hashCode = id % hashLimit;

        }

        @Override
        public int hashCode() {
            return hashCode;
        }

        @Override
        public boolean equals(final Object o) {
            return ((Key)o).id == this.id;
        }

    }

    static class Result implements Comparable<Result> {

        final int sampleSize;
        final int initialCapacity;
        final float loadFactor;
        final double hashOverloadPercentage;
        final long averagePutTime;
        final long averageGetTime;
        final int hashLimit;

        Result(final int sampleSize, final int initialCapacity, final float loadFactor, 
                final double hashOverloadPercentage, final long averagePutTime, 
                final long averageGetTime, final int hashLimit) {

            this.sampleSize = sampleSize;
            this.initialCapacity = initialCapacity;
            this.loadFactor = loadFactor;
            this.hashOverloadPercentage = hashOverloadPercentage;
            this.averagePutTime = averagePutTime;
            this.averageGetTime = averageGetTime;
            this.hashLimit = hashLimit;

        }

        @Override
        public int compareTo(final Result o) {

            final long putDiff = o.averagePutTime - this.averagePutTime;
            final long getDiff = o.averageGetTime - this.averageGetTime;

            return (int)(putDiff + getDiff);
        }

        void printSummary() {

            System.out.println("" + averagePutTime + " ns per 10 puts, "
                + averageGetTime + " ns per 10 gets, for a load factor of "
                + loadFactor + ", initial capacity of " + initialCapacity
                + " for " + sampleSize + " mappings and " + hashOverloadPercentage 
                + "% hash code overload.");

        }

    }

}

L'esecuzione di questo potrebbe richiedere del tempo. I risultati vengono stampati su standard out. Potresti notare che ho commentato una riga. Quella linea chiama un visualizzatore che genera rappresentazioni visive dei risultati in file png. La classe per questo è data di seguito. Se desideri eseguirlo, rimuovi il commento dalla riga appropriata nel codice sopra. Attenzione: la classe visualizer presuppone che tu stia eseguendo su Windows e creerà cartelle e file in C: \ temp. Quando si esegue su un'altra piattaforma, regolare questo.

package hashmaptest;

import hashmaptest.HashMapTest.Result;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.imageio.ImageIO;

public class ResultVisualizer {

    private static final Map<Integer, Map<Integer, Set<Result>>> sampleSizeToHashLimit = 
        new HashMap<Integer, Map<Integer, Set<Result>>>();

    private static final DecimalFormat df = new DecimalFormat("0.00");

    static void visualizeResults(final List<Result> results) throws IOException {

        final File tempFolder = new File("C:\\temp");
        final File baseFolder = makeFolder(tempFolder, "hashmap_tests");

        long bestPutTime = -1L;
        long worstPutTime = 0L;
        long bestGetTime = -1L;
        long worstGetTime = 0L;

        for(final Result result : results) {

            final Integer sampleSize = result.sampleSize;
            final Integer hashLimit = result.hashLimit;
            final long putTime = result.averagePutTime;
            final long getTime = result.averageGetTime;

            if(bestPutTime == -1L || putTime < bestPutTime)
                bestPutTime = putTime;
            if(bestGetTime <= -1.0f || getTime < bestGetTime)
                bestGetTime = getTime;

            if(putTime > worstPutTime)
                worstPutTime = putTime;
            if(getTime > worstGetTime)
                worstGetTime = getTime;

            Map<Integer, Set<Result>> hashLimitToResults = 
                sampleSizeToHashLimit.get(sampleSize);
            if(hashLimitToResults == null) {
                hashLimitToResults = new HashMap<Integer, Set<Result>>();
                sampleSizeToHashLimit.put(sampleSize, hashLimitToResults);
            }
            Set<Result> resultSet = hashLimitToResults.get(hashLimit);
            if(resultSet == null) {
                resultSet = new HashSet<Result>();
                hashLimitToResults.put(hashLimit, resultSet);
            }
            resultSet.add(result);

        }

        System.out.println("Best average put time: " + bestPutTime + " ns");
        System.out.println("Best average get time: " + bestGetTime + " ns");
        System.out.println("Worst average put time: " + worstPutTime + " ns");
        System.out.println("Worst average get time: " + worstGetTime + " ns");

        for(final Integer sampleSize : sampleSizeToHashLimit.keySet()) {

            final File sizeFolder = makeFolder(baseFolder, "sample_size_" + sampleSize);

            final Map<Integer, Set<Result>> hashLimitToResults = 
                sampleSizeToHashLimit.get(sampleSize);

            for(final Integer hashLimit : hashLimitToResults.keySet()) {

                final File limitFolder = makeFolder(sizeFolder, "hash_limit_" + hashLimit);

                final Set<Result> resultSet = hashLimitToResults.get(hashLimit);

                final Set<Float> loadFactorSet = new HashSet<Float>();
                final Set<Integer> initialCapacitySet = new HashSet<Integer>();

                for(final Result result : resultSet) {
                    loadFactorSet.add(result.loadFactor);
                    initialCapacitySet.add(result.initialCapacity);
                }

                final List<Float> loadFactors = new ArrayList<Float>(loadFactorSet);
                final List<Integer> initialCapacities = new ArrayList<Integer>(initialCapacitySet);

                Collections.sort(loadFactors);
                Collections.sort(initialCapacities);

                final BufferedImage putImage = 
                    renderMap(resultSet, loadFactors, initialCapacities, worstPutTime, bestPutTime, false);
                final BufferedImage getImage = 
                    renderMap(resultSet, loadFactors, initialCapacities, worstGetTime, bestGetTime, true);

                final String putFileName = "size_" + sampleSize + "_hlimit_" + hashLimit + "_puts.png";
                final String getFileName = "size_" + sampleSize + "_hlimit_" + hashLimit + "_gets.png";

                writeImage(putImage, limitFolder, putFileName);
                writeImage(getImage, limitFolder, getFileName);

            }

        }

    }

    private static File makeFolder(final File parent, final String folder) throws IOException {

        final File child = new File(parent, folder);

        if(!child.exists())
            child.mkdir();

        return child;

    }

    private static BufferedImage renderMap(final Set<Result> results, final List<Float> loadFactors,
            final List<Integer> initialCapacities, final float worst, final float best,
            final boolean get) {

        //[x][y] => x is mapped to initial capacity, y is mapped to load factor
        final Color[][] map = new Color[initialCapacities.size()][loadFactors.size()];

        for(final Result result : results) {
            final int x = initialCapacities.indexOf(result.initialCapacity);
            final int y = loadFactors.indexOf(result.loadFactor);
            final float time = get ? result.averageGetTime : result.averagePutTime;
            final float score = (time - best)/(worst - best);
            final Color c = new Color(score, 1.0f - score, 0.0f);
            map[x][y] = c;
        }

        final int imageWidth = initialCapacities.size() * 40 + 50;
        final int imageHeight = loadFactors.size() * 40 + 50;

        final BufferedImage image = 
            new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_3BYTE_BGR);

        final Graphics2D g = image.createGraphics();

        g.setColor(Color.WHITE);
        g.fillRect(0, 0, imageWidth, imageHeight);

        for(int x = 0; x < map.length; ++x) {

            for(int y = 0; y < map[x].length; ++y) {

                g.setColor(map[x][y]);
                g.fillRect(50 + x*40, imageHeight - 50 - (y+1)*40, 40, 40);

                g.setColor(Color.BLACK);
                g.drawLine(25, imageHeight - 50 - (y+1)*40, 50, imageHeight - 50 - (y+1)*40);

                final Float loadFactor = loadFactors.get(y);
                g.drawString(df.format(loadFactor), 10, imageHeight - 65 - (y)*40);

            }

            g.setColor(Color.BLACK);
            g.drawLine(50 + (x+1)*40, imageHeight - 50, 50 + (x+1)*40, imageHeight - 15);

            final int initialCapacity = initialCapacities.get(x);
            g.drawString(((initialCapacity%1000 == 0) ? "" + (initialCapacity/1000) + "K" : "" + initialCapacity), 15 + (x+1)*40, imageHeight - 25);
        }

        g.drawLine(25, imageHeight - 50, imageWidth, imageHeight - 50);
        g.drawLine(50, 0, 50, imageHeight - 25);

        g.dispose();

        return image;

    }

    private static void writeImage(final BufferedImage image, final File folder, 
            final String filename) throws IOException {

        final File imageFile = new File(folder, filename);

        ImageIO.write(image, "png", imageFile);

    }

}

L'output visualizzato è il seguente:

  • I test vengono divisi prima per dimensione della raccolta, quindi per limite hash.
  • Per ogni test, c'è un'immagine di output riguardante il tempo di put medio (per 10 put) e il tempo di get medio (per 10 get). Le immagini sono "mappe termiche" bidimensionali che mostrano un colore per combinazione di capacità iniziale e fattore di carico.
  • I colori nelle immagini si basano sul tempo medio su una scala normalizzata dal miglior risultato al peggiore, che va dal verde saturo al rosso saturo. In altre parole, il tempo migliore sarà completamente verde, mentre il tempo peggiore sarà completamente rosso. Due diverse misurazioni del tempo non dovrebbero mai avere lo stesso colore.
  • Le mappe dei colori vengono calcolate separatamente per put e gets, ma comprendono tutti i test per le rispettive categorie.
  • Le visualizzazioni mostrano la capacità iniziale sull'asse xe il fattore di carico sull'asse y.

Senza ulteriori indugi, diamo un'occhiata ai risultati. Inizierò con i risultati per i put.

Metti i risultati


Dimensione della raccolta: 100. Limite hash: 50. Ciò significa che ogni codice hash deve essere ripetuto due volte e ogni altra chiave entra in collisione nella mappa hash.

size_100_hlimit_50_puts

Bene, questo non inizia molto bene. Vediamo che c'è un grande hotspot per una capacità iniziale del 25% superiore alla dimensione della raccolta, con un fattore di carico di 1. L'angolo inferiore sinistro non funziona troppo bene.


Dimensioni della raccolta: 100. Limite hash: 90. Una chiave su dieci ha un codice hash duplicato.

size_100_hlimit_90_puts

Questo è uno scenario leggermente più realistico, non avendo una funzione hash perfetta ma ancora un sovraccarico del 10%. L'hotspot è sparito, ma la combinazione di una bassa capacità iniziale con un basso fattore di carico ovviamente non funziona.


Dimensioni della raccolta: 100. Limite hash: 100. Ciascuna chiave come codice hash univoco. Non sono previste collisioni se ci sono abbastanza secchi.

size_100_hlimit_100_puts

Una capacità iniziale di 100 con un fattore di carico di 1 sembra soddisfacente. Sorprendentemente, una capacità iniziale più elevata con un fattore di carico inferiore non è necessariamente buona.


Dimensioni della raccolta: 1000. Limite hash: 500. Qui sta diventando più serio, con 1000 voci. Proprio come nel primo test, c'è un sovraccarico di hash di 2 a 1.

size_1000_hlimit_500_puts

L'angolo inferiore sinistro continua a non funzionare bene. Ma sembra esserci una simmetria tra la combinazione di conteggio iniziale inferiore / fattore di carico alto e conteggio iniziale più alto / fattore di carico basso.


Dimensioni della raccolta: 1000. Limite hash: 900. Ciò significa che un codice hash su dieci verrà ripetuto due volte. Scenario ragionevole per quanto riguarda le collisioni.

size_1000_hlimit_900_puts

C'è qualcosa di molto divertente in corso con l'improbabile combinazione di una capacità iniziale troppo bassa con un fattore di carico superiore a 1, il che è piuttosto controintuitivo. Altrimenti, ancora abbastanza simmetrico.


Dimensioni della raccolta: 1000. Limite hash: 990. Alcune collisioni, ma solo poche. Piuttosto realistico sotto questo aspetto.

size_1000_hlimit_990_puts

Abbiamo una bella simmetria qui. L'angolo inferiore sinistro non è ancora ottimale, ma le combo capacità di 1000 init / fattore di carico 1.0 contro capacità di 1250 init / fattore di carico 0,75 sono allo stesso livello.


Dimensioni della raccolta: 1000. Limite hash: 1000. Nessun codice hash duplicato, ma ora con una dimensione del campione di 1000.

size_1000_hlimit_1000_puts

Non c'è molto da dire qui. La combinazione di una capacità iniziale più elevata con un fattore di carico di 0,75 sembra superare leggermente la combinazione di una capacità iniziale di 1000 con un fattore di carico di 1.


Dimensioni della collezione: 100_000. Limite hash: 10_000. Va bene, ora si fa sul serio, con una dimensione del campione di centomila e 100 duplicati di codice hash per chiave.

size_100000_hlimit_10000_puts

Yikes! Penso che abbiamo trovato il nostro spettro più basso. Una capacità di inizializzazione esattamente della dimensione della collezione con un fattore di carico di 1 sta andando molto bene qui, ma a parte questo è in tutto il negozio.


Dimensioni della collezione: 100_000. Limite hash: 90_000. Un po 'più realistico rispetto al test precedente, qui abbiamo un sovraccarico del 10% nei codici hash.

size_100000_hlimit_90000_puts

L'angolo inferiore sinistro è ancora indesiderabile. Capacità iniziali più elevate funzionano meglio.


Dimensioni della collezione: 100_000. Limite hash: 99_000. Ottimo scenario, questo. Una vasta raccolta con un sovraccarico di codice hash dell'1%.

size_100000_hlimit_99000_puts

L'utilizzo della dimensione esatta della raccolta come capacità di inizializzazione con un fattore di carico di 1 vince qui! Tuttavia, capacità di inizializzazione leggermente maggiori funzionano abbastanza bene.


Dimensioni della collezione: 100_000. Limite hash: 100_000. Quello grande. La più grande collezione con una perfetta funzione hash.

size_100000_hlimit_100000_puts

Alcune cose sorprendenti qui. Una capacità iniziale con il 50% di spazio aggiuntivo con un fattore di carico di 1 vince.


Va bene, è tutto per le put. Ora, controlleremo i risultati. Ricorda, le mappe sottostanti sono tutte relative ai tempi di recupero migliori / peggiori, i tempi di put non vengono più presi in considerazione.

Ottieni risultati


Dimensioni della raccolta: 100. Limite hash: 50. Ciò significa che ogni codice hash dovrebbe essere ripetuto due volte e ci si aspettava che ogni altra chiave entrasse in collisione nella mappa hash.

size_100_hlimit_50_gets

Eh ... cosa?


Dimensioni della raccolta: 100. Limite hash: 90. Una chiave su dieci ha un codice hash duplicato.

size_100_hlimit_90_gets

Whoa Nelly! Questo è lo scenario più probabile da correlare alla domanda del richiedente, e apparentemente una capacità iniziale di 100 con un fattore di carico di 1 è una delle cose peggiori qui! Giuro di non aver simulato questo.


Dimensioni della raccolta: 100. Limite hash: 100. Ciascuna chiave come codice hash univoco. Non sono previste collisioni.

size_100_hlimit_100_gets

Questo sembra un po 'più tranquillo. Per lo più gli stessi risultati su tutta la linea.


Dimensioni della raccolta: 1000. Limite hash: 500. Proprio come nel primo test, c'è un sovraccarico hash di 2 a 1, ma ora con molte più voci.

size_1000_hlimit_500_gets

Sembra che qualsiasi impostazione produrrà un risultato decente qui.


Dimensioni della raccolta: 1000. Limite hash: 900. Ciò significa che un codice hash su dieci verrà ripetuto due volte. Scenario ragionevole per quanto riguarda le collisioni.

size_1000_hlimit_900_gets

E proprio come con i put per questa configurazione, otteniamo un'anomalia in un punto strano.


Dimensioni della raccolta: 1000. Limite hash: 990. Alcune collisioni, ma solo poche. Piuttosto realistico sotto questo aspetto.

size_1000_hlimit_990_gets

Prestazioni decenti ovunque, fatta eccezione per la combinazione di un'elevata capacità iniziale con un basso fattore di carico. Mi aspetto questo per i put, poiché potrebbero essere previsti due ridimensionamenti della mappa hash. Ma perché succede?


Dimensioni della raccolta: 1000. Limite hash: 1000. Nessun codice hash duplicato, ma ora con una dimensione del campione di 1000.

size_1000_hlimit_1000_gets

Una visualizzazione assolutamente non spettacolare. Sembra funzionare qualunque cosa accada.


Dimensioni della collezione: 100_000. Limite hash: 10_000. Entrando di nuovo nei 100K, con un sacco di codice hash sovrapposto.

size_100000_hlimit_10000_gets

Non sembra carino, anche se i punti negativi sono molto localizzati. Le prestazioni qui sembrano dipendere in gran parte da una certa sinergia tra le impostazioni.


Dimensioni della collezione: 100_000. Limite hash: 90_000. Un po 'più realistico rispetto al test precedente, qui abbiamo un sovraccarico del 10% nei codici hash.

size_100000_hlimit_90000_gets

Molta varianza, anche se strizzando gli occhi puoi vedere una freccia che punta verso l'angolo in alto a destra.


Dimensioni della collezione: 100_000. Limite hash: 99_000. Ottimo scenario, questo. Una vasta raccolta con un sovraccarico di codice hash dell'1%.

size_100000_hlimit_99000_gets

Molto caotico. È difficile trovare molta struttura qui.


Dimensioni della collezione: 100_000. Limite hash: 100_000. Quello grande. La più grande collezione con una perfetta funzione hash.

size_100000_hlimit_100000_gets

Qualcun altro pensa che questa stia iniziando a somigliare alla grafica Atari? Ciò sembra favorire una capacità iniziale esattamente della dimensione della raccolta, -25% o + 50%.


Va bene, è ora di trarre conclusioni ...

  • Per quanto riguarda i tempi di put: ti consigliamo di evitare capacità iniziali inferiori al numero previsto di voci sulla mappa. Se un numero esatto è noto in anticipo, quel numero o qualcosa di leggermente superiore sembra funzionare meglio. Fattori di carico elevati possono compensare capacità iniziali inferiori a causa di precedenti ridimensionamenti delle mappe hash. Per capacità iniziali più elevate, non sembrano avere molta importanza.
  • Per quanto riguarda i tempi di recupero: i risultati sono leggermente caotici qui. Non c'è molto da concludere. Sembra fare molto affidamento su rapporti sottili tra la sovrapposizione del codice hash, la capacità iniziale e il fattore di carico, con alcune configurazioni presumibilmente cattive che funzionano bene e buone configurazioni che si comportano in modo pessimo.
  • Apparentemente sono pieno di schifezze quando si tratta di supposizioni sulle prestazioni di Java. La verità è che, a meno che tu non stia adattando perfettamente le tue impostazioni all'implementazione di HashMap, i risultati saranno ovunque. Se c'è una cosa da togliere da questo, è che la dimensione iniziale predefinita di 16 è un po 'stupida per qualsiasi cosa tranne le mappe più piccole, quindi usa un costruttore che imposta la dimensione iniziale se hai qualche idea su quale ordine di dimensione sarà.
  • Stiamo misurando in nanosecondi qui. Il miglior tempo medio per 10 put è stato di 1179 ns e il peggior 5105 ns sulla mia macchina. Il miglior tempo medio per 10 risultati è stato di 547 ns e il peggiore 3484 ns. Potrebbe essere una differenza di fattore 6, ma stiamo parlando di meno di un millisecondo. Su collezioni molto più grandi di quanto aveva in mente il poster originale.

Bene, questo è tutto. Spero che il mio codice non abbia una supervisione orrenda che invalida tutto ciò che ho pubblicato qui. È stato divertente e ho imparato che alla fine potresti fare affidamento su Java per fare il suo lavoro piuttosto che aspettarti molta differenza da piccole ottimizzazioni. Questo non vuol dire che alcune cose non debbano essere evitate, ma stiamo parlando principalmente della costruzione di lunghe stringhe in cicli for, utilizzando le strutture dati sbagliate e creando un algoritmo O (n ^ 3).


1
Grazie per lo sforzo, sembra fantastico! Per non essere pigro, ho aggiunto anche dei bei grafici ai miei risultati. I miei test sono un po 'più di forza bruta del tuo, ma ho scoperto che le differenze sono più evidenti quando si usano mappe più grandi. Con piccole mappe, qualunque cosa tu faccia, non puoi perderla. Le prestazioni tendono ad essere caotiche, a causa delle ottimizzazioni JVM e GC, e ho una teoria secondo cui qualsiasi conclusione forte è stata mangiata da quel caos per alcuni dei tuoi set di dati più piccoli.
Domchi

Un altro commento su get performance. Sembra caotico, ma ho scoperto che varia molto in un intervallo molto ristretto, ma nel complesso è costante e noioso da morire. Ho avuto occasionalmente strani picchi come hai fatto con 100/90. Non posso spiegarlo, ma in pratica è probabilmente impercettibile.
Domchi

G_H, per favore dai un'occhiata alla mia risposta, so che questo è un thread molto vecchio ma forse i tuoi test dovrebbero essere rifatti tenendo presente questo.
durron597

Ehi, dovresti postare questo articolo su ACM come documento della conferenza :) Che sforzo!
yerlilbilgin

12

Questo è un thread abbastanza grande, tranne che c'è una cosa cruciale che ti manca. Tu hai detto:

Curiosamente, capacità, capacità + 1, capacità + 2, capacità-1 e persino capacità-10 producono tutti esattamente gli stessi risultati. Mi aspetto che almeno capacità 1 e capacità 10 diano risultati peggiori.

Il codice sorgente salta internamente la capacità iniziale alla successiva più alta potenza di due. Ciò significa che, ad esempio, le capacità iniziali di 513, 600, 700, 800, 900, 1000 e 1024 useranno tutte la stessa capacità iniziale (1024). Ciò non invalida il test eseguito da @G_H, tuttavia, è necessario rendersi conto che ciò viene fatto prima di analizzare i suoi risultati. E spiega lo strano comportamento di alcuni test.

Questo è il diritto di costruzione per la sorgente JDK:

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and load factor.
 *
 * @param  initialCapacity the initial capacity
 * @param  loadFactor      the load factor
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;

    this.loadFactor = loadFactor;
    threshold = (int)(capacity * loadFactor);
    table = new Entry[capacity];
    init();
}

Questo è molto interessante! Non ne avevo idea. Spiega davvero quello che ho visto nei test. E, ancora una volta, conferma che l'ottimizzazione prematura è spesso utile perché semplicemente non sai davvero (o dovresti aver bisogno di sapere) cosa potrebbe fare il compilatore o il codice alle tue spalle. E poi ovviamente potrebbe variare in base alla versione / implementazione. Grazie per il chiarimento!
G_H

@ G_H Mi piacerebbe rivedere i tuoi test, scegliendo i numeri più appropriati in base a queste informazioni. Ad esempio, se ho 1200 elementi, dovrei usare una mappa 1024, una mappa 2048 o una mappa 4096? Non conosco la risposta alla domanda originale, ecco perché ho trovato questo thread per cominciare. Tuttavia, so che Guava moltiplica il tuo expectedSizeper 1.33quando lo faiMaps.newHashMap(int expectedSize)
durron597

Se HashMap non arrotondasse per eccesso a un valore di potenza di due per capacity, alcuni bucket non verrebbero mai utilizzati. L'indice del bucket in cui inserire i dati della mappa è determinato da bucketIndex = hashCode(key) & (capacity-1). Quindi, se capacityfosse qualcosa di diverso da una potenza di due, la rappresentazione binaria di (capacity-1)avrebbe degli zeri, il che significa che l'operazione &(binary and) azzererebbe sempre alcuni bit inferiori di hashCode. Esempio: (capacity-1)è 111110(62) invece di 111111(63). In questo caso è possibile utilizzare solo bucket con indici pari.
Michael Geier,

2

Vai con 101 . In realtà non sono sicuro che sia necessario, ma non potrebbe valere la pena di preoccuparsi di scoprirlo con certezza.

... basta aggiungere il file 1.


EDIT: qualche giustificazione per la mia risposta.

Primo, presumo che la tua HashMapvolontà non crescerà oltre 100; se lo fa, dovresti lasciare il fattore di carico così com'è. Allo stesso modo, se la tua preoccupazione è la prestazione, lascia il fattore di carico così com'è . Se la tua preoccupazione è la memoria, puoi salvarne un po 'impostando la dimensione statica. Questo potrebbe forse essere la pena di fare se si sta stipare un sacco di roba in memoria; ad esempio, stanno memorizzando molte mappe o creando mappe di dimensioni che evidenziano lo spazio.

Secondo, scelgo il valore 101perché offre una migliore leggibilità ... se dopo guardo il tuo codice e vedo che hai impostato la capacità iniziale su 100e lo stai caricando con 100elementi, dovrò leggi il Javadoc per assicurarti che non venga ridimensionato quando raggiunge con precisione 100. Ovviamente non troverò la risposta lì, quindi dovrò guardare la fonte. Non ne vale la pena ... lascialo 101e tutti sono felici e nessuno sta guardando il codice sorgente dijava.util.HashMap . Hoorah.

Terzo, l'affermazione che l'impostazione della HashMapcapacità esatta di ciò che ci si aspetta con un fattore di carico di 1 " ucciderà le prestazioni di ricerca e inserimento " non è vera, anche se è in grassetto.

... se hai dei nsecchi e assegni gli noggetti in modo casuale ai nsecchi, sì, finirai con gli oggetti nello stesso secchio, certo ... ma non è la fine del mondo ... in pratica, sono solo un paio di confronti in più. In effetti, c'è esp. poca differenza se si considera che l'alternativa è l'assegnazione di narticoli in n/0.75bucket.

Non c'è bisogno di credermi sulla parola ...


Codice di test rapido:

static Random r = new Random();

public static void main(String[] args){
    int[] tests = {100, 1000, 10000};
    int runs = 5000;

    float lf_sta = 1f;
    float lf_dyn = 0.75f;

    for(int t:tests){
        System.err.println("=======Test Put "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        long norm_put = testInserts(map, t, runs);
        System.err.print("Norm put:"+norm_put+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        long sta_put = testInserts(map, t, runs);
        System.err.print("Static put:"+sta_put+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        long dyn_put = testInserts(map, t, runs);
        System.err.println("Dynamic put:"+dyn_put+" ms. ");
    }

    for(int t:tests){
        System.err.println("=======Test Get (hits) "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        fill(map, t);
        long norm_get_hits = testGetHits(map, t, runs);
        System.err.print("Norm get (hits):"+norm_get_hits+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        fill(map, t);
        long sta_get_hits = testGetHits(map, t, runs);
        System.err.print("Static get (hits):"+sta_get_hits+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        fill(map, t);
        long dyn_get_hits = testGetHits(map, t, runs);
        System.err.println("Dynamic get (hits):"+dyn_get_hits+" ms. ");
    }

    for(int t:tests){
        System.err.println("=======Test Get (Rand) "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        fill(map, t);
        long norm_get_rand = testGetRand(map, t, runs);
        System.err.print("Norm get (rand):"+norm_get_rand+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        fill(map, t);
        long sta_get_rand = testGetRand(map, t, runs);
        System.err.print("Static get (rand):"+sta_get_rand+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        fill(map, t);
        long dyn_get_rand = testGetRand(map, t, runs);
        System.err.println("Dynamic get (rand):"+dyn_get_rand+" ms. ");
    }
}

public static long testInserts(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    for(int i=0; i<runs; i++){
        fill(map, test);
        map.clear();
    }
    return System.currentTimeMillis()-b4;
}

public static void fill(HashMap<Integer,Integer> map, int test){
    for(int j=0; j<test; j++){
        if(map.put(r.nextInt(), j)!=null){
            j--;
        }
    }
}

public static long testGetHits(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    ArrayList<Integer> keys = new ArrayList<Integer>();
    keys.addAll(map.keySet());

    for(int i=0; i<runs; i++){
        for(int j=0; j<test; j++){
            keys.get(r.nextInt(keys.size()));
        }
    }
    return System.currentTimeMillis()-b4;
}

public static long testGetRand(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    for(int i=0; i<runs; i++){
        for(int j=0; j<test; j++){
            map.get(r.nextInt());
        }
    }
    return System.currentTimeMillis()-b4;
}

Risultati del test:

=======Test Put 100
Norm put:78 ms. Static put:78 ms. Dynamic put:62 ms. 
=======Test Put 1000
Norm put:764 ms. Static put:763 ms. Dynamic put:748 ms. 
=======Test Put 10000
Norm put:12921 ms. Static put:12889 ms. Dynamic put:12873 ms. 
=======Test Get (hits) 100
Norm get (hits):47 ms. Static get (hits):31 ms. Dynamic get (hits):32 ms. 
=======Test Get (hits) 1000
Norm get (hits):327 ms. Static get (hits):328 ms. Dynamic get (hits):343 ms. 
=======Test Get (hits) 10000
Norm get (hits):3304 ms. Static get (hits):3366 ms. Dynamic get (hits):3413 ms. 
=======Test Get (Rand) 100
Norm get (rand):63 ms. Static get (rand):46 ms. Dynamic get (rand):47 ms. 
=======Test Get (Rand) 1000
Norm get (rand):483 ms. Static get (rand):499 ms. Dynamic get (rand):483 ms. 
=======Test Get (Rand) 10000
Norm get (rand):5190 ms. Static get (rand):5362 ms. Dynamic get (rand):5236 ms. 

re: ↑ - si tratta di questo → || ← molta differenza tra le diverse impostazioni .


Rispetto alla mia risposta iniziale (il bit sopra la prima linea orizzontale), è stato volutamente glib perché nella maggior parte dei casi , questo tipo di micro-ottimizzazione non è buona .


@EJP, le mie supposizioni non sono corrette. Vedi le modifiche sopra. Le tue supposizioni non sono corrette su chi è corretto e chi è sbagliato.
badroit

(... forse sono un po 'irriverente ... sono un po' seccato però: P)
badroit

3
Potresti essere giustamente infastidito dall'EJP, ma ora tocca a me; P - mentre sono d'accordo che l'ottimizzazione prematura è molto simile all'eiaculazione precoce, per favore non dare per scontato che qualcosa che di solito non vale uno sforzo non vale uno sforzo nel mio caso . Nel mio caso, è abbastanza importante che non voglio indovinare, quindi l'ho cercato: +1 non è necessario nel mio caso (ma potrebbe essere dove la tua capacità iniziale / effettiva non è la stessa e loadFactor non è 1, guarda questo cast su int in HashMap: threshold = (int) (capacity * loadFactor)).
Domchi

@badroit Hai detto esplicitamente che non sono sicuro che sia necessario '. Quindi era un'ipotesi. Ora che hai fatto e pubblicato la ricerca, non è più un'ipotesi, e poiché chiaramente non l'avevi fatta in precedenza, era chiaramente un'ipotesi, altrimenti saresti stato sicuro. Per quanto riguarda "errato", il Javadoc impone esplicitamente un fattore di carico di 0,75, così come diversi decenni di ricerca e la risposta di G_H. Infine, per quanto riguarda "non può valere la pena", vedere qui il commento di Domchi. Non lascia molto che fosse corretto, anche se in generale sono d'accordo con te sulla microottimizzazione.
user207421

Rilassatevi tutti. Sì, la mia risposta ha esagerato le cose. Se hai 100 oggetti che non hanno una equalsfunzione tremendamente pesante , probabilmente te la caveresti inserendoli in una lista e usando semplicemente "contiene". Con un set così piccolo, non ci saranno mai grandi differenze nelle prestazioni. È davvero importante solo se i problemi di velocità o memoria vanno al di sopra di ogni altra cosa, o se uguale e hash sono molto specifici. Farò un test più tardi con grandi raccolte e vari fattori di carico e capacità iniziali per vedere se sono pieno di schifezze o meno.
G_H

2

Dal punto di vista dell'implementazione, Google Guava ha un comodo metodo di fabbrica

Maps.newHashMapWithExpectedSize(expectedSize)

Che calcola la capacità utilizzando la formula

capacity = expectedSize / 0.75F + 1.0F

1

Dal HashMapJavaDoc:

Come regola generale, il fattore di carico predefinito (0,75) offre un buon compromesso tra i costi di tempo e spazio. Valori più alti riducono l'overhead di spazio ma aumentano il costo di ricerca (riflesso nella maggior parte delle operazioni della classe HashMap, inclusi get e put). Il numero previsto di voci nella mappa e il suo fattore di carico dovrebbero essere presi in considerazione quando si imposta la sua capacità iniziale, in modo da ridurre al minimo il numero di operazioni di rehash. Se la capacità iniziale è maggiore del numero massimo di voci diviso per il fattore di carico, non si verificheranno mai operazioni di rehash.

Quindi, se ti aspetti 100 voci, forse un fattore di carico di 0,75 e una capacità iniziale di soffitto (100 / 0,75) sarebbero i migliori. Ciò si riduce a 134.

Devo ammettere che non sono sicuro del motivo per cui il costo di ricerca sarebbe maggiore per un fattore di carico più elevato. Solo perché HashMap è più "affollato" non significa che più oggetti verranno posizionati nello stesso secchio, giusto? Dipende solo dal loro codice hash, se non sbaglio. Quindi, supponendo una discreta diffusione del codice hash, la maggior parte dei casi non dovrebbe essere ancora O (1) indipendentemente dal fattore di carico?

EDIT: dovrei leggere di più prima di postare ... Ovviamente il codice hash non può mappare direttamente a qualche indice interno. Deve essere ridotto a un valore adeguato alla capacità attuale. Ciò significa che maggiore è la capacità iniziale, minore è il numero di collisioni di hash. Scegliendo una capacità iniziale esattamente la dimensione (o +1) del tuo set di oggetti con un fattore di carico di 1 assicurerai che la tua mappa non venga mai ridimensionata. Però, ucciderà le tue prestazioni di ricerca e inserimento. Un ridimensionamento è ancora relativamente veloce e potrebbe verificarsi solo una volta, mentre le ricerche vengono eseguite praticamente su qualsiasi lavoro rilevante con la mappa. Di conseguenza, l'ottimizzazione per ricerche rapide è ciò che vuoi veramente qui. Puoi combinarlo con il non dover mai ridimensionare facendo come dice JavaDoc: prendi la capacità richiesta, dividi per un fattore di carico ottimale (es. 0,75) e usalo come capacità iniziale, con quel fattore di carico. Aggiungi 1 per assicurarti che l'arrotondamento non ti dia.


1
" ucciderà le tue prestazioni di ricerca e inserimento ". Questo è esagerato / semplicemente errato.
badroit

1
I miei test mostrano che le prestazioni di ricerca non sono influenzate dall'impostazione del fattore di carico 1. Le prestazioni di inserimento sono effettivamente migliorate; poiché non ci sono ridimensionamenti, è più veloce. Quindi, la tua affermazione è corretta per un caso generale (la ricerca di una HashMap con un numero minimo di elementi sarà più veloce con 0.75 che con 1), ma non corretta per il mio caso specifico quando HashMap è sempre pieno alla sua capacità massima, che non cambia mai. Il tuo suggerimento di impostare la dimensione iniziale più alta è interessante ma irrilevante per il mio caso poiché la mia tabella non cresce, quindi il fattore di carico è importante solo alla luce del ridimensionamento.
Domchi
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.