【多线程】多线程进阶(包含大量面试常考概念)

1. CAS概念

什么是 CAS:
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线
程只会收到操作失败的信号。

  • CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
  • 因为硬件予以了支持,软件层面才能做到CAS。

2. CAS 应用

2.1 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

本来 check and set 这样的操作在代码角度不是原子的. 但是在硬件层面上可以让一条指令完成这
个操作, 也就变成原子的了.

3. 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

3.1 ABA问题的解决方案

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

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

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

3.1 相关面试题

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

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

4. Synchronized 原理

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

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.(自适应)
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁(随机调度)
  5. 是一种可重入锁(避免死锁问题)
  6. 不是读写锁(不区分)

加锁工作过程:JVM 将 synchronized 锁分为 无锁->偏向锁(并不是真正加锁,只是标记)->轻量级锁->重量级锁 状态。会根据情况,进行依次升级。

4.1 偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态.

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

4.2 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
  • 自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
    因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
    也就是所谓的 “自适应”

4.3 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .

  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
    这个线程, 尝试重新获取锁.

4.4 其他的优化操作

4.4.1 锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
比如:
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销. 此时就会产生 锁消除 ,提高效率;

4.4.2 锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化. (锁的粒度: 粗和细)

在这里插入图片描述
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释
放锁.

4.5 相关面试题

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

  • synchronized 实现原理 是什么?
    上述 标题4 的全部内容便是在描述这个问题。

5. JUC(java.util.concurrent) 的常见类

5.1 Callable 接口

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便借助多线程的方式计算结果.

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本

  • 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  • 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  • 把 callable 实例使用 FutureTask 包装一下.
  • 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的
    call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  • 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结
    果.
public static void main(String[] args) throws ExecutionException, InterruptedException {
        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();

        int result=futureTask.get();//会自动阻塞等待,直到计算完成,结果返回,才会继续向下执行
        System.out.println(result);
    }

计算结果正确:
在这里插入图片描述

5.2 理解 Callable

Callable 和 Runnable 相对, 都是描述一个 “任务”:Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果

因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. ,FutureTask 就可以负责这个等待结果出来的工作.

5.3 相关面试题

  • 介绍下 Callable 是什么

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便我们借助多线程的方式计算
结果.

Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作.

5.4 ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等.
  • trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  • unlock(): 解锁
ReentrantLock lock = new ReentrantLock(); 
lock.lock();   
try {    
 // working    
} finally {   //解锁操作需要放在finally中,防止未解锁出现死锁情况
 lock.unlock()    
} 

ReentrantLock 和 synchronized 的区别:

  • synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准
    库的一个类, 在 JVM 外实现的(基于 Java 实现).
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
    但是也容易遗漏 unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就
    放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启
    公平锁模式.
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一
    个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
    定的线程

如何选择使用哪个锁?

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock. (构造方法传入TRUE)

6. 原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

以 AtomicInteger 举例,常见方法有

addAndGet(int delta);   i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;

7. 线程池

(前面博文已经做出详细讲解,传送点此💨
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.

线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子”
中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了

7.1 ExecutorService 和 Executors

代码示例:

  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
   }
});

Executors 创建线程池的几种方式

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

Executors 本质上是 ThreadPoolExecutor 类的封装. (ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定. )

8. 信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.

8.1 理解信号量

可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.

  • 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作,acquire)
  • 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作,release)
  • Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

代码示例:

  • 创建 Semaphore 实例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 使用acquire操作一次获取一个资源,当获取到第四个要获取第五个资源时,由于没有release操作,此时因为资源数量不够再次获取线程陷入阻塞等待状态…
public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore=new Semaphore(4);
        semaphore.acquire(1);
        System.out.println("获取到一个资源");
        semaphore.acquire(1);
        System.out.println("获取到一个资源");
        semaphore.acquire(1);
        System.out.println("获取到一个资源");
        semaphore.acquire(1);
        System.out.println("获取到一个资源");


        semaphore.acquire(1);
        System.out.println("获取到一个资源");

    }

在这里插入图片描述

9. CountDownLatch

同时等待 N 个任务执行结束.

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

代码示例:

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
 public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    latch.countDown();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
        }
        // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");

    }

一段时间过后才显示 比赛结束!
在这里插入图片描述

10. 相关面试题

  1. 线程同步的方式有哪些?
    synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.

  2. 为什么有了 synchronized 还需要 juc 下的 lock?

以 juc 的 ReentrantLock 为例,

  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更
    灵活,

  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时
    间就放弃.

  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个
    true 开启公平锁模式.

  • synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程.

  • ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

  • AtomicInteger 的实现原理是什么?
    基于 CAS 机制(执行过程参考 “CAS” 章节. )

  1. 信号量听说过么?之前都用在过哪些场景下?
    信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
    使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为
    加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,
    直到前面的线程执行了 V 操作.

  2. 解释一下 ThreadPoolExecutor 构造方法的参数的含义
    参考上面的 ThreadPoolExecutor 章节

  • over !✨
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值