文章目录
一、常见的锁策略
1、乐观锁 vs 悲观锁
- 乐观锁:预测接下来锁冲突的概率不大. 做一系列操作.
- 悲观锁:预测接下来锁冲突的概率很大. 做另一系列操作.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
- 当前锁冲突不大,以乐观锁的方式运行,往往是纯用户态执行的.
- 一旦发现锁冲突概率大了,以悲观锁的方式运行,往往要进入内核,对当前线程进行挂起等待.
2、读写锁
- 普通互斥锁:synchronized 就属于普通的互斥锁,两个加锁操作之间会发生竞争.
- 读写锁:在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥.
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
Synchronized 不是读写锁.
3、重量级锁 vs 轻量级锁
- 重量级锁:锁开销比较大,做的工作比较多. 悲观锁经常会是重量级锁.(不绝对)
- 轻量级锁:锁开销比较小,做的工作比较少. 乐观锁经常会是轻量级锁.(不绝对)
重量级锁:主要是依赖了 操作系统 提供的 锁;使用这种锁,就容易产生阻塞等待.
轻量级锁:主要尽量的避免使用 操作系统 提供的 锁;而是尽量在用户态来完成功能,尽量避免 用户态 和 内核态 的切换,尽量避免挂起等待.
synchronized 是自适应锁,既是轻量级锁,又是重量级锁.
当冲突程度不高时,是轻量级锁;当冲突程度很高时,是重量级锁.
4、自旋锁(Spin Lock)
- 自旋锁:轻量级锁的具体实现. 既是轻量级锁,也是乐观锁.
- 挂起等待锁:重量级锁的具体实现. 既是重量级锁,也是悲观锁.
自旋锁:当发现锁冲突的时候,不会挂起等待,会迅速再来尝试看这个锁能不能获取到!
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
自旋锁:更轻量,乐观锁
- 一旦锁被释放,就可以第一时间获取到.
- 如果锁一直不释放,就会消耗大量的 CPU.
挂起等待锁:更重量,悲观锁
- 一旦锁被释放,就不能第一时间获取到.
- 在锁被其他线程占用的时候,会放弃 CPU 资源.
synchronized 作为轻量级锁的时候,内部是自旋锁;作为重量级锁的时候,内部是挂起等待锁.
5、公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生什么呢?
- 公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
- 非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注意:操作系统内部对于挂起等待锁,就是非公平的;如果想要使用公平锁,就需要搞额外的数据结构来进行控制实现.
synchronized 是非公平锁.
6、可重入锁 vs 不可重入锁
例1:
package thread;
public class Demo26 {
private static void func() {
// 进行一些多线程操作..........
// 第一次加锁
synchronized (Demo26.class) {
// 第二次加锁
synchronized (Demo26.class) {
}
}
}
public static void main(String[] args) {
func();
}
}
对于例1代码:
第一次加锁能够成功,此时 Demo26.class 处于被加锁的状态;
第二次加锁的时候,由于 Demo26.class 已经处于被加锁的状态;
按照之前的理解,这里加锁就会阻塞等待,需要等待第一次加锁释放,第二次加锁才能成功;但是第一次加锁释放需要第二次加锁成功后,执行完才能释放掉,这就形成了一个逻辑上的循环,即死锁.
为了避免上述问题,就引入了 “可重入锁”:一个线程,可以对同一个锁,反复加锁多次!
可重入锁:
在内部记录这个锁是哪个线程获取到的,如果发现当前加锁的线程和持有锁的线程是同一个,则不挂起等待,而是直接获取到锁.
同时还会给锁内部加上一个计数器,记录当前是第几次加锁,只有当计数器为 0 时,才会真正释放锁.
synchronized 是可重入锁.
7、相关面试题
① 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
- 乐观锁:预测接下来锁冲突的概率不大. 不会进行加锁,而是在访问的同时识别是否出现访问冲突.
- 悲观锁:预测接下来锁冲突的概率很大. 每次访问共享变量之前都会进行加锁.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
② 介绍下读写锁?
读写锁就是把读操作和写操作区分对待.
其中,
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
③ 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相对于挂起等待锁:
- 优点:一旦锁被释放,就可以第一时间获取到. 更加高效,在锁持有时间比较短的场
景下非常有用. - 缺点:如果锁一直不释放,就会消耗大量的 CPU.
④ synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁. 实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数).
如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增.
二、CAS
1、概念
CAS 是操作系统 / 硬件,给 JVM 提供的另外一种 更轻量的 原子操作的机制.
CAS 是 CPU 提供的一个特殊指令:Compare and swap(比较并交换).
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.
例2:CAS 伪代码
// address:内存地址
// expecteValue:用来比较的值(寄存器)
// swapValue:用来交换的值(另一个寄存器)
boolean CAS(address, expecteValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
2、CAS 应用
① 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
例3:
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo27 {
// private static int count = 0;
public static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// count++;
// 这个方法相当于 count++
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count); // 10_0000
}
}
例4:getAndIncrement 伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
② 实现自旋锁
例5:自旋锁伪代码
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;
}
}
当 owner 为 null 的时候,CAS 才能成功,循环结束;
当 owner 为 非null,则说明当前的锁已经被其他线程占用了,就要继续循环(自旋).
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.
我们无法确定,在修改过程中,变量是否发生改变:
② ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 我们 有 1000 存款. 想从 ATM 取 500 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
正常情况:
- 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
- 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 500, 和之前读到的 1000 不相同, 执行失败.
异常情况:
- 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
- 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 500, 账户余额变成 1000 !!
- 轮到线程2 执行了, 发现当前存款为 1000, 和之前读到的 1000 相同, 再次执行扣款操作.
本来只想扣款一次,但是实际上扣款了两次!!!
③ 解决方案
引入了 “版本号” 来解决 ABA 问题!
- CAS 操作在读取旧值的同时, 也要读取版本号.
- 真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
4、相关面试题
① 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.
② ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增;如果发现当前版本号比之前读到的版本号大, 就认为操作失败.
三、Synchronized 原理
1、基本特点
synchronized 使用的所策略:
- 既是悲观锁,也是乐观锁(自适应).
- 既是轻量级锁,也是重量级锁(自适应).
- 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现.
- 不是读写锁.
- 是非公平锁.
- 是可重入锁.
2、加锁工作过程
synchronized 在加锁的时候要经历几个阶段:
无锁(没加锁)
偏向锁(刚开始加锁,未产生竞争的时候)
轻量级锁(产生锁竞争了)
重量级锁(锁竞争的更激烈了)
① 偏向锁
偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.
这个过程类似于单例模式 “懒汉模式”,必要的时候再加锁,节省开销.
② 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
③ 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
3、其他的优化操作
① 锁消除
锁消除:编译器自动判定,如果认为这个代码没必要加锁,就不加了.
例6:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
② 锁粗化
锁的粒度:synchronized 包含的代码范围是大还是小.
范围越大,粒度约粗;范围约小,粒度约细.
举例:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.
4、相关面试题
① 什么是偏向锁?
偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
② synchronized 实现原理 是什么?
参考该章节全部内容!
四、Callable 接口
1、Callable 的用法
和 Runnable 非常相似.都是可以在创建线程的时候,来指定一个 “具体的任务”.
- Callable 指定的任务是带返回值的.
- Runnable 则不带返回值.
例7:创建线程计算 1 + 2 + 3 + … + 1000
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo28 {
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> task = new FutureTask<Integer>(callable);
Thread t = new Thread(task);
t.start();
// 在线程 t 执行结束之前,get 会阻塞等待,直到 t 执行完了,结果算好了.
// get 才能返回,返回值是 call 方法 return 的内容.
System.out.println(task.get()); // 499500
}
}
2、相关面试题
介绍下 Callable 是什么
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.
五、JUC(java.util.concurrent) 的常见类
1、ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 和 synchronized 的区别:
- synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock
是标准库的一个类, 在 JVM 外实现的(基于 Java 实现). - synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,但是也容易遗漏 unlock.
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
- ReentrantLock 提供了更强大的等待 / 唤醒 机制,synchronized 搭配的是 Object 的 wait / notify,唤醒的时候,随机唤醒其中一个;ReentrantLock 搭配了 Condition 类来实现等待唤醒,可以做到随机唤醒一个,也可以指定线程唤醒.
例8:
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class Demo29 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
try {
// 加锁
// locker.lock();
locker.tryLock(); // 加锁失败,不会死等.
// 代码...... 如果中间抛出异常了,就可能执行不到 unlock
} finally {
// 解锁
locker.unlock();
}
}
}
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
- 如果需要使用公平锁, 使用 ReentrantLock.
2、原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); // i += delta;
decrementAndGet(); // --i;
getAndDecrement(); // i--;
incrementAndGet(); // ++i;
getAndIncrement(); // i++;
3、线程池
① ExecutorService 和 Executors
- ExecutorService 表示一个线程池实例.
- Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
- ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
例9:
package thread;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo24 {
public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(10);
// Executors.newCachedThreadPool();
threadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
Executors 创建线程池的几种方式
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装.
② ThreadPoolExecutor
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理.
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
③ 线程池的工作流程
4、信号量 Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
可用把信号量视为一个更加广义的锁,当信号量的取值为 0 ~ 1 的时候,就退化成了一个普通的锁.
- 申请一个可用资源,信号量 -= 1,称为 P 操作.
- 释放一个可用资源,信号量 += 1,称为 V 操作.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
例10:
package thread;
import java.util.concurrent.Semaphore;
public class Demo30 {
public static void main(String[] args) throws InterruptedException {
// 构造方法传入有效资源的个数
Semaphore semaphore=new Semaphore(3);
// P 操作 申请资源
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 没有资源,无法打印
// V 操作 释放资源
// semaphore.release();
}
}
5、CountDownLatch
相当于,在一个大任务被拆分为若干的子任务的时候,用这个来衡量什么时候这些子任务都执行结束.
例如:进行一次跑步比赛,CountDownLatch 描述什么时候所有人都通过终点.
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调用 latch . countDown () . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch . await () ; 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
例11:
package thread;
import java.util.concurrent.CountDownLatch;
public class Demo31 {
public static void main(String[] args) throws InterruptedException {
// 模拟跑步比赛
// 构造方法中设定有几个选手参赛
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int finalI = i;
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println(finalI + "号选手到达终点");
// countDown 相当于 “撞线”
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
// await 在等待所有的线程 “撞线”
// 即调用 countDown 的次数达到初始化的时候设定的值
// await 就返回. 否则 await 就阻塞等待!
latch.await();
System.out.println("比赛结束!");
}
}
6、相关面试题
① 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
② 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更 灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时 间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线 程.
③ AtomicInteger 的实现原理是什么?
参考 CAS - CAS 应用 - 实现原子类 - 例4:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
④ 信号量听说过么?之前都用在过哪些场景下?
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.
⑤ 解释一下 ThreadPoolExecutor 构造方法的参数的含义
参考 ThreadPoolExecutor 章节
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理.
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
六、线程安全的集合类
- 线程安全:Vector,Stack(Stack 继承自 Vector 实现的),HashTable.
- 线程不安全:ArrayList,LinkeList,TreeSet,TreeMap,HashSet,HashMap,Queue.
1、多线程环境使用 ArrayList
① 自己使用同步机制 (synchronized 或者 ReentrantLock)
前面做过很多相关的讨论了. 此处不再展开.
② Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
③ 使用 CopyOnWriteArrayList
CopyOnWriteArrayList:如果出现修改操作,就把 ArrayList 进行复制.
先拷贝一份数据,,新线程修改副本,再用副本替换原有的数据. 这样做的好处是我们可以对 CopyOnWrite
容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
- 在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到.
2、多线程环境使用队列
① ArrayBlockingQueue
基于数组实现的阻塞队列
② LinkedBlockingQueue
基于链表实现的阻塞队列
③ PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
④ TransferQueue
最多只包含一个元素的阻塞队列
3、多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- HashTable
- ConcurrentHashMap
① HashTable
HashTable 保证线程安全,直接 synchronized !!!
这相当于直接针对 HashTable 对象本身加锁.
- 如果多个线程要操作同一个 HashTable 就会直接造成锁冲突.
② ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化(Java 8).
- 把锁的粒度细化了,加锁的方式仍然是用 synchronized, 但是不是锁整个对象, 而是 “锁桶”(每个链表的头结点);桶 的数量越多,出现锁冲突的概率越低.
- 读操作不进行加锁,只针对写操作加锁.
- 更充分的使用了 CAS 特性,更高效的操作,比如 size 属性通过 CAS 来更新.
- 针对扩容场景进行了优化:化整为零,每次基本操作,都扩容一点点,逐渐完成整个扩容
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小
部分元素. - 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组
③ HashMap,HashTable, ConcurrentHash 的区别
4、相关面试题
① ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了 volatile 关键字.
② 介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment),
针对每个段分别加锁. 目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
③ ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树.
④ Hashtable和HashMap、ConcurrentHashMap 之间的区别?
很多同学会说:HashMay key允许为 null,另外两个不允许为 null
这个是区别,但是不是重要的区别!
应该从线程安全上开始:
- HashMap 线程不安全,HashTable 和 ConcurrentHash 线程安全.
- HashTable 是一把大锁,锁冲突的概率很高;ConcurrentHash 则是每个哈希桶一把锁,锁冲突的概率大大降低.
- 然后再说 ConcurrentHash 使用了 CAS 特性,以及扩容优化.
- 最后再说 HashMay key允许为 null,另外两个不允许为 null.
七、死锁
1、概念
死锁是多线程代码中的常见 BUG!
尝试加锁的时候发现上次锁没有及时释放(因为一些原因,BUG),导致加锁加不上.
造成死锁的原因:
-
一个线程一把锁
- 线程 A 针对锁 1 连续加锁两次,如果锁 1 是不可重入锁,就死锁了.
-
两个线程两把锁
- 线程 A 获取到锁 1,线程 B 获取到锁 2;线程 A 尝试获取锁 2,线程 B 尝试获取锁1,就死锁了.
-
N 个线程 M 把锁
- 著名的 “哲学家就餐问题”:有个桌子, 围着一圈 哲学家, 桌子中间放着一盘意大利面. 每个哲学家两两之间, 放着一根筷子.每个 哲学家 只做两件事: 思考人生 或者 吃面条. 思考人生的时候就会放下筷子. 吃面条就会拿起左右两边的筷子(先拿起左边, 再拿起右边). 如果 哲学家 发现筷子拿不起来了(被别人占用了), 就会阻塞等待. 假设同一时刻, 五个 哲学家 同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于 哲学家 们互不相让, 这个时候就形成了 死锁.
2、如何避免死锁
死锁的四个必要条件:
- 互斥使用:线程 1 拿到 锁 A,其他线程无法获取到 A.
- 不可抢占:线程 1 拿到 锁 A,其他线程只能阻塞等待,等待 线程 1 主动释放锁,而不是强行把锁抢走.
- 请求和等待:当 线程 1 拿到 锁 A 后,就会一直持有这个获取到锁的状态,直到主动释放.
- 循环等待:线程 1 等待 线程 2,线程2 又尝试等待 线程 1.
其中,前三条是在描述锁的基本特点;第四条和代码编写,密切相关,是可以通过注意解决的!
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待.
3、相关面试题
① 谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?
参考整个 “死锁” 章节
被问到什么是死锁,起手式别搞错了,千万不要上来就说 “死锁的四个必要条件”!
流程:
- 概况死锁的概念
- 产生死锁的三个典型常见
- 死锁的必要条件(一定要说出来 循环等待)
- 从 循环等待 的角度切入,对锁编号,并按顺序加锁,破坏循环等待.
八、其他常见面试题
1、谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰的变量, 可以第一时间读取到最新的值.
2、Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域: 方法区, 堆区, 栈区, 程序计数器.
其中堆区这个内存区域是多个线程之间共享的.
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到.
3、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
- 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.
LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.
4、Java线程共有几种状态?状态之间怎么切换的?
- NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
- RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在 CPU 上运行/在即将准备运行 的状态.
- BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
- WAITING: 调用 wait 方法会进入该状态.
- TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
- TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
5、在多线程下,如果对一个数进行叠加,该怎么做?
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作.
6、Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下.
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的.
7、Thread和Runnable的区别和联系?
Thread 类描述了一个线程.
Runnable 描述了一个任务.
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用 Runnable 来描述这个任务.
8、多次start一个线程会怎么样
第一次调用 start 可以成功调用.
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常.
9、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:
- 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到锁之后才能执行方法内容.
如果这两个方法属于不同实例:
- 两者能并发执行, 互不干扰.
10、进程和线程的区别?
进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
进程是系统分配资源的最小单位,线程是系统调度的最小单位。