固定大小的 HashMap 的最佳容量和负载因子是什么?

我在试图找出一个特定情况下的最佳容量和负载系数。我想我大概明白了,但我还是很感激比我更有见识的人的确认。:)

如果我知道我的 HashMap 将包含,比如说,100个对象,并且将花费大部分时间拥有100个对象,我猜测最佳值是初始容量100和负载因子1?或者我需要容量101,还是有其他陷阱?

编辑: 好的,我留出几个小时做了一些测试。以下是结果:

  • 奇怪的是,容量,容量 + 1,容量 + 2,容量 -1,甚至容量 -10都产生了完全相同的结果。我认为至少容量 -1和容量 -10会产生更糟糕的结果。
  • 使用初始容量(相对于使用默认值16)可以显著提高 put ()速度,最高可提高30% 。
  • 使用1的负载因子对于少量对象具有相同的性能,对于大量对象具有更好的性能(> 100000)。但是,这并不会随着对象数量的增加而成比例地改善; 我怀疑还有其他因素会影响结果。
  • Get ()性能对于不同数量的对象/容量有所不同,但是尽管它可能因情况而略有不同,通常它不受初始容量或负载因子的影响。

编辑2: 我也添加了一些图表。在初始化 HashMap 并将其填满到最大容量的情况下,下面是负载因子0.75和1之间的区别。在 y 刻度上,时间以 ms 表示(越小越好) ,x 刻度表示大小(对象的数量)。因为大小是线性变化的,所需的时间也是线性增长的。

看看我有什么。下面两个图表显示了负载因子的差异。第一个图表显示了当 HashMap 被填充到容量时会发生什么; 由于调整大小,负载因子0.75的性能更差。然而,情况并不总是更糟,而且有各种各样的颠簸和跳跃——我想 GC 在这方面有很大的作用。负载系数1.25的性能与1相同,因此不包括在图表中。

fully filled

这个图表证明了0.75由于调整大小而变得更糟; 如果我们将 HashMap 填充到一半的容量,0.75并没有变得更糟,只是... ... 不同(它应该使用更少的内存,并且具有不明显的更好的迭代性能)。

half full

我还想展示一样东西。这样就可以获得所有三个负载因子和不同 HashMap 大小的性能。除了负荷因子1有一个尖峰外,其余都是一致的,变化不大。我真的很想知道那是什么(可能是 GC,但谁知道呢)。

go spike

下面是感兴趣的人的代码:

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


}
29302 次浏览

Just go with 101. I'm not actually sure that it's needed, but it couldn't possibly be worth the effort to ever bother finding out for sure.

...just add the 1.


EDIT: Some justification for my answer.

First, I'm assuming that your HashMap will not grow beyond 100; if it does, you should leave the load-factor as it is. Similarly, if your concern is performance, leave the load-factor as is. If your concern is memory, you can save some by setting the static size. This might maybe be worth doing if you're cramming a lot of stuff in memory; i.e., are storing many maps, or creating heap-space-stressing-sized maps.

Second, I choose the value 101 because it offers better readability... if I'm looking at your code afterwards and see that you've set the initial capacity to 100 and you're loading it with 100 elements, I'm going to have to read through the Javadoc to make sure that it won't resize when it reaches precisely 100. Of course, I won't find the answer there, so I'll have to look at the source. This is not worth it... just leave it 101 and everyone is happy and no-one is looking though the source-code of java.util.HashMap. Hoorah.

Third, the claim that setting the HashMap to the exact capacity of what you expect with a load factor of 1 "will kill your lookup and insertion performance" is just not true, even if it's made in bold.

...if you have n buckets, and you randomly assign n items into n buckets, yep, you're going to end up with items in the same bucket, sure... but that's not the end of the world... in practice, it's just a couple more equals comparisons. In fact, there's esp. little difference when you consider that the alternative is assigning n items into n/0.75 buckets.

No need to take my word for it...


Quick test code:

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

Test Results:

=======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: ↑ — there's about this →||← much difference between the different settings.


With respect to my original answer (the bit above the first horizontal line), it was deliberately glib because in most cases, this type of micro-optimising is not good.

