JUC 知识补充
封面来源:碧蓝航线 箱庭疗法 活动CG
1. JMM
谈谈对 volatile 的理解
volitile
是 Java 虚拟机提供的轻量级的同步机制,它有三大特性:
1、保证可见性
2、不保证原子性
3、禁止指令重排
什么是 JMM
参考链接:java内存模型JMM理解整理
JMM(Java Memory Model),即:Java 内存模型。这是一种不存在的东西,这属于一种概念、规范或约定。
因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java 内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM 从 Java 5 开始的 JSR-133 发布后,已经成熟和完善起来。
关于 JMM 的一些同步约定:
1、线程解锁前,必须将共享变量 立即 刷回主存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存中
3、加锁和解锁是同一把锁
内存划分
参考链接:java内存模型JMM理解整理
JMM 规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是 Java 堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。
JVM 在设计时候考虑到,如果 Java 线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因此 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。
内存交互操作
参考链接:java内存模型JMM理解整理
内存交互操作有 8 种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于 double
和 long
类型的变量来说,load、store、read 和 write 操作在某些平台上允许例外):
-
lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态
-
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
-
load(载入):作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中
-
use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
-
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
-
store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
-
write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中
JMM对这八种指令的使用,制定了如下规则:
-
不允许 read 和 load、store 和 write 操作之一单独出现。即:使用了 read 必须 load,使用了 store 必须 write
-
不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存
-
不允许一个线程将没有 assign 的数据从工作内存同步回主内存
-
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 assign 和 load 操作
-
一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁
-
如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值
-
如果一个变量没有被 lock,就不能对其进行 unlock 操作,也不能 unlock 一个被其他线程锁住的变量
-
对一个变量进行 unlock 操作之前,必须把此变量同步回主内存
JMM 对这八种操作规则和对 volatile 的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用 Java 的 Happen-Before 规则来进行分析。
Happen-Before 被翻译成先行发生原则,意思就是当 A 操作先行发生于 B 操作,则在发生 B 操作的时候,操作 A 产生的影响能被 B 观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。
比如在上述的内存交互操作中也存在问题,比如:线程 B 先将主存的 flag 修改为 false 并成功写入,线程 A 还在工作内存中对 flag 进行操作,这个时候的 flag 还是未修改前的 true,这就是一种数据不一致。
或者说:线程 B 修改了值,但是线程 A 不能及时可见。那应该怎么办呢?
使用 volatile
关键字即可!
我们可以测试一下不可见性:
1 | /** |
运行上述代码后,控制台会输出 1,并且一直不会结束。这主要是线程 A 不知道主线程中的 num 已经发生了变化,以为仍然是 0,因此一直循环,导致程序不会停止。
这种情况啊,使用 volatile
就可以解决。
2. volatile
2.1 volatile 是什么
volatile
在 Java 语言中是一个关键字,用于修饰变量。被 volatile
修饰的变量,表示这个变量在不同线程中是共享,编译器与运行时都会注意到这个变量是共享的,因此不会对该变量进行重排序。
就像最开始说的那样,volatile
有三大特性:
1、保证可见性
2、不保证原子性
3、禁止指令重排
2.2 保证可见性
还是使用第一节最后给出的代码,我们对静态变量 num 添加一个 volatile
关键字看看:
1 | /** |
添加 volatile
关键字后,控制台打印出 1,程序会停止运行,也就表示线程 A 感知到 num 的值发生了变化,因此就不再进行循环了。
2.3 不保证原子性
事务的特性
原子性这个词在数据库的事务中听过,事务有四个特性,分别是:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durabiliy),即:ACID。
根据高等教育出版社的《数据库系统概论(第 5 版)》中的概念:
原子性: 事务是数据库的逻辑工作单位,事务中包括的诸操作要么都做,要么都不做。
我的理解是:表示组成一个事务的多个数据库操作是一个不可分割的原子单元,只有所有的操作执行成功,整个事务才提交。事务中的任何一个数据库操作失败,已经执行的任何操作都必须被撤销,让数据库返回初始状态。
一致性: 事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。
我的理解是:假设账户 A 向账户 B 转了 100 元,那么最后账户 A 与账户 B 的金额总数不会变。
隔离性: 一个事务的执行不能被其他事务干扰。即:一个事务的内部操作及使用的数据对其他并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
持久性: 持久性也称为永久性(Permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。
我的理解是:在事务提交后,数据库突然崩溃,在数据库重启时,也必须保证能够通过某种机制恢复数据。
volatile 不保证原子性
这里的原子性是指:一个操作或者多个操作,要么全部执并且执行的过程不会被任何因素打断,要么就都不执行。
假设有以下代码:
1 | /** |
上述代码理论上应该输出 4w,但是实际运行上述代码后输出的结果都小于 4w。
那么我们对 num
添加 volatile
关键字呢?
1 | public class VolatileTest2 { |
输出结果仍就无法达到 4w,这也证明了 volatile
不能保证原子性。
同时 num++;
这个语句的执行也不是一个原子性的操作,我们可以查看底层字节码代码的实现:
前往 target 或 out 目录下,进入 VolatileTest2
类对应的文件夹中,使用命令行,执行以下语句:
1 | javap -c VolatileTest2.class |
可以看到 num++
执行了三个命令,因此在多线程环境下是不安全的。那么怎么解决?
可以对 add()
方法添加关键字 synchronized
或者使用 Lock
锁。那不使用这两个呢?
JUC 中还剩一个 java.util.concurrent.atomic
包没讲,这个包中就包含了一些原子类。
num
变量的类型是整型,可以将其修改为原子类的整型:
然后对案例代码进行修改:
1 | public class VolatileTest2 { |
运行后,成功输出:mian 40000
getAndIncrement
底层就直接使用的 num++
?点开后看看?
1 | /** |
有一个 unsafe
,这是什么?再点击看看:
1 | // setup to use Unsafe.compareAndSwapInt for updates |
这里有一个 Unsafe
类,这又是啥,再点开看看,点开后就会发现是一些奇怪的代码,而且来自一个奇怪的包:
1 | package sun.misc; |
这些类的底层都是直接和操作系统有关,是在内存中修改值,而 Unsafe
类是一个很特殊的存在。
先卖个关子,待会讲!
2.4 指令重排
什么是指令重排
其实我们写的程序,计算机并不按照你写的那样去执行。
程序的执行步骤为:
上图给出的就是三种指令重排。
参考链接:漫画:volatile对指令重排的影响
指令重排是指 JVM 在编译 Java 代码的时候,或者 CPU 在执行 JVM 字节码的时候,对现有的指令顺序进行重新排序。
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。
比如:
1 | int x = 1; // 1 |
针对上述四句代码,经过指令重排后可能会变成:1234、2134、1324,但是不可能是:4123
这是因为:处理器在进行指令重排时,会考虑数据间的依赖性。
又假设有两个线程 A 和 B,同时有四个变量 a、b、c、d,它们的初始值都是 0:
线程 A | 线程 B |
---|---|
x = a | y = b |
b = 1 | a = 2 |
正常的结果:x = 0,y = 0。
如果对上述代码执行指令重排后,可能会出现下面的情况:
线程 A | 线程 B |
---|---|
b = 1 | a = 2 |
x = a | y = b |
指令重排导致的结果为:x = 2,y = 1
如果使用了 volatile
关键字就可以避免指令重排。
先说一个概念,内存屏障。内存屏障(Memory Barrier)又称内存栅栏,是一个 CPU 指令,它的作用有两个:
1、保证特定操作的执行顺序。
2、保证某些变量的内存可见性(利用该特性实现 volatile
的内存可见性)。
可以通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化!
JMM 基于保守策略的 JMM 内存屏障插入策略:
1、在每个 volatile
写操作的前面插入一个StoreStore 屏障
2、在每个 volatile
写操作的后面插入一个 SotreLoad 屏障
3、在每个 volatile
读操作的后面插入一个 LoadLoad 屏障
4、在每个 volatile
读操作的后面插入一个 LoadStore 屏障
使用 volatile
可以保持可见性、不能保证原子性,由于内存屏障,可以保证避免指令重排的产生。
总结
通过可见性、原子性、禁止指令重排就可以保证线程的安全。
工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized
或 volatile
关键字解决,它
们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题和有序性问题,可以利用 volatile
关键字解决,因为 volatile
可以禁止指令重排。
3. 深入理解 CAS
3.1 什么是 CAS
CAS(Compare-and-Swap),即比较且替换,是一种实现并发算法时常用到的技术,Java 并发包中的很多类都使用了 CAS 技术。
JDK 5 增加了并发包 java.util.concurrent.*
,其下面的类使用 CAS 算法实现了区别于 synchronouse
同步锁的一种乐观锁。JDK 5 之前 Java 语言是靠 synchronized
关键字保证同步的,这是一种独占锁,也是是悲观锁。
我们使用 AtomicInteger
测试一下:
1 | public class CASDemo { |
输出结果:
1 | true |
3.2 CAS 底层与 Unsafe
CAS 是操作系统层上的原子性操作。
1 | public static void main(String[] args) { |
我们在前面使用了 AtomicInteger
原子类,点开源码查看:
1 | public final int getAndIncrement() { |
可以看到有一个 unsafe
,我们再点击查看一下:
1 | // setup to use Unsafe.compareAndSwapInt for updates |
Java 无法操作内存,Java 可以调用 C++ 操作内存,使用 native()
方法。
这个 Unsafe
类就相当于 Java 的后门,可以通过这个类操作内存。
再点开 getAndAddInt()
方法的源码:
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
我们对 getAndAddInt()
方法与进行比较:
先获取当前对象与内存地址偏移量,然后根据这两个值获取当前内存地址的值。
然后执行了一个 CAS(比较并交换),如果var1 和 var2 相比于 var5 并没有发生改变,就让 var5 加上 var4,也就是 var5 加一。
这也是一种期望值与更新值之间的关系。
Unsafe 总结
UnSafe
是 CAS 的核心类,由于 Java 方法无法直接访问底层系统,需要通过本地(native)方法来访问,UnSafe
相当于一个后门,基于该类可以直接操作特定内存的数据,Unsafe
类存在于 sun.misc
包中,其内部方法操作可以像 C 的指针一样直接操作内存,因为 Java 中 CAS 操作的执行依赖于 Unsafe
类的方法。
注意:Unsafe
类中的所有方法都是 native
修饰的,也就是说 Unsafe
类中的方法都直接调用操作系统底层
资源执行相应任务。
CAS 总结
CAS:比较当前工作内存中的值和主内存中的值是否相等,如果这个值是期望的,那么就执行操作。如果不是就一直循环。
CAS 并发原语体现在 Java
语言中就是 sun.misc.Unsafe
类中的各个方法。调用 UnSafe
类中的 CAS 方法,
JVM 会帮我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 的原子指令,不会造成所谓的数据不一致问题。
从 compareAndSwapInt()
方法中可以看到,CAS 有 3 个操作数,内存值 A,旧的预期值 B,更新值 C。当且仅当 A 与 B 相等时,将 A 更新为 C,否则一直循环。(自旋锁)
CAS 似乎很完美?但它也有缺点:
1、如果 CAS 失败,会一直循环,那么循环时间开销很大
2、只能保证一个共享变量的原子操作。对多个共享变量进行原子操作时,就只能用锁。
3.3 ABA 问题
简单来说:你有一个女朋友,然后分手了,过了那么久又复合了,鬼知道这些时间内你女朋友身上发生了啥。
线程 M 从内存位置取出 A ,此时 A = 1,还未执行 CAS 操作,突然线程 N 也从内存位置取出 A,A 仍然是 1,然后立即执行两个 CAS 操作,将 1 变成 3,又将 3 变成 1,最后线程 M 执行 CAS 操作,将 A 更新为 2。
尽管线程 M 的 CAS 操作成功,但并不是说就没问题的。
代码测试:
1 | /** |
运行后会输出三个 true,但是我们知道中间发生了变化。
那怎么解决 ABA 问题呢?
4. 原子引用
要想解决 ABA 问题,可以引入原子引用!这里对应了一个思想:乐观锁。
那什么是原子引用?在 java.util.concurrent.atomic
包中,有这样几个类:
使用 AtomicStampedReference
类来测试一下:
1 | /** |
运行上述代码得:
使用原子引用后,从输出结果来看确实解决了 ABA 问题。
踩坑补充
在上述代码中,AtomicStampedReference
对象的泛型是 Integer
包装类,需要注意对象的引用问题。
Integer
使用了对象缓存机制,默认返回是 -128 ~ 127,推荐使用静态工厂方法 valueOf()
获取对象实例,而不是 new
。因为 valueOf()
使用了缓存,而 new
一定会创建新的对象分配新的内存空间。
在阿里巴巴开发手册中有这样一段:
最开始将数值设置超过了 127,程序运行一直不符合预期,后改成范围内的数值才测试成功!
5. Java 锁
5.1 公平锁与非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,CPU 唤醒阻塞线程的开销会很大。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少 CPU 唤醒线程的开销,整体的吞吐效率会高点,CPU 也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
参考链接:面试官:说一下公平锁和非公平锁的区别?
ReentrantLock
就是一个非公平锁,源码中有:
1 | public ReentrantLock() { |
对于 synchronized
而言,也是一种非公平锁。
5.2 可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会 自动 获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
将相当于进入了家里的大门后,就可以进入其他房间了。
Java 中 ReentrantLock
和 synchronized
都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
参考链接:可重入锁 VS 非可重入锁
synchronized 测试
1 | /** |
运行后有:
发现线程 A 在外层获取锁时,也会自动获取里面的锁,因此线程 A 的发短信和打电话总是在一起,中间不会穿插线程 B 的操作,同理对线程 B 也是这样。
ReentrantLock 测试
1 | /** |
运行代码后有:
可以发现运行结果与 synchronized
关键词的测试一样。
Lock
锁可以很清晰的看出有两把锁,假设叫 lock1 和 lock2,那么解锁顺序如下:
1 | lock1 ---> lock2 ---> unlock2 ---> unlock1 |
还需要注意的是:Lock 锁必须成对出现,否则会死锁!
5.3 自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成 busy-waiting。
概念参考链接:认真的讲一讲:自旋锁到底是什么
我们在 Unsafe
类中已经看到过自旋锁了,getAndAddInt()
就是使用了自旋锁:
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
自旋锁测试
自己编写一个简单的自旋锁:
1 | /** |
编写一份测试代码进行测试:
1 | /** |
运行测试代码后有:
T2 一直在自旋,等待 T1 解锁,因此输出结果最终一定是 T1 先解锁,然后 T2 才解锁。
5.4 乐观锁与悲观锁
参考链接:面试必备之乐观锁与悲观锁
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现。
乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。乐观锁可能会导致 ABA 问题。
5.5 死锁
参考链接:死锁
什么是死锁?
百度百科是这么说的:死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁的规范定义:集合中的每一个进程都在等待只能由本集合中的其他进程才能引发的事件,那么该组进程是死锁的。
死锁产生的条件
死锁的发生必须具备以下四个必要条件:
互斥条件: 指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
请求和保持条件: 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
不剥夺条件: 指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
环路等待条件: 指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
死锁产生的原因
1、竞争资源引起进程死锁
2、可剥夺资源和不可剥夺资源
3、竞争不可剥夺资源
4、竞争临时资源
死锁的示例
1 | package com.yang.lock; |
运行后,除非人为关闭,否则程序一直运行,控制台输出:
1 | T1 lock: lockA =>get lockB |
如何解决
使用 Java 自带的工具解决:
1、运行程序,产生死锁
2、在 IDEA 的终端 Terminal 输入命令 jps -l
,定位进程号:
3、使用 jstack 进程号
查看堆栈信息。
同样在 IDEA 的终端输入命令,上图给出进程号为 20336,因此输入命令 jstack 20336
则有:
根据上图,我们就很简单地看到了死锁的信息。