在Java编程中使用到集合是经常会用到List,Set,Map这三大集合接口,而Map作为集合的一种也是经常广泛的被使用,而Map的最常用到的一个实现类就要说到HashMap了,而HashMap并不是线程安全的,下面我们将会带着大家来一起研究HashMap的线程安全问题以及线程安全的Map。
HashMap的实现分析
此处主要从两个方面分析:
- put方法
- get方法
put方法
下面是put方法的源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* @param hash key的hash值
* @param key 键
* @param value 值
* @param onlyIfAbsent 设为true表示如果键不存在,才会写入值。
* @param evict
* @return 返回value
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// 如果当前Map的元素数组为空 或者 数组长度为0,那么需要初始化元素数组,同时该方法也是扩容方法
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根据hash值和数组长度取摸计算出数组下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 红黑树解析
else if (p instanceof TreeNode)
e = ((TreeNode)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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 说明找了和要写入的key对应的元素,根据情况来决定是否覆盖值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
逐步分析put方法的实现大致可以分为以下几个步骤:
1、hash(key),该方法获取到key的hashCode,然后通过位运算 异或(^)
重新计算hash(为了将高位参与到后续计算中,避免重复发生hash碰撞的几率)
2、判断是否需要扩容(初始化)
3、i = (n - 1) & hash
通过容量大小 与运算(&)
hash得出一个要放置数据的下标,判断该位置是否已存在元素,如果不存在则创建一个node放置到该位置
4、如果已经存在元素则比较key是否相等或者key的hashCode方法返回值是否相等,如果相等则替换并返回替换前的值
5、如果key不相等并且hashCode也不相等,则再判断原节点类型是否是TreeNode(红黑树),再调用红黑树的putTreeVal方法
6、如果上述两个条件都不是则说明是使用链表存储的,通过链表的方式查找是否有原数据或者是新创建数据
7、判断当前链表上元素是否超过阈值TREEIFY_THRESHOLD
(8),如果超过则转换为红黑树进行存储
8、如果没超过则按照正常的链表增加元素,并从putVal方法返回
get方法
get方法的实现相对较为简单,下面是get方法的具体源码:
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 该方法是Map.get方法的具体实现
* 接收两个参数
* @param hash key的hash值,根据hash值在节点数组中寻址,该hash值是通过hash(key)得到的,可参见:hash方法解析
* @param key key对象,当存在hash碰撞时,要逐个比对是否相等
* @return 查找到则返回键值对节点对象,否则返回null
*/
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get方法的实现分析可以分为以下几个步骤:
1、与put时一致,调用hash方法重新计算hash
2、通过计算集合长度 与运算(&)
hash的结果来得出放置元素时的下标,并且判断元素不为空
3、继续判断key是否与存储节点的key相等或者equals结果是否相等,如果相等则直接返回该节点
4、如果不相等并且该节点有下一个节点,此时可能是红黑树结构或者是链表结构
5、如果是红黑树,则调用红黑树的getTreeNode
方法返回节点
6、否则肯定是链表,遍历链表直到获取到匹配的key的节点,或者直到节点遍历完还没找到,则返回null
HashMap线程不安全
看过了HashMap的get和put方法的实现之后,该思考为什么HashMap会有线程不安全的问题了呢?
首先我们要先看java1.7中的HashMap的线程安全问题,可能会造成死循环和数据丢失,由于Java1.7中的HashMap是使用头插法,在put的时候可能造成两个entry节点的循环引用,从而造成下一次get时死循环问题,主要问题的源码如下:
/**对HashMap进行容量扩充
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {//遍历原table中的所有表头
Entry e = src[j];
if (e != null) {
src[j] = null;
do {//依次将链表中的元素,重新添加到新的table中
Entry next = e.next;// 代码 1
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
此处由于篇幅较多,不再仔细分析循环引用出现的具体步骤,有兴趣的话可以参考https://blog.csdn.net/swpu_ocean/article/details/88917958
那么Java1.8中的HashMap已经改为尾插法,为什么还会有线程不安全问题呢?
Java1.8中的HashMap已经修改解决了死循环和数据丢失,但是依然可能造成数据覆盖的问题。
我们现在重新回去看putVal方法代码块的第20行,此处判断了是否发生了hash碰撞,如果没有则直接插入,如果有则转为链表或红黑树存储。假设有A和B两个线程同时进入了此处判断条件,A判断该位置为空,此时CPU调度切换为B线程,线程判断此处位置依然为空,即执行插入并且结束,回到A线程由于已经判断过为空,则将原位置上的元素直接替换为A线程的value,此时原来B线程的数据被直接覆盖。