HashMap为何线程不安全?HashMap,HashTable,ConcurrentHashMap对比
这两天写爬虫帮组里收集网上数据做训练,需要进一步对收集到的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对比的更多相关文章
- [Java集合] 彻底搞懂HashMap,HashTable,ConcurrentHashMap之关联.
注: 今天看到的一篇讲hashMap,hashTable,concurrentHashMap很透彻的一篇文章, 感谢原作者的分享. 原文地址: http://blog.csdn.net/zhanger ...
- Java集合——HashMap,HashTable,ConcurrentHashMap区别
Map:“键值”对映射的抽象接口.该映射不包括重复的键,一个键对应一个值. SortedMap:有序的键值对接口,继承Map接口. NavigableMap:继承SortedMap,具有了针对给定搜索 ...
- 彻底搞懂HashMap,HashTable,ConcurrentHashMap之关联.
注: 今天看到的一篇讲hashMap,hashTable,concurrentHashMap很透彻的一篇文章, 感谢原作者的分享. 原文地址: http://blog.csdn.net/zhange ...
- 浅谈HashMap与线程安全 (JDK1.8)
HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型.HashMap 继承自 AbstractMap 是基于哈希表的 Map 接口的实现,以 Key-Value 的形式存在,即 ...
- hashmap,hashTable concurrentHashMap 是否为线程安全,区别,如何实现的
线程安全类 在集合框架中,有些类是线程安全的,这些都是jdk1.1中的出现的.在jdk1.2之后,就出现许许多多非线程安全的类. 下面是这些线程安全的同步的类: vector:就比arraylist多 ...
- 为什么HashMap线程不安全,Hashtable和ConcurrentHashMap线程安全
HashMap源码 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final ...
- HashMap,HashTable,concurrentHashMap,LinkedHashMap 区别
HashMap 不是线程安全的 HashTable,concurrentHashMap 是线程安全 HashTable 底层是所有方法都加有锁(synchronized) 所以操作起来效率会低 con ...
- 非线程安全的HashMap 和 线程安全的ConcurrentHashMap
在平时开发中,我们经常采用HashMap来作为本地缓存的一种实现方式,将一些如系统变量等数据量比较少的参数保存在HashMap中,并将其作为单例类的一个属性.在系统运行中,使用到这些缓存数据,都可以直 ...
- java基础解析系列(五)---HashMap并发下的问题以及HashTable和CurrentHashMap的区别
java基础解析系列(五)---HashMap并发下的问题以及HashTable和CurrentHashMap的区别 目录 java基础解析系列(一)---String.StringBuffer.St ...
- Java 非线程安全的HashMap如何在多线程中使用
Java 非线程安全的HashMap如何在多线程中使用 HashMap 是非线程安全的.在多线程条件下,容易导致死循环,具体表现为CPU使用率100%.因此多线程环境下保证 HashMap 的线程安全 ...
随机推荐
- Vue3 封装 Element Plus Menu 无限级菜单组件
本文分别使用 SFC(模板方式)和 tsx 方式对 Element Plus el-menu 组件进行二次封装,实现配置化的菜单,有了配置化的菜单,后续便可以根据路由动态渲染菜单. 1 数据结构定义 ...
- MySQL 数据更新过程
文章转载自:https://mp.weixin.qq.com/s?__biz=MzI1MDgwNzQ1MQ==&mid=2247486441&idx=1&sn=fcf93709 ...
- PPR的断管
1. 小管径PPR管的断管 2. 大管径PPR管的断管
- 使用coverlet统计单元测试的代码覆盖率
单元测试是个好东西, 可以在一定程度上兜底 虽然写单元测试这件事情非常麻烦 但是好的单元测试可以显著提高代码质量, 减少bug, 避免无意中的修改导致其他模块出错 写测试用例的过程中, 靠人力去确保所 ...
- SQL通用语法和SQL分类
SQL通用语法 1.SQL 语句可以单行或多行书写,以分号结尾 2.可使用空格和缩进来增强语句的可读性 3.MySQL 数据库的SQL语句不区分大小写,关键字建议使用大写 4.3种注释 单行注释: - ...
- C#中Math.Round()实现中国式四舍五入问题
C#中的Math.Round()并不是使用的"四舍五入"法.实际上是四舍六入五取偶. 对于这个问题我们常见的解决方式是使用MidpointRounding.AwayFromZero ...
- Effective java 总结
用静态工厂方法代替构造器的最主要好处 1.不必每次都创建新的对象 Boolean.valueOf Long.valueOf 2.直接返回接口的子类型,对于外界来说并不需要关心实现细节,主要知道这个接口 ...
- 从SVN导出项目出现的乱码问题
解决的方法很简单,只需要将Eclipse的编码标准设置为UTF-8即可 1.Window->Preferences->General->Workspace 面板Text file ...
- 3.ElasticSearch系列之Docker本地部署
对于之前的部署方式一般用于生产环境,而对于学习而言Docker方式快速部署就好了,本示例在window10环境下进行. 1. Docker使用Elasticsearch 需要对vm.max_map_c ...
- 一键体验 Istio
背景介绍 Istio 是一种服务网格,是一种现代化的服务网络层,它提供了一种透明.独立于语言的方法,以灵活且轻松地实现应用网络功能自动化.它是一种管理构成云原生应用的不同微服务的常用解决方案.Isti ...