这两天写爬虫帮组里收集网上数据做训练,需要进一步对收集到的json数据做数据清洗,结果就用到了多线程下的哈希表数据结构,猛地回想起自己看《Java并发编程的艺术》框架篇的时候,在ConcurrentHashMap的章节看到过使用HashMap是线程不安全的,HashTable虽然安全但效率很低,推荐使用ConcurrentHashMap巴拉巴拉,突然有了兴趣来查阅一下各自的源码,看看具体区别在哪里呢?HashMao为什么线程不安全?顺带记录下来,还是那句话,好记性不如烂笔头


我们知道的Java中的哈希表数据结构有下面三种

  • HashMap
  • HashTable
  • ConcurrentHashMap

下面就依次来看看它们是如何保证并发时可靠的,各自有什么优缺点

HashMap

首先是大家都很熟悉的哈希表:HashMap,刷算法题必备哈希表数据结构。它的存储结构如下图所示

很好看懂的一个图,简单来说就是HashMap采用的是拉链法处理哈希冲突。所谓哈希冲突就是由于哈希表根据哈希值索引目标节点来对随机存取获得O(1)的时间复杂度,那么每个哈希值当然只能站一个节点,如果存在多个节点计算出的哈希值一致就发生了哈希冲突,此时一般有三种思路:

  • 拉链法:在一个哈希值上设置一个数据集结构,也就是一个哈希值代表一个数据集,我们对数据集的随机存取获得O(1)时间复杂度,对数据集内获取目标Key节点获得O(m)时间复杂度,如果哈希值的数量远多于数据集内节点的数量,那么我们近似取到O(1)时间复杂度
  • 开放定址法:一旦碰到哈希冲突就顺延后来的节点的哈希值,比如节点A取哈希为1,而哈希值1,2,3上都已经有节点在了,那么我们根据顺延规则取4作为该节点的真实存储位置,这种方案一般表现比较糟糕
  • 再哈希法:同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个,第四个等等等等的其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是大大增加了计算时间

这里我们常用的三种哈希表结构全部是采用的拉链法,这是一种认可度较高的解决方案,那么拉链法就要求我们每个哈希值都独立设置一个链表来存储哈希冲突的节点。那么我们关于多线程安全的问题自然也就来自于此。

总的来说,HashMap在多线程时视使用的Java版本有以下三大问题

  • 数据覆盖(一直存在)
  • 死循环(JDK1.7前存在)
  • 数据丢失(JDK1.7前存在)

    我们一个一个来看

数据覆盖

顾名思义,两个线程同时往里面放数据,但是其中一个数据放丢了,这个问题是根本问题,目前的HashMap依然有这个问题,出问题的原因也很简单,HashMap本来就没做多线程适配当然出问题,但是原理还是值得一看。

// 代码截取自HashMap.java,方法final V putVal()中
for (int binCount = 0; ; ++binCount) {
// 根据下面代码,我们看出其实插入新节点就是反复探测目前节点的next指针是否为空,若为空则在该指针上插入新节点
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;
}

看完以后我们发现,其实就是很简单的探测链表尾部的方法,看该节点next指针是否为null,若为null说明是尾节点,在其后插入新节点,那么数据覆盖的真相也就很简单了:A,B两个线程同时向同一个哈希值的链表发起插入,A探测到C节点的next为空然后时间片用完被换下,此时B也探测到C的next为空并完成了插入,等到A再次换入时间片,完成插入,最终,A,B运行结束,但B插入的新节点就这样消失了。这就是数据覆盖问题。

死循环与数据丢失

其实这两个问题的核心都来自JDK1.7前,HashMap的扩容操作(扩容采用头插法插入)会重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。而头插法会将链表的顺序翻转,这也是造成死循环和数据丢失的关键。

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

怎么一回事呢?

现在假设线程启动,有线程A和线程B都准备对HashMap进行扩容操作, 此时A和B指向的都是链表的头节点NodeA,而A和B的下一个节点的指针即head.next和head.next都指向NodeB节点。

