其他分享
首页 > 其他分享> > 多线程(进阶版)(八股文)

多线程(进阶版)(八股文)

作者:互联网

一、常见的锁策略

1. 乐观锁 vs 悲观锁

悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。

乐观锁的一个重要的功能就是检测出数据是否发生访问冲突,我们可以引入一个 “版本号” 来解决:
:::success
设当前余额为 100,引入一个版本号 versions,初始值为 1,并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”

  1. 线程 A 此时准备将其取出(versions = 1, balance = 100),线程 B 也读入此信息(versions = 1,balance = 100)

image.png

  1. 线程 A 操作的过程中并从其账户余额中扣除 50 (100-50),线程 B 从其账户余额中扣除 20 (100-20)

image.png

  1. 线程 A 完成修改工作,将数据版本号加1(versions = 2),连同账户扣除余额(balance = 50),写回到内存中。

image.png

  1. 线程 B 完成了操作,也将版本号加1(versions = 2)试图向内存中提交数据(balance=80),但此时对比版本发现,线程 B 提交的数据版本号为 2,数据库记录的当前版本号也为2,不满足** “提交版本必须大于记录当前版本才能执行更新” **的乐观锁策略。就认为这次操作失败。

image.png
:::

2. 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方之间以及读写方之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

一个线程对于数据的访问,主要存在两种操作:读数据 和 写数据

读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock类,实现了读写锁。(Reentrant:可重入)

其中,

注意:只要涉及到 “互斥”,就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了。
因此,尽可能减少 “互斥” 的机会,就是提高效率的重要途径。

读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).

Synchronized 不是读写锁

3. 轻量级锁 vs 重量级锁

轻量级锁:加锁机制尽可能不适应 mutex,而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex

重量级锁:加锁机制重度依赖了 OS 提供了 mutex

synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

4. 自旋锁 vs 挂起等待锁

自旋锁:
如果线程获取不到锁,不是阻塞等待,而是循环的快速的再试一次,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁。
挂起等待锁:
如果线程获取不到锁,就会阻塞等待。什么时候结束阻塞,就取决于操作系统具体的调度,当线程挂起的时候,不占用 CPU。

自旋锁伪代码:

while (抢锁(lock) == 失败) {}

如果获取锁失败,立即再尝试获取锁,无线循环,直到获取锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。

自旋锁是一种典型的 轻量级锁 的实现方式:

挂起等待锁 和 自旋锁 的使用场景:
大的原则来说:

  1. 如果锁冲突的概率比较低,使用自旋锁比挂起等待锁,更合适。
  2. 如果线程持有锁的时间比较短,使用自旋锁比挂起等待锁,更合适。
  3. 如果对 CPU 比较敏感,不希望吃太多的 CPU 资源,那么就不太合适使用自旋锁。

这个自旋和挂起等待,这样的策略都在 synchronized 中内置。

5. 公平锁 vs 非公平锁

假设三个线程 A,B,C,A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待。然后 C 也尝试获取锁,C也获取失败,也阻塞等待。
当线程 A 释放锁的时候,会发生什么?
公平锁:遵守 ”先来后到“ ,B 比 C 先来的。当 A 释放锁,B 就能先于 C 获取到锁。
非公平锁:不遵守 “先来后到”,B 和 C 都有可能获取到锁。

synchronized 是非公平锁。

6. 可重入锁 vs 不可重入锁

如果针对同一把锁,连续加锁两次:
可重入锁:不出现死锁。
不可重入锁:出现死锁。

死锁的例子可以查看笔记 线程安全-》synchronized关键字-》synchronized特性-》可重入

引入 可重入的概念,就是在解决这个死锁的问题:
让当前的锁,记录下这个锁是谁持有的,如果发现当前有同一个线程再次尝试获取锁,这个时候就让代码能够继续运行,而不是阻塞等待。
同时在这个锁里也维护一个计数器,计数器记录了当前这个线程,针对这把锁尝试加了几次锁。
每次加锁,计数器++,每次解锁,计数器 --,直到计数器为0了,此时才真的释放锁,此时才能够让其他线程获取到这个锁。

