HashMap

JDK 1.8 以前

JDK1.8 之前HashMap底层通过 数组和链表 结合。HashMap通过Key的hashcode经过扰动函数处理得到hash值,然后通过(n - 1) & hash判断当前元素的存放位置(n指数组长度),如果当前位置存在元素,就判断该元素与要存入的元素的hash值与key是否相同,如果相同直接覆盖,如果不同就通过拉链法解决冲突。

扰动函数就是HashMap的hash方法,使用hash方法可以减少哈希冲突。
两个版本的hash方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//jdk 1.7
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).

h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

//jdk 1.8
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

jdk1.7的hash方法扰动了四次,所以性能不如jdk 1.8

拉链法

即创建一个链表数组,这就是1.7数组+链表的由来,数组中的每一格就是一个链表,如果遇到哈希冲突,则将冲突的值加入链表即可。
结构图

jdk 1.8 以后

jdk 1.8 以后,HashMap的底层变为数组+链表+红黑树。

为什么要改成“数组+链表+红黑树”?
主要是为了提升在哈希冲突严重时(链表过长)的查找性能,使用链表的查找性能是O(n),而使用红黑树是O(logn)。

重要初始属性

1
2
3
4
5
6
7
8
9
10
11
12
//初始容量大小,若不指定默认为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//treeifyBin阈值
static final int TREEIFY_THRESHOLD = 8;
//untreeify阈值
static final int UNTREEIFY_THRESHOLD = 6;
//树化最小容量
static final int MIN_TREEIFY_CAPACITY = 64;

扩容原则

对于插入操作,默认情况下使用链表节点,当同一个索引位置的节点在**新增达到9个(阈值默认为8)**:

  • 如果此时数组长度大于等于64,则触发链表节点转为红黑树节点(treeifyBin)
  • 如果数组长度小于64,则先对数组扩容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 判断当前数组的长度是否小于 64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果当前数组的长度小于 64,那么会选择先进行数组扩容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 否则才将列表转换为红黑树

TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

对于移除操作,当用一个索引位置的节点在移除后达到6个,并且该索引位置的节点是红黑树节点,则触发红黑树节点转为链表节点(untreeify)

为什么链表转红黑树的阈值是8?
简单来说,阈值为8是在时间和空间上权衡的结果。
红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显,没必要付出2倍的空间。
理想情况下,使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,当链表中节点个数为8时的概率为 0.00000006,这个概率足够低,并且到达8个节点时,红黑树的性能优势也会展现出来,因此8是一个合理的数字。

为什么转回链表节点是用的6而不是复用8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

默认初始容量

HashMap的默认初始容量为16,且HashMap的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。

1
2
3
4
5
6
7
8
9
10
//容量计算方法
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

HashMap 的长度为什么是 2 的幂次方?
计算索引位置的公式为:(n - 1) & hash,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时任何值跟 n - 1 进行 & 运算的结果为该值的低 N 位,达到了和取模同样的效果,实现了均匀分布。

put方法

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
//jdk 1.8
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab: 引用当前hashMap的散列表
//p: 表示当前散列表的元素
//n: 表示散列表数组的长度
//i: 表示路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容(采用了延时初始化,第一次put才会初始化散列表。)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 寻址找到的桶位为null
//(n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 寻址找到的桶位已经存在元素
else {
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等,也就是判断是否是重复的值。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 完全一致
//e:找到的一个与当前要插入的元素一致的元素
e = p;
// hash值不相等,即key不相等;且为红黑树结点
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//替换
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}

总结:

  1. 判断table数组是否为空,若为空就进行resize()扩容,不是就通过(n - 1) & hash得到存储键值对的索引
  2. 判断索引处是否为空,为空就直接插入到对应数组
  3. 若不为空, 首先判断key是否已经存在,若存在就直接覆盖
  4. 否则,判断插入的是不是红黑树节点,如果是就放入树中
  5. 如果不是就遍历链表插入链表尾部,然后判断是否需要转为红黑树
  6. 若节点数量达到阈值,执行treeifyBin方法,如果数组长度大于等于64,才会转为红黑树,否则对数组扩容
  7. 判断数组的size是否大于阈值,若大于就执行resize()扩容

再来对比一下jdk 1.7中的put方法
源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) { // 先遍历
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}

modCount++;
addEntry(hash, key, value, i); // 再插入
return null;
}

大致分为两个步骤:

  1. 如果当前数组位置没有元素就直接插入
  2. 否则,遍历以这个元素为头节点的链表,依次和插入的key比较,如果相同则直接覆盖,不同就采用头插法插入元素。

ConcurrentHashMap

和HashMap的区别