14讲多线程之锁优化(下):使⽤乐观锁优化并⾏操作
你好,我是刘超。
前两讲我们讨论了Synchronized和Lock实现的同步锁机制,这两种同步锁都属于悲观锁,是保护线程安全最直观的⽅式。
我们知道悲观锁在⾼并发的场景下,激烈的锁竞争会造成线程阻塞,⼤量阻塞线程会导致系统的上下⽂切换,增加系统的性能开销。那有没有可能实现⼀种⾮阻塞型的锁机制来保证线程的安全呢?答案是肯定的。今天我就带你学习下乐观锁的优化⽅ 法,看看怎么使⽤才能发挥它最⼤的价值。
什么是乐观锁
开始优化前,我们先来简单回顾下乐观锁的定义。
乐观锁,顾名思义,就是说在操作共享资源时,它总是抱着乐观的态度进⾏,它认为⾃⼰可以成功地完成操作。但实际上,当多个线程同时操作⼀个共享资源时,只有⼀个线程会成功,那么失败的线程呢?它们不会像悲观锁⼀样在操作系统中挂起,⽽仅仅是返回,并且系统允许失败的线程重试,也允许⾃动放弃退出操作。
所以,乐观锁相⽐悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远⽐悲观锁要⼩。更为重要的是,乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜⼀筹。
乐观锁的实现原理
相信你对上⾯的内容是有⼀定的了解的,下⾯我们来看看乐观锁的实现原理,有助于我们从根本上总结优化⽅法。
CAS是实现乐观锁的核⼼算法,它包含了3个参数:V(需要更新的变量)、E(预期值)和N(最新值)。
只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值,如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作,返回V的真实值。
CAS如何实现原⼦操作
在JDK中的concurrent包中,atomic路径下的类都是基于CAS实现的。AtomicInteger就是基于CAS实现的⼀个线程安全的整型
类。下⾯我们通过源码来了解下如何使⽤CAS实现原⼦操作。
我们可以看到AtomicInteger的⾃增⽅法getAndIncrement是⽤了Unsafe的getAndAddInt⽅法,显然AtomicInteger依赖于本地
⽅法Unsafe类,Unsafe类中的操作⽅法会调⽤CPU底层指令实现原⼦操作。
//基于CAS操作更新值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
//基于CAS操作增1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//基于CAS操作减1
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
2.处理器如何实现原⼦操作
CAS是调⽤处理器底层指令来实现原⼦操作,那么处理器底层⼜是如何实现原⼦操作的呢?
处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有⾃⼰的内部缓存。如下图所示,在执⾏操作时,频繁使⽤的内存数据会缓存在处理器的L1、L2和L3⾼速缓存中,以加快频繁读取的速度。
⼀般情况下,⼀个单核处理器能⾃我保证基本的内存操作是原⼦性的,当⼀个线程读取⼀个字节时,所有进程和线程看到的字
节都是同⼀个缓存⾥的字节,其它线程不能访问这个字节的内存地址。
但现在的服务器通常是多处理器,并且每个处理器都是多核的。每个处理器维护了⼀块字节的内存,每个内核维护了⼀块字节的缓存,这时候多线程并发就会存在缓存不⼀致的问题,从⽽导致数据不⼀致。
这个时候,处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原⼦性。
当处理器要操作⼀个共享变量的时候,其在总线上会发出⼀个Lock信号,这时其它处理器就不能操作共享变量了,该处理器 会独享此共享内存中的变量。但总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致⼤量阻塞,从⽽增加系统的性能开销。
于是,后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进⾏了操作,就会通知其它处理器放弃存储该共享资源或者重新读取该共享资源。⽬前最新的处理器都⽀持缓存锁定机制。
优化CAS乐观锁
虽然乐观锁在并发性能上要⽐悲观锁优越,但是在写⼤于读的操作场景下,CAS失败的可能性会增⼤,如果不放弃此次CAS 操作,就需要循环做CAS重试,这⽆疑会⻓时间地占⽤CPU。
在Java7中,通过以下代码我们可以看到:AtomicInteger的getAndSet⽅法中使⽤了for循环不断重试CAS操作,如果⻓时间不成功,就会给CPU带来⾮常⼤的执⾏开销。到了Java8,for循环虽然被去掉了,但我们反编译Unsafe类时就可以发现该循环 其实是被封装在了Unsafe类中,CPU的执⾏开销依然存在。
public final int getAndSet(int newValue) { for (;;) {
int current = get();
if (compareAndSet(current, newValue)) return current;
}
}
在JDK1.8中,Java提供了⼀个新的原⼦类LongAdder。LongAdder在⾼并发场景下会⽐AtomicInteger和AtomicLong的性能更
好,代价就是会消耗更多的内存空间。
LongAdder的原理就是降低操作共享变量的并发数,也就是将对单⼀共享变量的操作压⼒分散到多个变量值上,将竞争的每个写线程的value值分散到⼀个数组中,不同线程会命中到数组的不同槽中,各个线程只对⾃⼰槽中的value值进⾏CAS操作,最后在读取值的时候会将原⼦操作的共享变量与各个分散在数组的value值相加,返回⼀个近似准确的数值。
LongAdder内部由⼀个base变量和⼀个cell[]数组组成。当只有⼀个写线程,没有竞争的情况下,LongAdder会直接使⽤base 变量作为原⼦操作变量,通过CAS操作修改变量;当有多个写线程竞争的情况下,除了占⽤base变量的⼀个写线程之外,其它各个线程会将修改的变量写⼊到⾃⼰的槽cell[]数组中,最终结果可通过以下公式计算得出:
我们可以发现,LongAdder在操作后的返回值只是⼀个近似准确的数值,但是LongAdder最终返回的是⼀个准确的数值, 所以在⼀些对实时性要求⽐较⾼的场景下,LongAdder并不能取代AtomicInteger或AtomicLong。
总结
在⽇常开发中,使⽤乐观锁最常⻅的场景就是数据库的更新操作了。为了保证操作数据库的原⼦性,我们常常会为每⼀条数据定义⼀个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执⾏该操作。
CAS乐观锁在平常使⽤时⽐较受限,它只能保证单个变量操作的原⼦性,当涉及到多个变量时,CAS就⽆能为⼒了,但前两讲讲到的悲观锁可以通过对整个代码块加锁来做到这点。
CAS乐观锁在⾼并发写⼤于读的场景下,⼤部分线程的原⼦操作会失败,失败后的线程将会不断重试CAS原⼦操作,这样就会导致⼤量线程⻓时间地占⽤CPU资源,给系统带来很⼤的性能开销。在JDK1.8中,Java新增了⼀个原⼦类LongAdder,它使⽤了空间换时间的⽅法,解决了上述问题。
11~13讲的内容,我详细地讲解了基于JVM实现的同步锁Synchronized,AQS实现的同步锁Lock以及CAS实现的乐观锁。相信你也很好奇,这三种锁,到底哪⼀种的性能最好,现在我们来对⽐下三种不同实现⽅式下的锁的性能。
鉴于脱离实际业务场景的性能对⽐测试没有意义,我们可以分别在“读多写少”“读少写多”“读写差不多”这三种场景下进⾏测试。
⼜因为锁的性能还与竞争的激烈程度有关,所以除此之外,我们还将做三种锁在不同竞争级别下的性能测试。
综合上述条件,我将对四种模式下的五个锁Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock以及乐观锁LongAdder进⾏压测。
这⾥简要说明⼀下:我是在不同竞争级别的情况下,⽤不同的读写线程数组合出了四组测试,测试代码使⽤了计算并发计数器,读线程会去读取计数器的值,⽽写线程会操作变更计数器值,运⾏环境是4核的i7处理器。结果已给出,具体的测试代码可以点击Github查看下载。
通过以上结果,我们可以发现:在读⼤于写的场景下,读写锁ReentrantReadWriteLock、StampedLock以及乐观锁的读写性能是最好的;在写⼤于读的场景下,乐观锁的性能是最好的,其它4种锁的性能则相差不多;在读和写差不多的场景下,两种读写锁以及乐观锁的性能要优于Synchronized和ReentrantLock。
思考题
我们在使⽤CAS操作的时候要注意的ABA问题指的是什么呢?
期待在留⾔区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起学习。
精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315928091-8dda1fdf-9d72-4075-a554-449aee1fc971.png#)张学磊<br />变量的原值为A,当线程T读取后到更新前这段时间,可能被其他线程更新为B值后⼜更新回A值,到当线程T进⾏CAS操作时感知不到这个变化,依然可以更新成功;StampdLock通过过去锁时返回⼀个时间戳可以解决该问题。<br />2019-06-20 07:50<br />作者回复<br />不仅回答了问题,还给出了解决⽅案,赞⼀个<br />2019-06-20 09:40
crazypokerk
LongAdder原理如下:将热点数据value被分离成多个单元的cell,每个cell独⾃维护内部的值,当前对象的实际值由cell[]数组中所有的cell累计合成。这样,热点就进⾏了有效的分离,提⾼了并⾏度,所以LongAdder虽然降低了并发竞争,但是却对实时更新的数据不友好。
2019-06-20 08:56
作者回复
是的
2019-06-20 09:10
colin
⽼师您好,cell数组⾥存数得是+1 -1这种操作值么?
还有,“LongAdder 在操作后的返回值只是⼀个近似准确的数值,但是 LongAdder 最终返回的是⼀个准确的数值”这句话中“操作后返回值”和“最终返回值”怎么理解?
2019-06-20 08:01
作者回复
假设操作后⽴即要获取到值,这个值可能是⼀个不准确的值。如果我们等待所有线程执⾏完成之后去获取,这个值肯定是准确的值。⼀般在做统计时,会经常⽤到这种操作,实时展现的只要求⼀个近似值,但最终的统计要求是准确的。
2019-06-20 09:39
QQ怪
Longaddr还是不能理解,能否在举个简单点的例⼦理解吗?
2019-06-20 12:24
左瞳
根据你的测试结果,都是乐观锁最优,是不是线程变为100个或者以上,其他测试结果才会优于乐观锁?
2019-06-26 08:04
作者回复
通常情况下,乐观锁的性能是要优于悲观锁,跟线程数量没有太⼤关系
2019-06-26 09:23
Loubobooo
⼀个变量V,如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?如果在这段期间它的值曾经被改成了B,然后⼜改回A,那CAS操作就会误认为它从来没有被修改过。
2019-06-20 09:42
⽂灏
LongAdder 在操作后的返回值只是⼀个近似准确的数值, 但是 LongAdder 最终返回的是⼀个准确的数值. 那什么时候才能知道L
ongAdder现在返回的值是正确的了呢?
2019-07-03 20:36
作者回复
例如,我们在做销量统计的时候,⽤到LongAdder 统计销量,我们只需要保证最终写⼊的销量,在以后查询是是准确的。具体的时间也许是毫秒之后能查到,也许是分钟之后,但我们只需要保证在写⼊之后,能最终统计之前写⼊的销量。
2019-07-04 10:46
寻
很有帮助,系统性的重新审视学习各个锁,顺带将⽼师的测试代码⽤JMH测试框架、⾯向对象化重构了下。
https://github.com/seasonsolt/lockTest,有助于⾃⼰进⼀步深度学习研究。
2019-06-27 09:54
作者回复
赞
2019-06-28 09:15
左瞳
说乐观锁会占⽤⼤量的cpu导致性能下降,如果不是线程数影响,那哪些场景下乐观锁效率会低于悲观锁?
2019-06-26 09:27
z.l
cas⽅法的三个参数是如何和cpu的缓存锁定机制联系到⼀起的呢?感觉没有理解,还请⽼师解答。
2019-06-23 22:36
作者回复
原理就是,当某个处理器对缓存中的共享变量进⾏了操作,就会通知其它处理器放弃对存储该共享资源或者重新读取该共享资源。
2019-06-24 09:13
陆离
解决ABA可以利⽤⼀个版本号进⾏验证,每次更新,版本号都+1,同时满⾜版本号与旧值相同才更新
2019-06-21 19:34
晓杰
ABA问题指的是假设现在有⼀个变量count的值为A,线程T1在未读取count值之前,线程T2把count值改为B,线程T3⼜把coun
t值改为A,当线程T1读取count发现值为A,虽然值还是A,但是已经被其他线程改变过。
数值的原⼦递增可以不关注ABA问题,但是如果是原⼦化的更新对象,就需要关注ABA问题,因为有可能属性值发⽣了改变
2019-06-21 16:35
slowChef
如果从这个图看,LongAdder在⼏乎所有场景都远优于其他锁呀,是不是有问题呢?
2019-06-21 14:30
作者回复
乐观锁的性能要优于悲观锁,这个没问题。但乐观锁并不是适合所有场景,所以很多时候还是需要使⽤到悲观锁。
2019-06-23 10:08
明翼
关于最后的问题⽼师是不是可以通过版本号之类来控制,版本号只增加不减少是不是可以解决这个问题那
2019-06-21 12:14
作者回复
是的
2019-06-23 10:09
明翼
⽼师总线锁和缓存锁,这个缓存是l1还是L2还是L3那,总线锁可以理解成锁内存吗
2019-06-21 12:10
作者回复
这三个等级的缓存都会涉及到,理解没问题。
2019-06-23 10:10
WL
请教⽼师两个问题:
- ⽂章中的这句话我不太理解, “我们可以发现,LongAdder 在操作后的返回值只是⼀个近似准确的数值,但是 LongAdder 最终返回的是⼀个准确的数值”. 这么判断的依据是value的计算公式吗, 为什么value的计算公式可以保证最终返回的准确性, 公式中base和数组中各个槽的权重不⼀样, 为什么base有这么⼤的权重呢?
- 单核CPU是靠什么机制保证其他线程和进程都看到的缓存中的内容⽽不是内存中的内容呢?
2019-06-20 20:12
趙衍
⽼师好,我有两个问题想请教⽼师:
1.CAS实现原⼦性的⽅式我有点不太明⽩,缓存锁定解决的不应该是可⻅性的问题吗,保证当前线程对内存中数值的修改⼀定对其他线程可⻅,它是如何保证当前线程在获取值—>修改值—>写⼊值这个过程中不被其他的线程打断的呢?
2.通过性能对⽐其实可以发现,读写锁和StampedLock的性能相较于synchronized和可重⼊锁,要么差不多,要么更好,甚⾄ 在读少写多的情况下也体现出了⼀点点的优势,那么在使⽤锁的时候,我们为什么还要⽤后两种锁呢?他们的优点在哪⾥? 谢谢⽼师!
2019-06-20 10:09
Jxin
参考参数从a到b再到a,当前更新看不到变更过程,会误以为未变,从⽽更新成功。解决办法是引⼊累增版本,时间节也是累增版本。最后是⼀个性能对⽐图,和我以前看的问章性能对⽐差距有点⼤,隐试锁性能不该这么差的。
2019-06-20 09:36
-W.LI-
⽼师那个测试的图是不是有问题啊,怎么⼀直是sysn和relock。两个悲观锁性能最好啊。
2019-06-20 09:28
作者回复
同学你好~测试图单位是时间,时间花费越多,性能越差。
2019-06-20 14:16
Liam
CAS compare的依据是变量的值,ABA是指该变量从A到B再到A的变化过程,虽然变量已经被修改,从结果来看,CAS还是会认为变量没有被修改
2019-06-20 08:07
作者回复
对的,Liam基础功扎实
2019-06-20 09:35