Ich versuche, die optimale Kapazität und den optimalen Auslastungsfaktor für einen bestimmten Fall herauszufinden. Ich glaube, ich habe das Wesentliche verstanden, aber ich wäre trotzdem dankbar für eine Bestätigung von jemandem, der besser informiert ist als ich. :) :)
Wenn ich weiß, dass meine HashMap beispielsweise 100 Objekte enthält und die meiste Zeit mit 100 Objekten verbracht wird, schätze ich, dass die optimalen Werte die Anfangskapazität 100 und der Lastfaktor 1 sind. Oder brauche ich Kapazität 101 oder gibt es noch andere Fallstricke?
EDIT: OK, ich habe ein paar Stunden eingeplant und einige Tests durchgeführt. Hier sind die Ergebnisse:
- Seltsamerweise liefern Kapazität, Kapazität + 1, Kapazität + 2, Kapazität 1 und sogar Kapazität 10 genau die gleichen Ergebnisse. Ich würde erwarten, dass mindestens Kapazität 1 und Kapazität 10 schlechtere Ergebnisse liefern.
- Die Verwendung der Anfangskapazität (im Gegensatz zur Verwendung des Standardwerts von 16) führt zu einer spürbaren Verbesserung von put () - bis zu 30% schneller.
- Die Verwendung des Lastfaktors 1 bietet die gleiche Leistung für eine kleine Anzahl von Objekten und eine bessere Leistung für eine größere Anzahl von Objekten (> 100000). Dies verbessert sich jedoch nicht proportional zur Anzahl der Objekte. Ich vermute, dass es einen zusätzlichen Faktor gibt, der die Ergebnisse beeinflusst.
- Die Leistung von get () ist für unterschiedliche Anzahlen von Objekten / Kapazitäten etwas unterschiedlich, kann jedoch von Fall zu Fall geringfügig variieren, wird jedoch im Allgemeinen nicht von der anfänglichen Kapazität oder dem Auslastungsfaktor beeinflusst.
EDIT2: Ich füge auch einige Diagramme hinzu. Hier ist der Unterschied zwischen dem Auslastungsfaktor 0,75 und 1, wenn ich HashMap initialisiere und bis zur vollen Kapazität fülle. Auf der y-Skala ist die Zeit in ms (niedriger ist besser) und auf der x-Skala ist die Größe (Anzahl der Objekte). Da sich die Größe linear ändert, wächst auch die erforderliche Zeit linear.
Also mal sehen, was ich habe. Die folgenden beiden Diagramme zeigen den Unterschied in den Lastfaktoren. Das erste Diagramm zeigt, was passiert, wenn HashMap voll ist. Der Lastfaktor 0,75 ist aufgrund der Größenänderung schlechter. Es ist jedoch nicht durchweg schlimmer und es gibt alle möglichen Unebenheiten und Sprünge - ich denke, dass GC dabei eine große Rolle spielt. Der Lastfaktor 1,25 entspricht 1, ist also nicht im Diagramm enthalten.
Diese Grafik zeigt, dass 0,75 aufgrund der Größenänderung schlechter war. Wenn wir die HashMap mit der halben Kapazität füllen, ist 0,75 nicht schlechter, nur ... anders (und es sollte weniger Speicher verbrauchen und eine merklich bessere Iterationsleistung haben).
Noch etwas möchte ich zeigen. Dies ist die Leistung für alle drei Lastfaktoren und verschiedene HashMap-Größen. Konsequent konstant mit einer kleinen Variation, bis auf eine Spitze für Lastfaktor 1. Ich möchte wirklich wissen, was das ist (wahrscheinlich GC, aber wer weiß).
Und hier ist der Code für Interessierte:
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);
}
}
Antworten:
Okay, um dieses Problem zu lösen, habe ich eine Test-App erstellt, mit der einige Szenarien ausgeführt und einige Visualisierungen der Ergebnisse abgerufen werden können. So werden die Tests durchgeführt:
equals
Methode verwendet nur die ID, sodass keine Schlüsselzuordnung eine andere überschreibt.Object
.Hier ist die Klasse:
Das Ausführen kann eine Weile dauern. Die Ergebnisse werden standardmäßig ausgedruckt. Sie werden vielleicht bemerken, dass ich eine Zeile auskommentiert habe. Diese Zeile ruft einen Visualizer auf, der visuelle Darstellungen der Ergebnisse in PNG-Dateien ausgibt. Die Klasse hierfür ist unten angegeben. Wenn Sie es ausführen möchten, kommentieren Sie die entsprechende Zeile im obigen Code aus. Seien Sie gewarnt: Die Visualizer-Klasse geht davon aus, dass Sie unter Windows ausgeführt werden, und erstellt Ordner und Dateien in C: \ temp. Passen Sie dies an, wenn Sie auf einer anderen Plattform ausgeführt werden.
Die visualisierte Ausgabe lautet wie folgt:
Schauen wir uns ohne weiteres die Ergebnisse an. Ich werde mit den Ergebnissen für Puts beginnen.
Ergebnisse setzen
Sammlungsgröße: 100. Hash-Limit: 50. Dies bedeutet, dass jeder Hash-Code zweimal vorkommen sollte und jeder andere Schlüssel in der Hash-Map kollidiert.
Nun, das fängt nicht sehr gut an. Wir sehen, dass es einen großen Hotspot für eine Anfangskapazität gibt, die 25% über der Sammlungsgröße liegt, mit einem Auslastungsfaktor von 1. Die untere linke Ecke funktioniert nicht besonders gut.
Sammlungsgröße: 100. Hash-Limit: 90. Jeder zehnte Schlüssel hat einen doppelten Hash-Code.
Dies ist ein etwas realistischeres Szenario, das keine perfekte Hash-Funktion hat, aber dennoch eine Überlastung von 10% aufweist. Der Hotspot ist weg, aber die Kombination einer geringen Anfangskapazität mit einem niedrigen Auslastungsfaktor funktioniert offensichtlich nicht.
Sammlungsgröße: 100. Hash-Limit: 100. Jeder Schlüssel als eigener eindeutiger Hash-Code. Keine Kollisionen zu erwarten, wenn genügend Eimer vorhanden sind.
Eine Anfangskapazität von 100 mit einem Lastfaktor von 1 scheint in Ordnung zu sein. Überraschenderweise ist eine höhere Anfangskapazität mit einem niedrigeren Auslastungsfaktor nicht unbedingt gut.
Sammlungsgröße: 1000. Hash-Limit: 500. Hier wird es mit 1000 Einträgen immer ernster. Genau wie im ersten Test gibt es eine Hash-Überladung von 2 zu 1.
Die untere linke Ecke läuft immer noch nicht gut. Es scheint jedoch eine Symmetrie zwischen der Kombination aus niedrigerer Anfangszahl / hohem Lastfaktor und höherer Anfangszahl / niedrigem Lastfaktor zu bestehen.
Sammlungsgröße: 1000. Hash-Limit: 900. Dies bedeutet, dass jeder zehnte Hash-Code zweimal vorkommt. Angemessenes Szenario in Bezug auf Kollisionen.
Es ist etwas sehr lustiges los mit der unwahrscheinlichen Kombination einer Anfangskapazität, die mit einem Auslastungsfaktor über 1 zu niedrig ist, was ziemlich kontraintuitiv ist. Ansonsten noch recht symmetrisch.
Sammlungsgröße: 1000. Hash-Limit: 990. Einige Kollisionen, aber nur wenige. In dieser Hinsicht ziemlich realistisch.
Wir haben hier eine schöne Symmetrie. Die untere linke Ecke ist immer noch nicht optimal, aber die Combos 1000 Init-Kapazität / 1,0 Lastfaktor gegenüber 1250 Init-Kapazität / 0,75 Lastfaktor sind auf dem gleichen Niveau.
Sammlungsgröße: 1000. Hash-Limit: 1000. Keine doppelten Hash-Codes, jetzt jedoch mit einer Stichprobengröße von 1000.
Hier gibt es nicht viel zu sagen. Die Kombination einer höheren Anfangskapazität mit einem Lastfaktor von 0,75 scheint die Kombination von 1000 Anfangskapazitäten mit einem Lastfaktor von 1 leicht zu übertreffen.
Sammlungsgröße: 100_000. Hash-Limit: 10_000. Okay, es wird jetzt ernst, mit einer Stichprobengröße von einhunderttausend und 100 Hash-Code-Duplikaten pro Schlüssel.
Huch! Ich denke, wir haben unser unteres Spektrum gefunden. Eine Init-Kapazität von genau der Sammlungsgröße mit einem Auslastungsfaktor von 1 ist hier wirklich gut, aber ansonsten ist es überall im Shop.
Sammlungsgröße: 100_000. Hash-Limit: 90_000. Etwas realistischer als der vorherige Test, hier haben wir eine 10% ige Überlastung der Hash-Codes.
Die untere linke Ecke ist immer noch unerwünscht. Höhere Anfangskapazitäten funktionieren am besten.
Sammlungsgröße: 100_000. Hash-Limit: 99_000. Gutes Szenario, das. Eine große Sammlung mit einer 1% igen Hash-Code-Überladung.
Hier gewinnt die exakte Sammlungsgröße als Init-Kapazität mit einem Auslastungsfaktor von 1! Etwas größere Init-Kapazitäten funktionieren jedoch recht gut.
Sammlungsgröße: 100_000. Hash-Limit: 100_000. Der Grosse. Größte Sammlung mit perfekter Hash-Funktion.
Einige überraschende Sachen hier. Eine anfängliche Kapazität mit 50% zusätzlichem Raum bei einem Auslastungsfaktor von 1 gewinnt.
Okay, das ist es für die Puts. Jetzt werden wir die bekommen überprüfen. Denken Sie daran, dass die folgenden Karten alle relativ zu den besten / schlechtesten Abrufzeiten sind. Die Put-Zeiten werden nicht mehr berücksichtigt.
Ergebnisse bekommen
Sammlungsgröße: 100. Hash-Limit: 50. Dies bedeutet, dass jeder Hash-Code zweimal vorkommen sollte und jeder andere Schlüssel in der Hash-Map kollidieren sollte.
Eh ... was?
Sammlungsgröße: 100. Hash-Limit: 90. Jeder zehnte Schlüssel hat einen doppelten Hash-Code.
Whoa Nelly! Dies ist das wahrscheinlichste Szenario, das mit der Frage des Fragestellers korreliert, und anscheinend ist eine Anfangskapazität von 100 mit einem Auslastungsfaktor von 1 eines der schlimmsten Dinge hier! Ich schwöre, ich habe das nicht vorgetäuscht.
Sammlungsgröße: 100. Hash-Limit: 100. Jeder Schlüssel als eigener eindeutiger Hash-Code. Keine Kollisionen zu erwarten.
Das sieht etwas friedlicher aus. Meist die gleichen Ergebnisse auf ganzer Linie.
Sammlungsgröße: 1000. Hash-Limit: 500. Genau wie im ersten Test gibt es eine Hash-Überladung von 2 zu 1, aber jetzt mit viel mehr Einträgen.
Es sieht so aus, als würde jede Einstellung hier ein anständiges Ergebnis liefern.
Sammlungsgröße: 1000. Hash-Limit: 900. Dies bedeutet, dass jeder zehnte Hash-Code zweimal vorkommt. Angemessenes Szenario in Bezug auf Kollisionen.
Und genau wie bei den Puts für dieses Setup erhalten wir eine Anomalie an einer seltsamen Stelle.
Sammlungsgröße: 1000. Hash-Limit: 990. Einige Kollisionen, aber nur wenige. In dieser Hinsicht ziemlich realistisch.
Überall gute Leistung, abgesehen von der Kombination einer hohen Anfangskapazität mit einem niedrigen Lastfaktor. Ich würde dies für die Puts erwarten, da zwei Größenänderungen der Hash-Map erwartet werden könnten. Aber warum auf die bekommen?
Sammlungsgröße: 1000. Hash-Limit: 1000. Keine doppelten Hash-Codes, jetzt jedoch mit einer Stichprobengröße von 1000.
Eine völlig unspektakuläre Visualisierung. Dies scheint zu funktionieren, egal was passiert.
Sammlungsgröße: 100_000. Hash-Limit: 10_000. Wieder in die 100K gehen, mit einer ganzen Menge Hash-Code-Überlappungen.
Es sieht nicht schön aus, obwohl die schlechten Stellen sehr lokalisiert sind. Die Leistung scheint hier weitgehend von einer gewissen Synergie zwischen den Einstellungen abzuhängen.
Sammlungsgröße: 100_000. Hash-Limit: 90_000. Etwas realistischer als der vorherige Test, hier haben wir eine 10% ige Überlastung der Hash-Codes.
Viel Varianz, obwohl Sie beim Schielen einen Pfeil sehen können, der in die obere rechte Ecke zeigt.
Sammlungsgröße: 100_000. Hash-Limit: 99_000. Gutes Szenario, das. Eine große Sammlung mit einer 1% igen Hash-Code-Überladung.
Sehr chaotisch. Es ist schwer, hier viel Struktur zu finden.
Sammlungsgröße: 100_000. Hash-Limit: 100_000. Der Grosse. Größte Sammlung mit perfekter Hash-Funktion.
Glaubt noch jemand, dass dies allmählich wie Atari-Grafiken aussieht? Dies scheint eine Anfangskapazität von genau der Sammlungsgröße von -25% oder + 50% zu begünstigen.
Okay, jetzt ist es Zeit für Schlussfolgerungen ...
HashMap
, werden die Ergebnisse überall sein. Wenn Sie etwas davon wegnehmen möchten, ist die Standard-Anfangsgröße von 16 für alles andere als die kleinsten Karten etwas dumm. Verwenden Sie also einen Konstruktor, der die Anfangsgröße festlegt, wenn Sie eine Vorstellung von der Größenordnung haben Es wird.Das war's. Ich hoffe, mein Code hat kein schreckliches Versehen, das alles ungültig macht, was ich hier gepostet habe. Das hat Spaß gemacht, und ich habe gelernt, dass Sie sich am Ende genauso gut auf Java verlassen können, um seinen Job zu erledigen, als von winzigen Optimierungen einen großen Unterschied zu erwarten. Das heißt nicht, dass einige Dinge nicht vermieden werden sollten, aber dann geht es hauptsächlich darum, lange Strings in for-Schleifen zu konstruieren, die falschen Datenstrukturen zu verwenden und O (n ^ 3) -Algorithmen zu erstellen.
quelle
Dies ist ein ziemlich guter Thread, außer dass es eine entscheidende Sache gibt, die Sie vermissen. Du sagtest:
Der Quellcode überspringt die Anfangskapazität intern um die nächsthöhere Zweierpotenz. Dies bedeutet, dass beispielsweise Anfangskapazitäten von 513, 600, 700, 800, 900, 1000 und 1024 alle dieselbe Anfangskapazität verwenden (1024). Dies macht die von @G_H durchgeführten Tests jedoch nicht ungültig. Man sollte sich darüber im Klaren sein, dass dies durchgeführt wird, bevor man seine Ergebnisse analysiert. Und es erklärt das merkwürdige Verhalten einiger Tests.
Dies ist das Konstruktorrecht für die JDK-Quelle:
quelle
expectedSize
durch ,1.33
wenn Sie tunMaps.newHashMap(int expectedSize)
capacity
aufrunden würde, würden einige Buckets niemals verwendet. Der Bucket-Index für die Position der Kartendaten wird durch bestimmtbucketIndex = hashCode(key) & (capacity-1)
. Wenn alsocapacity
etwas anderes als eine Zweierpotenz wäre, würde die binäre Darstellung von(capacity-1)
einige Nullen enthalten, was bedeutet, dass die&
(binäre und) Operation immer bestimmte untere Bits des HashCodes auf Null setzen würde. Beispiel:(capacity-1)
ist111110
(62) anstelle von111111
(63). In diesem Fall können nur Eimer mit geraden Indizes verwendet werden.Geh einfach mit
101
. Ich bin mir nicht sicher, ob es gebraucht wird, aber es könnte unmöglich die Mühe wert sein, es jemals sicher herauszufinden.... einfach hinzufügen
1
.EDIT: Eine Begründung für meine Antwort.
Erstens gehe ich davon aus, dass Ihr
HashMap
Wille nicht darüber hinaus wächst100
. Wenn dies der Fall ist, sollten Sie den Lastfaktor unverändert lassen. Wenn es um Leistung geht, lassen Sie den Lastfaktor unverändert . Wenn es um Speicher geht, können Sie einige speichern, indem Sie die statische Größe festlegen. Dies könnte sich vielleicht lohnen, wenn Sie eine Menge Dinge in Erinnerung behalten. Das heißt, Sie speichern viele Karten oder erstellen Karten in Heap-Space-Stressing-Größe.Zweitens wähle ich den Wert,
101
weil er eine bessere Lesbarkeit bietet. Wenn ich Ihren Code anschließend betrachte und feststelle, dass Sie die anfängliche Kapazität auf eingestellt haben100
und ihn mit100
Elementen laden , muss ich dies tun Lesen Sie das Javadoc durch, um sicherzustellen, dass die Größe nicht geändert wird, wenn es genau erreicht wird100
. Natürlich werde ich dort keine Antwort finden, also muss ich mir die Quelle ansehen. Das ist es nicht wert ... lass es einfach101
und jeder ist glücklich und niemand schaut durch den Quellcode vonjava.util.HashMap
. Hoorah.Drittens die Behauptung, dass die Einstellung der
HashMap
exakten Kapazität, die Sie erwarten, mit einem Lastfaktor von1
" Ihre Such- und Einfügungsleistung beeinträchtigt ", einfach nicht wahr, selbst wenn sie fett gedruckt ist.... wenn Sie
n
Eimer haben undn
Gegenstände zufällig inn
Eimern zuweisen , ja, werden Sie am Ende Gegenstände im selben Eimer haben, sicher ... aber das ist nicht das Ende der Welt ... in der Praxis, Es sind nur noch ein paar Vergleiche. In der Tat gibt es esp. wenig Unterschied, wenn man bedenkt, dass die Alternative darin besteht,n
Elemente inn/0.75
Eimern zuzuweisen.Keine Notwendigkeit, mein Wort dafür zu nehmen ...
Schnelltestcode:
Testergebnisse:
re: ↑ - da ist was dran → || ← viel Unterschied zwischen den verschiedenen Einstellungen .
In Bezug auf meine ursprüngliche Antwort (die etwas oberhalb der ersten horizontalen Linie) wurde absichtlich glib , weil in den meisten Fällen , diese Art von Mikro-Optimierung ist nicht gut .
quelle
equals
Funktion haben, würden Sie wahrscheinlich davonkommen, sie in eine Liste aufzunehmen und nur "enthält" zu verwenden. Mit einem so kleinen Set wird es nie große Leistungsunterschiede geben. Es ist wirklich nur wichtig, wenn Geschwindigkeits- oder Speicherprobleme über alles stehen oder Gleichheit und Hash sehr spezifisch sind. Ich werde später einen Test mit großen Sammlungen und verschiedenen Auslastungsfaktoren und anfänglichen Kapazitäten durchführen, um zu sehen, ob ich voller Mist bin oder nicht.In Bezug auf die Implementierung verfügt Google Guava über eine praktische Factory-Methode
Welches berechnet die Kapazität mit der Formel
quelle
Aus dem
HashMap
JavaDoc:In der Regel bietet der Standardlastfaktor (.75) einen guten Kompromiss zwischen Zeit- und Raumkosten. Höhere Werte verringern den Speicherplatzaufwand, erhöhen jedoch die Suchkosten (was sich in den meisten Operationen der HashMap-Klasse widerspiegelt, einschließlich get und put). Die erwartete Anzahl von Einträgen in der Karte und ihr Auslastungsfaktor sollten bei der Einstellung der Anfangskapazität berücksichtigt werden, um die Anzahl der Wiederaufbereitungsvorgänge zu minimieren. Wenn die Anfangskapazität größer ist als die maximale Anzahl von Einträgen geteilt durch den Lastfaktor, werden niemals Wiederaufbereitungsvorgänge durchgeführt.
Wenn Sie also 100 Einträge erwarten, ist möglicherweise ein Auslastungsfaktor von 0,75 und eine anfängliche Höchstkapazität (100 / 0,75) am besten. Das sind 134.
Ich muss zugeben, ich bin mir nicht sicher, warum die Suchkosten für einen höheren Auslastungsfaktor höher wären. Nur weil die HashMap "überfüllt" ist, heißt das nicht, dass mehr Objekte im selben Bucket platziert werden, oder? Das hängt nur von ihrem Hash-Code ab, wenn ich mich nicht irre. Sollten die meisten Fälle nicht immer noch O (1) sein, unabhängig vom Auslastungsfaktor?
EDIT: Ich sollte vor dem Posten mehr lesen ... Natürlich kann der Hash-Code nicht direkt einem internen Index zugeordnet werden. Es muss auf einen Wert reduziert werden, der der aktuellen Kapazität entspricht. Das heißt, je größer Ihre anfängliche Kapazität ist, desto geringer ist die Anzahl der Hash-Kollisionen. Wenn Sie eine Anfangskapazität wählen, die genau der Größe (oder +1) Ihres Objektsatzes mit einem Lastfaktor von 1 entspricht, wird in der Tat sichergestellt, dass die Größe Ihrer Karte niemals geändert wird. Jedoch, beeinträchtigt jedoch Ihre Such- und Einfügeleistung. Eine Größenänderung ist immer noch relativ schnell und würde möglicherweise nur einmal auftreten, während bei so ziemlich jeder relevanten Arbeit mit der Karte nachgeschlagen wird. Daher ist die Optimierung für schnelle Suchvorgänge genau das, was Sie hier wirklich wollen. Sie können dies damit kombinieren, dass Sie niemals die Größe ändern müssen, indem Sie das tun, was JavaDoc sagt: Nehmen Sie Ihre erforderliche Kapazität, dividieren Sie durch einen optimalen Auslastungsfaktor (z. B. 0,75) und verwenden Sie diese als anfängliche Kapazität mit diesem Auslastungsfaktor. Fügen Sie 1 hinzu, um sicherzustellen, dass Sie nicht gerundet werden.
quelle