目录
一、多线程使用HashMap的一些线程安全问题
①造成数据新增丢失
②扩容时候,造成链表成环
二、Hashtable和HashMap的区别
①核心方法加锁
②其他语法上面的略微差异
三、引入ConcurrentHashMap【重要】
①ConcurrentHashMap相比于Hashtable的优势
Hashtable的缺点:
ConcurrentHashMap的一些优化措施(jdk1.8往后)
(1)每一个哈希桶,都是一把"锁"
编辑
(2)ConcurrentHashMap不针对get(Object key)方法加锁
(3)ConcurrentHashMap针对部分修改操作,采用了volatile+原子的方式,让写的操作与不加锁的get()方式不会产生锁冲突
(4)扩容操作的优化
这三种数据结构,都是对于哈希表的具体实现。
有关哈希表的具体设计,我们在前面的文章当中也提到了,关于HashMap的简单源码分析,以及HashMap的具体的一些实现,也在下面这两篇文章当中提到了:
(1条消息) HashMap简单源码分析_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/127786631?spm=1001.2014.3001.5501
(1条消息) Java的手写简单的哈希表_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/127416821?spm=1001.2014.3001.5501
下面,将重点分析,多线程使用HashMap的一些问题,以及Hashtable、ConcurrentHashMap和HashMap之间的一些区别。
一、多线程使用HashMap的一些线程安全问题
①造成数据新增丢失
因为HashMap当中,并没有涉及任何的加锁操作,因此:当多个线程同时调用put()的时候,有可能在两个键的哈希值一样的时候,之后调用put()的线程新增的值覆盖掉最开始线程新增的值。
图解:
②扩容时候,造成链表成环
我们了解到,扩容的操作,实际上是HashMap重现初始化一个原来大小2倍的数组,并且根据新的数组的长度,重新哈希的这样一个过程。
如果执行并发扩容,那么,很有可能在扩容的时候,让哈希表当中某一个哈希桶的链表变成了一个"环"。那么,也就意味着如果想获取某一个元素,对哈希桶对应位置的链表进行遍历的时候,没有任何一个节点的next指针为null,那么将引发死循环。
二、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对于写数据的操作,采用的很多步骤都是CAS,让"写"的很多步骤变成原子的,从而避免了线程安全问题。
(3)ConcurrentHashMap针对部分修改操作,采用了volatile+原子的方式,让写的操作与不加锁的get()方式不会产生锁冲突
ConcurrentHashMap内部充分利用了CAS,来削减了加锁的次数。
例如,当让存放元素之后,个数+1的时候,采用的是CAS的做法来保证数组当中实际存储key的数量+1这个操作的原子性
其中,addCount方法内部采用的就是CAS的做法来实现个数+1的原子性的。
(4)扩容操作的优化
回顾一下HashMap或者Hashtable扩容的操作,它们都是创建一个更大容量的数组,然后把每一个元素重新哈希的做法。
在数据量比较大的时候,会造成可能某次put()之后,线程会阻塞等待很长的时间,才可以完成扩容。
扩容条件:
①当存放Node<K,V>节点的数组长度小于64,并且单个哈希桶的链表的存放节点个数达到8的时候,会触发扩容;如果存放节点的数组长度>=64,那么会把当前的链表树化为红黑树。
②当存储的实际key的数量/Node<K,V>数组的长度达到负载因子的时候,会触发扩容
以上两点,和jdk1.8版本的HashMap的扩容前提条件类似,没有太大的差别。
满足上面的条件之后,会进入到下面的操作:
首先申请一个原来数组大小2倍的新数组
如果有多个线程同时尝试扩容,那么,ConcurrentHashMap会对这些线程进行"分工”。何为分工呢?画个图简单看一看:
也就是,每一个线程,分别对原有的数组上面的元素分别进行"搬运"操作,让它们都被各自的线程重新哈希到新的数组上面的位置,这样的效率会更加的高。