synchronized 就是 可重入

扩展:synchronized 与 锁策略对应关系

synchronized 是一个自适应的锁,会根据实际情况来决定采取哪种锁策略。

二、CAS(compare and swap)

1. 什么是 CAS

compare:拿两个内存进行比较,或者拿寄存器的值和内存的值比较。
swap:如果值相同,就把 另一个寄存器/某个内存 的值 和当前的这个内存的值,进行交换。

举个例子:
:::success
假设内存中的原数据 V,旧的预期值 A,需要修改的新值 B。

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

下面来看个伪代码帮助理解 CAS:
image.png
这是一个 CAS 函数,里面有 3 个参数。

代码的意思:
内存地址存储的值 与 旧值 相比较,如果相等,就说明内存地址存储的值没有改变,新值 就会把 内存地址上的旧值给覆盖掉。
如果内存地址中存储的值 与 新值 不相等,就什么不做。交换成功返回 true,交换失败返回 false。

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

关键在于,这个操作,是个 “原子的”。CPU 提供了一组 CAS 相关的指令,使用一条这样的指令就可以完成上面的比较交换的过程。

CAS 可以视为一种乐观锁。(或者可以理解成 CAS 是乐观锁的一种实现方式)

2. CAS 是怎么实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

简而言之,是因为 硬件给予了支持,软件层面才能做到。

3. CAS 的应用

3.1 实现原子类

标准库中提供了 java.util.concurrent.atomic包,里面的类都是基于 CAS 来实现的。
image.png
伪代码实现:

class AtomicInteger { 
    private int value;
    public int getAndIncrement() { 
        int oldValue = value; 
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
        } 
        return oldValue; 
    }
}

image.png
假设两个线程同时调用 getAndIncrement

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

image.png

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

注意:

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

image.png

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

image.png

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

image.png

  1. 线程1 和 线程2 返回各自的 oldValue 的值即可。

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;
    }
}

在 while 循环里,如果 owner 一直不为 null,这个循环就会一直执行。
如果为 null 则改为当前线程,即此线程拿到了锁。
如果不为 null,就返回false,进入下一次循环,下一次循环仍是 CAS 操作。

4. CAS 的 ABA 问题

4.1 什么是 ABA 问题

假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A。
接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要

但是,在 t1 执行到这两个操作之间,t2 线程可能把 num 的值改为 B,又从 B 改成了 A。

到了这一步,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程。
image.png
虽然最终的值和旧值相同,但是它确实改变了。而当前的决策:只要值相同就没有发生改变。
这是一个漏洞,在大多数情况下是没有什么影响的。
但是在极端的情况也会引起 bug。
这种问题称为 ABA问题:本来是旧值 A,当前值也是 A。但是你不知道这个A是原来的A,还是从 A -> B -> A。

4.2 ABA 问题引来的 bug

:::success
假设一个老哥有 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 问题。
:::

4.3 ABA 问题的解决方案

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

对比上面的转账的例子:
:::success
假设一个老哥有 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,版本小于当前版本,认为操作失败。
    :::

5. 相关面试题

5.1 讲解下你自己理解的 CAS 机制

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

5.2 ABA 问题怎么解决

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

三、synchronized 原理

1. 基本特点

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

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

2. 加锁工作过程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
image.png

2.1 偏向锁

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

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

2.2 轻量级锁

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

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源. 因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 "自适应"

2.3 重量级锁

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

3. 其他的优化操作

3.1 锁消除

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

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

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

3.2 锁粗化

:::success
有锁的粗化,也有锁的细化。
此处的粗细指的是 “锁的粒度”
“粒度”:加锁代码涉及到的范围。
加锁代码的范围越大,认为锁的粒度就 越粗。
加锁代码的范围越小,认为锁的粒度就 越细。
:::
image.png
所以,很难说到底是锁粗化好,还是细化好。
所以编译器就提供了一个优化:自动判定 代码锁粒度的粗细。