From the HashMap JavaDoc:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

So if you're expecting 100 entries, perhaps a load factor of 0.75 and an initial capacity of ceiling(100/0.75) would be best. That comes down to 134.

I have to admit, I'm not certain why lookup cost would be greater for a higher load factor. Just because the HashMap is more "crowded" doesn't mean that more objects will be placed in the same bucket, right? That only depends on their hash code, if I'm not mistaken. So assuming a decent hash code spread, shouldn't most cases still be O(1) regardless of load factor?

EDIT: I should read more before posting... Of course the hash code cannot directly map to some internal index. It must be reduced to a value that fits the current capacity. Meaning that the greater your initial capacity, the smaller you can expect the number of hash collisions to be. Choosing an initial capacity exactly the size (or +1) of your object set with a load factor of 1 will indeed make sure that your map is never resized. However, it will kill your lookup and insertion performance. A resize is still relatively quick and would only occur maybe once, while lookups are done on pretty much any relevant work with the map. As a result, optimizing for quick lookups is what you really want here. You can combine that with never having to resize by doing as the JavaDoc says: take your required capacity, divide by an optimal load factor (e.g. 0.75) and use that as the initial capacity, with that load factor. Add 1 to make sure rounding doesn't get you.

Alright, to put this thing to rest, I've created a test app to run a couple of scenarios and get some visualizations of the results. Here's how the tests are done:

  • A number of different collection sizes have been tried: one hundred, one thousand and one hundred thousand entries.
  • The keys used are instances of a class that are uniquely identified by an ID. Each test uses unique keys, with incrementing integers as IDs. The equals method only uses the ID, so no key mapping overwrites another one.
  • The keys get a hash code that consists of the module remainder of their ID against some preset number. We'll call that number the hash limit. This allowed me to control the number of hash collisions that would be expected. For example, if our collection size is 100, we'll have keys with IDs ranging from 0 to 99. If the hash limit is 100, every key will have a unique hash code. If the hash limit is 50, key 0 will have the same hash code as key 50, 1 will have the same hash code as 51 etc. In other words, the expected number of hash collisions per key is the collection size divided by the hash limit.
  • For each combination of collection size and hash limit, I've run the test using hash maps initialized with different settings. These settings are the load factor, and an initial capacity that is expressed as a factor of the collection setting. For example, a test with a collection size of 100 and an initial capacity factor of 1.25 will initialize a hash map with an initial capacity of 125.
  • The value for each key is simply a new Object.
  • Each test result is encapsulated in an instance of a Result class. At the end of all tests, the results are ordered from worst overall performance to best.
  • The average time for puts and gets is calculated per 10 puts/gets.
  • All test combinations are run once to eliminate JIT compilation influence. After that, the tests are run for actual results.

Here's the class:

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.");
            

}
        

}
    

}

Running this might take a while. The results are printed out on standard out. You might notice I've commented out a line. That line calls a visualizer that outputs visual representations of the results to png files. The class for this is given below. If you wish to run it, uncomment the appropriate line in the code above. Be warned: the visualizer class assumes you're running on Windows and will create folders and files in C:\temp. When running on another platform, adjust this.

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

}
    

}

The visualized output is as follows:

  • Tests are divided first by collection size, then by hash limit.
  • For each test, there's an output image regarding the average put time (per 10 puts) and the average get time (per 10 gets). The images are two-dimensional "heat maps" that show a color per combination of initial capacity and load factor.
  • The colours in the images are based on the average time on a normalized scale from best to worst result, ranging from saturated green to saturated red. In other words, the best time will be fully green, while the worst time will be fully red. Two different time measurements should never have the same colour.
  • The colour maps are calculated separately for puts and gets, but encompass all tests for their respective categories.
  • The visualizations show the initial capacity on their x axis, and the load factor on the y axis.

Without further ado, let's take a look at the results. I'll start with the results for puts.

Put results


Collection size: 100. Hash limit: 50. This means each hash code should occur twice and every other key collides in the hash map.

size_100_hlimit_50_puts

