ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

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

2022-07-02 20:34:38  阅读:148  来源: 互联网

标签:加锁 八股文 进阶 synchronized CAS 版本号 线程 100 多线程


一、常见的锁策略

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:可重入)

  • ReentrantReadWriteLock.ReadLock类表示一个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁。
  • ReentrantReadWriteLock.WriteLock类表示一个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁。

其中,

  • 读加锁和读加锁之间, 不互斥。
  • 写加锁和写加锁之间, 互斥。
  • 读加锁和写加锁之间, 互斥。

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

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

Synchronized 不是读写锁

3. 轻量级锁 vs 重量级锁

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

  • 少量的内核态用户态切换
  • 不太容易引发线程调度

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

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

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

4. 自旋锁 vs 挂起等待锁

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

自旋锁伪代码:

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

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

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

  • 优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源(而挂起等待的时候是不销毁 CPU 的)。

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

  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 是一个自适应的锁,会根据实际情况来决定采取哪种锁策略。

  • synchronized 开始的时候是一个轻量级锁 / 乐观锁,如果冲突比较严重,就会升级成 重量级锁 / 悲观锁。
  • synchronized 不是读写锁,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 个参数。

  • address:待比较的内存地址。
  • expectedValue:预期内存中旧的值。
  • swapValue:希望修改的新值。

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

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

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

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

2. CAS 是怎么实现的

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

  • java 的 CAS 利用的是 unsafe 这个类提供的 CAS 操作。
  • unsafe 的 CAS 依赖的是 JVM 针对不同操作系统实现的 Atomic::cmpxchg
  • Atomic::cmpxchg的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

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

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,那么就需要

  • 先读取 num 的值,记录到 oldNum 变量中。
  • 使用 CAS 判定当前的 num 的值是否为 A,如果为 A,就修改成 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 比较数据当前值和旧值的同时,也要比较版本号是否符号预期。

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

对比上面的转账的例子:
:::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 来实现。

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

自旋操作是一直让 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 接口

关于线程的创建:

  • 继承 Thread,重写 run。
  • 实现 Runnable,重写 run。
  • 继承 Thread,重写 run,使用匿名内部类。
  • 实现 Runnable,重写 run,使用匿名内部类。
  • 使用 lambda 表达式。

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

说明:

  • image.png这里的泛型代表 Callable 接口返回值的类型。
  • image.png重写 call方法,里面只是描述任务,还没有被执行。
  • Callable 往往是在另一个线程中执行的,啥时候执行完不确定。所以就需要搭配 FutureTask来使用,FutureTask用来保存 Callable 的返回结果。

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

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

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

1. ReentrantLock

reentrant :可重入

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

  • lock():加锁,如果获取不到锁就死等。
  • trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
  • unlock():解锁。
    :::
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

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

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

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

2. 信号量 Semaphore

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

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

代码示例:

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
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个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

  • 构造 CountDownLatch 实例, 初始化 8 表示有 8 个任务需要完成。
  • 每个任务执行完毕, 都调用 latch.countDown(),在 CountDownLatch 内部的计数器同时自减。
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了(await 是给等待线程去调用的,当所有的任务都达到终点,await 就从阻塞中返回,就表示任务完成)。
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

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有