[多线程进阶]CAS与Synchronized基本原理


专栏简介: JavaEE从入门到进阶

题目来源: leetcode,牛客,剑指offer.

创作目标: 记录学习JavaEE学习历程

希望在提升自己的同时,帮助他人,,与大家一起共同进步,互相成长.

学历代表过去,能力代表现在,学习能力代表未来! 


目录:

1.CAS

1.1 什么是CAS?

1.2 CAS伪代码

1.3 CAS 是怎么实现的

1.4 CAS 的应用场景

1) 实现原子类

2) 实现自旋锁(伪代码)

1.5 CAS 的 ABA 问题

1.6 ABA问题引发的 BUG

1.7 相关面试题

2. Synchronized 基本原理

2.1 基本特点

2.2 加锁过程

2.3 其他的优化操作


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 就会把锁粗化 , 频繁的申请释放锁.


猜你喜欢

转载自blog.csdn.net/liu_xuixui/article/details/128896521