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

从LongAdder看更高效的无锁实现

时间:2023-03-17 17:08:58 科技观察

开始接触AtomicLong是因为在看guava的LoadingCache相关代码的时候,LoadingCache的思路其实很简单明了:用模板方式解决逻辑缓存未命中时获取数据的思路,我之前在项目中正好用到了这个思路。言归正传,LongAdder引起我注意的原因有两个:作者是Douglea,他的地位真的很重要。他说这比AtomicLong更有效率。我们知道AtomicLong已经是一个非常好的解决方案。CAS操作用于并发,比较和设置操作在硬件级别执行。非常有效率。因此,我决定研究为什么LongAdder比AtomicLong更高效。首先看一下LongAdder的继承树:继承自Striped64,这个类包装了一些非常重要的内部类和操作。回头见。在正式开始之前,先强调一下,我们知道AtomicLong的实现是里面有一个value变量。多线程并发自增自减时,使用CAS指令在机器指令层面保证并发的原子性。再看看LongAdder的方法:难怪能和AtomicLong相提并论,连API都这么相似。我们随便挑一个API开始分析。这个API可以用,其他API类似。因此,我选择了add的方式。其实其他的API也依赖这个方法。LongAdder包含一个Cell数组。Cell是Striped64的一个内部类。顾名思义,Cell代表一个最小的单位。这个单位有什么用,后面会讲到。先看定义:Cell内部有一个很重要的值变量,它提供了CAS更新其值的方法。回到add方法:在这里,我有一个问题。AtomicLong已经使用了CAS指令,非常高效(相对于各种锁)。如果LongAdder还是用CAS指令更新值,怎么可能比AtomicLong更高效呢?更何况,还有那么多的内部判断!!!这是我开始时最大的问题,所以,我想,有没有比CAS指令更有效的方法?带着这个疑问,继续。***if第一次调用时判断cells数组必须为null。所以,进入casBase方法:原子更新基础没什么好说的。如果更新成功,本地调用开始返回,否则进入分支。什么时候更新失败?没错,并发的时候,好玩的就开始了。AtomicLong的处理方式是死循环尝试更新,直到成功才会返回,而LongAdder则进入这个分支。在分支内部,通过一个Threadlocal变量threadHashCode获取一个HashCode对象。HashCode对象仍然是Striped64类的内部类。看定义:有一个存储非零随机值的代码变量。回到add方法:得到线程相关的HashCode对象后,得到它的code变量,as[(n-1)&h]这句话相当于对h取模,但是相对于取模,因为它相同的操作效率更高。在Cells数组中计算当前线程的HashCode对应的一个索引位置,取出该位置的Cell对象,使用CAS更新其值。当然,如果as为null,更新失败,就会进入retryUpdate方法。看到这里,我想很多人应该明白为什么LongAdder比AtomicLong效率更高了。是的,制约AtomicLong效率的唯一原因就是高并发。高并发意味着CAS有更高的失败概率和更多的重试。线程重试越多,CAS失败的概率就越高,形成恶性循环,AtomicLong效率下降。如何解决?LongAdder给了我们一个很容易想到的解决方案:降低并发,将单个值的更新压力分担给多个值,降低单个值的“热度”,分段更新!!!这样无论有多少个线程,都会在多个值之间共享更新。只是增加值来降低值的“热度”。AtomicLong里面的恶性循环不就解决了吗?细胞就是这个“片段”。单元格中的值用于存储更新后的值。这样,当我需要合计的时候,只要将单元格中的数值相加即可!!当然,聪明远不止于此。看add方法中的代码,能不能跳过casBase方法,分段更新,计算索引位置,然后更新值?答案不好,也不是不可以,因为casBase操作相当于AtomicLong中的CAS操作。要知道LongAdder的处理方式是有害的。分段操作必然会造成空间的浪费,而空间是可以换取时间的。不过,改不了就别改了,省空间省时间~!所以casBase操作保证了并发低的时候不会马上进入分支进行segmentupdate操作,因为并发低的时候casBase操作基本会成功,只有并发足够高才会进入分支。因此,DougLea对这个类的描述是:LongAdder和AtomicLong在低并发时性能差不多,而LongAdder在高并发时更高效!不过,DoungLea还是没那么简单,小聪明还没完……这样,我基本知道了retryUpdate中做了什么,因为cell中的值更新失败(说明indextothiscell线程多,并发高)或者当cells数组为空时,会调用retryUpdate。所以retryUpdate应该有两件事:扩容,扩充cells数组,降低每个cell的并发度。同样,这也意味着cells数组的rehash动作。将新的Cell数组分配给空的cells变量。是这样吗?继续看代码:代码比较长,转成文字看,为了方便大家看ifelse分支,我把对应的{}标上了相同的颜色。可以看到,这个时候DougLea才愿意使用死循环保证更新成功~!booleancollide=false;//Trueiflastslotnonemptyfor(;;){Cell[]as;Cella;intn;longv;if((as=cells)!=null&&(n=as.length)>0){//分支1if((a=as[(n-1)&h])==null){if(busy==0){//TrytoattachnewCellCellr=newCell(x);//乐观创建if(busy==0&&casBusy()){booleancreated=false;try{//RecheckunderlockCell[]rs;intm,j;if((rs=cells)!=null&&(m=rs.length)>0&&rs[j=(m-1)&h]==null){rs[j]=r;创建=真;}}终于{忙=0;}如果(创建)中断;continue;//Slotisnownon-empty}}collide=false;}elseif(!wasUncontended)//CASalreadyknowntofailwasUncontended=true;//Continueafterrehashelseif(a.cas(v=a.value,fn(v,x)))break;elseif(n>=NCPU||cells!=as)collide=false;//Atmaxsizeorstaleelseif(!collide)collide=true;elseif(busy==0&&casBusy()){try{if(cells==as){//ExpandtableunlessstaleCell[]rs=newC埃尔[n<<1];for(inti=0;i>>17;h^=h<<5;}elseif(busy==0&&cells==as&&casBusy()){//分支2booleaninit=false;try{//初始化表if(cells==as){Cell[]rs=newCell[2];rs[h&1]=newCell(x);细胞=rs;初始化=真;}}终于{忙=0;}如果(初始化)中断;}elseif(casBase(v=base,fn(v,x)))break;//Fallbackonusingbase}hc.code=h;//Recordindexfornexttime}在分支2中,单元格为空的情况下,需要创建一个新的元胞数组。分支1稍微复杂一点:注意在几个分支中提到了busy方法。这个可以理解为CAS实现的锁,只有在cells数组需要更新的时候才用。该值会被更新为1,如果更新失败,说明当前有线程正在更新cells数组,当前线程需要等待。重试。回到分支1,首先判断当前cells数组中index位置的cell元素是否为空,如果为空则向数组中添加一个cell。否则,将指示冲突的标志位wasUncontended更新为true并重试。否则,再次更新单元格中的值,如果失败,再试一次。.......经过一系列的判断,如果还是失败,进行下一步,reHash,直接将cells数组的大小扩大一倍,更新当前线程的hash值,尽可能保证下次更新能够成功。可以看出,LongAdder在降低并发量上确实下了很多心思,每一步都是在“没有更好的办法”的情况下选择代价更高的操作,尽可能使用最简单的方法.完毕。追求简约,但绝对不粗鲁。#p#————————陈浩笔记————————***两个问题供大家思考:1)AtomicLong是否可以取消?2)如果cell创建后原来的casBase不离开,性能会不会更差?————————liuinsectnote——————————昨天和左耳鼠简单讨论了一下,发现左耳鼠华哥还是很不错的引导读者思维。第一次发现这节课后,对里面的实现提了更多的问题,引导大家思考,值得学习。我们发现的问题有几个(包括上面的问题),我简单总结一下。欢迎大家一起讨论:1.jdk1.7有这样的类吗?我确认后结果如下:jdk-7u51版本没有,jdk-8u20版本已经有。代码基本相同,增加了对double类型的支持,删除了一些冗余代码。有兴趣的同学可以下载JDK1.8看看2.base有没有参与总结?base在调用intValue等方法时会被聚合:3、如果cell创建后原来的casBase不会离开,性能会不会更差?base的顺序可以改变吗?一开始想能不能把add方法中的判断顺序改一下,比如先做casBase的判断?深思熟虑后,我觉得还是不转为好。切换之后,每次都要CAS。并发量高的时候,失败的概率就非常高,恶性循环。相比于一次判断,后者的成本明显小很多,而且没有副作用(上一题中,当基变量在sum中时,会计算基数,不会计算基数的值丢失的)。所以最好不要转置。4、AtomicLong可以取消吗?我的想法是可以废除,因为LongAdder虽然占用空间稍大,但是它的性能足以说明一切。无论是从节省空间还是执行效率的角度,AtomicLong基本没有优势。具体看这个测试(感谢Lemon的回复):http://blog.palominolabs.com/2014/02/10/java-8-performance-improvements-longadder-vs-atomiclong/(全文)原文链接正文:http://coolshell.cn/articles/11454.html