HashMap,Hashtable,ConcurrentHashMap
目录
一、多线程使用HashMap的一些线程安全问题
①造成数据新增丢失
②扩容时候,造成链表成环(jdk1.7版本)
二、Hashtable和HashMap的区别
①核心方法加锁
②其他语法上面的略微差异
三、引入ConcurrentHashMap【重要】
①ConcurrentHashMap相比于Hashtable的优势
Hashtable的缺点:
编辑
ConcurrentHashMap的一些优化措施(jdk1.8往后)
(1)每一个哈希桶,都是一把"锁"
编辑
(2)ConcurrentHashMap不针对get(Object key)方法加锁
(3)ConcurrentHashMap针对部分修改操作,采用了volatile+原子的方式,让写的操作变为"原子"的,从而与不加锁的读操作不会产生锁冲突
(4)扩容操作的优化
②关于ConcurrentHashMap线程安全问题的一些思考
get与put方法存在的一些"问题":
size与put方法存在的一些问题:
因此,对于ConcurrentHashMap来说,实现元素+1的思路大致是:
四、ConcurrentHashMap的jdk1.7和1.8版本的区别
区别1:(最重要的区别):锁的粒度
区别2:put的执行流程有所不同
区别3:计算size的方式不一样
这三种数据结构,都是对于哈希表的具体实现。
有关哈希表的具体设计,我们在前面的文章当中也提到了,关于HashMap的简单源码分析,以及HashMap的具体的一些实现,也在下面这两篇文章当中提到了:
(1条消息) HashMap简单源码分析_革凡成圣211的博客-CSDN博客=1001.2014.3001.5501
(1条消息) Java的手写简单的哈希表_革凡成圣211的博客-CSDN博客=1001.2014.3001.5501
下面,将重点分析,多线程使用HashMap的一些问题,以及Hashtable、ConcurrentHashMap和HashMap之间的一些区别。
一、多线程使用HashMap的一些线程安全问题
①造成数据新增丢失
因为HashMap当中,并没有涉及任何的加锁操作,因此:当多个线程同时调用put()的时候,有可能在两个键的哈希值一样的时候,之后调用put()的线程新增的值覆盖掉最开始线程新增的值。
图解:
②扩容时候,造成链表成环(jdk1.7版本)
由于jdk1.7版本以及其之前,是采用头插法的方式进行插入的,因此,会造成链表成环的问题;
扩容的操作,实际上是HashMap重现初始化一个原来大小2倍的数组,并且根据新的数组的长度,重新哈希的这样一个过程。
如果执行并发扩容,那么,很有可能在扩容的时候,让哈希表当中某一个哈希桶的链表变成了一个"环"。那么,也就意味着如果想获取某一个元素,对哈希桶对应位置的链表进行遍历的时候,没有任何一个节点的next指针为null,那么将引发死循环。
对于jdk1.8版本以及之后,采用的就是尾插法,因此不会造成链表成环。但是仍然有新增数据丢失的问题。
二、Hashtable和HashMap的区别
①核心方法加锁
Hashtable的核心方法put()和get()方法被加锁了。因此,Hashtable是线程安全的
②其他语法上面的略微差异
(1)HashMap允许null值作为键和值,而Hashtable不允许null作为键和值。
(2)添加值的哈希算法不同,对于HashMap来说,添加值的哈希算法采用的是内部自定义的一个哈希算法,而Hashtable采用的直接是key.hash()的方式计算出的哈希值。但是负载因子都是0.75.
(3)初始化的容量不同:HashMap的默认初始容量为16,而Hashtable默认的初始容量为11.
(4)扩容的方式不同:HashMap采用的是2倍大小的方式扩容,而Hashtable扩容的规则为当前容量*2+1.
...这些差异,罗列一部分即可,最重要的还是多线程和单线程的使用环境区别。
三、引入ConcurrentHashMap【重要】
①ConcurrentHashMap相比于Hashtable的优势
对于Hashtable来说,它解决线程安全问题的方式,比较"粗鲁”。
Hashtable的缺点:
第一:
Hashtable使用的直接是synchronized修饰核心方法的方式来加锁的,那么,如果两个线程同时只是读取某一个变量的值,根据之前对线程安全问题的概述,如果线程仅仅只是对变量进行读操作而并非写操作,那么并不会发生线程安全问题。
但是Hashtable即使是在多个线程同时读取某个Entry的值的时候,也照样会造成阻塞等待的情况,因此Hashtable的锁的粒度是比较大的。
第二:
即使是put()、get()操作,发生线程安全问题的前提条件也必须是需要put()的两个键的哈希值相同的情况,也就是,针对同一个哈希桶进行put()或者get()的情况。
而Hashtable采用的是直接“一棒打死”。无论是否针对同一个哈希桶进行读写操作,只要多个线程同时调用一个map的put()或者get()方法,都会发生阻塞等待的情况。
ConcurrentHashMap的一些优化措施(jdk1.8往后)
(1)每一个哈希桶,都是一把"锁"
让每一个哈希桶都是一把锁。当新增元素发生哈希冲突,也就是散列到同一个哈希地址的时候,才会发生锁冲突。
这样,就有效减少了不必要的锁冲突,减小了锁的粒度
观察上面的图:当两个线程同时尝试分别修改同一个哈希地址的1,2节点的时候,会产生阻塞等待的情况。
当两个线程同时修改3,4节点的时候,不会产生阻塞等待。
也就是,只有发生了哈希冲突的时候,才会产生阻塞等待的情况
观察一下源码:
对于jdk1.8之前的代码,是采用分段锁的方式进行修改的。也就是,其中的N个哈希桶作为一把锁,如果有线程同时针对这N个为一把锁的哈希桶进行修改操作,会产生锁冲突,造成阻塞等待。
(2)ConcurrentHashMap不针对get(Object key)方法加锁
由于get()方法是通过key得到对应的value的值的方法,本质上是“读取”操作,当多个线程同时调用get()方法的时候,不存在线程安全问题。
因此,ConcurrentHashMap取消了对于get()方法加锁的机制。
这里需要注意的地方是,ConcurrentHashmap当中:
Ⅰ一个线程读去数据,另外一个线程也同时去读取数据,这个时候不存在线程安全问题,也不存在锁冲突;因为ConcurrentHashMap没有针对get方法加锁
Ⅱ一个线程去写数据,另外一个线程也去写数据,不存在线程安全问题,但是有可能存在锁冲突;存在锁冲突的前提是两个线程针对同一个哈希桶进行写操作。
Ⅲ一个线程去读数据,另外一个线程去写数据,不存在线程安全问题,但是有可能产生锁冲突。
对于第Ⅲ点,如果产生了锁冲突,那么就是意味着两个线程一个对于同一哈希桶进行"读"操作,另外一个针对哈希桶进行"写"操作,并且都是哈希桶有存放元素,也就是需要遍历的时候,才会产生锁冲突。
如果哈希桶没有存放元素,是null的,那么,请往下面第(3)点查看。
(3)ConcurrentHashMap针对部分修改操作,采用了volatile+原子的方式,让写的操作变为"原子"的,从而与不加锁的读操作不会产生锁冲突
ConcurrentHashMap内部充分利用了CAS,来削减了加锁的次数。
下面,举几个例子,关于ConcurrentHashMap是如何使用CAS的:
例子1、当让存放元素之后,个数+1的时候,采用的是CAS的做法来保证数组当中实际存储key的数量+1这个操作的原子性
其中,addCount方法内部采用的就是CAS的做法来实现个数+1的原子性的。
例子2、 当对应的HashEntry数组当中,某一个位置不存放任何元素,也就是为null的时候,会采用cas的方式填充到对应的哈希地址。
对于ConcurrentHashMap当中修改key的个数,本质上还是在addCount方法内部,通过CAS的方式来修改
(4)扩容操作的优化
回顾一下HashMap或者Hashtable扩容的操作,它们都是创建一个更大容量的数组,然后把每一个元素重新哈希的做法。
在数据量比较大的时候,会造成可能某次put()之后,线程会阻塞等待很长的时间,才可以完成扩容。
扩容条件:
①当存放Node<K,V>节点的数组长度小于64,并且单个哈希桶的链表的存放节点个数达到8的时候,会触发扩容;如果存放节点的数组长度>=64,那么会把当前的链表树化为红黑树。
②当存储的实际key的数量/Node<K,V>数组的长度达到负载因子的时候,会触发扩容
以上两点,和jdk1.8版本的HashMap的扩容前提条件类似,没有太大的差别。
满足上面的条件之后,会进入到下面的操作:
首先申请一个原来数组大小2倍的新数组
如果有多个线程同时尝试扩容,那么,ConcurrentHashMap会对这些线程进行"分工”。何为分工呢?画个图简单看一看:
也就是,每一个线程,分别对原有的数组上面的元素分别进行"搬运"操作,让它们都被各自的线程重新哈希到新的数组上面的位置,这样的效率会更加的高。
同时,如果ConcurrentHashMap如果正在扩容的时候:
其他线程调用get方法,那么调用的线程会查询旧数组和新的数组当中是否存在对应的key。
其他线程如果调用remove方法,那么调用的线程会把旧数组和新的数组当中的key都删除掉。
而不是一直阻塞等待,直到扩容执行完毕。
②关于ConcurrentHashMap线程安全问题的一些思考
get与put方法存在的一些"问题":
问题:现在有这样的一个场景:一个ConcurrentHashMap当中,某一处的哈希桶上面有不止一个的元素:如图:
假如,此时,有一个线程执行一条对于Node2节点的修改操作指令:
map.put(k2,v3);
另外一个线程同时执行读取操作的指令:
map.get(k2);
结合前面介绍的ConcurrentHashMap的知识,可以了解到,ConcurrentHashMap不针对get()方法加锁:结合源码,也可以看出来
get方法源码:
再看一下,对于put方法当中,对于已经存在的key的修改value操作
put方法源码
可以看到,ConcurrentHashMap当中,虽然更新value的操作位于同步代码块当中,但是,由于读取操作get方法没有加锁,并且,此处采用的更新value的操作是直接e.value=value。那么,这样,就有可能导致,调用get方法的线程读取到的是一个还没有来得及更新的值——v2。
图解(模拟出现"线程安全"问题的线程调度情况):
时间轴 | 线程1 | 线程2 |
t1 | 携带(k2,v3)进入put方法内部 | |
t2 | 执行到更新value的代码之前 | |
t3 | 进入get方法内部,调用get(k2) | |
t4 | 通过k2得到v2的值,然后返回 | |
t5 | 执行把v2更新为v3的代码 |
可以看到,在t4时刻,线程2得到了一个"脏数据"——v2。
对于上述操作,看似存在线程安全问题,实际上,这种做法也是可以采取的。
原因呢:本人认为有以下两点:
①ConcurrentHashMap的设计初衷就是追求极致的效率,因此它就是需要减少加锁的粒度。
②即使真的发生了上述的情况,那么get的时候,要么读取到更新之前的值v2,要么读取到更新之后的值v3,不会get到其他的value。因此,在一定的场景下面,也可以认为是线程安全的。
size与put方法存在的一些问题:
同理,ConcurrentHashMap当中的size()方法也存在类似的问题:
在put方法的尾部,可以看到ConcurrentHashMap是使用addCount方法来实现元素个数+1
这个addCount内部虽然采用的是CAS的方式来实现+1的,但是,如果执行put方法的线程还没有执行addCount方法,就被调度离开CPU内核,此时另外一个线程调用size方法来获取key的个数,也会读取到"脏数据",也就是更新之前的数据。
图解(假如此时ConcurrentHashMap当中只有3个key)
时间轴 | 线程1 | 线程2 |
t1 | 进入put方法内部 | |
t2 | 执行完插入操作,但是在执行addCount之前被调度离开cpu内核 | |
t3 | 调用size方法获取key的个数:3 | |
t4 | 重新回到cpu内核,执行addCount方法,让key变为4 | |
t5 | 返回 |
可以看到,此时线程2在t3时刻得到的key的个数为3,是一个脏数据。
但是,上述情况也被认为是线程安全的,因为,addCount方法内部采用的是CAS的做法。因此,不会造成多个线程同时调用+1而造成增加数量丢失过大的情况。
即使其他线程调用size方法,要么获取到的是更细你个数之前的值,要么是执行一次则增之后的
下面,我们来看一下size()方法的源码
因此,对于ConcurrentHashMap来说,实现元素+1的思路大致是:
当线程竞争不激烈的时候:直接在addCount方法内部使用CAS的方式来实现元素个数(baseCount)的值+1.
当线程竞争激烈的时候:使用CounterCell数组,然后采用分而治之的方式来统计总的元素个数。然后遍历CountCell数组里面的value的值,进行累加。
可以看到,size方法并没有实现和put方法的同步锁,因此它是线程不安全的。
为什么要这样设计,不像Hashtable这样加同步锁?
原因1:CoucurrentHashMap追求性能的高效,如果直接在size方法上面加锁有可能影响性能。
原因2: 在counurrentHashMap的设计理念当中,对于size的数据要求严格性不高。主要还是对于数据存储的安全性要求比较高。
四、ConcurrentHashMap的jdk1.7和1.8版本的区别
区别1:(最重要的区别):锁的粒度
jdk1.7采用的是采用Segment+HashEntry数组的方式来处理线程安全问题的。也就是采用分段锁的方式来加锁的。
而jdk1.8取消了分段锁的机制,而是把每一个哈希桶当中的头节点都作为一把锁。并且采用的方式为synchronized+CAS+红黑树的方式。
这样的措施,减小了锁的粒度,增加了并发度(同一时间支持访问一个map的线程数量)
区别2:put的执行流程有所不同
jdk1.7当中,ConcurrentHashMap要进行两次定位:
先对Segment进行定位,再对于数组下标进行定位。定位成功之后,采用自旋锁的方式获取锁。
如果自旋次数超过64,就会发生膨胀,令put的线程进入阻塞状态,等待唤醒。
而jdk1.8,只需要1次定位:
如果对应的Node数组内部的哈希地址为null,那么采用CAS的方式插Node节点。
如果不为null,说明该位置有元素了,需要加锁,锁住头节点。
区别3:计算size的方式不一样
对于jdk1.7:采用乐观锁的机制。
首先进行遍历,不是直接加锁。如果两次遍历的结果一致,那么直接返回。
如果不一致,那么对每个Segment加锁,再遍历一次,然后返回。
对于jdk1.8的size()
维护一个baseCount属性来记录节点的数量。每put一次就CAS自增一次。通过CAS来确保线程安全的自增。