2.2CAS 是怎么实现的
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
- java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
- unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子 性。
简而言之,是因为硬件予以了支持,软件层面才能做到。
2.3CAS 有哪些应用
2.3.1实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0); // 相当于 i++ atomicInteger.getAndIncrement();
伪代码实现:
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
CAS 返回 false之后,就要进入循环。循环内部重新读取 value 的值到 oldValue 中。
此时再次CAS比较发现相等了,然后进行CAS操作,并返回true,同时循环结束了。这就是一个CAS操作的全部过程,这样就会避免了脏读问题,以及内存可见性问题,CAS操作会保证程序的正常运行,从而进行一个线程安全的自增(原子类这里的实现,在每次修改之前,再确认一下这个值是否符合要求)。
原子类实现代码:
import java.util.concurrent.atomic.AtomicInteger; /** * Created with IntelliJ IDEA. * Description: * User: 晓星航 * Date: 2023-08-02 * Time: 16:15 */ public class ThreadDemo28 { public static void main(String[] args) throws InterruptedException { //这些原子类,就是基于 CAS 实现了 自增,自减等操作,此时进行这类操作不需要加锁,也是线程安全的。 AtomicInteger count = new AtomicInteger(0); //使用原子类,来解决线程安全问题 Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { //因为 Java 不支持运算符重载,所以只能使用普通方法来表示自增自减。 count.getAndIncrement();//count++ //count.incrementAndGet();//++count //count.getAndDecrement();//count-- //count.decrementAndGet();//--count } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count.getAndIncrement();//count++ } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count.get()); } }
假设两个线程同时调用 getAndIncrement
- 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
- 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
注意:
- CAS 是直接读写内存的, 而不是操作寄存器.
- CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.
- 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要 进入循环.
在循环里重新读取 value 的值赋给 oldValue
- 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
- 线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
本来 check and set 这样的操作在代码角度不是原子的. 但是在硬件层面上可以让一条指令完成这 个操作, 也就变成原子的了.
2.3.2实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码
public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; }
这里的 private Thread owner = null; 是为了寻找当前锁是谁加的。
下面的while循环是为了监测当前的 owner 是否是null,如果是null,就进行交换,也就是把当前线程的引用赋值给 owner。如果赋值成功,此时循环结束,加锁完成了。
如果当前锁,已经被别的线程占用了,CAS就会发现, this.woner 不是null,CAS 就不会产生赋值,也同时返回 false。循环就会继续执行,并进行下次判定。
2.4CAS 的 ABA 问题
2.4.1什么是 ABA 问题
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
- 先读取 num 的值, 记录到 oldNum 变量中.
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这 个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手 机.
2.4.2ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一 些特殊情况.
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼!!
2.4.3解决方案
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
- CAS 操作在读取旧值的同时, 也要读取版本号.
- 真正修改的时候,
- 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
- 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的 手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意 这是翻新机, 就买. 如果买家在意, 就可以直接略过.
对比理解上面的转账例子
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100, 版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读 到的版本号为 1, 版本小于当前版本, 认为操作失败.
在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提 供了上面描述的版本管理功能.
关于AtomicStampedReference的具体用法此处不再展开. 有需要的同学自行查找文档了解 使用方法即可.
2.5相关面试题
- 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比 较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.
- ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当 前版本号比之前读到的版本号大, 就认为操作失败.
3.Synchronized 原理
3.1基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略 实现重量级锁的时候大概率用到的挂起等待锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
3.2加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。
3.2.1偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
偏向锁举例:比如说一个妹子为了寻求刺激,像同时和多个男生交往,但是一个一个官宣再分手比较低效,于是妹子想到了一个更好地方法,那就是只和小哥哥搞暧昧,不和他确立关系(有情侣之实,无情侣之名),此时妹子如果想换个小哥哥那么就很简单了。但是因为没有确立关系(加锁),因此如果有竞争对手来了(此时另外一个妹子也在接近我的小哥哥),我就立即和小哥哥确立关系(加锁),立即官宣。并且勒令小哥哥离这个妹子远点。
上述过程就是"“偏向锁”"的整个过程。
当 synchronized 发生锁竞争的时候,就会从偏向锁,升级成轻量锁。此时, synchronized 相当于是通过自旋的方式,来进行加锁的。如果别人很快就释放锁了,自旋是划算的,但是如果迟迟拿不到锁,一直自旋,并不划算。synchronized 自选不是无休止的自旋,自旋到一定程度之后,就会再次升级成 重量级锁 (挂起等待锁)
3.2.2轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 “自适应”
3.2.3重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.
3.3其他的优化操作
3.3.1锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
什么是 “锁消除”
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer(); sb.append("a"); sb.append("b"); sb.append("c"); sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
3.3.2锁粗化
锁的粒度:synchronized 包含的代码越多,粒度就越粗。包含的代码越少,粒度就越细。
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁粗化:就是将许多个需要加锁的代码直接加一个大锁,即将那些小锁合成为了一个大锁。
锁的粒度: 粗和细
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化(直接加一个大锁代替那几个小锁), 避免频繁申请释放锁.
举个栗子理解锁粗化
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂, 也不至于写出特别慢的程序.
JVM 开发者为了 Java 程序猿操碎了心.
3.4相关面试题
- 什么是偏向锁?
偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线 程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.
- synchronized 实现原理 是什么?
4.Callable 接口
4.1Callable 的用法
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本
- 创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.
- main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
- 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不 必等待了).
- 当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
static class Result { public int sum = 0; public Object lock = new Object(); } public static void main(String[] args) throws InterruptedException { Result result = new Result(); Thread t = new Thread() { @Override public void run() { int sum = 0; for (int i = 1; i <= 1000; i++) { sum += i; } synchronized (result.lock) { result.sum = sum; result.lock.notify(); } } }; t.start(); synchronized (result.lock) { while (result.sum == 0) { result.lock.wait(); } System.out.println(result.sum); } }
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class ThreadDemo29 { public static void main(String[] args)throws ExecutionException, InterruptedException { //使用 Callable 来计算 1 + 2 + 3 + ... + 1000 Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 1; i < 1000; i++) { sum += i; } return sum; } }; FutureTask<Integer> futureTask = new FutureTask<>(callable); Thread t = new Thread(futureTask); t.start(); Integer result = futureTask.get(); System.out.println(result); } }
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.
此时我们代码中的futureTask就类似于我们吃麻辣烫时,店家为了区分我们每个人的碗,于是给拿了两个牌子,一个夹在碗上,一个给客户取餐使用,这时我们的Thread就相当于拿到了对应的futureTask(牌子)从而获取到了callable(自己夹的食物)。
get 方法就是获取结果,get 会发生阻塞,直到 callable 执行完毕,get 才阻塞完成,才获取到结果。
理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是 FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.
Integer 和 int 区别:
1.Integer是int的包装类,int则是java的一种基本的数据类型;
2.Integer变量必须实例化之后才能使用,而int变量不需要实例化;
3.Integer实际是对象的引用,当new一个Integer时,实际上生成一个指针指向对象,而int则直接存储数值
4.Integer的默认值是null,而int的默认值是0。
4.2相关面试题
介绍下 Callable 是什么
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算 结果.
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.