当我们提到ConurrentHashMap
时,先想到的就是HashMap
不是线程安全的:
在多个线程共同操作HashMap时,会出现一个数据不一致的问题。
ConcurrentHashMap
是HashMap的线程安全版本。
它通过在相应的方法上加锁,来保证多线程情况下的数据一致性。
hashmap导致数据不一致的原因?
数据不一致问题的表象有两种情况:
1.写-读冲突:一个线程修改后,另一个线程读到的不是最新的数据。
2.写-写冲突:两个线程同时修改数据,发生数据覆盖的情况。
原因是Java内存模型(JVM)的一些相关规定。
Java内存模型(JVM)
Java内存模型将内存分为两种,主内存和工作内存。
并且规定,所有的变量都存储在主内存中(不包括局部变量与方法参数)。
主内存中的变量是所有线程共享的。
每个线程都有自己的工作内存,存储的是当前线程所使用到的变量值。即主内存变量中的一个副本数据。
线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
不同线程间无法直接访问对方工作内存中的变量。
线程间变量值的传递需要通过主内存实现。
这样规定的原因:
是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
关于各种硬件间的内存访问差异
CPU,内存,IO设备都在不断迭代,不断朝着更快的方向努力,但三者的速度是有差异的。
CPU最快,内存其次,IO设备(硬盘)最慢。
为了合理利用CPU的高性能,平衡三者间的速度差异,计算机体系结构,操作系统,编译系统都做了贡献,主要体现为:
-
CPU增加了缓存,以平衡与内存的速度差异,
这样CPU运算时所需要的变量,优先会从缓存中读取。
缓存没有时,会从主内存中加载并缓存。如下图所示:
事物都是有两面性的,缓存提高了CPU的运算速度,也带来了相应的问题:
当多个线程在不同的CPU上运行并访问同一个变量时,由于缓存的存在,可能读取不到做最新的值,也就是可见性问题。
可见性指的是一个线程对共享变量的修改,另一个线程能够立刻看到,被称为可见性。
-
操作系统增加了进程,线程,以时分复用CPU,进而均衡CPU与IO设备的速度差异
操作系统通过任务的一个切换来减少CPU的等待时间,从而提高效率。
任务切换的时间,可能是发生在任何一条CPU指令执行完之后。
但是我们平时使用的编程语言,如C,Java,Python等都是高级语言,高级语言转换成CPU指令时,一条指令可能对应多条CPU指令。 相当于1=n,这是违背我们直觉的地方。
所以问题来了,著名的count+=1问题就是这个原因。也就是原子性问题。
我们把一个或多个操作在CPU执行的过程中不被中断的特性为原子性。(这里的操作是指我们高级语言中相应的一些操作)
-
编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。
指令重排序可以提高了缓存的利用率,同样也带来了有序性问题。
也就是单例模式问题。
重排序提高缓存利用率的例子:
在平时写代码时,经常会在方法内部的开始位置,把这个方法用到的变量全部声明了一遍。缓存的容量是有限的,声明的变量多的时候 前面的变量可能就会在缓存中失效 。
接下来再写业务时,用到了最先声明的变量 然后发现在缓存中已经失效了,需要重新的去主内存进行加载。
所以指令重排序可以看成编译器对我们写的代码进行的一个优化。就类似于让变量都能用上,不至于等到失效在使用。
所以要想实现在各种平台都能达到一直的内存访问效果,就需要解决硬件和操作系统之间产生的问题:
1.CPU增加缓存最后导致的可见性问题
2.操作系统增加了线程,进程之后出现的原子性问题
3.指令重排序导致的有序性问题
Java内存模型如何解决三个问题?
原子性问题解决方案
-
JVM定义了8种操作来完成主内存与工作内存之间的数据交互,虚拟机在实现时需要保证每一种操作都是原子的,不可再分的。
Java中基本数据类型的访问、读写都是具备原子性的(long和Double除外),更大的原子性保证:Java提供了synchronized关键字(synchronized的字节码指令monitorenter和monitorexit来隐式的使用了lock和unlock操作),在synchronized块之间的操作也具备原子性。
八种操作: lock,unlock,read,load,assign,use,store,write
CAS(乐观锁),比较并替换,(Compare And Swap),CAS是一条CPU的原子指令(即cmpxchg指令),Java中的Unsafe
类提供了相应的CAS方法,如(compareAndSwapXXX)底层实现即为CPU指令cmpxchg
,从而保证操作的原子性。
可见性问题与有序性问题解决方案
-
JVM定义了Happens-Before原则来解决内存的不可见性与重排序的问题。
Happens-Before规则约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后要遵守Happens-Before规则。
Happens-Before规则:
对于两个操作A和B,这两个操作可以在不同的线程中执行,如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作时可见的。
8种Happens-Before规则
程序次序规则、锁定规则、volatile变量规则、线程启动规则、线程终止规则、线程中断规则、对象终结原则、传递性原则。
volatile变量规则(重点):对一个volatile变量的写操作先行发生于后面的这个变量的读操作。
hashmap导致数据不一致的解决方案
常规思路是加锁,但是锁的存在会大大影响性能,所以提升性能的关键就是减少锁的粒度,以及找出哪些操作可以无锁化。
对于写操作:涉及到对数据的改动,需要加锁,这只能尽量减少锁的粒度。
对于读操作:确保数据改动不会出错之后,读操作就相对好办;主要考虑的能不能读到另外一个线程对数据的一个改动(一致性)(等待写操作的完成)
这时就有三种情况:
-
强一致性 : 读写都加锁,类似于串行化,这样可以保证读到最新的数据,但性能过低
-
顺序一致性 : 变量使用volatile关键字修饰
-
弱一致性 : 读不加锁
对应方案:
-
强一致性 使用synchronized 修饰方法或者代码块,来保证代码块或方法的一致性,可见性(串行,即有序性),性能较低
-
顺序一致性 : 使用volatile关键字修饰变量,volatile 可以保证一个共享变量的可见性以及禁止指令的重排序
-
弱一致性: 使用CAS,CAS操作可以保证一个共享变量的原子操作。
我们可以去读一下ConcurrentHashMap的源码,
可以发现代码中一会使用CAS,一会使用synchronized,让人摸不清,为什么呢?
这是因为在高级语言中一条语句往往需要多条CPU指令完成。
而Java中基本数据类型的访问、读写都具备原子性(long和Double除外),其他大部分不是原子性操作,
就比如在new一个对象时,就不是一个原子性操作,它需要三步才能完成,分配内存,初始化对象,将对象赋值给变量。
所以在创建数组的时候,除了使用synchronized外,CAS是不能保证原子性的,CAS只是CPU的一条指令,他不能保证多个指令的原子性,但是我们可以参考AQS,使用CAS锁一个基本类型的变量,其他线程进行自旋。
其次,synchronized锁需要一个对象,当数组的元素为null时,是无法使用synchronized锁的,所以此时使用的就是CAS操作来保证赋值的原子性。
以及底层的数组table已经被volatile修饰,但是数组元素的修改却不能保证可见性。
明明volatile保证共享变量的可见性,为什么数组元素的修改却不能保证可见性呢?
原因:
volatile保证共享变量的可见性,但是如果该变量是一个对象的引用,那么volatile此时指的就是对象引用的可见性。
而在Java中,数组也是一个对象,当使用volatile来修饰数组arr时,代表的是arr的引用具有可见性,即arr的引用地址修改了之后,其他线程是可见的,但是无法保证数组内的元素具有可见性。
HashTable与ConcurrentHashMap
Hashtable
前置知识:在JDK1.0时,加锁只有synchronized一种方法,synchronized是重量级锁(需要去CPU申请锁)
底层结构:数组+链表 链表使用头插法 定位数组下标使用取余操作
线程安全: 使用synchronized来保证线程安全,在所有的方法上都加了synchronized关键字,即使用一把全局锁来同步不同线程间的并发访问(锁住整个table结构),性能较低。
相关操作: put,get,remove,size方法体上都添加synchronized关键字,扩容逻辑在put方法内发生,也是线程安全的
优点:实现简单
缺点:一个线程在插入数据时,其他线程不能读写,并发效率低下
ConcurrentHashMap(JDK1.5)
在JDK1.5时引入,此时Java内存模型已经成熟完善,在此基础上开发了java.util.concurrent包,ConcurrentHashMap随着JUC包一起引入JDK,同时引入了AQS,实现了ReentrantLock
底层结构:数组+链表 链表使用头插法 定位下标使用&运算
线程安全:使用分段锁的思想,其内部是一个Segment数组,Segment继承了ReentrantLock(可重复锁),即Segment自身就是一个锁。
Segment内部有一个HashEntry数组(Segment有点类似HashTable),每个HashEntry是一个链表结构的元素,一把锁只锁住容器中的一部分数据,多线程访问容器中里不同数据段的数据,就不会存在锁竞争,提高并发访问率
相关操作:调用put方法时,当前的segment会将自己锁住,此时其他线程无法操作这个segment,但不会影响到其他segment的操作。
调用get方法时,使用unsafe.getObjectVolatile方法获取节点;底层使用C++的volatile来实现Java中的volatile效果(保证共享变量的可见性(一个线程对共享变量的修改,另一个线程能够立刻看到))
调用remove方法时,当前的segment会将自己锁住。
put,get,remove操作都是在单个Segment上进行的,size操作是在多个segment进行的
size方法采用了一种比较巧妙的方式,来尽量避免对所有的Segment都加锁。
每个Segment都有一个modCount 变量,代表的是对Segment中元素的数量造成影响的操作次数。这个值只增不减。
size 操作就是遍历了两次Segment,每次记录Segment 的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回。如果不相同,则把这个过程再重复做一次,如果再不同,则就需要将所有的Segment都锁住,然后一个一个遍历。
扩容操作,发生在put方法内部,跟put方法使用的是同一个锁.
扩容不会增加Segment的数量,只会增加Segment中链表数组的容量大小。
这样的好处是扩容过程不需要对整个ConcurrentHashMap
做 rehash,只需要对Segment里面的元素做一个rehash即可。这样就不会去影响其他的segment里面的元素。
优点:每次只锁住一部分数据,访问不同数据段的数据,不会存在锁竞争。提高了并发访问率;
扩容只针segment内部的HashEntry数组进行扩容,不影响其他segment内部的HashEntry数组。
缺点:定位一个元素,需要经过两次hash操作。 当某个segment很大时,类似Hashtable,性能会下降。
比较浪费内存空间(因为每个segment内部的HashEntry数组是不连续的)
拓展:
在JDK6中,针对synchronized做了大量的优化,引入了轻量级锁和偏向锁。性能与ReentrantLock已相差无几,甚至synchronized的自动释放锁会更好用。
Java官方表示,在多线程环境下不建议使用HashMap。
随着互联网的快速发展,业务场景随之更加复杂,很多人在使用多线程的情况下使用HashMap的时候,结果导致cpu100%的情况。
主要原因:HashMap的链表使用的是头插法,在多线程的情况下触发扩容,链表可能会形成一个死循环。
在JDK8中也做了相应的优化,将头插法改为尾插法,引入了红黑树,来优化链表过长导致的查询速度变慢。
连带着ConcurrentHashMap也做了相应的修复,使得ConcurrentHashMap与HashMap的结构更加统一。
ConcurrentHashMap(JDK8之后)
由类图可知,ConcurrentHashMap中有四种类型的节点,四种类型的节点的用途不同。
-
Node节点是ConcurrentHashMap中存储数据的最基本结构,也是其他类型节点的父类,他可以用来构建链表。hash值>=0
-
TreeNode节点主要用来构造红黑树以及存储数据。hash值>=0
-
TreeBin节点是红黑树的代理节点,不存储数据,他的Hash值是一个固定值-2
-
ForWardingNode节点,表示的是底层数组table正在扩容,当前节点的数据已经迁移完毕,不存储数据,hash值也是固定值-1
注意事项:TreeBin为什么是红黑树的代理节点?
因为在向红黑树添加数据或删除数据时可能会触发红黑树的自平衡,根节点可能会被子节点替代,如果此时有线程来红黑树读取数据,可能会出现读取不到数据的情况。
而红黑树的查找是从根节点开始遍历的,当根节点变成子节点时,作为根节点的左子树或者右子树可能是不被遍历的。
ConcurrentHashMap的get方法是没有使用锁的,不可能通过加锁来保证强一致性,而红黑树的并发操作需要加上一层锁来保证在红黑树自平衡时的读操作没有问题。这就是TreeBin的工作。
TreeBin重要属性:
-
root
:指向的是红黑树的根节点 -
first
:指向的是双向链表,也就是所有的TreeNode节点构成的一个双向链表 -
lockState
:用于实现基于CAS的读写锁。
总结:对红黑树添加或删除数据的整体操作:
首先在最外层加上synchronized同步锁,然后再红黑树自平衡时加上lockState的写锁。
当由线程来读红黑树的时候,会先判断此时是否有线程持有写锁或者是否有线程在等待获取写锁,如果有的话,读线程直接读取双向链表,否则会加上lockState的读锁。然后读取红黑树的数据,从而来保证读操作不被阻塞以及它的正确性。
双向链表的作用:
-
读操作会来读取链表上的数据。
-
在扩容时,会遍历双向链表,根据hash值判断是放在新数组的高位还是低位。
底层结构:数组+链表+红黑树 链表使用尾插法 定位下标使用 & 运算
线程安全:消了分段锁的设计,1取而代之的是通过 cas 操作和 synchronized 关键字来保证并发更新的安全。
Synchronized只是用于锁住链表或者红黑树的第一个节点,只要没有Hash冲突,就不存在并发问题,效率也就大大的提升。
相关操作:
put方法,使用cas + synchronized 来保证线程安全.
get方法,没有使用加锁,使用的是Unsafe.getObjectVolatile方法获取数据。保证数据的可见性。
remove方法、使用synchronized 来保证线程安全。
size方法(难点):主要是LongAdder
的思想进行的累加计算。
扩容操作(难点):扩容操作发生在数据添加成功之后,并且支持多个线程。
优点:锁粒度更精细,性能更强
缺点:实现更加复杂。
希望对大家有所帮助!