Well, that doesn't start off very good. We see that there's a big hotspot for an initial capacity 25% above the collection size, with a load factor of 1. The lower left corner doesn't perform too well.


Collection size: 100. Hash limit: 90. One in ten keys has a duplicate hash code.

size_100_hlimit_90_puts

This is a slightly more realistic scenario, not having a perfect hash function but still 10% overload. The hotspot is gone, but the combination of a low initial capacity with a low load factor obviously doesn't work.


Collection size: 100. Hash limit: 100. Each key as its own unique hash code. No collisions expected if there are enough buckets.

size_100_hlimit_100_puts

An initial capacity of 100 with a load factor of 1 seems fine. Surprisingly, a higher initial capacity with a lower load factor isn't necessarily good.


Collection size: 1000. Hash limit: 500. It's getting more serious here, with 1000 entries. Just like in the first test, there's a hash overload of 2 to 1.

size_1000_hlimit_500_puts

The lower left corner is still not doing well. But there seems to be a symmetry between the combo of lower initial count/high load factor and higher initial count/low load factor.


Collection size: 1000. Hash limit: 900. This means one in ten hash codes will occur twice. Reasonable scenario regarding collisions.

size_1000_hlimit_900_puts

There's something very funny going on with the unlikely combo of an initial capacity that's too low with a load factor above 1, which is rather counter-intuitive. Otherwise, still quite symmetrical.


Collection size: 1000. Hash limit: 990. Some collisions, but only a few. Quite realistic in this respect.

size_1000_hlimit_990_puts

We've got a nice symmetry here. Lower left corner is still sub-optimal, but the combos 1000 init capacity/1.0 load factor versus 1250 init capacity/0.75 load factor are at the same level.


Collection size: 1000. Hash limit: 1000. No duplicate hash codes, but now with a sample size of 1000.

size_1000_hlimit_1000_puts

Not much to be said here. The combination of a higher initial capacity with a load factor of 0.75 seems to slightly outperform the combination of 1000 initial capacity with a load factor of 1.


Collection size: 100_000. Hash limit: 10_000. Alright, it's getting serious now, with a sample size of one hundred thousand and 100 hash code duplicates per key.

size_100000_hlimit_10000_puts

Yikes! I think we found our lower spectrum. An init capacity of exactly the collection size with a load factor of 1 is doing really well here, but other than that it's all over the shop.


Collection size: 100_000. Hash limit: 90_000. A bit more realistic than the previous test, here we've got a 10% overload in hash codes.

size_100000_hlimit_90000_puts

The lower left corner is still undesirable. Higher initial capacities work best.


Collection size: 100_000. Hash limit: 99_000. Good scenario, this. A large collection with a 1% hash code overload.

size_100000_hlimit_99000_puts

Using the exact collection size as init capacity with a load factor of 1 wins out here! Slightly larger init capacities work quite well, though.


Collection size: 100_000. Hash limit: 100_000. The big one. Largest collection with a perfect hash function.

size_100000_hlimit_100000_puts

Some surprising stuff here. An initial capacity with 50% additional room at a load factor of 1 wins.


Alright, that's it for the puts. Now, we'll check the gets. Remember, the below maps are all relative to best/worst get times, the put times are no longer taken into account.

Get results


Collection size: 100. Hash limit: 50. This means each hash code should occur twice and every other key was expected to collide in the hash map.

size_100_hlimit_50_gets

Eh... What?


Collection size: 100. Hash limit: 90. One in ten keys has a duplicate hash code.

size_100_hlimit_90_gets

Whoa Nelly! This is the most likely scenario to correlate with the asker's question, and apparently an initial capacity of 100 with a load factor of 1 is one of the worst things here! I swear I didn't fake this.


Collection size: 100. Hash limit: 100. Each key as its own unique hash code. No collisions expected.

size_100_hlimit_100_gets

This looks a bit more peaceful. Mostly the same results across the board.


Collection size: 1000. Hash limit: 500. Just like in the first test, there's a hash overload of 2 to 1, but now with a lot more entries.

size_1000_hlimit_500_gets

Looks like any setting will yield a decent result here.


