《实战 Java 高并发程序设计》笔记——第4章 锁的优化及注意事项(二)

本文是学习《实战 Java 高并发程序设计》的笔记,重点关注无锁编程,介绍了比较交换(CAS)原理及其优势,无锁线程安全的AtomicInteger类的实现与应用,以及AtomicIntegerArray和AtomicIntegerFieldUpdater的使用。文章还提到了无锁算法的挑战,如无锁Vector的实现,以及如何避免死锁问题。
摘要由CSDN通过智能技术生成

声明:

本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

4.4 无锁

就人的性格而言,我们可以分为乐天派和悲观派。对于乐天派来说,总是会把事情往好的方面想。他们认为所有事情总是不太容易发生问题,出错是小概率的,所以我们可以肆无忌惮地做事。如果真的不幸遇到了问题,则有则改之无则加勉。而对于悲观的人群来说,他们总是担惊受怕,认为出错是一种常态,所以无论巨细,都考虑得面面俱到,滴水不漏,确保为人处世,万无一失。

对于并发控制而言,锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,就宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。而无锁是一种乐观的策略,它会假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下持续执行。那遇到冲突怎么办呢?无锁的策略使用一种叫做比较交换的技术(CAS Compare And Swap)来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止

4.4.1 与众不同的并发策略:比较交换(CAS)