优化的前提:

总而言之,锁的粗化就是把 频繁加锁的地方,合并成一次加锁。

四、Callable 接口

关于线程的创建:

Callable 也是一种创建线程的方式。

Runnable 只是描述一个过程,不关注结果(不关注返回值)
Callable 也是描述一个过程,同时要关注返回结果。
Callable 中包含一个 call方法 和 Runnable.run 类似,都是描述一个具体的任务。
但是 call方法是带返回值的。

如果我们期望创建一个线程,并关注这个线程产生的返回结果,使用 Callable 就比较合适。

例如,创建一个线程,这个线程计算 1~1000 的和,如果不使用 Callable,就会比较麻烦。

public class Test29 {
    static class Result {
        public int sum = 0;
        public final 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);
        }
    }
}

此时我们需要使用 waitnotify以及 synchronized这些机制互相配合,才能完成工作。

1. Callable 的用法

此处使用 Callable 接口,就会更加方便。

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Test30 {
    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;
            }
        };

        // 由于 Thread 不能直接传一个 callable 实例
        // 就需要一个辅助的类来包装下
        // futureTask 保存 callable 返回的结果
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        // 尝试在 main 线程中获取结果
        // 如果 FutureTask 中的结果还没有生成,此时就会阻塞等待。
        // 一直等到最终线程把结果计算出来之后,get 才会返回
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

说明:

理解 FutureTask:
想象去吃麻辣烫,当餐点好后, 后厨就开始做了。同时前台会给你一张 "小票" 。这个小票就是 FutureTask后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。

五、JUC(java.util.concurrent)的常见类

concurrent :并发
这个包里面包含了很多和并发相关的操作。

1. ReentrantLock

reentrant :可重入

ReentrantLock 是可重入锁,提供了 synchronized 没有的功能。
:::success
ReentrantLock 的用法:

public ReentrantLock locker = new ReentrantLock();

// 加锁
locker.lock();

// 解锁
locker.unlock();

:::success
ReentrantLock 把加锁和解锁两个操作分开了。
分开的做法不太好用,很容易就忘记解锁操作 unlock。
一旦没有 unlock,就容易出现死锁。
:::

public ReentrantLock locker = new ReentrantLock();

// 加锁
locker.lock();

// 这种写法,一旦 lock 与 unlock 之间抛出了异常,就容易导致 unlock 执行不到

// 解锁
locker.unlock();

:::success
通常为了保证 unlock 的执行,需要像下面去写:
:::

public ReentrantLock locker = new ReentrantLock();

locker.lock();
try {
    // working
} finally {
    locker.unlock();
}

ReentrantLock 和 synchronized 的区别:
:::success

如何选择使用哪个锁?
:::success

2. 信号量 Semaphore

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

理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源

代码示例:

import java.util.concurrent.Semaphore;

public class Test32 {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                // 先尝试申请资源
                try {
                    System.out.println("准备申请资源");
                    semaphore.acquire();
                    System.out.println("申请资源成功");
                    // 申请到了之后, sleep 1000 ms
                    Thread.sleep(1000);
                    // 再释放资源
                    System.out.println("即将释放资源");
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        // 创建 20 个线程
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}

实际开发中,不会经常用到信号量。应付面试即可。

3. CountDownLatch

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

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

import java.util.concurrent.CountDownLatch;

public class Test33 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(8);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("起跑!");
                // random 方法得到一个 [0,1] 之间的浮点数
                // sleep 的单位是 ms,此处 * 10000 的意思是 sleep [0,10) 区间范围内的秒数
                try {
                    Thread.sleep((long)(Math.random() * 10000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();
                System.out.println("撞线完成!");
            }
        };
        for (int i = 0; i < 8; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
        latch.await();
        System.out.println("比赛结束!");
    }
}

代码运行结果:
image.png

4. 相关面试题

  1. 线程同步的方式有哪些?

synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.