那么,开始扩容,这时候,假设线程B的时间片用完,被换下CPU,而线程A开始执行扩容操作,一直到线程A扩容完成后,线程B才被唤醒。

此时因为HashMap扩容采用的是头插法,线程A执行之后,链表中的节点顺序已经倒转,本来NodeA->NodeB,现在变成了NodeB->NodeA。但就绪态的线程B对于发生的一切都不清楚,所以它指向的节点引用依然没变。那么一旦B被换上CPU,重复一次刚刚A做过的事情,就会导致NodeA和NodeB的next指针相互指向,导致死循环和数据丢失。

不过JDK1.8以后,HashMap的哈希值扩容改为了尾插法扩容,就不会再出现这些问题了。


HashTable

效率很差的一个类,根据我自己的周边统计学,我的感觉是这个玩意根本没人用,用它就类似于如果你给线上项目的MySQL突然整出个表级锁一样,等你的只能是一通臭骂!为什么呢?看下面

// 你就看这个synchronized关键字就可以了,不用往下看了
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
} // Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
} addEntry(hash, key, value, index);
return null;
}

很明显了。HashTable采用了Synchronized关键字来保证线程安全。

我们知道Synchronized关键字的底层原理是给对象头上的MarkWord的内容做改动从而将该对象当作互斥变量使用,也就是说,这把锁是对象级别的。

问题就在于我本来哈希冲突只是一个哈希值上的冲突,而你的解决方案是锁住整个哈希表,这会不会有点太过分了?可以说表级锁的比喻是很贴切了。

不推荐使用,效率很低。


ConcurrentHashMap

《Java并发编程的艺术》里面提到的第一个并发结构,它的思路就是在HashTable表级锁的基础上把它改为行级锁,什么意思呢?放源码

// 截取自ConcurrentHashMap.java,final V putVal()方法
// 注意下面这句,f 是我们需要的哈希值对应的首节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
// 来到这里,上面的都不用看,主要是排查初始化情况,这里synchronized(f)就解释清楚了我们的ConcurrentHashMap的方法
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}

很明显,ConcurrentHashMap同样是通过Synchronized关键字来实现线程安全的,只不过这把锁从原来的表级锁,变为了以首节点为对象的行级锁,当我们并发的对ConcurrentHashMap操作时,锁只会锁住某一个哈希值,而不会锁住整个表,保证了我们的哈希表在高并发场景下的效率。

总结

HashMap只适用于非并发情况下,ConcurrentHashMap适用于并发情况下,而HashTable则不推荐使用

HashMap为何线程不安全?HashMap,HashTable,ConcurrentHashMap对比的更多相关文章

  1. [Java集合] 彻底搞懂HashMap,HashTable,ConcurrentHashMap之关联.

    注: 今天看到的一篇讲hashMap,hashTable,concurrentHashMap很透彻的一篇文章, 感谢原作者的分享. 原文地址: http://blog.csdn.net/zhanger ...

  2. Java集合——HashMap,HashTable,ConcurrentHashMap区别

    Map:“键值”对映射的抽象接口.该映射不包括重复的键,一个键对应一个值. SortedMap:有序的键值对接口,继承Map接口. NavigableMap:继承SortedMap,具有了针对给定搜索 ...

  3. 彻底搞懂HashMap,HashTable,ConcurrentHashMap之关联.

    注: 今天看到的一篇讲hashMap,hashTable,concurrentHashMap很透彻的一篇文章, 感谢原作者的分享.  原文地址: http://blog.csdn.net/zhange ...

  4. 浅谈HashMap与线程安全 (JDK1.8)

    HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型.HashMap 继承自 AbstractMap 是基于哈希表的 Map 接口的实现,以 Key-Value 的形式存在,即 ...

  5. hashmap,hashTable concurrentHashMap 是否为线程安全,区别,如何实现的

    线程安全类 在集合框架中,有些类是线程安全的,这些都是jdk1.1中的出现的.在jdk1.2之后,就出现许许多多非线程安全的类. 下面是这些线程安全的同步的类: vector:就比arraylist多 ...

  6. 为什么HashMap线程不安全,Hashtable和ConcurrentHashMap线程安全

    HashMap源码 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final ...

  7. HashMap,HashTable,concurrentHashMap,LinkedHashMap 区别

    HashMap 不是线程安全的 HashTable,concurrentHashMap 是线程安全 HashTable 底层是所有方法都加有锁(synchronized) 所以操作起来效率会低 con ...

  8. 非线程安全的HashMap 和 线程安全的ConcurrentHashMap

    在平时开发中,我们经常采用HashMap来作为本地缓存的一种实现方式,将一些如系统变量等数据量比较少的参数保存在HashMap中,并将其作为单例类的一个属性.在系统运行中,使用到这些缓存数据,都可以直 ...

  9. java基础解析系列(五)---HashMap并发下的问题以及HashTable和CurrentHashMap的区别

    java基础解析系列(五)---HashMap并发下的问题以及HashTable和CurrentHashMap的区别 目录 java基础解析系列(一)---String.StringBuffer.St ...

  10. Java 非线程安全的HashMap如何在多线程中使用

    Java 非线程安全的HashMap如何在多线程中使用 HashMap 是非线程安全的.在多线程条件下,容易导致死循环,具体表现为CPU使用率100%.因此多线程环境下保证 HashMap 的线程安全 ...

