浅析JDK1.7 HashMap源码。
HashMap中几个重要的属性
1 | /** |
Entry
当实例化一个HashMap时,会创建一个长度为Capacity的Entry数组。在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素。每个bucket中存储一个元素,即一个Entry对象,但每一个Entry对象可以带一个引用变量,用于指向下一个元素,因此,在一个桶中,就有可能生成一个Entry链。
Entry源码如下:1
2
3
4
5
6
7
8
9
10
11
12
13static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
构造方法
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity或loadFactor,会使用默认值。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public 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);
this.loadFactor = loadFactor;
threshold = initialCapacity;
// 在HashMap中没有实际实现
init();
}
在常规构造器中,并没有马上为数组table分配内存空间(有一个入参为指定Map的构造器例外),事实上是在执行第一次put操作的时候才真正构建table数组。
put()
1 | public V put(K key, V value) { |
建哈希表是在inflateTable()中实现的,我们来看一看:1
2
3
4
5
6
7
8
9
10
11private void inflateTable(int toSize) {
// 比容量(capacity)大的2的次幂,作为哈希表的容量
int capacity = roundUpToPowerOf2(toSize);
// 计算新的扩容阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 分配空间
table = new Entry[capacity];
// 根据容量判断是否需要初始化hashSeed
initHashSeedAsNeeded(capacity);
}
继续看下addEntry()源码:1
2
3
4
5
6
7
8
9
10
11void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容,新容量为旧容量的2倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 将新Entry放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
使用头插法将Entry插入桶中:1
2
3
4
5void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
Alternative hashing和hashSeed
这里我要补充一下hashSeed这个属性,inflateTable()中最后还调用了一个initHashSeedAsNeeded()方法,该方法是用来依据容量决定是否需要初始化hashSeed。
在源码中有一个常量ALTERNATIVE_HASHING_THRESHOLD_DEFAULT,它是一个默认的阈值,当一个键值对的键是String类型时,且map的容量达到了这个阈值,就启用备用哈希(alternative hashing)。备用哈希可以减少String类型的key计算哈希码(更容易)发生哈希碰撞的发生率。该值可以通过定义系统属性jdk.map.althashing.threshold来指定。如果该值是1,表示强制总是使用备用哈希;如果是-1则表示禁用。
HashMap有一个静态内部类Holder,它的作用是在虚拟机启动后根据jdk.map.althashing.threshold和ALTERNATIVE_HASHING_THRESHOLD_DEFAULT初始化ALTERNATIVE_HASHING_THRESHOLD,相关代码如下: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/**
* Holder维护着一些只有在虚拟机启动后才能初始化的值
*/
private static class Holder {
/**
* 触发启用备用哈希的哈希表容量阈值
*/
static final int ALTERNATIVE_HASHING_THRESHOLD;
static {
// 读取JVM参数 -Djdk.map.althashing.threshold
String altThreshold = java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
"jdk.map.althashing.threshold"));
int threshold;
try {
// 如果该参数没有值,采用默认值
threshold = (null != altThreshold)
? Integer.parseInt(altThreshold)
: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
// 如果参数值为-1,禁用备用哈希
// ALTERNATIVE_HASHING_THRESHOLD_DEFAULT也是等于Integer.MAX_VALUE
// 所以jdk默认是禁用备用哈希的
if (threshold == -1) {
threshold = Integer.MAX_VALUE;
}
// 参数为其它负数,则视为非法参数
if (threshold < 0) {
throw new IllegalArgumentException("value must be positive integer.");
}
} catch(IllegalArgumentException failed) {
throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
}
ALTERNATIVE_HASHING_THRESHOLD = threshold;
}
}
继续看inflateTable()中initHashSeedAsNeeded()这个方法,该方法是用来依据容量决定是否需要初始化hashSeed,hashSeed默认是0,如果初始化hashSeed。所以下面来看看这个方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/**
* A randomizing value associated with this instance that is applied to
* hash code of keys to make hash collisions harder to find. If 0 then
* alternative hashing is disabled.
*/
transient int hashSeed = 0;
final boolean initHashSeedAsNeeded(int capacity) {
// 如果hashSeed != 0,表示当前正在使用备用哈希
boolean currentAltHashing = hashSeed != 0;
// 如果vm启动了且map的容量大于阈值,使用备用哈希
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 异或操作,如果两值同时为false,或同时为true,都算是false。
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
// 把hashSeed设置成随机值
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
从hashSeed变量的注释可以看出,哈希种子一个随机值,在计算key的哈希码时会用到这个种子,目的是为了进一步减少哈希碰撞。如果hashSeed=0表示禁用备用哈希。
而Holder中维护的ALTERNATIVE_HASHING_THRESHOLD是触发启用备用哈希的阈值,该值表示,如果容器的容量(注意是容量,不是实际大小)达到了该值,容器应该启用备用哈希。
Holder会尝试读取JVM启动时传入的参数-Djdk.map.althashing.threshold并赋值给ALTERNATIVE_HASHING_THRESHOLD。它的值有如下含义:
- ALTERNATIVE_HASHING_THRESHOLD = 1,总是使用备用哈希
- ALTERNATIVE_HASHING_THRESHOLD = -1,禁用备用哈希
在initHashSeedAsNeeded(int capacity)方法中,会判断如果容器的容量>=ALTERNATIVE_HASHING_THRESHOLD,就会生成一个随机的哈希种子hashSeed,该种子会在put方法调用过程中的hash()中使用到:1
2
3
4
5
6
7
8
9
10
11
12
13final int hash(Object k) {
// 如果哈希种子是随机值,使用备用哈希
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
//
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
扩容
1 | void resize(int newCapacity) { |
这个时候不得不提resize()中的核心方法transfer():1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; // 新容量
for (Entry<K,V> e : table) { // 遍历所有桶
while(null != e) { // 遍历桶中所有的元素
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 采用头插法的方式将旧数组中的数据拷贝到新数组中
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
get()
1 | public V get(Object key) { |
获取key为空的值:1
2
3
4
5
6
7
8
9
10
11private V getForNullKey() {
if (size == 0) {
return null;
}
// key为null的元素存储在table的下标为0的位置
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
getEntry()相对简单,直接贴源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
remove()
remove()也比较简单直接贴源码:1
2
3
4public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
1 | final Entry<K,V> removeEntryForKey(Object key) { |
为什么JDK1.7 HashMap是线程不安全的?
在多线程环境下,HashMap主要会出现下面三种问题:
- 死循环
- 数据丢失
- 数据覆盖
死循环和数据丢失
死循环问题出现在resize()中的核心方法transfer()中:1
2
3e.next = newTable[i];
newTable[i] = e;
e = next;
头插法会将链表的顺序翻转,这也是形成死循环的关键点。
假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
正常扩容后的结果是下面这样的:
但如果线程A在e.next = newTable[i];
处CPU时间片耗尽,线程A被挂起,此时线程A中:e=3、next=7、e.next=null即:
当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据拷贝,此时线程B中7.next=3、3.next=null,即:
随后线程A获得CPU时间片继续执行,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:
接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:
执行下一次循环可以发现,next=3.next=null,所以此轮循环将会是最后一轮循环。接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示:
此时next=null,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
数据覆盖
还是在put()中:
比如有两个线程A和B,首先A希望插入一个元素到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头,这就导致了线程B插入的数据被线程A覆盖了。
参考:
Alternative hashing和hashSeed部分 https://segmentfault.com/a/1190000018520768