  1. 为什么有了 synchronized 还需要 JUC 下的 lock?

以 juc 的 ReentrantLock 为例

  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放,使用起来更灵活
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
  • synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

5. 线程安全的集合类

原来的集合类, 大部分都不是线程安全的。

Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.

5.1 多线程环境使用 ArrayList

  1. 自己使用同步机制(synchronized 或者 ReentrantLock)

此处前面已经讨论过了。

  1. Collections.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
synchronizedList 的关键操作上都带有 synchronized。

注意:第二种方法没有第一种方法灵活,因为并不是所有的方法都涉及到加锁。
但是,第二种方法,属于无脑加锁的一种。

  1. 使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素
  • 添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会 添加任何元素。

所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:

  1. 占用内存较多。
  2. 新写的数据不能被第一时间读取到。

5.2 多线程环境使用队列

  1. ArrayBlockingQueue

基于数组实现的阻塞队列。

  1. LinkedBlockingQueue

基于链表实现的阻塞队列。

  1. PriorityBlockingQueue

基于堆实现的带优先级的阻塞队列。

  1. TransferQueue

最多只包含一个元素的阻塞队列。

5.3 多线程环境使用哈希表

HashMap 这个类是线程不安全的,不能直接在多线程中使用。
解决方法:

  1. 使用 HashTable 类【不推荐使用】
  2. 使用 ConcurrentHashMap 类 【推荐使用】

下面就来解释下为什么不推荐使用 HashTable。我们需要了解 HashMap 的内部构造:
:::success
对于哈希表来说,最重要的两个操作就是 put 和 get 操作。
image.png
image.png
上面这种对方法进行加锁的操作,其实就是在针对 this 来进行加锁。
当有多个线程来访问 HashTable 的时候,无论什么操作、数据,都会出现锁竞争。
这样的设计就会导致锁竞争的概率非常大,效率就比较低!
:::
image.png
一个 HashTable 只有一把锁,两个线程访问 HashTable 中的任意数据都会出现锁竞争。

为了解决这种问题,我们需要 “放权” ,把锁分配到链表数组里的每个元素,这就是 ConcurrentHashMap 里面的情况:
image.png
ConcurrentHashMap 每个哈希桶都有一把锁,只有两个线程访问的恰好是同一个哈希桶上的数据才会出现锁冲突。
当我们操作元素的时候,是针对这个元素所在的链表的头节点进行加锁的。
如果两个线程操作针对链表上不同节点的元素,是线程安全的,不必加锁。
由于 哈希表中,链表的数目非常多,每个链表的长度是相对较短的,根据负载因子,可以保证锁冲突的概率非常小。

结论:
:::success

  1. ConcurrentHashMap 为了减少锁冲突,给每个链表的头结点进行加锁。
  2. ConcurrentHashMap 只是针对 写操作 加锁了,读操作没有加锁,只是使用了 volatile 关键字,来避免 “内存可见性” 的问题。
  3. ConcurrentHashMap 中更广泛的使用了 CAS,进一步提高了效率。(比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况)
  4. ConcurrentHashMap 针对扩容,进行了巧妙的化整为零:
    :::
    :::success
    如果元素多了,链表的长度就很长,就会影响到 哈希表的效率。
    就需要扩容,增加数组的长度。
    扩容就需要创建一个更大的数组,然后把之前旧的元素都给搬运过去。【非常耗时】
    对于HashTable来说,只要你这次put触发了扩容,就一口气全部搬运完。这样就会导致这次put非常卡顿。
    对于ConcurrentMap来说,每次操作只搬运一点点,通过多次操作完成整个搬运的过程。
    同时维护一个新的 HashMap 和 一个旧的, 查找的时候即需要查旧的也要查新的。
    插入的时候直插入新的。
    这个时候,我们就可以保证 Hash表 能正常工作的同时 完成这样的一个逐渐搬运的过程。
    直到搬运完毕,再来销毁旧的。
    :::

标签:加锁,八股文,进阶,synchronized,CAS,版本号,线程,100,多线程
来源: https://www.cnblogs.com/zhenyucode/p/16438473.html