与锁相比,使用比较交换(下文简称 CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能

CAS 算法的过程是这样:它包含三个参数 CAS(V,E,N)。V 表示要更新的变量,E 表示预期值,N 表示新值。仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。CAS 操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理

简单地说,CAS 需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。

在硬件层面,大部分的现代处理器都已经支持原子化的 CAS 指令。在 JDK 5.0 以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。

4.4.2 无锁的线程安全整数:AtomicInteger

为了让 Java 程序员能够受益于 CAS 等 CPU 指令,JDK 并发包中有一个 atomic 包,里面实现了一些直接使用 CAS 操作的线程安全的类型

其中,最常用的一个类,应该就是 AtomicInteger。你可以把它看做是一个整数。但是与 Integer 不同,它是可变的,并且是线程安全的。对其进行修改等任何操作,都是用 CAS 指令进行的。这里简单列举一下 AtomicInteger 的一些主要方法,对于其他原子类,操作也是非常类似的:

在这里插入图片描述

就内部实现上来说,AtomicInteger 中保存一个核心字段:

在这里插入图片描述

它就代表了 AtomicInteger 的当前实际取值。此外还有一个:

在这里插入图片描述

它保存着 value 字段在 AtomicInteger 对象中的偏移量。后面你会看到,这个偏移量是实现 AtomicInteger 的关键。

AtomicInteger 的使用非常简单,这里给出一个示例:

在这里插入图片描述

第 6 行的 AtomicInteger.incrementAndGet() 方法会使用 CAS 操作将自己加 1,同时也会返回当前值(这里忽略了当前值)。如果你执行这段代码,你会看到程序输出了 100000。这说明程序正常执行,没有错误。如果不是线程安全,i 的值应该会小于 100000 才对。

使用 AtomicInteger 会比使用锁具有更好的性能。出于篇幅限制,这里不再给出 AtomicInteger 和锁的性能对比的测试代码,相信写一段简单的小代码测试两者的性能应该不是难事。这里让我们关注一下 incrementAndGet() 的内部实现(我们基于 JDK 1.7 分析,JDK 1.8 与 1.7 的实现有所不同)。

在这里插入图片描述

其中 get() 方法非常简单,就是返回内部数据 value。

在这里插入图片描述

这里让人映像深刻的,应该是 incrementAndGet() 方法的第 2 行 for 循环吧!如果你是初次看到这样的代码,可能会觉得很奇怪,为什么连设置一个值那么简单的操作都需要一个死循环呢?原因就是:CAS 操作未必是成功的,因此对于不成功的情况,我们就需要进行不断的尝试。第 3 行的 get() 取得当前值,接着加 1 后得到新值 next。这里,我们就得到了 CAS 必需的两个参数:期望值以及新值。使用 compareAndSet() 方法将新值 next 写入,成功的条件是在写入的时刻,当前的值应该要等于刚刚取得的 current。如果不是这样,就说明 AtomicInteger 的值在第 3 行到第 5 行代码之间,又被其他线程修改过了。当前线程看到的状态就是一个过期状态。因此,compareAndSet 返回失败,需要进行下一次重试,直到成功。

以上就是 CAS 操作的基本思想。在后面我们会看到,无论程序多么复杂,其基本原理总是不变的。

和 AtomicInteger 类似的类还有 AtomicLong 用来代表 long 型,AtomicBoolean 表示 boolean 型,AtomicReference 表示对象引用。

4.4.3 Java 中的指针:Unsafe 类

如果你对技术有着不折不挠的追求,应该还会特别在意 incrementAndGet() 方法中 compareAndSet() 的实现。现在,就让我们更进一步看一下它吧!

在这里插入图片描述

在这里,我们看到一个特殊的变量 unsafe,它是 sun.misc.Unsafe 类型。从名字看,这个类应该是封装了一些不安全的操作。那什么操作是不安全的呢?学习过 C 或者 C++ 的话,大家应该知道,指针是不安全的,这也是在 Java 中把指针去除的重要原因。如果指针指错了位置,或者计算指针偏移量时出错,结果可能是灾难性的,你很有可能会覆盖别人的内存,导致系统崩溃。

而这里的 Unsafe 就是封装了一些类似指针的操作。compareAndSwapInt() 方法是一个 navtive 方法,它的几个参数含义如下:

在这里插入图片描述

第一个参数 o 为给定的对象,offset 为对象内的偏移量(其实就是一个字段到对象头部的偏移量,通过这个偏移量可以快速定位字段),expected 表示期望值,x 表示要设置的值。如果指定的字段的值等于 expected,那么就会把它设置为 x。

不难看出,compareAndSwapInt() 方法的内部,必然是使用 CAS 原子指令来完成的。此外,Unsafe 类还提供了一些方法,主要有以下几个(以 Int 操作为例,其他数据类型是类似的):

在这里插入图片描述

如果大家还记得 “3.3.4 深度剖析 ConcurrentLinkedQueue” 一节中描述的 ConcurrentLinkedQueue 实现,应该对 ConcurrentLinkedQueue 中的 Node 还有些印象。Node 的一些 CAS 操作也都是使用 Unsafe 类来实现的。大家可以回顾一下,以加深对 Unsafe 类的印象。

这里就可以看到,虽然 Java 抛弃了指针。但是在关键时刻,类似指针的技术还是必不可少的。这里底层的 Unsafe 实现就是最好的例子。但是很不幸,JDK 的开发人员并不希望大家使用这个类。获得 Unsafe 实例的方法是调动其工厂方法 getUnsafe() 。但是,它的实现却是这样:

在这里插入图片描述

注意加粗部分的代码,它会检查调用 getUnsafe() 函数的类,如果这个类的 ClassLoader 不为 null,就直接抛出异常,拒绝工作。因此,这也使得我们自己的应用程序无法直接使用 Unsafe 类。它是一个 JDK 内部使用的专属类。

注意: 根据 Java 类加载器的工作原理,应用程序的类由 App Loader 加载。而系统核心类,如 rt.jar 中的类由 Bootstrap 类加载器加载。Bootstrap 加载器没有 Java 对象的对象,因此试图获得这个类加载器会返回 null。所以,当一个类的类加载器为 null 时,说明它是由 Bootstrap 加载的,而这个类也极有可能是 rt.jar 中的类。

4.4.4 无锁的对象引用:AtomicReference

AtomicReference 和 AtomicInteger 非常类似,不同之处就在于 AtomicInteger 是对整数的封装,而 AtomicReference 则对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。在介绍 AtomicReference 的同时,我希望同时提出一个有关原子操作的逻辑上的不足。

之前我们说过,线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致。这个逻辑从一般意义上来说是正确的。但有可能出现一个小小的例外,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过这两次修改后,对象的值又恢复为旧值。这样,当前线程就无法正确判断这个对象究竟是否被修改过。如图 4.2 所示,显示了这种情况。

在这里插入图片描述

一般来说,发生这种情况的概率很小。而且即使发生了,可能也不是什么大问题。比如,我们只是简单地要做一个数值加法,即使在我取得期望值后,这个数字被不断的修改,只要它最终改回了我的期望值,我的加法计算就不会出错。也就是说,当你修改的对象没有过程的状态信息,所有的信息都只保存于对象的数值本身。

但是,在现实中,还可能存在另外一种场景,就是我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关,这时,AtomicReference 就无能为力了

打一个比方,如果有一家蛋糕店,为了挽留客户,决定为贵宾卡里余额小于 20 元的客户一次性赠送 20 元,刺激消费者充值和消费。但条件是,每一位客户只能被赠送一次。

现在,我们就来模拟这个场景,为了演示 AtomicReference,我在这里使用 AtomicReference 实现这个功能。首先,我们模拟用户账户余额。

定义用户账户余额:

在这里插入图片描述

接着,我们需要若干个后台线程,它们不断扫描数据,并为满足条件的客户充值。

在这里插入图片描述

上述代码第 8 行,判断用户余额并给予赠送金额。如果已经被其他用户处理,那么当前线程就会失败。因此,可以确保用户只会被充值一次。

此时,如果很不幸,用户正好正在进行消费,就在赠予金额到账的同时,他进行了一次消费,使得总金额又小于 20 元,并且正好累计消费了 20 元。使得消费、赠予后的金额等于消费前、赠予前的金额。这时,后台的赠予进程就会误以为这个账户还没有赠予,所以,存在被多次赠予的可能。下面模拟了这个消费线程:

在这里插入图片描述

上述代码中,消费者只要贵宾卡里的钱大于 10 元,就会立即进行一次 10 元的消费。执行上述程序,得到的输出如下:

在这里插入图片描述

从这一段输出中,可以看到,这个账户被先后反复多次充值。其原因正是因为账户余额被反复修改,修改后的值等于原有的数值,使得 CAS 操作无法正确判断当前数据状态。

虽然说这种情况出现的概率不大,但是依然是有可能出现的。因此,当业务上确实可能出现这种情况时,我们也必须多加防范。体贴的 JDK 也已经为我们考虑到了这种情况,使用 AtomicStampedReference 就可以很好地解决这个问题

4.4.5 带有时间戳的对象引用:AtomicStampedReference

AtomicReference 无法解决上述问题的根本因为是对象在修改过程中,丢失了状态信息。对象值本身与状态被画上了等号。因此,我们只要能够记录对象在修改过程中的状态值,就可以很好地解决对象被反复修改导致线程无法正确判断对象状态的问题。

AtomicStampedReference 正是这么做的。它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数来表示状态值)。当 AtomicStampedReference 对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当 AtomicStampedReference 设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。

