并发编程
第一章:并发编程要关注的微观问题
1.保证线程安全要解决的三大问题
安全性:线程安全的含义就是程序按照我们期望的执行,不要让我们感到意外。一半保证了原子性、可见性和有序性的程序就是线程安全的。不需要在每一处代码都分析这三大特性,只要在会产生数据竞争的情况下保证代码的正确就可以了。
可见性
- 概念:当一个线程对共享变量做了修改,另外一个线程能够立刻看到,这就是可见性,可见性问题是由于多核CPU中,多个线程操作不同的CPU缓存导致可见性无法被保证。
原子性
- 概念:原子性本意是“不可分割性”,意思是指一组对共享变量的操作对于执行线程以外的线程要么没发生要么已结束,不会看到中间状态。所以保证对共享资源的一组操作的原子性就等于保证线程对共享资源的访问互斥。
有序性
- 概念:顾名思义就是程序执行的顺序按照代码的先后顺序执行。并发环境下代码经过指令重排序后有序性无法保证进而引发可见性问题,最经典的有序性问题就是DCL双检锁的失效问题。
(双重检验创建单例,new操作会被拆成三条指令:分配内存,在内存上初始化单例对象,将内存地址赋值给instance变量,如果发生了指令重排序比如说在初始化对象之前就把内存地址赋值给了instance变量,此时另外一个线程getInstance()拿到这个单例来用,那就会发生空指针异常。)
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
2.JMM 与 硬件内存模型 的映射关系
-
Java内存模型:划分为线程私有的本地缓存和共享的主内存。
-
硬件内存模型:分为Core核心、缓存层(寄存器、L1、L2、L3)、内存
- CPU缓存:为了解决内存的IO速度与CPU的处理速度 的巨大差距,在CPU与内存间引入缓存层(寄存器、L1、L2、L3),CPU运行时,会将指令与数据从主存复制到缓存层,后续的读写与运算都是基于缓存层的指令与数据,运算结束后,再将结果从缓存层写回主存。
JMM 与 硬件内存模型 的映射关系
- 线程对应到CPU中的Core核心
- 线程本地缓存对应到CPU中的缓存层(寄存器<-->L1<-->L2<-->L3)
- 主内存对应到内存
3.Java内存模型(JMM)
-
概念:Java内存模型 是保证
J V M在不同平台达到相同的内存交互效果的一种规范。 -
结构:Java内存模型的抽象结构划分为线程私有的本地缓存和共享的主内存
-
意义:屏蔽了下游不同硬件模型的内存交互差异,使 JVM 在不同平台达到相同的内存交互效果。
-
Happens-Before规则:happens-before原则体现在Java语言层面的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,它就像个说明书一样让程序员一下子就学会了如何正确编写线程安全的代码,简单来说就是——如果需要一个操作对另一个操作可见,那么这两个操作之间必须要存在happens-before关系
- 程序顺序规则:一个线程中 【前面的操作】 happens-before于 该线程中 【后面的操作】
- 监视器锁规则:对锁的 【unlock操作】 happens-before于 后续对这个锁的 【lock操作】
- volatile变量规则:对volatile变量的 【写】 happens-before于 后续对这个volatile变量的 【读】
- 线程 start() 规则:主线程 【启动子线程前的操作】 happens-before于 【子线程的操作】
- 线程 join() 规则: 【子线程的操作】 happens-before于 【主线程中子线程的join()之后的操作】
- 传递性:如果 【A happens-before B】且【B happens-before C】 ,则 【A happens-before C】
JMM定义的8个基本内存交互操作
JMM定义了8种内存交互操作,要求JVM开发商实现这些原子操作
-
lock(锁定) :将一个主内存中的变量标识为一条线程独占的状态
-
unlock(解锁) :将一个主内存中处于锁定状态的变量释放
- 规定一个变量同一时刻只有一条线程对其进行lock操作
- 规定不允许unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作前要将共享变量 store 到主内存中
- 对一个变量执行lock操作需要从主存中重新 load
-
read(读取) :将一个主内存的变量值传入线程的本地缓存中
-
load(装载) :将read操作得到的主内存中的变量值赋值给本地缓存中的变量副本
-
store(存储) :将本地缓存中的一个变量值传送到主内存中
-
write(写入) :将store操作放入主内存中的变量值赋值给主内存的变量。
- 规定 read和load、store和write 这两组操作需要组合使用,不能单独使用
-
use(使用) :将本地缓存中的一个变量值传入执行引擎
-
assign(赋值) :将一个从执行引擎接收到的值赋值给本地缓存的变量。
内存屏障指令
-
概念:JVM内部使用内存屏障来禁止生成指令序列时的某些重排序,保证有序性、可见性,作为一个业务开发工程师而非JVM工程师,直观的认识到”指令重排序无法越过内存屏障“即可
我理解中的内存屏障大致有写屏障和读屏障两种:
- 写屏障通过强迫线程把工作内存刷到主内存的方式让其他线程拿到最新值
- 读屏障通过强迫线程从主存中读取共享变量的方式让当前线程拿到最新值
原子性、可见性、有序性
原子性
- 概念:原子性本意是“不可分割性”,意思是指一组对共享变量的操作对于执行线程以外的线程要么没发生要么已结束,不会看到中间状态。
- 如何保证可见性:JMM直接保证的原子性操作有八个,依靠read、load、write、store、assign、use可以保证对基本数据类型的读写具备原子性,依靠lock、unlock操作可以保证更大范围的原子性,尽管JVM不把lock和unlock操作开放给用户,但却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令在Java代码层面就是synchornized
可见性
-
概念:可见性是指当一个线程修改了共享变量,其他线程能够立刻得知这个修改,可见性问题是由于多核CPU中,多个线程操作不同的CPU缓存导致可见性无法被保证。
-
如何保证可见性:根据 Happens-Before规则 ,我们知道有三个办法可以解决可见性:
-
volatile关键字:我们知道对volatile变量的**【写】** happens-before 之后对该变量的**【读】**
实现原理:volatile保证每次对volatile变量的写都及时 store 到主存,对volatile变量的读都直接从主存中 load,且对volatile变量的load和store都会通过内存屏障来指令的有序性
-
synchronized关键字:对一个共享变量的解锁操作happens-before 之后对该变量的加锁
实现原理:synchronized的解锁-加锁 和 volatile的读-写 具有相同的内存语义,在JVM中对一个共享变量执行unlock操作前要将共享变量store到主内存中,使用同步指令的时候需要从主存中重新load,为了保证内存交互操作的有序性还需要借助内存屏障来禁止指令重排序。
-
final关键字:final关键字修饰的变量生而不变,这样的变量自然不存在可见性问题
-
有序性
-
概念:顾名思义就是程序执行的顺序按照代码的先后顺序执行。并发环境下代码经过指令重排序后导致一个线程观察另一个线程看到无需的操作进而诱发bug,最经典的有序性问题就是DCL双检锁的失效问题
-
如何保证有序性:
3.总线事务
读写操作的原子性是通过总线事务来实现的
总线事务:总线的工作机制使得同一时间点只有一个处理器在执行总线事务,这保证了总线事务(包括 读事务 和 写事务) 的原子性。
4.volatile
volatile的特性
-
可见性:根据 Happens-Before规则 ,知道对volatile变量的**【写】** happens-before 之后对该变量的**【读】**
实现原理:volatile保证每次对volatile变量的写都及时 store 到主存,对volatile变量的读都直接从主存中 load,且对volatile变量的load和store都会通过内存屏障来指令的有序性
-
有序性:JMM制定了针对volatile变量的重排序规则,实现了有序性
-
原子性:对任意单个volatile变量的读/写具有原子性(哪怕它是64位的long或者double),但像volatile++这种复合操作不具有原子性
单例模式与Volatile
在多线程下保证单例模式,volatile关键字必不可少,否则DCL双检锁会由于指令重排序导致有序性问题,可能引发空指针异常。
手写一个volatile的单例模式
把这个 volatile+DCL的单例模式 更新到手写Spring的项目里面去,更新简历
volatile+DCL双检锁可以实现线程安全的单例模式,但是不代表单例是安全的。
追问:那Spring容器的bean是线程安全的吗?
答:Spring容器本身并没有为bean提供线程安全的策略。
-
默认情况下,bean的Scope是单例的,
- 如果单例bean是一个无状态的bean,线程只能对它做查询操作,那这个bean是安全的,例如SpringMVC中的Controller、Service和Dao;
- 如果是有状态的bean,那在并发环境下就会导致竞态条件(原子性问题)和数据竞争(可见性问题) ,就不是线程安全的。
-
原型bean不会产生竞争,所以是线程安全的。
public class VolatileSingleton {
/**
* 私有化构造方法、只会构造一次
*/
private VolatileSingleton(){
System.out.println("构造方法");
}
private static volatile VolatileSingleton instance = null;
public static VolatileSingleton getInstance(){
if(instance == null){
synchronized (VolatileSingleton.class){
if(instance == null){
instance = new VolatileSingleton();
}
}
}
return instance;
}
public static void main(String[] args) {
// new 30个线程,观察构造方法一共被调用几次
for (int i = 0; i < 30; i++) {
new Thread(()->{
VolatileSingleton.getInstance();
}).start();
}
// 输出:构造方法
}
}
第二章:并发编程要关注的宏观问题
并发编程三个核心问题:分工、同步、互斥
分工(完成)
分工指的是如何高效地拆解任务并分配给线程
- 线程池和Executor
- Fork/Join
- 其他设计模式
同步
同步指的是线程之间如何协作
-
CountDownLatch
-
CyclicBarrier
-
Future
-
管程模型
-
Monitor管程
- Synchronized
-
Lock&Condition的AQS管程
- ReentrantLock可重入锁
- ReentrantReadWriteLock可重入读写锁
- StampedLock版本号锁
-
互斥
互斥保证同一时间只有一个线程访问共享资源(锁)
-
互斥锁
- JDK自带的Monitor管程=>实现synchronized锁
- SDK中的AQS管程=>Lock&Condition接口=>实现ReentranLock锁
- AQS=>Lock&Condition、ReadWriteLock接口=>ReentrantReadWriteLock读写锁
-
无锁并发
- 原子类
- CAS()
- ThreadLocal
- COW(Copy on Write)
5.synchronized
synchronized的特性包含了可见性、有序性、原子性,最重要的是synchronized能够保证原子性。
(要看可见性有序性到上面JMM那里去看,这里只关注锁如何解决原子性问题)
互斥=原子性
-
互斥:互斥指的是“同一时刻只有一个线程执行”
-
原子性:原子性本意是“不可分割性”,意思是指一组对共享变量的操作对于执行线程以外的线程要么没发生要么已结束,不会看到中间状态。所以保证对共享资源的一组操作的原子性就等于保证线程对共享资源的访问互斥。
-
保证原子性:
- 正确的使用排他锁可以保障对共享变量的访问具有互斥性
- 利用处理器提供的CAS指令,CAS实现原子性的方式与锁实现原子性的方式是一样的,只不过CAS是直接在硬件层次实现的。
锁相关概念
-
synchronized修饰静态方法、非静态方法和代码块的区别:
- 修饰静态方法时,锁定的是类的Class对象
- 修饰非静态方法时锁定的是this实例对象
- 修饰代码块时要显式的传入锁定的对象
-
synchronized的底层实现:在JVM层面,lock操作被保证了原子性,一个共享变量同一时刻只能被一个线程执行lock操作也只有加锁的线程才能对共享变量执行unlock操作
-
锁和受保护资源的关系:受保护资源和锁之间的数量关系是 N:1 的关系
多个资源可以交给一把锁保护,但一个资源不可以交给多把锁保护,否则就如同两个观众都买了票却要抢一个座位一样荒唐,对资源的访问不互斥,就没法保证原子性。
-
细粒度锁:用不同的锁对受保护资源进行精细化管理,能够提升并发性,这种锁叫做细粒度锁。
死锁
死锁是操作系统的通用概念,问死锁的时候,不要问是不是Java的锁,直接站在宏观角度说出四个条件即可。
1. **死锁的产生条件**:
1. 互斥,共享资源只能被一个线程占用;
2. 占有且等待,线程获得一个共享资源后等待另一个共享资源
3. 不可抢占,线程不能强行获得已被占用的共享资源
4. 循环等待,线程1拥有锁1需要锁2,线程B拥有锁2需要锁1
2. **避免死锁**:避免其中一个死锁的产生条件成立
1. 互斥是不可避免的,我们用锁就是为了互斥
2. 避免 占有且等待,我们可以一次性申请所有资源
3. 避免 不可抢占,我们可以给线程设置等待时间,拿不到想要的资源就释放手中的资源
4. 避免 循环等待,可以在申请资源时按照资源序号从小到大申请
手写死锁
package com.Eckel.someCase;
public class DeadLock {
private static Object lock1=new Object();
private static Object lock2=new Object();
public static void main(String[] args) {
Thread t1=new Thread(()->{
synchronized (lock1){
System.out.println("t1 get lock1,wait lock2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2){
System.out.println("t1 get lock2");
}
}
});
Thread t2=new Thread(()->{
synchronized (lock2){
System.out.println("t2 get lock2,wait lock1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1){
System.out.println("t2 get lock1");
}
}
});
t1.start();
t2.start();
}
}
synchronzied的锁升级机制
synchronized关键字的执行过程涉及到锁机制的升级过程,升级顺序为 自旋锁、轻量级锁、重量级锁和无关乎锁升级的偏向模式下的偏向锁
- 自旋锁:自旋锁让线程通过 CAS+自旋 的方式在较短时间内不断尝试获得对象操作权,如果没有成功,则锁状态就会升级。自旋锁减少了线程阻塞造成的线程切换开销(挂起和唤醒)
- 偏向锁:偏向锁是在实际不存在资源竞争的情况下发挥作用的,对象的锁标志位为偏向锁的状态下对于这个锁的整个同步都被消除了,仅在初始化时执行一次CAS在Mark Word中记录偏向线程ID,如果发生资源竞争,则偏向锁的约定被破坏,退出偏向模式。偏向锁降低了无资源竞争时的同步开销
- 轻量级锁:轻量级锁是在存在资源竞争但竞争不激烈的情况下发挥作用的,轻量级锁同样是通过 CAS+自旋 实现的乐观锁,如果CAS总是失败说明锁资源竞争激烈,轻量级锁的约定被破坏,需要升级为重量级锁。轻量级锁减少了资源竞争不激烈时使用重量级锁的开销(线程挂起和唤醒)
- 重量级锁:重量级锁就是悲观锁,当对象的锁标志位为重量级锁时,JVM用Monitor管程来控制线程抢占锁资源的同步与互斥
Monitor管程与“wait()-notify()”机制
-
管程
-
概念:是一种进程同步互斥工具,Java的锁机制就是基于管程技术实现的,synchronized基于Monitor管程实现,ReentrantLock基于AQS管程实现。而synchronized关键字、wait()、notify()、notifyAll()都是Monitor管程的一部分。JVM中synchronized关键字被编译后会在同步块前后生成monitorenter和monitorexit两个字节码指令,用来锁定和解锁指定的对象(这两个字节码指令是基于JMM保证的lock、unlock操作实现的)
-
管程模型:管程将共享资源(锁)及对共享资源的操作统一封装起来。想要访问共享变量必须进入管程内部,线程要进入管程需要在管程入口的同步等待队列排队,同一时刻只有一个线程可以进入管程,进入后owner被设为当前线程,退出时变回null。
-
-
wait()-notify()机制:在Monitor管程中,如果线程要求的条件不满足无法继续执行时可以使用**wait()-notify()**的方式来进行线程协作
-
使用位置: wait()、notify()、notifyAll() 都是 Monitor管程内部 才可以调用,在Monitor管程外调用会出现异常(当然也不能在AQS管程中调用)
-
实现原理:
- wait():线程在同步等待队列排队,之后进入管程,如果线程要执行某一操作但条件不满足,可以调用wait()方法将线程挂起,将获得的锁释放,线程被放入条件等待队列,被唤醒后需要重新加入同步等待队列排队进入管程
- notify():当某个owner线程执行了某一操作导致某一条件变化的时候,可以用notify()/notifyAll()来唤醒条件等待队列中的(所有)线程
-
使用示例:不用记
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(Object from, Object to){
// 经典写法
while(als.contains(from) || als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}
6.Java线程的生命周期
要会画图,边画图边跟面试官说
Java 语言中线程共有六种状态,分别是:
-
NEW(初始状态):线程对象刚被创建,还没调用start()方法的状态
-
RUNNABLE(运行状态):对于JVM来说线程调用start()方法之后就进入了运行状态
-
BLOCKED(阻塞状态):只有在线程等待锁资源这一种情况下才会从运行状态进入阻塞状态,得到锁后恢复运行状态;在JVM层面即使线程调用阻塞式API之后线程也不会进入阻塞状态,JVM认为线程依然在运行当中
-
WAITING(等待状态):有三种场景会使线程进入等待状态
- 在管程内的线程调用wait()/await()方*进入等待状态**,需要等待其他线程在同一个管程内调用notify()/notifyAll()/signal()/signalAll()方法唤醒
- 调用Thread.join()方*进入等待状态**,当join()返回就会被唤醒
- 调用LockSupport.park()方*进入等待状态**,等待其他线程调用unpark(Thread t1)方法唤醒
-
TIMED_WAITING(超时等待状态):在指定的时间后会自动唤醒的超时等待状态
- 在管程内的线程调用带超时参数的wait()方*进入超时等待状态**
- 调用带参数的sleep()、join()都会进入超时等待状态
-
TERMINATED(终止状态):线程执行完或出现异常(包括被其他线程调用interrupt()方法)都会进入终止状态
7.创建多少线程才是合适的
使用多线程是为了提高性能,主要就是降低延迟,提高吞吐量,为此我们要提升 I/O 和 CPU 的综合利用率,到底要创建多少线程还得看应用场景——对于 I/O密集型程序 和 CPU密集型程序 最佳线程数的计算公式是不同的
-
CPU密集型:最佳线程数=N+1(N是CPU核数)
对于CPU密集型来说,理论上让线程数=N就可以了,但工程上多创建一个线程是为了在线程因为某些原因阻塞的时候可以顶上
-
IO密集型:最佳线程数=N(1+(IO耗时/CPU耗时)) * 面试官必会让你解释这公式什么意思
我是这样理解这个公式的,在IO密集型的场景下,IO的利用率肯定很高,那我们就要着力于提升CPU的利用率。我们令R=IO耗时/CPU耗时,那么当线程A在执行CPU操作时,另外R个线程刚好执行完了CPU操作,那么CPU的利用率就达到了百分之百,不停的运行,以上是针对单核的情况,多核的情况就等比扩大就行
8.保证线程安全的方式
- 使用线程封闭的策略,将变量放在方法中,局部变量在独属于线程的栈内存中,其它线程无权访问
- 使用ThreadLocal的策略,多个线程访问同一共享变量时,ThreadLocal类为每个线程提供一份该变量的副本,各个线程拥有一份属于自己的变量副本,操作修改的是各自的变量副本,而不会相互影响。
- 将变量声明为final,无法修改自然线程安全
- 使用互斥锁保护共享变量
- 使用乐观锁也就是CAS
9.AQS管程=>Lock&Condition接口=>实现ReentranLock锁
巨几把重要!
学习目标:
-
明白为什么在有了Monitor管程的情况下SDK还要设计AQS管程(Lock接口解决Monitor管程无法唤醒阻塞线程的问题)
-
明白AQS管程做出的改进(两个管程的区别=Synchornized和ReentranLock的区别 必考):
- Lock接口中三个方法解决了Monitor管程无法唤醒阻塞线程的问题(三个API要记住:能够响应中断的阻塞获取锁、非阻塞的获取锁、带时间参数的非阻塞获取锁)
- AQS管程中实现Condition接口可以支持多个条件变量,synchronized的Monitor管程只能有一个
- 基于AQS管程的实现类ReentrantLock可以支持公平锁非公平锁,synchornized没有
-
AQS管程中Lock接口的实现类:
- ReentrantLock可重入锁
- ReentrantReadWriteLock可重入读写锁
- StampedLock版本号锁
-
AQS管程和Monitor管程的相似之处:
- AQS管程和Monitor管程本质上都是基于管程实现
- Monitor底层也是有两个队列(EntryList 和waitSet队列)、recursion(共享资源)、owner组成
- AQS底层也是两个队列(LCH和condition队列)、state(共享资源)、ownerThread组成
AQS管程
- SDK设计新管程的理由:Monitor管程由很多美中不足,包括且不限于 同步等待队列中的线程无法响应中断、不支持非阻塞的获取锁、条件变量只能有一个 等等限制。为此,SDK并发包实现了新的AQS管程模型,基于AQS设计了Lock和Condition 两个接口作为锁的实现基础,弥补了Monitor管程的不足,其中Lock解决互斥问题,Condition解决同步问题
AQS内部数据结构
- AQS管程内部的数据结构和Monitor管程内部的数据结构是相似的,同步等待队列、条件等待队列、锁的状态值state、ownerThread持有线程,不同的是AQS的条件等待队列可以有多个
- 线程在进入同步等待队列和条件等待队列之前都要被包装成Node节点,节点中最重要的成员变量是waitStatus,它表示节点的状态(我不想记)。
AQS管程的工作流程
-
AQS支持两种锁的实现:独占式和共享式,独占式表示只有一个线程能够工作,共享式表示能有多个满足条件的线程同时工作,而调度这些线程依靠的就是state状态值,state是volatile变量保证了可见性,同时对状态值的修改使用CAS的方式保证原子性
-
AQS本身只是个抽象类而已,里面的tryAcquire()、tryRelease()之类的方法都需要交给子类实现,我们可以大致看下ReentrantLock中AQS管程的工作流程:
-
在多线程竞争 锁资源——确切来说是state 时,使用CAS指令能够保证只有一个线程获取资源使用权,使state由0变为1,而且state是volatile变量保证了可见性,然后ownerThread变量被设为当前线程
-
未获取到资源的线程加入同步等待队列
-
在ownerThread释放资源的时候,state-1,ownerThread被设为空
-
此时要根据是否是公平模式来竞争资源
- 公平锁则让同步等待队列中的队头节点获取资源
- 非公平锁则让同步等待队列中的队头节点与新来的线程一同竞争资源
-
如果ownerThread工作的过程中调用了condition.await()方法那就会被放入对应的条件等待队列中,state-1,ownerThread被设为空
-
条件等待队列中的节点如果被signal唤醒则会被移动到同步等待队列的尾部,参与争抢锁资源
-
Lock接口
-
Lock接口全面弥补了synchronized的问题,体现在Lock接口的其中三个方法上:
-
void lockInterruptibly() throws InterruptedException;lockInterruptibly()方法是支持响应阻塞获取锁的方式,线程进入阻塞状态后也能够响应中断信号然后苏醒,线程出现异常从而能够进入finally块中释放资源
-
boolean tryLock();不带参数的tryLock()方法是非阻塞地尝试获取锁
-
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;带参数的tryLock()方法则是让线程在目标时间内非阻塞地获取锁,没有获得锁就抛异常,不进入阻塞状态
-
-
当然Lock接口也有类似于synchronized普通的阻塞获取锁方法lock()方法
Condition接口
我们知道Monitor管程中有条件变量及条件等待队列的概念,而AQS管程中就由Conditon接口来实现条件变量
-
实现原理:Condition 内部维护一个条件队列,在获取锁的情况下,线程调用 await,线程会被放置在条件队列中并被阻塞。直到调用 signal、signalAll 唤醒线程,此后线程唤醒,会放入到 AQS 的同步队列,参与争抢锁资源。
-
不同点:
- AQS管程内可以有多个条件变量,而Monitor管程只支持一个条件变量
- AQS管程要使用条件变量需要显式地创建Conditon对象,在Condition对象之上调用await()/signal()/signalAll() ,synchronized不需要,直接在管程内调用wait()/notify()/notifyAll()就行,反正也只有一个条件变量等待队列
ReentranLock
- 概念:ReentranLock是基于AQS管程实现的锁,翻译过来是可重入锁,意思是线程可以重复获得同一把锁
公平模式与非公平模式
- 公平模式:ReentranLock的构造函数可以传入参数,若为true则构造一个公平锁,在同步等待队列中等待时间最长的线程将会被唤醒
- 非公平模式:不提供公平的保证,可能等待时间短的线程反而先被唤醒
实例代码:线程安全的阻塞队列
用ReentrantLock实现阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队)
public class BlockedQueue<T>{
final Lock lock = new ReentrantLock();
// 条件变量:队列不满
final Condition notFull = lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty = lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
// 入队后, 通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
// 出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
10.读写锁ReadWriteLock接口下的ReentrantReadWriteLock
SDK并发包为各种场景做了对应的工具类,更加方便地解决问题
在读多写少场景,可以使用SDK包中提供的读写锁——ReadWriteLock接口及其实现类ReentrantReadWriteLock,非常容易使用,并且性能很好。
-
读写锁的作用:跟MySQL里面的读写锁(共享锁、排他锁)一样读读不互斥,读写互斥,写写互斥
-
使用细节:
- ReentrantReadWriteLock类似于ReentrantLock,其读/写锁都是可重入的,也支持公平锁非公平锁;读写锁的读锁和写锁也都实现了Lock接口,Lock接口里的方法都是可以用的
- 读写锁的读锁是不能使用条件变量的,写锁可以使用条件变量
- 读写锁不支持锁的升级即获取了读锁后还未释放就获取写锁,但是支持锁的降级即获取了写锁后还未释放就获取读锁(获取到的锁最后都要记得释放)
实例:线程安全的HashMap
-
示例代码:hashmap是线程不安全的,但如果有*就犟就要硬用hashmap死活不肯用ConcurrentHashMap的话,那可以写一个类把HashMap包装起来,用读写锁来保证一定的性能以及线程安全。(Collection包里有包装类的实现,可以传入一个map到Collection.synchronizedMap(Map map)包装成同步容器**,所有方法都用 synchronized 来保证互斥)
public class SafetyHashMap<K,V> { private HashMap<K,V> hashMap; private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private Lock rl = rwl.readLock();// 读锁 private Lock wl = rwl.writeLock();// 写锁 public SafetyHashMap(HashMap<K, V> hashMap) { this.hashMap = hashMap; } public V get(K k){ rl.lock(); try{ return hashMap.get(k); }finally { rl.unlock(); } } public void put(K k,V v){ wl.lock(); try{ hashMap.put(k,v); }finally { wl.unlock(); } } }
11.版本号锁StampedLock(不蛮考)
不会主动问你StampedLock,但是问你Java有哪些锁你可以介绍,这里简单介绍一下,感兴趣自己看看吧(PS:我都不想写了,但想到可能可以用来装逼还是写一下吧也不是坏事)
-
概念:StampedLock直译过来是版本号锁(只有我自己这么叫它),它在支持写锁和悲观读锁的前提下还通过版本号机制提供乐观读的功能,效率比读写锁还高,版本号机制就是名字中Stamp(印记)的意思
-
使用细节:
-
StampedLock的tryOptimisticRead()乐观读方法其实就是返回一个版本号,并不真的返回数据,要得到数据还得自己获取,也就是得到版本号之后你自己读一下共享变量,然后再将版本号传入validate()方法验证一下看变没变,没变返回true自然万事大吉,变了会返回false那你自己决定怎么处理,是要继续自旋用乐观读还是干脆获取悲观读锁读取数据
-
获取悲观读锁和写锁的时候也会返回版本号,然后解锁的时候要把版本号传进去
-
它的功能只是读写锁的一个子集,只能在简单的应用场景代替读写锁,比如:
- StampedLock是不支持重入的
- StampedLock的悲观读锁、写锁都不支持条件变量
-
使用 StampedLock 时如果线程阻塞在readLock() 或者 writeLock() 上时一定不要调用中断interrupt()方法,如果需要支持中断功能,要使用StampedLock的支持中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
-
12.CountDownLatch和CyclicBarrier(不蛮考)
如果让你介绍线程间同步的方法,除了说管程里的条件变量,还可以说一说这个
-
作用:在使用线程池的时候,主线程不知道线程池中的任务什么时候处理完成,SDK并发包为此提供了CountDownLatch和CyclicBarrier这两个工具类使线程进行同步
-
原理:其实这两个工具类维护的就是一个计数器,每当线程执行一个任务结束的时候可以显式地将计数器自减,让等待的线程等待计数器减为0时再被唤醒
-
CountDownLatch
- 使用方法:创建对象时要传入初始值,需要与其他线程做同步的线程调用latch.await()方法就会进入休眠状态,如果某个线程调用了latch.countDown()则会让计数器自减,减为0时就会唤醒等待的线程
- 适用场景:适合一个线程等待多个线程的场景,就像是老师等待所有学生都到期了之后才能开始上课
-
CyclicBarrier则是自动调用创建CyclicBarrier对象时传入的方法,同时将计数器恢复初始值,并
- 使用方法:创建对象时要传入初始值和一个回调方法,需要互相之间做同步的线程们调用barrier.await()方法后就会将计数器自减,并进入休眠状态中,当减为0时barrier会将计数器恢复初始值并将休眠的线程们同时唤醒进行下一轮工作,同时自动调用回调方法(CyclicBarrier是循环利用的)
- 使用场景:适合几个线程互相做同步的场景(等待最慢的那个线程做完),就像是做盖浇饭,线程A炒菜,线程B煮饭,线程A做好了就等等B,它们都做好了之后就立马开始做下一份盖浇饭,同时会有一个人来把炒菜和煮饭装在一起做成盖浇饭
13.并发容器
清楚每种容器的特性,能选对容器,这才是关键,至于每种容器的用法,用的时候看一下 API 说明就可以了,这些容器的使用都不难。
JDK1.5之前线程安全的容器就是同步容器——vector、stack、Hashtable、synchronizedMap/List这些,方法全都用synchronized保证互斥,性能太低了,JDK1.5之后提供了很多高效的并发容器
-
List
-
CopyOnWriteArrayList
-
特点:
- 操作是不加锁的,读写是可以并行的,读操作性能非常高
- 写操作需要获得互斥锁,然后将原内存拷贝一份到新内存中,在新内存中进行写操作,之后再将原内存的指针指向新内存,然后释放锁,原内存将被GC回收。
-
适用场景:适合读多写少,数据量不大的高并发场景。
-
-
-
Map
-
ConcurrentHashMap
线程安全的HashMap,key 是无序的
-
ConcurrentSkipListMap
线程安全的LinkedHashMap,key 是有序的
-
-
Set
-
CopyOnWriteArraySet
原理同CopyOnWriteArrayList
-
ConcurrentSkipListSet
线程安全的HashSet,同时key 是有序的
-
-
Queue
阻塞与非阻塞:所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。
单端与双端:单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。
-
单端阻塞队列:
- ArrayBlockingQueue:内部持有队列为数组。
- LinkedBlockingQueue:内部持有队列为链表。
- SynchronousQueue:内部不持有队列,此时生产者线程的入队操作必须等待消费者线程的出队操作。
- LinkedTransferQueue:融合2 和 3 的功能,性能比 2 更好。
- PriorityBlockingQueue:支持按照优先级出队。
- DelayQueue:支持延时出队。
-
双端阻塞队列:LinkedBlockingDeque
-
单端非阻塞队列:ConcurrentLinkedQueue
-
双端非阻塞队列:ConcurrentLinkedDeque
-
14.CAS(乐观锁)
主要了解CAS,其他原子类大致了解一下,也不考
CAS与ABA问题
乐观锁:认为线程中运行时大概率不会产生资源竞争的情况,因此不会运行的时候就开始获取锁,只有等到有资源竞争的时候才会获取锁,Java中一般通过CAS机制来解决。
-
概念:原子类的实现就是依靠 CPU 提供的 CAS 指令的(Compare And Swap),CAS作为一条 CPU 指令直接在硬件层次实现了原子性。
-
实现:
- CAS 包含三个参数,共享变量的地址A、共享变量的期望值B 和共享变量的新值C,当 内存地址 A处的值 与 B 相等时,就把A处的值更新为C,如果不相等,证明在执行CAS指令之前其他线程更改过共享变量了,更新失败,所以一般我们使用CAS会伴随自旋使更新成功。
- Java的CAS底层是unsafe类中的
native本地方法 ,本地方法可以直接调用操作系统底层资源执行任务,因此unsafe类中的CAS方***直接操作内存的数据。 - 原子类维护的变量要保证可见性所以一定得是volatile变量,禁止指令重排序、写操作会强制刷本地缓存、读操作强制从主内存获取
-
缺点:
-
CAS一般伴随自旋,如果在资源竞争激烈的时候自旋次数太多会导致CPU开销太大
-
CAS只能保证一个共享变量的原子操作,对于多个共享变量无法保证原子性,因为每次比较的都是一个元素
解决方法:可以使用AtomicReference类,将含有多个共享变量的对象传入AtomicReference的实例中,对这个实例进行操作
public static void main(String[] args) { User a = new User("a",12); User b = new User("b",24); AtomicReference<User> userRef = new AtomicReference<>(); userRef.set(a); System.out.println(a.name +" "+a.cnt);//a 12 System.out.println(userRef.get().name +" "+userRef.get().cnt);/a 12 userRef.compareAndSet(a,b); System.out.println(a.name +" "+a.cnt);//a 12 System.out.println(userRef.get().name +" "+userRef.get().cnt);/b 24 } -
ABA问题:CAS也会有些问题,比如说虽然内存中实际值等于期望值,但这不代表内存中的实际值没有被修改过,可能已经被其他线程更新过了
解决方法:可以为变量增加一个版本号,每次修改变量时把版本号+1,比如原先会造成问题的A->B->A变成了1A->2B->3A,这样就不会有问题了。AtomicStampedReference和AtomicMarkableReference两个原子类自带版本号解决ABA问题。
-
原子类
概念:对于简单的原子性问题,SDK 并发包封装提炼了一系列无锁方案的原子类,相比互斥锁方案性能更好
- 原子化的基本数据类型:相关实现有 AtomicBoolean、AtomicInteger 和 AtomicLong,用法都很简单
- 原子化的对象引用类型:相关实现有 AtomicReference、AtomicStampedReference 和 AtomicMarkableReference,利用它们可以实现对象引用的原子化更新。AtomicStampedReference 和 AtomicMarkableReference 这两个原子类自带解决 ABA 问题。
- 原子化数组:相关实现有 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,利用这些原子类,我们可以原子化地更新数组里面的每一个元素。
- 原子化对象属性更新器:相关实现有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater,利用它们可以原子化地更新对象的属性,这三个方法都是利用反射机制实现的。要求**对象的属性必须是 volatile 类型的,**否则会抛异常
- 原子化的累加器:DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快,但是不支持 compareAndSet() 方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
15.Executor与线程池
为了避免频繁创建和销毁线程导致开销过大,我们可以使用线程池ThreadPoolExecutor
-
设计:线程池的设计,普遍采用的都是生产者 - 消费者模式。线程池的使用方是生产者,线程池本身是消费者。用户通过调用 execute() 方法来提交 Runnable 任务,execute() 方法的内部实现仅仅是将任务加入到 workQueue 中,ThreadPoolExecutor内部维护的工作线程消费 工作队列 中的任务
-
参数:
-
corePoolSize:表示线程池保有的最小线程数。
-
maximumPoolSize:表示线程池创建的最大线程数,当线程池繁忙时会增加线程,这部分线程在空闲时会被销毁
-
keepAliveTime & unit:表示空闲超过多少时间会被认定为空闲
-
workQueue:表示传入一个工作队列
-
threadFactory:可以通过这个参数自定义如何创建线程
-
handler:通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了,那么此时提交任务,线程池就会拒绝接收。ThreadPoolExecutor自带的四种拒绝策略是:
- CallerRunsPolicy:提交任务的线程自己去执行该任务。
- AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。
- DiscardPolicy:直接丢弃任务,没有任何异常抛出。
- DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
ThreadPoolExecutor executor=new ThreadPoolExecutor( 4,8,60,SECONDS, workQueue,factory, new ThreadPoolExecutor.CallerRunsPolicy()); -
16.ThreadLocal线程本地存储
通过局部变量可以做到线程封闭,避免共享,此外Java 语言提供的**线程本地存储(ThreadLocal)**也能够做到。就像是某个对象的私有变量一样,ThreadLocal变量就是线程的私有变量,但对象的私有变量不施加手段可能会被共享,线程的私有变量必然不会被共享。
-
使用方法:
ThreadLocal<K> k = ThreadLocal.withInitial(()->new k()); -
工作原理:Thread类内部持有一个ThreadLocalMap,它的Key是ThreadLocal,Value是Object
-
ThreadLocal 与内存泄露:在线程池中使用 ThreadLocal 可能导致内存泄露,原因是线程池中线程的存活时间太长,往往都是和程序同生共死的,这就意味着ThreadLocalMap一直不会被回收,同时虽然ThreadLocal作为key是被弱引用的可以在生命周期结束后被回收,但是Value是被强引用的无法被回收。
解决方案:我们在线程池中使用ThreadLocal的时候就只能用try-finally块来调用ThreadLocal的remove()方法手动清除资源了。总而言之,ThreadLocal的使用需要非常谨慎。
强引用:普遍存在的引用赋值,类似
Object obj = new Object()。强引用的对象不会被gc弱引用:不管当前内存空间足够与否,弱引用的对象生命周期结束后都会被gc(这样声明弱引用↓)
WeakReference<String> weakRef = new WeakReference<String>(str);
17.Future(不蛮考)
Future这块儿要用的话,还有工具类CompletableFuture,里面的方法非常复杂,我也不想搞了,大致了解一下吧,面试顶多问问了不了解Future,大致说下什么用处
Future就像是下单后的订货单,拿到订货单后你可以干自己的事,干完了之后凭订货单去取货,如果另一个线程准备好货了那你直接取,没准备好那你就要阻塞等待。
-
作用:利用 Java 并发包提供的 Future 可以很容易获得异步任务的执行结果,使异步任务也能相互同步
-
获取任务执行结果:一般我们会使用Future接口的实现类FutureTask工具类,比较方便,内部有两个构造函数:
FutureTask(Callable<V> callable);:提交一个callable任务,callable接口的call()方法本来就有返回值,FutureTask.get()就是返回call()方法的返回值FutureTask(Runnable runnable, V result);:提交一个runnable任务,runnable接口的run()方法本身是没有返回值的,需要再传入一个对象result,run()方法执行的过程中可以对result进行修改,get()的返回值就是run()执行结束后的result
18.Fork/Join(不蛮考)
分工:第n个斐波那契数f(n)可以通过分治算法递归计算f(n-1)+f(n-2)得到答案,这种分治模型非常适合异步计算,Fork/Join框架就是用来支持异步的分治任务模型的
-
概念:Fork/Join 是一个并行计算的框架,主要就是用来支持分治任务模型的,Fork 是指任务分解,Join 是指结果合并。
-
使用:Fork/Join 计算框架主要包含两部分,分治任务的线程池 ForkJoinPool 和分治任务 ForkJoinTask。
-
ForkJoinPool 就是一个线程池,一般CPU核数多少,就创建多少个线程
-
ForkJoinTask 是一个抽象类,核心的方法有 fork() 方法和 join() 方法
- fork()方***异步地执行一个子任务
- join()方***阻塞当前线程等待子任务执行完成
ForkJoinTask 有两个子类RecursiveAction 和 RecursiveTask,都定义了抽象方法 compute()区别是
- RecursiveAction 的 compute() 没有返回值
- RecursiveTask 的 compute() 方法有返回值。
-
-
工作原理:ForkJoinPool跟ThreadPoolExecutor 本质上都是生产者-消费者的实现,只不过ThreadPoolExecutor内部只有一个任务队列,而ForkJoinPool有多个任务队列
-
示例代码:用Fork/Join实现斐波那契求解(来自JDK官方示例代码),
- 递归任务类Fibonacci要继承RecursiveTask,实现有返回值的compute()方法,在compute()方法里创建两个子任务,fork()异步启动两个子任务,再调用子任务的join()方法等待完成同时得到返回值,将两个子任务的返回值加起来return回去
- main()方法里创建ForkJoinPool线程池,假设我们电脑是四核,那就应该创建4个线程的ForkJoinPool,然后再创建任务比如说计算
new Fibonacci(10),然后把任务对象传入线程池的invoke方法里启动就可以得到返回值了
public static void main(String[] args){ // 创建分治任务线程池,创建CPU核数个线程就可以了 ForkJoinPool fjp = new ForkJoinPool(4); // 创建分治任务 Fibonacci fib = new Fibonacci(10); // 启动分治任务 Integer result = fjp.invoke(fib); // 输出结果 System.out.println(result);//55 } // 递归任务 static class Fibonacci extends RecursiveTask<Integer> { final int n; Fibonacci(int n){ this.n = n; } // RecursiveTask是ForkJoinTask的子类,需要实现compute()方法, // 就像继承runnable接口必须实现run()方法一样 @Override protected Integer compute(){ if (n <= 1) return n; Fibonacci f1 = new Fibonacci(n - 1); // 异步启动f1子任务 f1.fork(); Fibonacci f2 = new Fibonacci(n - 2); // 当前线程同步执行f2任务并等待f1子任务结果,再合并结果 return f2.compute()+f1.join();; } }