3.多线程(进阶)(二)

简介: 3.多线程(进阶)(二)

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

  1. 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)

  1. 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.

注意:

  • CAS 是直接读写内存的, 而不是操作寄存器.
  • CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.

  1. 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要 进入循环.

在循环里重新读取 value 的值赋给 oldValue

  1. 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.

  1. 线程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 的方式来完成这个扣款过程就可能出现问题.

正常的过程

  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 问题搞的鬼!!

2.4.3解决方案

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

  • CAS 操作在读取旧值的同时, 也要读取版本号.
  • 真正修改的时候,
  • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
  • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的 手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意 这是翻新机, 就买. 如果买家在意, 就可以直接略过.

对比理解上面的转账例子

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.

我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.

为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.

  1. 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100, 版本号为 1, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读 到的版本号为 1, 版本小于当前版本, 认为操作失败.

在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提 供了上面描述的版本管理功能.

关于AtomicStampedReference的具体用法此处不再展开. 有需要的同学自行查找文档了解 使用方法即可.

2.5相关面试题

  1. 讲解下你自己理解的 CAS 机制

全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比 较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.

  1. ABA问题怎么解决?

给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当 前版本号比之前读到的版本号大, 就认为操作失败.

3.Synchronized 原理

3.1基本特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略 实现重量级锁的时候大概率用到的挂起等待锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

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相关面试题

  1. 什么是偏向锁?

偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线 程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.

  1. 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 就可以负责这个等待结果出来的工作.

目录
相关文章
|
Linux API C++
|
关系型数据库 MySQL 编译器
C++进阶 多线程相关(下)
C++进阶 多线程相关(下)
60 0
|
安全 Java 调度
多线程【进阶版】(下)
多线程【进阶版】
61 0
|
安全
多线程【进阶版】(中)
多线程【进阶版】
53 0
|
2月前
|
存储 安全 Java
多线程进阶
本文介绍了多种锁策略及其应用。首先区分了乐观锁与悲观锁:乐观锁假定冲突较少,悲观锁则预期频繁冲突。接着讨论了自旋锁与挂起等待锁,前者适合冲突少且持有时间短的场景,后者适用于长锁持有时间。随后对比了轻量级锁与重量级锁,前者开销小、效率高,后者开销大、效率低。此外,文章还探讨了公平锁与非公平锁的区别,以及可重入锁如何避免死锁。最后介绍了读写锁,其允许多个读操作并发,但写操作独占资源。通过详细解析各种锁机制的特点及适用场景,本文为读者提供了深入理解并发控制的基础。
42 15
多线程进阶
|
6月前
|
安全 调度
多线程入门
多线程入门
130 1
|
6月前
|
安全 算法 Java
多线程知识点总结
多线程知识点总结
66 3
|
算法 Ubuntu C++
[总结] C++ 知识点 《四》多线程相关
[总结] C++ 知识点 《四》多线程相关
|
安全 程序员 API
[笔记]C++并发编程实战 《一》你好,C++的并发世界(一)
[笔记]C++并发编程实战 《一》你好,C++的并发世界