[多线程进阶]CAS与Synchronized基本原理
Posted Node_Hao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[多线程进阶]CAS与Synchronized基本原理相关的知识,希望对你有一定的参考价值。
专栏简介: JavaEE从入门到进阶
题目来源: leetcode,牛客,剑指offer.
创作目标: 记录学习JavaEE学习历程
希望在提升自己的同时,帮助他人,,与大家一起共同进步,互相成长.
学历代表过去,能力代表现在,学习能力代表未来!
目录:
1.CAS
1.1 什么是CAS?
CAS: 全称 Compare and swap , 字面意思是"比较并交换" , 一个 CAS 涉及到以下操作:
假设内存中原数据 V , A B 分别为寄存器中 , 旧的预期值和需要修改的新值.
- 1. 比较 A 与 V 是否相等.(比较)
- 2. 如果比较相等 , 将 B 写入 V. (交换)
- 3. 返回操作是否成功.
Tips: 上述交换过程中 , 并不关心 B 变量后续的情况 , 更关心的是 V 这个变量的情况(这里的交换可以理解为赋值) , CAS 可以理解成 CPU 的一个特殊指令 , 通过这个指令就可以一定程度的处理线程安全问题.
1.2 CAS伪代码
真实的 CAS 是一个原子硬件指令完成的 , 这个伪代码只是辅助理解 CAS 的工作流程.
boolean CAS(address , expectvalue , swapvalue)
if(&address == expectedValue)
&address = swapValue;
return true;
return false;
两种典型的不是"原子性"的代码
1.check and set (判定然后设定值)[上面的 CAS 伪代码就是这种形式]
2.read and update(i++)
当多个线程对某个资源进行 CAS 操作 , 只有一个线程操作成功 , 但是并不会阻塞其他线程 , 其实线程只会收到操作失败的信号.
CAS 可以视为是一种乐观锁(或者乐观锁是 CAS 的一种实现方式)
1.3 CAS 是怎么实现的
针对不同的操作系统 , JVM 用到了不同的 CAS 实现原理 , 简单来讲:
- Java 的 CAS 利用的是 unsafe 这个类提供的 CAS操作;
- unsafe 的 CAS 依赖的是 jvm 针对不同操作系统实现的 Atomic::cmpxchg
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作 , 并使用 CPU 硬件提供的 lock 机制保证其原子性.
简而言之 , 就是因为硬件给予了支持 , 软件层面才能做得到.
1.4 CAS 的应用场景
1) 实现原子类
标准库中提供了 java.util.concurrent.atomic 包 , 里面的类都是基于这个方式实现的.
典型的就是 AtomicInteger 类.
代码示例:
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo14
public static void main(String[] args) throws InterruptedException
AtomicInteger count = new AtomicInteger();
Thread t1 = new Thread(() ->
for (int i = 0; i < 5000; i++)
count.getAndIncrement();
);
Thread t2 = new Thread(() ->
for (int i = 0; i < 5000; i++)
count.getAndIncrement();
);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
伪代码实现:
Class AtomicInteger
private int value;
public int getAndIncrement()
int oldVaue = value;//相当于load操作
while((CAS(value , oldvalue , oldvalue+1) != true)
oldvalue = value;
return oldvalue;
oldervalue 相当于寄存器中的值 , value 相当于内存中的值.
正常情况下 , oldvalue 和 value 是一样的 , 可以直接执行 CAS 操作. 但有可能当oldvalue在内存中读取值后 , 线程发生了切换 , 另一个线程也修改了 value 的值 , 此时等这个线程重新回来 . oldvalue和value已经不相等了.
图示:
假设两个线程同时调用 getAndIncrement.
(1). 两个线程都读取 value 的值到 oldvalue 中.
(2). 线程1先进行 CAS 操作. 由于 oldvalue 和 value的值相同 , 直接对 oldvalue 进行赋值.
Tips:
- CAS 是直接写内存的不是操作寄存器的.
- CAS 读内存 , 比较 , 写内存 是一套原子的硬件指令.
(3) 线程2再进行 CAS 操作 , 第一次 CAS 的时候 , oldvalue和value不相等 , 不能进行赋值 , 因此需要进入循环. 在循环中重新读取 value 的值赋值给 oldvalue.
(4) 线程2 接下来进行第二次 CAS , 此时 oldvalue 和 value 相同 , 于是直接进行赋值操作.
(5) 线程1 和 线程2 返回各自的 oldvalue即可.
通过上述代码就可以实现一个原子类 , 不需要使用重量级锁 , 就可以完成多线程的自增操作.
本来 check and set 这样的操作在代码角度不是原子的 , 但是在硬件层面上可以让一条指令完成这个操作 , 也就变成原子的了.
2) 实现自旋锁(伪代码)
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;
1.5 CAS 的 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 呢?
1.6 ABA问题引发的 BUG
大部分情况下 t2 线程反复横跳对 t1 是否修改 num 是没有影响的 , 但不排除一些特殊情况.
假设小明有 100 存款 , 小明想从 ATM 机中取 50元. 不小心多按了几次 , 取款机创建了两个线程 , 并发的执行 -50 操作.
我们期望一个线程执行 -50 成功 , 另一个线程 -50 失败.
如果 CAS 的实现方式来完成这个扣款过程就会出现问题.
正常的过程:
- 1. 存款100 , 线程1 获取到当前的存款值为100 , 期望更新为50; 线程2 获取到当前存款值为 100 , 期望更新为50.
- 2. 线程1 扣款成功 , 存款改为50 , 线程2 阻塞等待中.
- 3. 轮到线程2 执行 , 发现当前存款为 50 , 和之前读到的 100 不相同 , 执行失败.
异常的过程:
- 1. 存款100 , 线程1 获取到当前的存款值为100 , 期望更新为50; 线程2 获取到当前存款值为 100 , 期望更新为50.
- 2. 线程1 扣款成功 , 存款改为50 , 线程2 阻塞等待中.
- 3. 在线程2 执行之前 , 小明的朋友正好给他转了50 , 账户余额变为100.
- 4. 轮到线程2 执行了 , 发现当前存款为100 , 和之前读到的100相同 , 再次执行扣款操作.
此时扣款操作执行了两次 , 这就是 ABA 问题引发的 BUG.
解决方案:
给要修改的值 , 引入版本号. 在 CAS 比较当前值和旧值的同时 , 也要比较版本号是否符合预期.
真正修改时:
- 在当前值等于旧值的前提下:
- 如果当前版本号和之前读到的版本号相同 , 则修改数据 , 并把版本号 + 1.
- 如果当前版本号高于之前读到的版本号 , 就操作失败(认为数据已经被修改过了).
在 Java 标准库中提供了 AtomicStampedReference<E>类. 这个类可以对某个类进行包装 , 在内部就提供了上述描述的版本管理功能.
1.7 相关面试题
1. 讲解下自己理解的 CAS 机制.
CAS 全称 Compare and Swap , 相当于一个原子操作 , 同时完成"读取内存 比较数据是否相等 修改内存" 这三个步骤. 本质上是一条 CPU 指令.
2. ABA 问题怎么解决?
给要修改的数据引入一个版本号 , CAS 不仅要比较当前值和旧值是否相等 , 还要比较版本号是否符合预期. 在当前值和旧值相等的前提下 , 如果当前版本号和之前读到的版本号一致 , 就修改数据 , 并让版本号自增. 如果发现当前版本号比之前读的版本号大 , 操作失败.
2. Synchronized 基本原理
2.1 基本特点
结合上述所策略 , 我们可以总结出 Synchronized 具有以下特性(只考虑 jdk 1.8)
- 1. 开始是乐观锁 , 如果锁冲突频繁 , 就转换为悲观锁.
- 2. 开始是轻量级锁 , 如果锁持有时间较长 , 就转换为重量级锁.
- 3. 实现轻量级锁的时候大概率使用自旋锁策略.
- 4. 是一种不公平锁.
- 5 . 是一种可重入锁.
- 6. 不是读写锁.
2.2 加锁过程
JVM 将 synchronized 锁分为: 无锁 , 偏向锁 , 轻量级锁 , 重量级锁 状态. 会根据情况 , 进行依次升级.
1) 偏向锁
第一个加锁的线程 , 优先进入偏向锁状态.
偏向锁不是真的"加锁" , 只是给对象做一个"偏向锁的标记". 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁 , 那么就不用执行加锁操作(由此避免了加锁的开销)
如果后续有线程来竞争该锁 , 那就取消原来偏向锁的状态 , 进入一般的轻量级锁状态.(刚才已在锁对象中记录了当前锁属于哪个线程 , 很容易识别当前申请锁的线程是不是原来的线程)
Tips: 偏向锁本质上相当于 "延迟加锁" , 能不加锁就不加锁 , 尽量避免不必要的加锁开销.
但该做的标记还是得做 , 否则无法区分何时需要真正加锁.
举个例子: 假设小明有个女朋友叫小美 , 但由于没有其他女生对小明感兴趣 , 因此小美有恃无恐 , 一直拖着不和小明结婚. 直到有一天 , 出现一个对小明感兴趣的女生 , 小美慌了 , 立即和小明去领证.
2)轻量级锁
随着其他线程进入竞争 , 偏向锁状态被消除 , 进入轻量级锁状态(自适应的自旋锁)
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存(比如 null => 该线程引用)
- 如果更新成功 , 则认为加锁成功
- 如果更新失败则认为锁被占用 , 继续自旋式的等待(不放弃 CPU)
何为"自适应"?
自选操作会让 CPU 一直空转 , 比较浪费 CPU 资源.
因此此处的自旋不会一直进行 , 达到一定次数或时间后 , 就不在自旋了.也是"自适应"
3) 重量级锁
如果竞争进一步激烈 , 自选不能快速获取到锁状态 , 就会膨胀为重量级锁
此处的重量级锁就是指内核提供的 mutex.
- 执行加锁操作 , 先进入内核态.
- 在内核态判定当前锁是否被占用.
- 如果该锁没有被占用 , 则加锁成功 , 并切换会用户态.
- 如果该锁被占用了 , 则加锁失败 , 此时线程进入锁的等待队列(挂起) , 等待被操作系统唤醒.
- 经过漫长的等待 , 该锁被其他线程释放 , 操作系统也想起了这个被挂起的线程 , 于是唤醒这个线程重新尝试获取锁.
2.3 其他的优化操作
锁消除
编译器 + JVM 判断锁是否可以消除 , 如果可以 , 就直接消除.
有些应用程序的代码块 , 在单线程的情况下也用到了synchronized(例如 StringBuffer)
StringBuffer str = new StringBuffer();
str.append("H");
str.append("e");
str.append("l");
str.append("l");
str.append("o");
此时每次调用 append 操作都会涉及到加锁/解锁 , 在单线程情况下是不必要的 , 白白浪费资源开销.
锁粗化
一段操作中如果多次进行加锁操作 , 编译器 + JVM 会自动进行锁的粗化.
锁的力度: 粗和细
实际开发过程中使用细粒度锁 , 是希望释放锁的时候其他线程能使用锁.
但如果实际上并没有那么多的线程抢占锁 , 这种情况下 JVM 就会把锁粗化 , 频繁的申请释放锁.
以上是关于[多线程进阶]CAS与Synchronized基本原理的主要内容,如果未能解决你的问题,请参考以下文章