极客时间已完结课程限时免费阅读

14 | 多线程之锁优化(下):使用乐观锁优化并行操作

14 | 多线程之锁优化(下):使用乐观锁优化并行操作-极客时间

14 | 多线程之锁优化(下):使用乐观锁优化并行操作

讲述:李良

时长09:05大小8.32M

你好,我是刘超。
前两讲我们讨论了 Synchronized 和 Lock 实现的同步锁机制,这两种同步锁都属于悲观锁,是保护线程安全最直观的方式。
我们知道悲观锁在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。那有没有可能实现一种非阻塞型的锁机制来保证线程的安全呢?答案是肯定的。今天我就带你学习下乐观锁的优化方法,看看怎么使用才能发挥它最大的价值。

什么是乐观锁

开始优化前,我们先来简单回顾下乐观锁的定义。
乐观锁,顾名思义,就是说在操作共享资源时,它总是抱着乐观的态度进行,它认为自己可以成功地完成操作。但实际上,当多个线程同时操作一个共享资源时,只有一个线程会成功,那么失败的线程呢?它们不会像悲观锁一样在操作系统中挂起,而仅仅是返回,并且系统允许失败的线程重试,也允许自动放弃退出操作。
所以,乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。更为重要的是,乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜一筹。

乐观锁的实现原理

相信你对上面的内容是有一定的了解的,下面我们来看看乐观锁的实现原理,有助于我们从根本上总结优化方法。
CAS 是实现乐观锁的核心算法,它包含了 3 个参数:V(需要更新的变量)、E(预期值)和 N(最新值)。
只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值,如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作,返回 V 的真实值。

1.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 问题指的是什么呢?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 12

提建议

上一篇
13 | 多线程之锁优化(中):深入了解Lock同步锁的优化方法
下一篇
15 | 多线程调优(上):哪些操作导致了上下文切换?
unpreview
 写留言