随机推荐

  1. 知识图谱实体对齐1:基于平移(translation)的方法

    1 导引 在知识图谱领域,最重要的任务之一就是实体对齐 [1](entity alignment, EA).实体对齐旨在从不同的知识图谱中识别出表示同一个现实对象的实体.如下图所示,知识图谱\(\ma ...

  2. Raft 共识算法

    转载请注明出处:https://www.cnblogs.com/morningli/p/16745294.html raft是一种管理复制日志的算法,raft可以分解成三个相对独立的子问题: 选主(L ...

  3. P1084 [NOIP2012 提高组] 疫情控制 (二分答案、贪心)

    因为若一个时间限制满足题意,则所有比它大的时间限制一定都满足题意,因此本题答案具有单调性,可以想到二分答案求解. 本题思路不是很难,但细节和代码实现比较复杂. 见牛人博客:https://www.lu ...

  4. windows下利用_popen,_wpoen创建管道进行系统命令输出数据

    转载: https://blog.csdn.net/greless/article/details/72383762 参考: http://www.linuxidc.com/Linux/2011-04 ...

  5. 关于for循环当中发生强制类型转换的问题

    Map map1 = new HashMap(); Map map2 = new HashMap(); Map map3 = new HashMap(); List<Map> list = ...

  6. Oracle导出和导入

    导出 exp exp 用户名/密码@实例名 file=导出的dmp文件存放路径 l og=导出日志存放路径 exp hr/123456@orcl file= C:\Users\Administrato ...

  7. 驱动开发:内核枚举PspCidTable句柄表

    在上一篇文章<驱动开发:内核枚举DpcTimer定时器>中我们通过枚举特征码的方式找到了DPC定时器基址并输出了内核中存在的定时器列表,本章将学习如何通过特征码定位的方式寻找Windows ...

  8. if、where、trim、choose、when、otherwise、foreach

    1.if if标签可通过test属性的表达式进行判断,若表达式的结果为true,则标签中的内容会执行:反之标签中 的内容不会执行 <!--List<Emp> getEmpListBy ...

  9. paddle&蜜度 文本智能较对大赛经验分享(17/685)

    引言 我之前参加了一个中文文本智能校对大赛,拿了17名,虽然没什么奖金但好歹也是自己solo拿的第一个比较好的名次吧,期间也学到了一些BERT应用的新视角和新的预训练方法,感觉还挺有趣的,所以在这里记 ...

  10. Bootstrap中的dropdown、下拉选择框、dropdown-toggle

    注意:如果您想要单独引用该插件的功能,那么您需要引用 dropdown.js.可以引用 bootstrap.js 或压缩版的 bootstrap.min.js. <!DOCTYPE html&g ...