多线程(进阶版)(八股文)
作者:互联网
一、常见的锁策略
1. 乐观锁 vs 悲观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
乐观锁的一个重要的功能就是检测出数据是否发生访问冲突,我们可以引入一个 “版本号” 来解决:
:::success
设当前余额为 100,引入一个版本号 versions,初始值为 1,并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”
- 线程 A 此时准备将其取出(versions = 1, balance = 100),线程 B 也读入此信息(versions = 1,balance = 100)
- 线程 A 操作的过程中并从其账户余额中扣除 50 (100-50),线程 B 从其账户余额中扣除 20 (100-20)
- 线程 A 完成修改工作,将数据版本号加1(versions = 2),连同账户扣除余额(balance = 50),写回到内存中。
- 线程 B 完成了操作,也将版本号加1(versions = 2)试图向内存中提交数据(balance=80),但此时对比版本发现,线程 B 提交的数据版本号为 2,数据库记录的当前版本号也为2,不满足** “提交版本必须大于记录当前版本才能执行更新” **的乐观锁策略。就认为这次操作失败。
2. 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方之间以及读写方之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
一个线程对于数据的访问,主要存在两种操作:读数据 和 写数据
- 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可。
- 两个线程都要写一个数据,有线程安全问题。
- 一个线程读另一个线程写,也要线程安全问题。
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock
类,实现了读写锁。(Reentrant:可重入)
ReentrantReadWriteLock.ReadLock
类表示一个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁。ReentrantReadWriteLock.WriteLock
类表示一个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁。
其中,
- 读加锁和读加锁之间, 不互斥。
- 写加锁和写加锁之间, 互斥。
- 读加锁和写加锁之间, 互斥。
注意:只要涉及到 “互斥”,就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不知道隔了多久了。
因此,尽可能减少 “互斥” 的机会,就是提高效率的重要途径。
读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).
3. 轻量级锁 vs 重量级锁
轻量级锁:加锁机制尽可能不适应 mutex,而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex
- 少量的内核态用户态切换
- 不太容易引发线程调度
重量级锁:加锁机制重度依赖了 OS 提供了 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
4. 自旋锁 vs 挂起等待锁
自旋锁:
如果线程获取不到锁,不是阻塞等待,而是循环的快速的再试一次,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁。
挂起等待锁:
如果线程获取不到锁,就会阻塞等待。什么时候结束阻塞,就取决于操作系统具体的调度,当线程挂起的时候,不占用 CPU。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败,立即再尝试获取锁,无线循环,直到获取锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。
自旋锁是一种典型的 轻量级锁 的实现方式:
- 优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
- 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源(而挂起等待的时候是不销毁 CPU 的)。
挂起等待锁 和 自旋锁 的使用场景:
大的原则来说:
- 如果锁冲突的概率比较低,使用自旋锁比挂起等待锁,更合适。
- 如果线程持有锁的时间比较短,使用自旋锁比挂起等待锁,更合适。
- 如果对 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。
- 比较 A 与 V 是否相等(比较)
- 如果比较相等,将 B 写入 V(交换)
- 返回操作是否成功。
下面来看个伪代码帮助理解 CAS:
这是一个 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 来实现的。
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用 getAndIncrement
- 两个线程都读取 value 的值 到 oldValue中(oldValue 是一个局部变量,在栈上,每个线程都有自己的栈)
- 线程1先执行 CAS 操作,由于 oldValue 和 value 的值相同,直接进行对 value 赋值。
注意:
- CAS 是直接读写内存的,而不是操作寄存器。
- CAS 的读内存、比较、写操作是一条硬件指令,是原子的。
- 线程2再执行 CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值,因此需要进入循环。在循环里面重新读取 value 的值赋值给 oldValue。
- 线程2接下来第二次执行 CAS,此时 oldValue 与 value 相同,于是直接执行赋值操作。
- 线程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,还是经历了一个变化过程。
虽然最终的值和旧值相同,但是它确实改变了。而当前的决策:只要值相同就没有发生改变。
这是一个漏洞,在大多数情况下是没有什么影响的。
但是在极端的情况也会引起 bug。
这种问题称为 ABA问题:本来是旧值 A,当前值也是 A。但是你不知道这个A是原来的A,还是从 A -> B -> A。
4.2 ABA 问题引来的 bug
:::success
假设一个老哥有 100 存款,想从 ATM 取 50块钱。取款机创建了两个线程,并发的执行 -50 操作。
我们期望一个线程执行 -50 成功,另一个线程 -50 失败。
如果使用 CAS 的方式完成这个扣款的过程就可能出现问题。
正常过程
- 存款 100,线程1 获取当前存款值为 100,期望更新为 50。线程2 获得当前存款值为 100,期望更新为 50。
- 线程1 执行扣款成功,存款被改为 50,线程2 阻塞等待中。
- 轮到线程2 执行了,发现当前存款为 50,与之前读到的 100 不相同,执行失败。
异常过程
- 存款 100,线程1 获取当前存款值为 100,期望更新为 50。线程2 获得当前存款值为 100,期望更新为 50。
- 线程1 执行扣款成功,存款被改为 50,线程2 阻塞等待中。
- 在线程2 执行之前,老哥的朋友给老哥转账 50,账户余额变成 100 !
- 轮到线程2 执行了,发现当前存款为 100,和之前读到的100相同,再次执行扣款操作。
这个时候,扣款操作被执行了两次!!!都是 ABA 问题。
:::
4.3 ABA 问题的解决方案
给要修改的值,引入版本号,在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符号预期。
- CAS 操作在读取旧值的同时,也要读取版本号。
- 真正修改的时候,
- 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1。
- 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)
对比上面的转账的例子:
:::success
假设一个老哥有 100 存款,想从 ATM 取 50块钱。取款机创建了两个线程,并发的执行 -50 操作。
我们期望一个线程执行 -50 成功,另一个线程 -50 失败。
为了解决 ABA 问题,给余额搭配一个版本号,初始设为 1。
- 存款 100,线程1 获取当前存款值为 100,版本号为1,期望更新为 50。线程2 获得当前存款值为 100,版本号为1,期望更新为 50。
- 线程1 执行扣款成功,存款被改为 50,版本号改为2,线程2 阻塞等待中。
- 在线程2 执行之前,老哥的朋友给老哥转账 50,账户余额变成 100 ,版本号改为 3。
- 轮到线程2执行了,发现当前存款为 100,和之前读到的 100 相同,但是当前版本号为 3,之前读到的版本号为 1,版本小于当前版本,认为操作失败。
:::
5. 相关面试题
5.1 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 "比较并交换". 相当于通过一个原子的操作, 同时完成 "读取内存, 比 较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑.
5.2 ABA 问题怎么解决
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。
三、synchronized 原理
1. 基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
2. 加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
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
有锁的粗化,也有锁的细化。
此处的粗细指的是 “锁的粒度”
“粒度”:加锁代码涉及到的范围。
加锁代码的范围越大,认为锁的粒度就 越粗。
加锁代码的范围越小,认为锁的粒度就 越细。
:::
所以,很难说到底是锁粗化好,还是细化好。
所以编译器就提供了一个优化:自动判定 代码锁粒度的粗细。
- 如果锁的粒度太细,就会执行粗化。
- 如果锁的粒度太组,就会执行细化。
优化的前提:
- 加锁的代码范围较小(锁里面的代码量小),就很可能出现这样的优化。
- 加锁的代码范围较大,就不会轻易做出这样的优化。
总而言之,锁的粗化就是把 频繁加锁的地方,合并成一次加锁。
四、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);
}
}
}
此时我们需要使用 wait
、notify
以及 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);
}
}
说明:
- 这里的泛型代表 Callable 接口返回值的类型。
- 重写
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 开启公平锁模式.
- 更强大的唤醒机制. 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("比赛结束!");
}
}
代码运行结果:
4. 相关面试题
- 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
- 为什么有了 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
- 自己使用同步机制(synchronized 或者 ReentrantLock)
此处前面已经讨论过了。
Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
synchronizedList 的关键操作上都带有 synchronized。
注意:第二种方法没有第一种方法灵活,因为并不是所有的方法都涉及到加锁。
但是,第二种方法,属于无脑加锁的一种。
- 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素
- 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会 添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:
- 占用内存较多。
- 新写的数据不能被第一时间读取到。
5.2 多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列。
- LinkedBlockingQueue
基于链表实现的阻塞队列。
- PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列。
- TransferQueue
最多只包含一个元素的阻塞队列。
5.3 多线程环境使用哈希表
HashMap 这个类是线程不安全的,不能直接在多线程中使用。
解决方法:
- 使用 HashTable 类【不推荐使用】
- 使用 ConcurrentHashMap 类 【推荐使用】
下面就来解释下为什么不推荐使用 HashTable。我们需要了解 HashMap 的内部构造:
:::success
对于哈希表来说,最重要的两个操作就是 put 和 get 操作。
上面这种对方法进行加锁的操作,其实就是在针对 this 来进行加锁。
当有多个线程来访问 HashTable 的时候,无论什么操作、数据,都会出现锁竞争。
这样的设计就会导致锁竞争的概率非常大,效率就比较低!
:::
一个 HashTable 只有一把锁,两个线程访问 HashTable 中的任意数据都会出现锁竞争。
为了解决这种问题,我们需要 “放权” ,把锁分配到链表数组里的每个元素,这就是 ConcurrentHashMap 里面的情况:
ConcurrentHashMap 每个哈希桶都有一把锁,只有两个线程访问的恰好是同一个哈希桶上的数据才会出现锁冲突。
当我们操作元素的时候,是针对这个元素所在的链表的头节点进行加锁的。
如果两个线程操作针对链表上不同节点的元素,是线程安全的,不必加锁。
由于 哈希表中,链表的数目非常多,每个链表的长度是相对较短的,根据负载因子,可以保证锁冲突的概率非常小。
结论:
:::success
- ConcurrentHashMap 为了减少锁冲突,给每个链表的头结点进行加锁。
- ConcurrentHashMap 只是针对 写操作 加锁了,读操作没有加锁,只是使用了 volatile 关键字,来避免 “内存可见性” 的问题。
- ConcurrentHashMap 中更广泛的使用了 CAS,进一步提高了效率。(比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况)
- ConcurrentHashMap 针对扩容,进行了巧妙的化整为零:
:::
:::success
如果元素多了,链表的长度就很长,就会影响到 哈希表的效率。
就需要扩容,增加数组的长度。
扩容就需要创建一个更大的数组,然后把之前旧的元素都给搬运过去。【非常耗时】
对于HashTable来说,只要你这次put触发了扩容,就一口气全部搬运完。这样就会导致这次put非常卡顿。
对于ConcurrentMap来说,每次操作只搬运一点点,通过多次操作完成整个搬运的过程。
同时维护一个新的 HashMap 和 一个旧的, 查找的时候即需要查旧的也要查新的。
插入的时候直插入新的。
这个时候,我们就可以保证 Hash表 能正常工作的同时 完成这样的一个逐渐搬运的过程。
直到搬运完毕,再来销毁旧的。
:::
标签:加锁,八股文,进阶,synchronized,CAS,版本号,线程,100,多线程 来源: https://www.cnblogs.com/zhenyucode/p/16438473.html