AtomicStampedReference 的几个 API 在 AtomicReference 的基础上新增了有关时间戳的信息:

在这里插入图片描述

有了 AtomicStampedReference 这个法宝,我们就再也不用担心对象被写坏啦!现在,就让我们使用 AtomicStampedReference 来修正那个贵宾卡充值的问题:

在这里插入图片描述

第 2 行,我们使用 AtomicStampedReference 代替原来的 AtomicReference。第 6 行获得账户的时间戳,后续的赠予操作以这个时间戳为依据。如果赠予成功(第 13 行),则修改时间戳,使得系统不可能发生二次赠予的情况。消费线程也是类似,每次操作,都使得时间戳加 1(第 36 行),使之不可能重复。

执行上述代码,可以得到以下输出:

在这里插入图片描述

可以看到,账户只被赠予了一次。

4.4.6 数组也能无锁:AtomicIntegerArray

除了提供基本数据类型外,JDK 还为我们准备了数组等复合结构。当前可用的原子数组有:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,分别表示整数数组、long 型数组和普通的对象数组。

这里以 AtomicIntegerArray 为例,展示原子数组的使用方式。

AtomicIntegerArray 本质上是对 int[] 类型的封装,使用 Unsafe 类通过 CAS 的方式控制 int[] 在多线程下的安全性。它提供了以下几个核心 API:

在这里插入图片描述

下面给出一个简单的示例,展示 AtomicIntegerArray 的使用:

在这里插入图片描述

上述代码第 2 行,申明了一个内含 10 个元素的数组。第 3 行定义的线程对数组内 10 个元素进行累加操作,每个元素各加 1000 次。第 11 行,开启 10 个这样的线程。因此,可以预测,如果线程安全,数组内 10 个元素的值必然都是 10000。反之,如果线程不安全,则部分或者全部数值会小于 10000。

程序的输出结果如下:

在这里插入图片描述

这说明 AtomicIntegerArray 确实合理地保证了数组的线程安全性。

4.4.7 让普通变量也享受原子操作:AtomicIntegerFieldUpdater

有时候,由于初期考虑不周,或者后期的需求变化,一些普通变量可能也会有线程安全的需求。如果改动不大,我们可以简单地修改程序中每一个使用或者读取这个变量的地方。但显然,这样并不符合软件设计中的一条重要原则——开闭原则也就是系统对功能的增加应该是开放的,而对修改应该是相对保守的。而且,如果系统里使用到这个变量的地方特别多,一个一个修改也是一件令人厌烦的事情(况且很多使用场景下可能只是只读的,并无线程安全的强烈要求,完全可以保持原样)。

如果你有这种困扰,在这里根本不需要担心,因为在原子包里还有一个实用的工具类 AtomicIntegerFieldUpdater。它可以让你在不改动(或者极少改动)原有代码的基础上,让普通的变量也享受 CAS 操作带来的线程安全性,这样你可以修改极少的代码,来获得线程安全的保证。这听起来是不是让人很激动呢?

根据数据类型不同,这个 Updater 有三种,分别是:

  • AtomicIntegerFieldUpdater
  • AtomicLong-FieldUpdater
  • AtomicReferenceFieldUpdater

顾名思义,它们分别可以对 int、long 和普通对象进行 CAS 修改。

现在来思考这么一个场景。假设某地要进行一次选举。现在模拟这个投票场景,如果选民投了候选人一票,就记为 1,否则记为 0。最终的选票显然就是所有数据的简单求和。