Collection size: 1000. Hash limit: 900. This means one in ten hash codes will occur twice. Reasonable scenario regarding collisions.

size_1000_hlimit_900_gets

And just like with the puts for this setup, we get an anomaly in a strange spot.


Collection size: 1000. Hash limit: 990. Some collisions, but only a few. Quite realistic in this respect.

size_1000_hlimit_990_gets

Decent performance everywhere, save for the combination of a high initial capacity with a low load factor. I'd expect this for the puts, since two hash map resizes might be expected. But why on the gets?


Collection size: 1000. Hash limit: 1000. No duplicate hash codes, but now with a sample size of 1000.

size_1000_hlimit_1000_gets

A wholly unspectacular visualization. This seems to work no matter what.


Collection size: 100_000. Hash limit: 10_000. Going into the 100K again, with a whole lot of hash code overlap.

size_100000_hlimit_10000_gets

It doesn't look pretty, although the bad spots are very localized. Performance here seems to depend largely on a certain synergy between settings.


Collection size: 100_000. Hash limit: 90_000. A bit more realistic than the previous test, here we've got a 10% overload in hash codes.

size_100000_hlimit_90000_gets

Much variance, although if you squint you can see an arrow pointing to the upper right corner.


Collection size: 100_000. Hash limit: 99_000. Good scenario, this. A large collection with a 1% hash code overload.

size_100000_hlimit_99000_gets

Very chaotic. It's hard to find much structure here.


Collection size: 100_000. Hash limit: 100_000. The big one. Largest collection with a perfect hash function.

size_100000_hlimit_100000_gets

Anyone else thinks this is starting to look like Atari graphics? This seems to favour an initial capacity of exactly the collection size, -25% or +50%.


Alright, it's time for conclusions now...

  • Regarding put times: you'll wish to avoid initial capacities that are lower than the expected number of map entries. If an exact number is known beforehand, that number or something slightly above it seems to work best. High load factors can offset lower initial capacities due to earlier hash map resizes. For higher initial capacities, they don't seem to matter that much.
  • Regarding get times: results are slightly chaotic here. There's not much to conclude. It seems to rely very much on subtle ratios between hash code overlap, initial capacity and load factor, with some supposedly bad setups performing well and good setups performing awfully.
  • I'm apparently full of crap when it comes to assumptions about Java performance. The truth is, unless you are perfectly tuning your settings to the implementation of HashMap, the results are going to be all over the place. If there's one thing to take away from this, it's that the default initial size of 16 is a bit dumb for anything but the smallest maps, so use a constructor that sets the initial size if you have any sort of idea about what order of size it's going to be.
  • We're measuring in nanoseconds here. The best average time per 10 puts was 1179 ns and the worst 5105 ns on my machine. The best average time per 10 gets was 547 ns and the worst 3484 ns. That may be a factor 6 difference, but we're talking less than a millisecond. On collections that are vastly larger than what the original poster had in mind.

Well, that's it. I hope my code doesn't have some horrendous oversight that invalidates everything I've posted here. This has been fun, and I've learned that in the end you may just as well rely on Java to do its job than to expect much difference from tiny optimizations. That is not to say that some stuff shouldn't be avoided, but then we're mostly talking about constructing lengthy Strings in for loops, using the wrong datastructures and making O(n^3) algorithms.

This is a pretty great thread, except there is one crucial thing you're missing. You said:

Curiously, capacity, capacity+1, capacity+2, capacity-1 and even capacity-10 all yield exactly the same results. I would expect at least capacity-1 and capacity-10 to give worse results.

The source code jumps initial capacity the next highest power-of-two internally. That means that, for example, initial capacities of 513, 600, 700, 800, 900, 1000, and 1024 will all use the same initial capacity (1024). This doesn't invalidate the testing done by @G_H though, one should realize that this is being done before analyzing his results. And it does explain the odd behavior of some of the tests.

This is the constructor right for the JDK source:

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

Implementation-wise, Google Guava has a convenient factory method

Maps.newHashMapWithExpectedSize(expectedSize)

Which calculates the capacity using the formula

capacity = expectedSize / 0.75F + 1.0F