转载本文请联系后端技术小牛说公众号。简介大家好!今天我们就来说说Java并发下的乐观锁。在讲乐观锁之前,先给大家回顾一个概念:原子操作:什么是原子操作?我们知道,原子是指不能再分解为化学反应的基本粒子。在Java多线程编程中,所谓原子操作就是指即使命令中涉及多个操作,这些操作也是顺序执行的,不会被其他线程打断。原子操作说完原子操作,我们进入正题。众所周知,一般来说,由于多线程并发会带来安全问题,变量的读写操作都会使用锁机制。锁一般分为乐观锁和悲观锁。悲观锁对于悲观锁,开发者认为在数据传输过程中发生并发冲突的概率较高,因此会在每次读操作之前加锁。乐观锁对于乐观锁,开发者认为发送数据时发生并发冲突的概率较低,所以在读操作之前不加锁。写操作的时候会判断,这段时间数据是否被其他线程修改过。如果有修改,则返回写入失败;如果没有被修改,则执行修改操作,返回修改成功。乐观锁定通常使用比较和交换(CAS)算法来实现。顾名思义,该算法涉及两个操作,比较(Compare)和交换(Swap)。CAS算法流程CAS算法的思想如下:该算法考虑到不同线程对变量进行操作时,竞争较少。算法的核心是比较当前读取的变量值E和内存中旧的变量值V。如果相等,说明其他线程没有修改变量,将变量值更新为新值N。如果不相等,则认为其他线程修改了变量,从读取值E到比较阶段,并且不执行任何操作。当一个线程运行CAS算法时,运行的过程是一个原子操作。也就是说,CompareAndSwap的过程虽然逻辑复杂,但是具体的操作是一次性完成的。Java中CAS的底层实现是Java中的Unsafe类。先问大家一个问题:什么是指针?学过C和C++的同学一定不陌生。说白了,指针就是内存地址,指针变量就是用来存储内存地址的变量。但是对于指针的使用,有利也有弊。好处是如果我们有内存的偏移量,也就是数据在内存中的存储位置的坐标,我们就可以直接对内存的变量进行操作;缺点是指针在语言中是一个强大的组成部分,如果新手在编程时,不会考虑指针的安全性。如果他错误地操作指针修改了一个不该修改的内存值,很容易导致整个程序崩溃。指针的错误使用Java语言没有直接的指针成分,一般不能使用偏移量对一块内存进行操作。这些操作相对安全。但实际上,Java有一个类叫做Unsafe类。该类使Java具备了像C语言中的指针一样操作内存空间的能力,但同时也带来了指针问题。这个类可以说是Java并发开发的基础。Unsafe类中的CAS一般来说,大家接触到的CAS函数都是Unsafe类提供的封装。下面是一些CAS函数。publicfinalnativebooleancompareAndSwapObject(ObjectparamObject1,longparamLong,ObjectparamObject2,ObjectparamObject3);publicfinalnativebooleancompareAndSwapInt(ObjectparamObject,longparamLong,intparamInt1,intparamInt2);publicfinalnativebooleancompareAndSwapLong(ObjectparamObject,longparamLong1,longparamLong2,longparamLong3);这就是Unsafe包下提供的CAS更新对象、CAS更新int型变量、CAS更新了三个long类型变量的函数。我们以最好理解的compareAndSwapInt为例,一起来看看:publicfinalnativebooleancompareAndSwapInt(ObjectparamObject,longparamLong,intparamInt1,intparamInt2);可以看到,这个函数有四个参数:第一个是目标对象,第二个参数用来表示我们上面提到的指针是一个long类型的值,表示成员变量在其对应的对象属性中的偏移量.也就是说,函数可以通过这个参数找到变量在内存中的具体位置,从而进行CAS操作。第三个参数是预期的旧值,在示例中为V。第四个参数是修改后的新值,本例中为N。有的同学会问,Java中只有整型CAS函数吗?double和boolean是否有任何CAS函数?遗憾的是Java中的CAS操作和UnSafe类都没有提供对double和boolean数据的支持。怎么做。但是我们可以利用现有的方法进行封装,自己制作操作double和boolean数据的方法。对于boolean类型,我们可以在传入参数时将boolean类型转换为int类型,在返回值时将int类型转换为boolean类型。对于double类型,它依赖于long类型,double类型提供了double类型和long类型之间转换的功能。publicstaticnativedoublelongBitsToDouble(longbits);publicstaticnativelongdoubleToRawLongBits(双值);众所周知,基本数据类型都是以位类型存储在最底层的。所以long类型和double类型都是在计算机底层按位存储的。所以这两个函数很容易理解:longBitsToDouble函数将long类型的底层二进制存储数据强行转换为double类型。doubleToRawLongBits函数将double类型的实际二进制存储数据翻译成long类型。Java中的CAS使用一个比较常见的操作,使用变量i为程序计数,可以通过增加i来实现。因蒂=0;我++;但是稍有经验的同学都知道,这种写法并不是线程安全的。如果500个线程同时执行i++,i的结果不一定是500,有可能小于500。这是因为i++不仅仅是一行命令,它涉及以下操作:(以下代码是Java代码编译的字节码)getfield#从内存中获取变量i的值iadd#将count加到1putfield#将1后的结果赋值给i变量。可以看出,一个简单的自增操作涉及到这三个命令,而这些命令并不是一口气完成的,在多线程的情况下很容易被其他线程打断。虽然两个线程都进行了i++操作,i的值本来应该是2,但是按照上图的流程,i的值变成了1。如果我们需要执行我们想要的操作,代码可以这样重写。inti=0;synchronized{i++;}我们知道修改synchronized关键字的代价是非常大的。Java提供了一个原子类。如果把变量i声明为原子类,并进行相应的操作,就没有前面的问题解决了,而且比synchronized的开销小。AtomicIntegeri=newAtomicInteger(0);i.getAndIncrement();Java的Atomic基本数据类型类也为int类型的原子操作提供了AtomicIntegerAtomicLong为long类型的原子操作提供了AtomicBoolean为boolean类型的原子操作提供了原子基本数据类型支持的方法如下图所示:原子基本数据类型getCurrentValue:获取当前值此基本数据类型的当前值。setValue:将当前底层数据类型的值设置为目标值。getAndSet:获取底层数据类型的当前值,并将当前底层数据类型的值设置为目标值。getAndIncrement:获取底层数据类型的当前值并加1,类似于i++。getAndDecrement:获取底层数据类型的当前值并减1,类似于i--。getAndAdd:获取基础数据类型的当前值并递增给定参数的值。IncrementAndGet:自增1,得到底层数据类型的增量值,类似++i。decrementAndGet:减1,得到底层数据类型的增加值,类似于--i。AddAndGet:增加给定参数的值并获取基础数据类型的增加值。这些基本数据类型的函数底层实现都有CAS。我们以最简单的AtomicIntegergetAndIncrement函数为例:(sourcesourceJDK7)volatileintvalue;···publicfinalintgetAndIncrement(){for(;;){intcurrent=get();intnext=current+1;if(compareAndSet(current,next))returncurrent;}}这个和之前的i++自增操作类似。这里的compareAndSet其实是一个封装了Unsafe类的native函数:publicfinalcompareAndSet(intexpect,undate){returnunsafe.compareAndSwapInt(this,valueOffset,expect,update);}它返回的是我们刚才说的unsafe封装下的compareAndSwapInt函数。除了CAS,Atomic类还使用了一种方法来优化获取锁的过程。我们知道,当一个线程无法获得相应的锁时,有两种策略:策略一:放弃获得CPU,将线程置于阻塞状态,等待操作系统后续唤醒和调度。当然,这样做的弊端是显而易见的。这种状态切换涉及到从用户态到内核态的切换,一般开销比较大。如果线程快速释放占用的锁,这样做显然不划算。策略二:不放弃CPU,不断重试。此操作也称为旋转。当然,这样做也有缺点。如果一个线程持有锁的时间过长,会导致其他等待获取锁的线程无谓地消耗CPU资源。使用不当会导致CPU使用率过高。在这种情况下,策略1更有意义。我们上面提到的AtomicInteger和AtomicLong在进行相关操作时采用的是策略2。通常,这种策略也称为自旋锁。可以看出,在AtomicInteger的getAndIncrement函数中,函数外包了一个for(;;),实际上是一个不断重试的死循环,也就是这里说的自旋。但现在采用的策略大多是开发者设定一个阈值,在阈值内不断自旋。如果自旋失败次数超过阈值,则进入阻塞状态。自旋ABA问题AtomicMarkableCAS算法本身有一个很大的缺陷,就是ABA问题。我们可以看到CAS算法是根据值进行比较的。如果当前有两个线程,一个线程把变量值从A改成B,再把B改回A,当当前线程开始执行CAS算法时,很容易认为值没有changed,误认为在读取数据到执行CAS算法之间没有线程修改过数据。ABA问题乍一看这个缺陷似乎不会造成什么问题,其实不然。让我举一个例子。假设小爱同学银行卡余额100元,假设银行转账操作是一个简单的CAS命令,比较余额旧值与当前值是否相同,如果相同则进行扣款/增量将发生。我们将使用此命令与CAS(origin,expect)表示。那么,让我们看看接下来发生了什么:小明银行转账欠小爱同学100元,小爱同学欠小牛100元,小爱同学打算在1号ATM上转100元给小牛;它是用CAS算法实现的。因为1号ATM突然卡住了,小爱又跑到下一个2号ATM操作转账;2号ATM执行CAS(100,0),顺利完成转账。此时,小爱同学账户余额为0;小明此时向小爱账户转了100,小爱账户余额为100;此时ATM1的网络恢复,CAS(100,0)执行成功。小爱同学的账户余额又变成了0;可怜的小艾,由于CAS算法的缺陷,损失了100块钱。ABA问题的解决方案并不复杂。对于这个CAS函数,不仅要比较变量值,还要比较版本号。publicbooleancompareAndSet(VexpectedReference,VnewReference,intexpectedStamp,intnewStamp)之前的CAS只有两个参数,而带版本号比较的CAS有四个参数,其中expectedReference指的是变量的预期旧值,newReference指的是需要被比较的变量changedexpectedStamp指的是版本号的旧值,newStamp指的是版本号的新值。修改后的CAS算法执行流程如下:更正CAS算法AtomicStampedReference如何在Java中顺利使用带版本号比较的CAS功能?Java开发人员帮助我们解决了这个问题。他们提供了一个类叫JavaAtomicStampedReference,这个类封装了CAS功能和版本号比较,我们来看看。AtomicStampedReference在java.util.concurrent.atomic包下定义。下图描述了该类对应的几个常用的方法:AtomicStampedReferenceattemptStamp:如果expectReference与当前值一致,则将当前对象的版本号戳记设置为newStampcompareAndSet:该方法就是上面描述的带版本号的CAS方法。get:该方法返回当前对象的当前对象值和版本号戳getReference:该方法返回当前对象值getStamp:该方法返回当前对象的版本号戳set:直接设置当前对象值和版本对象的编号戳参考:Java并发实现原理:JDK源码分析https://mp.weixin.qq.com/s/Ad6ufmGSEiQpL38YrvO4mwhttps://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/AtomicStampedReference.htmlhttps://zhang0peter.blog.csdn.net/article/details/84020496?utm_medium=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.control&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.controlhttps://mp.weixin.qq.com/s/kvuPxn-vc8dke093XSE5IQ
