当前位置: 首页 > 科技观察

这篇文章澄清了一个网上普遍存在的对ConcurrentHashMap的误解!

时间:2023-03-20 22:17:34 科技观察

大家好,我是鲲哥!上周在极客时间的一门课程中看到一位讲师在讨论ConcurrentHashMap(以下简称CHM)是强一致性还是弱一致性时提到了这个问题。这个解释在网上也广为流传,所以不管真假,在回答这个问题之前,我们得想清楚两个问题。什么是强一致性,什么是弱一致性。上面说了get没有加锁,所以不能立即获取到put的数据,也就是说加了锁就可以立即获取到put的值了?那么除了加锁之外,还有其他方法可以立即获取它的投入值吗?强一致性和弱一致性强一致性首先我们来看第一个问题,什么是强一致性。Consistency是指Replications问题中的数据一致性。可分为强一致性和弱一致性。强一致性也可以称为原子一致性(AtomicConsistency)或线性一致性(LinearizableConsistency),必须满足以下两个要求。任意一次读取,都可以立即读取某条数据的最新写入数据。系统中所有进程看到的操作顺序与全局时钟下的顺序一致。简单的说,假设有两个线程A和B对同一个数据集进行操作,并且假设A先修改了操作,那么在时间上发生在A的操作之后的所有B的操作应该能够立即(或实时)看到A的修改操作的结果。弱一致性和强一致性的对立面是弱一致性,即数据更新后,如果立即访问,可能无法访问或者只能访问部分数据。如果线程A在更新数据后一段时间后还能访问到数据,这种情况称为最终一致性,最终一致性也是弱一致性,只是弱一致性的一种特例。那么Java中出现弱一致性的原因是什么,或者保证强一致性的方法有哪些呢?这需要理解两个概念,可见性和顺序。一致性的根本原因:可见性和顺序可见性首先我们需要了解Java中的内存模型。上图展示了JVM中的Java内存模型。可以看出主要由两部分组成,一个是线程特有的程序计数器,虚拟机栈,本地方法栈。由于这部分数据是线程独有的,所以不存在一致性问题(我们常说的一致性问题是指多个线程之间的数据一致性),其中一部分是线程共享的堆和方法区。让我们关注堆内存。我们知道线程执行占用CPU,CPU从寄存器中取数据。如果寄存器中没有数据,则必须从内存中取出。众所周知,两者之间的速度差异是巨大的,所以为了缓解这个矛盾,CPU内置了三级缓存。每次线程执行需要数据时,堆内存中的数据都会以cacheline(通常为64Byte)的形式加载到CPU的L3缓存中,这样后面取的数据就可以直接从缓存中取,大大提高了CPU的执行效率(如下图)。但是在这种情况下,线程加载并执行完数据后,数据往往会缓存在CPU寄存器中,并不会立即刷新到内存中,这样其他线程如果需要共享数据,就得不到最新的数据堆内存,导致数据不一致。例如,以下面代码的执行为例。//线程1执行的代码inti=0;i=10;//线程2执行的代码j=i;线程1执行完后i的值为10,然后2开始执行,此时j的值很可能还是0,因为线程1执行的时候会先加载i的值=0从内存到CPU缓存,再把10赋值给i。此时CPU缓存中更新了10,但没有刷新到内存中,当线程2开始执行时,它会先从内存中加载i的值(它的值为0)到CPU,所以它的值还是0,而不是10,这是典型的CPU缓存数据不一致的结果。那么如何解决可见性导致的数据不一致呢?其实只要让CPU在修改共享变量时立即回写到内存即可。这个值是可以读入的。有序性除了可见性导致的数据不一致,指令重排序也会导致数据不一致。公共类重新排序{私有静态布尔标志;私有静态整数;publicstaticvoidmain(String[]args){Threadt1=newThread(newRunnable(){@Overridepublicvoidrun(){while(!flag){Thread.yield();}System.out.println(num);}},"t1");t1.开始();数=5;①标志=真;②}}很多人可能认为上面的代码执行步骤是按照正常的①、②、③执行的,但实际上很有可能是编译器改变了位置。实际的执行顺序可能是①③②,也可能是②①③,也就是说①③是紧挨着的。为什么会这样?因为执行完1之后,CPU会从内存中加载x=1到寄存器中。如果此时直接调用③执行,那么CPU可以直接读取寄存器中x的值1进行计算。反之,如果语句先执行②,则有可能寄存器中x的值被覆盖,导致执行③后x的值从内存中重新加载。有人可能会说这样的指令重排序似乎不是什么大问题,那么考虑下面的代码:publicclassReordering{privatestaticbooleanflag;私有静态整数;publicstaticvoidmain(String[]args){Threadt1=newThread(newRunnable(){@Overridepublicvoidrun(){while(!flag){Thread.yield();}System.out.println(num);}},"t1");t1.开始();数=5;①标志=真;②}}上面代码最终输出的值正常是5,但是如果把上面①和②两行指令重新排序,结果可能是0,导致我们观察到的数据不一致的现象,所以显而易见的解决方法是为了避免指令重排序的发生,也就是保证指令按照我们看到的代码的先后顺序执行,也就是我们常说的有序性,一般通过在指令之间加入内存屏障来避免指令重排序那么如何确保能见度和秩序?相信大家都很熟悉了。使用volatile可以保证可见性和有序性。只要在声明属性变量的时候加上volatile,就可以让这个变量达到强一致性。也就是说,上面提到的Reordering类的flag只需要声明为volatile,那么打印出来的结果永远是5!那么问题来了,CHM是强一致性吗?首先我们以Java8为例,看一下它的设计结构(和之前的版本差别不大,主要是增加了一颗红黑树,提高了查询效率)。我们来看看表数组和节点是如何声明的(后面的定义8与之前版本相同):publicclassConcurrentHashMapextendsAbstractMapimplementsConcurrentMap,Serializable{transientvolatileNode[]表;...}staticclassNodeimplementsMap.Entry{finalinthash;最后的K键;挥发性V值;volatileNodenext;...}可以看到CHM的表数组,Node中的值val,下一个节点next都声明为volatile,所以有同学提出疑问?讲师的回答中也提到了CHM弱一致性的一个重要原因:即如果表中的某个slot为空,一个线程执行key和value的赋值操作,就会在这个slot上添加一个新的Node。Node,在JDK8之前,CHM通过以下方式将Node分配给slot。Vput(Kkey,inthash,Vvalue,booleanonlyIfAbsent){lock();...tab[index]=newHashEntry(...);...unlock();}then通过以下方式根据key读取value。Vget(Objectkey,inthash){if(count!=0){//读取易失性HashEntrye=getFirst(hash);while(e!=null){if(e.hash==hash&&key.equals(e.key)){Vv=e.value;如果(v!=null)返回v;返回readValueUnderLock(e);//重新检查}e=e.next;}}returnnull;}可以看出put直接给数组中的元素赋值,而get由于没有加锁,所以不能保证线程A新放入的元素对数组可见执行get的线程。Put是加锁的,所以其实如果get也加锁了,那么毫无疑问get可以马上得到put的值。为什么锁定也是可能的?其实这是JLS(JavaLanguageSpecificationJava语言规范)规定的几种情况。简单来说就是支持happensbefore语义,保证数据的强一致性。官网上(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)列出了之前支持Happens的几种情况,其中指出使用volatile、synchronize、而lock可以保证happensbefore的语义,也就是说这三者的使用可以保证数据的强一致性。可能有人会问,之前发生了什么?其实本质就是保证线程及时刷新数据到内存,其他线程可以实时从内存中读取。最新数据是一种保证线程间数据一致性的机制。下面以lock为例简单说明一下:publicclassLockDemo{privateintx=0;privatevoidtest(){锁();x++;开锁();}}如果线程1执行test,因为已经获取到了锁,它会先从内存中加载数据(本例中x=0)到CPU中执行。执行x++后,CPU中x的值会变为1,然后解锁。解锁时,x=1的值会立即刷新到内存中,这样当下一个线程执行test方法再次获取同一个锁时,就会从内存中获取x的最新值(即1)。这就是我们通常所说的解锁锁,happens-before然后锁定锁。可见这样可以保证数据的一致性。至此我们明白了:在Java8之前,CHM的get和put确实是弱一致的。可能有人会问为什么不锁定get。加锁不就是为了保证数据的一致性吗?可以是,但是不要忘记CHM是为高并发而设计的。添加锁不会导致并发性显着下降吗?那么CHM是什么意思呢?那么put和get是不能实现强一致性的吗?上面我们已经知道,volatile、synchronize、lock的使用可以保证happensbefore的语义。同时,经过分析我们知道使用synchronize和lock来加锁的设计不符合我们设计CHM的初衷,所以只有volatile。不幸的是,由于Java数组中缺乏元素级别的元数据设计,无法表达final和volatile等元素的语义。因此,volatile可以修改变量,但不能修改数组中的元素。还有其他方法吗?看看Java8是如何处理的(这里只列出write和read方法中的关键代码)。privatestaticfinalsun.misc.UnsafeU;//写最终VputVal(Kkey,Vvalue,booleanonlyIfAbsent){...for(Node[]tab=table;;){if((f=tabAt(tab,i=(n-1)&hash))==null){if(casTabAt(tab,i,null,newNode(hash,key,value,null)))休息;}}...}staticfinalbooleancasTabAt(Node[]tab,inti,Nodec,Nodev){返回U.compareAndSwapObject(tab,((long)i<0&&(e=tabAt(tab,(n-1)&h))!=null){...}returnnull;}@SuppressWarnings("unchecked")staticfinal节点tabAt(Node[]tab,inti){return(Node)U.getObjectVolatile(tab,((long)i<