内容主要包含 HashMap的初始、扩容,属于学习笔记,如果不对,欢迎指正。
一、HashMap
1、HashMap 中主要的成员变量:size、threshold、loadFactor
- size:map中 KV 键值对的数量。
- threshold:map 扩容的临界值,当 size 超过 threshold 时,map 进行扩容。
- loadFactor:负载因子,默认 0.75f。
- capacity:容量。
2、HashMap 的容量初始及扩容
HashMap 中有阈值:threshold,阈值的计算公式:threshold = capacity * loadFactor。当 hashmap 的 size 大于 threshold 时,hashmap 会扩容,触发 rehash,十分损耗性能。
故而,在初始化 hashmap 时,指定容量,避免 hashmap 的扩容,可以提升性能。
当初始化一个 hashmap 时,预测到了有100个键值对。
HashMap<Integer, Integer> hashMap = new HashMap<>(100);
此时,hashMap 的 capacity 是 100 么? 答案是否定的。
初始话 hashMap 的 capacity 的规则是,当初始化容量时,采用第一个大于它的2的幂作为初始容量。
@Slf4j
public class InitHashMapDemo {
public static void main(String[] args) throws Exception {
int size = 100;
HashMap<Integer, Integer> map = new HashMap<>(100);
Class<?> clz = map.getClass();
Method method = clz.getDeclaredMethod("capacity");
method.setAccessible(true);
log.error("map capacity before any put action : {}", method.invoke(map));
}
}
运行代码得到结果:
16:12:27.806 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 128
即便初始化 hashmap 时传入的是 100,hashmap 的 初始capacity 却是 128。
128 已经大于 100 了,但当我们往 hashmap 中循环 100 次 put 元素时,上面的初始化 hashmap 的写法,依旧会触发 hashmap 的扩容。
@Slf4j
public class InitHashMapDemo {
public static void main(String[] args) throws Exception {
int size = 100;
HashMap<Integer, Integer> map = new HashMap<>(100);
Class<?> clz = map.getClass();
Method method = clz.getDeclaredMethod("capacity");
method.setAccessible(true);
log.error("map capacity before any put action : {}", method.invoke(map));
for (int i = 0; i < size; i++) {
map.put(i, i);
}
log.error("map capacity after put 100 elements to map : {}", method.invoke(map));
}
}
运行代码得到结果:
16:19:58.340 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 128
16:19:58.346 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity after put 100 elements to map : 256
可以看到,循环结束后,hashmap 的 capacity = 256,说明 hashmap 在循环过程中进行了扩容。『扩容后的容量是扩容前容量的两倍』
具体原因是:
threshold = capacity * loadFactor => threshold = 128 * 0.75f => threshold = 96 当 hashmap 中 size 大于 96 时,会触发 hashmap 的扩容。
那么当我们可以预测到键值对数量时,应该怎么初始化 hashmap 呢?
通过上面的例子可以看出,我们预测到键值对为 100 时,hashmap 的 capacity 为 256 时不需要进行扩容。那么如何将 100 与 256 结合起来呢?
HashMap 的源码给了答案。
/**
* Implements Map.putAll and Map constructor.
*
* @param m the map
* @param evict false when initially constructing this map, else
* true (relayed to method afterNodeInsertion).
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
**float ft = ((float)s / loadFactor) + 1.0F;**
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
当 map.putAll(?) 时,会调用这段代码。hashmap 初始化 capacity 用的公式是:ft = size / loadFactor + 1.0f ⇒ ft = size / 0.75f + 1.0f,再取大于 ft 的第一个2的幂。
当我们的预测键值对 100 时,我们初始化 hashMap 时,传入 100/0.75 + 1.0 = 134。大于 134 的第一个2的幂就是 256,此刻 threshold = capacity * loadFactor = 256 * 0.75 = 192,这样就不会再次触发扩容了。
知道了公式后,每次我们初始化 hashmap 都需要进行计算吗?不用,guava 已经提供好了现成的方法了。
HashMap<Integer, Integer> hashMap = Maps.newHashMapWithExpectedSize(100);
初始化时,还是传入预测的键值对数量,内部会自动计算出最佳的入参。
它的内部实现也是根据 JDK8 中的公式:
public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
return new HashMap<>(capacity(expectedSize));
}
/**
* Returns a capacity that is sufficient to keep the map from being resized as long as it grows no
* larger than expectedSize and the load factor is ≥ its default (0.75).
*/
static int capacity(int expectedSize) {
if (expectedSize < 3) {
checkNonnegative(expectedSize, "expectedSize");
return expectedSize + 1;
}
if (expectedSize < Ints.MAX_POWER_OF_TWO) {
// This is the calculation used in JDK8 to resize when a putAll
// happens; it seems to be the most conservative calculation we
// can make. 0.75 is the default load factor.
**return (int) ((float) expectedSize / 0.75F + 1.0F);**
}
return Integer.MAX_VALUE; // any large value
}
此刻修改下代码再次运行
@Slf4j
public class InitHashMapDemo {
public static void main(String[] args) throws Exception {
int size = 100;
HashMap<Integer, Integer> map = Maps.newHashMapWithExpectedSize(size);
Class<?> clz = map.getClass();
Method method = clz.getDeclaredMethod("capacity");
method.setAccessible(true);
log.error("map capacity before any put action : {}", method.invoke(map));
for (int i = 0; i < size; i++) {
map.put(i, i);
}
log.error("map capacity after put 100 elements to map : {}", method.invoke(map));
}
}
可以看到结果,hashmap 并没有触发扩容
17:23:25.114 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 256
17:23:25.119 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity after put 100 elements to map : 256
3、HashMap 的一些概念
哈希碰撞
hashmap 的 put 操作,先计算 key 的 hashCode,再通过哈希算法的高位运算和取模运算,最终确定键值对的存储位置。当两个 key 定位到了相同的位置,就会发生哈希碰撞。
哈希得到的结果越分散,哈希碰撞的概率就越小,hashmap 的存储效率就越高。
哈希碰撞取决于 哈希桶数组 和 哈希算法。哈希桶数组属于空间成本,哈希算法属于时间成本,好的哈希算法加上扩容,减少哈希碰撞。
负载因子 load factor 为什么默认是 0.75f
结论:0.75f 是时间和空间效率的一个平衡选择。
hashmap 的阈值 threshold = load factor * capacity,当 hashmap 的 size 大于 threshold 时,hashmap 会进行扩容,扩容后的容量是扩容前的两倍。
当 load factor 过于小时,threshold 也随着变小,当持续进行 hashmap 的 put 操作时,也会触发 hashmap 的扩容,造成空间的浪费;
当 load factor 过于大时,threshold 也随便变大,扩容的次数减少,但产生哈希碰撞的概率上升,造成了时间上的低效。
在时间和空间比较特殊的情况下,可以做适当修改。例如,空间 > 时间,可适当减小 load factor 的值,其余情况不建议修改。
4、HashMap 与其它 Map 的比较
Name | Desc |
---|---|
HashMap | 根据 key 的 hashCode 来存储数据,访问速度快;遍历时的顺序不可确定;只允许存在一个为 null 的 key;非线程安全。 |
TreeMap | 实现了 SortedMap 接口,默认按 key 升序; |
LinkedHashMap | 是 HashMap 的一个子类,保存了数据插入的顺序。 |