当前位置: 首页 > 后端技术 > Node.js

Java中多线程ABA问题探讨

时间:2023-04-03 13:32:58 Node.js

前言本文是笔者在日常开发过程中遇到的关于JUC(java.util.concurrent)中CAS、ABA问题以及AtomicReference相关类的设计思考的一些记录.对于需要处理ABA问题的读者,或者像作者一样有设计问题探索好奇心的读者,或许会有一定的启发。本文主体由三部分组成:首先讲解多线程场景数据同步的常用语言工具,然后是什么是ABA问题,产生的原因和可能造成的影响,再探究JUC官方的工具设计解决ABA问题文末会对多线程数据同步的常用解决方案做一个简短的经验总结和概括。受限于作者的理解和知识水平,文章中的一些术语难免有失偏颇。对于有歧义或者有争议的地方,欢迎大家讨论指正。一、异步场景常用工具Java中多线程数据同步的场景经常出现:关键字volatile关键字synchronized可重入锁/读写锁java.util.concurrent.locks.*容器同步封装,比如Collections。synchronizedXxx()新的线程安全容器,如CopyOnWriteArrayList/ConcurrentHashMap阻塞队列java.util.concurrent.BlockingQueue原子类java.util.concurrent.atomic.*以及JUC中的其他工具如CountDownLatch/Exchanger/FutureTask等角色。其中volatile关键字用于刷新数据缓存,即保证A线程修改某个数据后,在B线程中可以看到。这里涉及的线程缓存和指令重排由于篇幅原因不在本文讨论范围之内。无论是synchronized关键字下的对象锁,还是基于同步器AbstractQueuedSynchronizer的Lock实现,都是悲观锁。但是在同步容器封装、新的线程安全容器、阻塞队列中使用了悲观锁;但是每种类型内部使用不同的Lock实现类和JUC工具,不同的容器有不同的加锁粒度和加锁策略。分别进行处理和优化。这里值得一提的是,本文的重点是原子类,即java.util.concurrent.atomic.*包下的几个类库,如AtomicBoolean/AtomicInteger/AtomicReference2.CAS和ABA问题我们知道我们在使用悲观锁的场景下,如果一个线程抢占了锁,那么其他想要获取锁的线程就必须阻塞等待,直到被锁线程计算完成释放锁资源。现代CPU提供硬件级指令来实现同步原语,这意味着线程可以在运行期间检测其他线程是否也在读写同一内存。基于此,Java提供了一个忙循环而不是阻塞的一系列工具类AutomicXxx,它是乐观锁的一种实现。它的常规使用如下:compareAndSet方法由Native层的硬件指令实现if(!isRequesting.compareAndSet(false,true)){return;}try{//dosth...}finally{isRequesting.set(false)}}}复制代码进入JDK11AtomicBoolean的源码,可以看到compareAndSet最终调用Native层的方式如下。事实上,在旧版本中,JDK是使用Unsafe类进行处理的。在输入参数中,有传入状态变量的字段偏移值。新版本将两者封装成VarHandle,并使用DL查找依赖(笔者猜测可能与JDK9相关的模块化改造相同)://老版本publicclassAtomicBoolean{privatestaticfinalsun.misc.UnsafeU=sun.misc.Unsafe.getUnsafe();privatestaticfinallongVALUE;static{try{VALUE=U.objectFieldOffset(AtomicBoolean.class.getDeclaredField("value"));}catch(ReflectiveOperationExceptione){thrownewError(e);}}privatevolatileint值;publicfinalbooleancompareAndSet(booleanexpect,booleanupdate){returnU.compareAndSwapInt(this,VALUE,(expect?1:0),(update?1:0));}}//新版本publicclassAtomicBoolean{privatestatic最终的VarHandle值;静态{尝试{MethodHandles.Lookupl=MethodHandles.lookup();VALUE=l.findVarHandle(AtomicBoolean.class,"value",int.class);}catch(ReflectiveOperationExceptione){thrownewExceptionInInitializerError(e);}}私有易失性整数value;publicfinalbooleancompareAndSet(booleanexpectedValue,booleannewValue){returnVALUE.compareAndSet(this,(expectedValue?1:0),(newValue?1:0));}}复制代码就像进入仓库一样和value偏移值,Native层可以根据这两个值定位到一定的栈内存,所以对于基本类型是没有问题的。在原子类型系统中,AtomicReference是用来引用复合类型实例的,而Java中的Object类型只是把堆保存在栈上。对象中对象数据块的地址,其结构如下:在实际运行过程中,在调用AtomicReference#compareAndSet()时,Native层只会比较栈中内存的值,不会进行支付注意它指向的堆中的数据。这可能有点抽象,看一段实验代码:StringBuildervarA=newStringBuilder("abc");StringBuildervarB=newStringBuilder("123");AtomicReferenceref=newAtomicReference<>(varA);ref.compareAndSet(varA,varB);//(1)System.out.println(ref.get());//(2)varB->123varB.append('4');//(3)改变了varB->1234if(ref.compareAndSet(varB,varA)){//(4)System.out.println("CASsucceed");//(5)CAS成功}System.out.println(ref.get());//abc复制代码喜欢做的读者可以尝试自定义一个类,观察Compare过程是否真的没有调用对象的equals方法。ref处理后,在(2)处引用了变量B,在注释(3)处修改了B的值,但是由于原子类不检查堆中的数据,所以还是可以通过相等评论中的比较(4)转到注释(5)。这也引入了所谓的ABA问题:假设,线程1的任务想把变量A改成C,但是一半的执行被线程2拿走了,线程2把变量从A改成了B。在这一次,CPU时间片被系统分配给了线程3。线程3将变量从B设置为一个新的A,线程1获取到时间片,检查变量,发现还是A(但是A对象里面的数据变了),检查变量是否设置为C.如果在业务场景中,线程1不关心变量是否发生了一轮变化,或者A中的数据是否发生了变化,那么这个问题是无关紧要的。而如果线程1对这两个变化比较敏感,那么将变量设置为C的操作就不是预期的那样。以维基百科为例,大概的思路是:你背着一个装满现金的包去机场,一个辣妹来逗你,把你的包换成一个你不在的时候看起来一样的空包留意钱袋,她不见了;这时你检查发现你的包还在,于是你赶紧带着它赶飞机。换个角度看这些关键词:packagewithcash:stackreference指向heap中的数据辣妹挑衅:其他线程抢占CPU,看起来一样Emptypackage:其他线程修改heap中的数据,发现package是还是那句话:只检查栈中的内存3.使用JUC工具处理ABA问题JDK为了处理ABA问题,提供了另外两个工具类:AtomicMarkableReference和AtomicStampedReference。除了比较栈中对象的引用地址外,它们还保存了一个boolean或int类型的A标签值,用于CAS比较。StringBuildervarA=newStringBuilder("abc");StringBuildervarB=newStringBuilder("123");AtomicStampedReferenceref=newAtomicStampedReference<>(varA,varA.toString().hashCode());ref.compareAndSet(varA,varB,varA.toString().hashCode(),varB.toString().hashCode());System.out.println(ref.get(newint[1]));varB.append('4');//CAS失败,因为Stamp值不匹配if(ref.compareAndSet(varB,varA,varB.toString().hashCode(),varA.toString().hashCode())){System.out.println("compareAndSet:succeed");}System.out.println(ref.get(newint[1]));复制代码注意:本设计是比较文件汇总值(MD5,SHA值)和思路是否符合预期有同样的效果。总结通常在多线程场景下,这些工具的应用场景都有各自的适用特点:如果各个线程之间没有竞争读写数据,可以考虑只使用volatile关键字;,可以优先使用乐观锁实现,即使用原子类型;如果每个线程都有竞争关系,一定不能按顺序去重某个资源,也就是必须用锁阻塞。如果对多条件队列没有吸引力,可以考虑优先使用。synchronized加了对象锁(但要注意锁对象的不可变性和私有化),否则可以考虑使用Lock来实现类,但特别是需要读写分离锁实现共享锁时,只能使用Lock。如果需要使用线程安全的容器,出于性能考虑,优先使用java.util.concurrent.*类,如ConcurrentHashMap、CopyOnWriteArrayList;然后考虑使用容器同步包装Collections.synchronizedXxx()。阻塞队列多用于生产-消费模型中的任务容器,典型的是线程池。