最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

HashMap,Hashtable,ConcurrentHashMap

运维笔记admin32浏览0评论

HashMap,Hashtable,ConcurrentHashMap

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来确保线程安全的自增。 

发布评论

评论列表(0)

  1. 暂无评论