精选留言(31)

  • 张学磊
    2019-06-20
    变量的原值为A,当线程T读取后到更新前这段时间,可能被其他线程更新为B值后又更新回A值,到当线程T进行CAS操作时感知不到这个变化,依然可以更新成功;StampdLock通过过去锁时返回一个时间戳可以解决该问题。

    作者回复: 不仅回答了问题,还给出了解决方案,赞一个

    52
  • colin
    2019-06-20
    老师您好,cell数组里存数得是+1 -1这种操作值么? 还有,“LongAdder 在操作后的返回值只是一个近似准确的数值,但是 LongAdder 最终返回的是一个准确的数值”这句话中“操作后返回值”和“最终返回值”怎么理解?

    作者回复: 假设操作后立即要获取到值,这个值可能是一个不准确的值。如果我们等待所有线程执行完成之后去获取,这个值肯定是准确的值。一般在做统计时,会经常用到这种操作,实时展现的只要求一个近似值,但最终的统计要求是准确的。

    24
  • gerry pang
    2020-05-12
    老师,我看源码中大量用for(;;),请问那它和while(true)之间有什么明显的性能区别吗?

    作者回复: 虽然都是无限循环,但for(;;)是无条件循环,而while(true)是有条件循环,for(;;)编译后的指令非常简单,而while(true)编译后的指令包含了跳转、判断等。

    共 3 条评论
    21
  • 风轻扬
    2019-08-25
    老师,ABA的问题,CAS最终关心的是:值是否是A。那ABA的影响是什么呢?

    作者回复: 我们假设一个队列来分析ABA问题,会更好理解。 如果一个队列有A\B\A三个数据,在线程1获取队列头节点数据A后,如果CAS发现数据没有变,则修改头节点A为A1,此时正好有一个线程删除了头节点A,又有另外一个线程也删除了后来成为头节点的B,此时头节点是依然是A,而此时第一个线程去修改A,这将导致实际修改的不是队列刚开始的那个节点A。

    21
  • crazypokerk
    2019-06-20
    LongAdder原理如下:将热点数据value被分离成多个单元的cell,每个cell独自维护内部的值,当前对象的实际值由cell[]数组中所有的cell累计合成。这样,热点就进行了有效的分离,提高了并行度,所以LongAdder虽然降低了并发竞争,但是却对实时更新的数据不友好。

    作者回复: 是的

    17
  • 陆离
    2019-06-21
    解决ABA可以利用一个版本号进行验证,每次更新,版本号都+1,同时满足版本号与旧值相同才更新
    9
  • QQ怪
    2019-06-20
    Longaddr还是不能理解,能否在举个简单点的例子理解吗?
    共 1 条评论
    6
  • 左瞳
    2019-06-26
    根据你的测试结果,都是乐观锁最优,是不是线程变为100个或者以上,其他测试结果才会优于乐观锁?

    作者回复: 通常情况下,乐观锁的性能是要优于悲观锁,跟线程数量没有太大关系

    4
  • WL
    2019-06-20
    请教老师两个问题: 1. 文章中的这句话我不太理解, "我们可以发现,LongAdder 在操作后的返回值只是一个近似准确的数值,但是 LongAdder 最终返回的是一个准确的数值". 这么判断的依据是value的计算公式吗, 为什么value的计算公式可以保证最终返回的准确性, 公式中base和数组中各个槽的权重不一样, 为什么base有这么大的权重呢? 2. 单核CPU是靠什么机制保证其他线程和进程都看到的缓存中的内容而不是内存中的内容呢?
    展开
    3
  • 码农Kevin亮
    2019-08-06
    这里想反馈一下,每个小节都讲得太绕了,老师可否直接点题,我越看越困惑: 1,关于“什么是乐观锁”。乐观锁=CAS?CAS不是属于无锁嘛,所以乐观锁=无锁? 2,关于“CAS的实现原理”。CAS是通过锁缓存来实现的,是吗?而synchronized是锁总线,是吗?

    作者回复: 1、乐观锁是一种概念,通过版本号来实现锁是一种乐观锁,而CAS是一种乐观锁的具体实现; 2、CAS是通过底层CPU缓存锁定实现的,这里的总线锁没有涉及到synchronized,只是之前老的CPU是根据总线锁来实现的,由于更新换代,目前使用的是性能更好的缓存锁。 建议多阅读几次文章。

    2
  • 文灏
    2019-07-03
    LongAdder 在操作后的返回值只是一个近似准确的数值, 但是 LongAdder 最终返回的是一个准确的数值. 那什么时候才能知道LongAdder现在返回的值是正确的了呢?

    作者回复: 例如,我们在做销量统计的时候,用到LongAdder 统计销量,我们只需要保证最终写入的销量,在以后查询是是准确的。具体的时间也许是毫秒之后能查到,也许是分钟之后,但我们只需要保证在写入之后,能最终统计之前写入的销量。

    2
  • 晓杰
    2019-06-21
    ABA问题指的是假设现在有一个变量count的值为A,线程T1在未读取count值之前,线程T2把count值改为B,线程T3又把count值改为A,当线程T1读取count发现值为A,虽然值还是A,但是已经被其他线程改变过。 数值的原子递增可以不关注ABA问题,但是如果是原子化的更新对象,就需要关注ABA问题,因为有可能属性值发生了改变
    共 1 条评论
    2
  • Bumblebee
    2022-05-22
    今日收获 LongAdder 的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS 操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。
    展开
    1
  • 考休
    2019-11-08
    ABA的问题使用自增版本号或者时间戳就可以解决
    1
  • 2019-06-27
    很有帮助,系统性的重新审视学习各个锁,顺带将老师的测试代码用JMH测试框架、面向对象化重构了下。 https://github.com/seasonsolt/lockTest,有助于自己进一步深度学习研究。

    作者回复: 赞

    1
  • z.l
    2019-06-23
    cas方法的三个参数是如何和cpu的缓存锁定机制联系到一起的呢?感觉没有理解,还请老师解答。

    作者回复: 原理就是,当某个处理器对缓存中的共享变量进行了操作,就会通知其它处理器放弃对存储该共享资源或者重新读取该共享资源。

    共 2 条评论
    1
  • slowChef
    2019-06-21
    如果从这个图看,LongAdder在几乎所有场景都远优于其他锁呀,是不是有问题呢?

    作者回复: 乐观锁的性能要优于悲观锁,这个没问题。但乐观锁并不是适合所有场景,所以很多时候还是需要使用到悲观锁。

    1
  • Loubobooo
    2019-06-20
    一个变量V,如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?如果在这段期间它的值曾经被改成了B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。
    1
  • 杯莫停
    2022-07-06
    ABA就是某个变量被修改后又被改回来,此时正在CAS的线程是分辨不出这个变量是否被修改过。这个时候应该给数据加个版本号。
  • perfect
    2021-08-19
    老师你的请求量1000在代码中指的是int maxValue = 1000吗??我测出来的结果跟你完全不一样,都是StampedLock锁最好