在这里插入图片描述

上述代码模拟了这个计票场景,候选人的得票数量记录在 Candidate.score 中。注意,它是一个普通的 volatile 变量。而 volatile 变量并不是线程安全的。第 6~7 行定义了 AtomicIntegerFieldUpdater 实例,用来对 Candidate.score 进行写入。而后续的 allScore 我们用来检查 AtomicIntegerFieldUpdater 的正确性。如果 AtomicIntegerFieldUpdater 真的保证了线程安全,那么最终 Candidate.score 和 allScore 的值必然是相等的。否则,就说明 AtomicIntegerFieldUpdater 根本没有确保线程安全的写入。第 12~21 行模拟了计票过程,这里假设有大约 60% 的人投赞成票,并且投票是随机进行的。第 17 行使用 Updater 修改 Candidate.score(这里应该是线程安全的),第 18 行使用 AtomicInteger 计数,作为参考基准。

大家如果运行这段程序,不难发现,最终的 Candidate.score 总是和 allScore 绝对相等。这说明 AtomicIntegerFieldUpdater 很好地保证了 Candidate.score 的线程安全。

虽然 AtomicIntegerFieldUpdater 很好用,但是还是有几个注意事项

  • 第一,Updater 只能修改它可见范围内的变量。因为 Updater 使用反射得到这个变量。如果变量不可见,就会出错。比如如果 score 申明为 private,就是不可行的。
  • 第二,为了确保变量被正确的读取,它必须是 volatile 类型的。如果我们原有代码中未申明这个类型,那么简单地申明一下就行,这不会引起什么问题。
  • 第三,由于 CAS 操作会通过对象实例中的偏移量直接进行赋值,因此,它不支持 static 字段(Unsafe.objectFieldOffset() 不支持静态变量)。

好了,通过 AtomicIntegerFieldUpdater,是不是让我们可以更加随心所欲地对系统关键数据进行线程安全的保护呢?

4.4.8 挑战无锁算法:无锁的 Vector 实现

4.4.9 让线程之间互相帮助:细看 SynchronousQueue 的实现

4.5 有关死锁的问题

在学习了无锁之后,让我们重新回到锁的世界吧!在众多的应用程序中,使用锁的情况一般要多于无锁。因为对于应用来说,如果业务逻辑很复杂,会极大增加无锁的编程难度。但如果使用锁,我们就不得不对一个新的问题引起重视——那就是死锁。

那什么是死锁呢?通俗的说,死锁就是两个或者多个线程,相互占用对方需要的资源,而都不进行释放,导致彼此之间都相互等待对方释放资源,产生了无限制等待的现象。死锁一旦发生,如果没有外力介入,这种等待将永远存在,从而对程序产生严重的影响

用来描述死锁问题的一个有名的场景是 “哲学家就餐” 问题。哲学家就餐问题可以这样表述,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面,每两个哲学家之间有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东西。他们只能使用自己左右手边的那两只餐叉。哲学家就餐问题有时也用米饭和筷子而不是意大利面和餐叉来描述,因为很明显,吃米饭必须用两根筷子。

哲学家从来不交谈,这就很危险,可能产生死锁,每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反)。如图 4.3 所示,显示了这种情况。

在这里插入图片描述

最简单的情况就是只有两个哲学家,假设是 A 和 B。桌面也只有两个叉子。A 左手拿着其中一只叉子,B 也一样。这样他们的右手等在等待对方的叉子,并且这种等待会一直持续,从而导致程序永远无法正常执行。

下面让我们用一个简单的例子来模拟这个过程:

在这里插入图片描述

上述代码模拟了两个哲学家互相等待对方的叉子。哲学家 A 先占用叉子 1,哲学家 B 占用叉子 2,接着他们就相互等待,都没有办法同时获得两个叉子用餐。

如果在实际环境中,遇到了这种情况,通常的表现就是相关的进程不再工作,并且 CPU 占用率为 0(因为死锁的线程不占用 CPU),不过这种表面现象只能用来猜测问题。如果想要确认问题,还需要使用 JDK 提供的一套专业工具。

首先,我们可以使用 jps 命令得到 java 进程的进程 ID,接着使用 jstack 命令得到线程的线程堆栈

在这里插入图片描述

上面显示了 jstack 的部分输出。可以看到,哲学家 A 和哲学家 B 两个线程发生了死锁。并且在最后,可以看到两者相互等待的锁的 ID。同时,死锁的两个线程均处于 BLOCK 状态。

如果想避免死锁,除了使用无锁的函数外,另外一种有效的做法是使用第三章节介绍的重入锁,通过重入锁的中断或者限时等待可以有效规避死锁带来的问题。大家可以再回顾一下相关内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bm1998

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值