11. 线程上下文切换
涉及时间片轮转,CPU给每个任务都服务一定时间,即每个任务都分配一定时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务 ,任务的状态保存及再加载,,这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能
引起线程上下文切换的原因
- 当前执行任务的时间片用完之后,系统 CPU 正常调度下一个任务
- 当前执行任务碰到 IO 阻塞,调度器将此任务挂起,继续下一任务
- 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务
- 用户代码挂起当前任务,让出 CPU 时间
多线程编程中⼀般线程的个数都⼤于 CPU 核⼼的个数,⽽⼀个 CPU 核⼼在任意时刻只能被⼀个线程使⽤,为了让这些线程都能得到有效执⾏, CPU 采取的策略是为每个线程分配时间⽚并轮转的形式。当⼀个线程的时间⽚⽤完的时候就会重新处于就绪状态让给其他线程使⽤,这个过程就属于⼀次上下⽂切换 。
概括来说:当前任务在执⾏完 CPU 时间⽚切换到另⼀个任务之前会先保存⾃⼰的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。 任务从保存到再加载的过程就是⼀次上下⽂切换。
上下⽂切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒⼏⼗上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下⽂切换对系统来说意味着消耗⼤量的CPU 时间,事实上,可能是操作系统中时间消耗最⼤的操作。
12. CyclicBarrier、 CountDownLatch、 Semaphore 关键字
CountDownLatch
线程计数器
作用:允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
实现类似计数器的功能,例如有一个任务A,它要等待其他4个任务执行完毕之后才能执行
CyclicBarrier
回环栅栏,通过它可以实现让一组线程等待至某个状态之后再全部同时执行。叫做回环,是因为当所有等待线程都被释放以后, CyclicBarrier 可以被重用。我们暂且把这个状态就叫做 barrier,当调用 await()方法之后,线程就处于 barrier 了
Semaphore
信号量,可以控制同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可
13. 如何在两个线程之间共享数据
- 将数据抽象成一个类,并将数据的操作作为这个类的方法
- 将 Runnable 对象作为一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个 Runnable 对象调用外部类的这些方法
14. CAS理论
乐观锁机制和锁自旋都涉及到 CAS 理论。
CAS,比较并交换,算法过程为:包含 3 个参数CAS(V,E,N)。 V 表示要更新的变量(内存值), E 表示预期值(旧的), N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后, CAS 返回当前 V 的真实值
ABA问题
CAS 会导致 “ABA 问题” 。 CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化
举个例子:一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的
部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题 ,由于操作的版本号每次都自增1,只会增加不会减少,所以不会出现 ABA 问题
15. AQS框架理论
抽象的队列同步器,AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的
ReentrantLock、Semaphore、CountDownLatch
**AQS核心思想:**如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是⽤ CLH 队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中 。
CLH队列:虚拟的双向队列,即不存在队列实例,仅存在结点之间的关联关系。
AQS 定义两种资源共享方式
- Exclusive 独占资源,例如 ReentrantLock
- Share 共享资源,例如 Semaphore、CountDownLatch
以下举2个例子
ReentrantLock,state 初始化为 0,表示未锁定状态。 A 线程lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前, A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的
CountDownLatch,任务分为 N 个子线程去执行, state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的, 每个子线程执行完后 countDown()一次, state会 CAS 减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作
16. 什么是线程死锁?如何避免死锁?
线程死锁:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。
例子:线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对⽅的资源,所以这两个线程就会互相等待⽽进⼊死锁状态。
死锁必须具备以下四个条件:
- 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
- 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕
后才释放资源。 - 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。
如何避免死锁?
为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以
- 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界
资源需要互斥访问)。 - 破坏请求与保持条件 :⼀次性申请所有的资源。
- 破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放,破坏循环等待条件。
17.sleep()和wait()的区别
- sleep() 是 Thread 的方法,而 wait() 是 Object方法
- 两者都可以暂停线程的执行,wait 通常用于线程间交互和通信,sleep 通常用于暂停当前线程执行
- wait 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify 方法或者 notifyAll方法,而 sleep 方法执行完成后,线程会自动苏醒,或者可以使⽤ wait(long timeout) 超时后线程会⾃动苏醒
主要区别:sleep 方法没有释放锁,也不需要占用锁,而wait 方法释放了锁,前提是当前线程先占有锁
详情看以下例子:
public class Chap6Main {
private static final Object lock = new Object();
public static void main(String[] args) throws Exception{
Stream.of("线程1","线程2").forEach(n->new Thread(n){
@Override
public void run() {
Chap6Main.test();
}
}.start());
}
private static void test(){
synchronized (lock){
try {
System.out.println(Thread.currentThread().getName() + "正在执行");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + "休眠结束");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
//输出以下结果
线程1正在执行
线程1休眠结束
线程2正在执行
线程2休眠结束
可以看到线程1先获得CPU资源,拿到 lock 锁,但是休眠5秒这期间,线程2根本拿不到 lock 锁,所以必须等线程1休眠结束,执行完后释放锁,线程2才能拿到锁并执行,这里可以看出 sleep 方法不释放锁
private static void test(){
synchronized (lock){
try {
System.out.println(Thread.currentThread().getName() + "正在执行");
lock.wait(5000); //修改为 wait 方法
System.out.println(Thread.currentThread().getName() + "休眠结束");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
//输出以下结果
线程1正在执行
线程2正在执行
线程2休眠结束
线程1休眠结束
可以看出线程1先获得CPU资源,但是 wait 5秒这期间,wait方法释放了锁,所以线程2也能拿到锁并进行执行
18. java内存模型
先注意一点知识点
- JVM 内存结构和 JAVA 虚拟机的运行时区域有关;
- Java 内存模型和 Java 的并发编程有关;
JMM仅仅只是定义了共享内存系统中多线程程序读写操作行为的规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
简单来说:JMM定义了线程进行共享数据读写的一种规则,在JVM内部,多线程就是这么读取数据的
然而,当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。
Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性
首先理解一下原子性、可见性、有序性
原子性
由 Java 内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的,即操作不可中断,要么执行完成、要么不执行
可见性
可见性是指一个线程修改共享变量的值,其他线程能够立即得知这个修改
java中有3个关键字可以保证多线程操作变量时的可见性:volatile、synchronized 和 final
volatile:保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,保证其他线程能感知变量的最新值,进而保证多线程操作变量时的可见性。
synchronized:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)
以下理解 JMM 关于synchronized的两条规定:
(注意:加锁与解锁需要是同一把锁)
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
final:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值
有序性
Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指 “线程内表现为串行语义” ,后半句是指“指令重排序”现象和“工作内存中主内存同步延迟”现象。
重点理解 指令重排序 和 happens-before 规则
指令重排序:硬件或者编译器等为了能够更好地执行指令,提高性能,所做出的一定程度的优化,重排序也不是随随便便的就改变了顺序的,它具有一定的规则,叫做貌似串行语义As-if-serial Semantics,也就是从单线程的角度保障不会出现问题,但是对于多线程就可能出现问题。
所以为什么就有happens-before 规则,因为指令重排序不是随便你想怎么排就怎么排的,需要遵循happens-before 规则。
happens-before 规则
重点理解规则一就行,主要理解 happens-before 规则是怎么一回事就行了
规则一:程序的顺序性规则
一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
解释:程序顺序规则中所说的每个操作happens-before于该线程中的任意后续操作并不是说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序
规则二:volatile规则
对一个volatile变量的写操作,happens-before后续对这个变量的读操作。
规则三:传递性规则
如果A happens-before B,B happens-before C,那么A happens-before C。
规则四:管程中的锁规则
对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
规则五:线程start()规则
主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。
规则六:线程join()规则
主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
19 .LongAdder和AtomicLong的区别
这两个变量都适用于多线程环境中需要使用线程安全的变量累加,但是两者使用起来又有区别,AtomicLong做累加的时候,实际是多个线程操作同一个目标资源,底层原理使用的是CAS操作,所以只有一个线程是执行成功的,其他线程都会失败,不断自旋重试,自旋肯定会有瓶颈;而LongAdder将对单一变量的CAS操作分散为对数组cells
中多个元素的CAS操作,最终取值时进行求和,解释一下,相当于多个部分累加,最后将累加后的值求和,所以大部分线程都是操作成功的,而在并发较低时仅对base
变量进行CAS操作,与AtomicLong
类原理相同。
20. 多并发中Controller中的线程安全问题
我们知道,Spring默认的组件都是单例的,但是对同个方法的多个HTTP请求,都有创建一个线程去执行,线程内的变量肯定是私有的,由于Controller是单例的,所以肯定是共享Controller的变量,所以不能在Controller中写成员变量,可以使用ThreadLocal变量。如果要共享Controller的成员变量,那只能是静态变量,变量值是固定不变的。
21. 如何开启合适的线程?
CPU密集型:计算密集型,如数据的解码、文件的加密解密、压缩等都是。
IO密集型:主要涉及网络、内存、硬盘的读写操作,如HTTP请求等,一般要提高IO利用率,选择的方式是创建较多的线程,这是因为当线程执行到涉及 IO 操作或 sleep 之类的函数时,会触发系统调用。线程执行系统调用,会从用户态进入内核态,之后在其准备从内核态返回用户态时,操作系统将触发一次线程调度的机会。对于正在执行 IO 操作的线程,操作系统很有可能将其调度出去。这是因为触发 IO 请求的线程,通常需要等待 IO 操作完成,操作系统就会暂时让其在一旁等着,先调度其他线程执行。当 IO 请求的数据准备好之后,线程才再次获得被调度的机会,然后继续之前的执行流程。
IO密集型:磁盘读写频繁,cpu等待io执行导致cpu使用率不高
CPU密集型:指的是系统的磁盘读写效率高于cpu效率
如果是CPU密集型的,线程数量一般为CPU的核数+1;
如果是IO密集型:可多分配一点 cpu核数*2
所以,针对 IO 和 CPU 都密集的任务,其优化思路是,尽可能让 CPU 不把时间浪费在等待 IO 完成上,同时尽可能降低操作系统消耗在线程调度上的时间。
线程数的设置的计算公式
线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)
根据一些经验,往往可以这么设置线程的数量
IO密集型-阻塞型,核心线程数 = CPU核数的两倍
CPU密集型-计算型,核心线程数 = CPU核数 + 1
22. 为什么多线程会带来性能问题?
学习重点:什么情况下多线程编程会带来性能问题?主要有两个方面,一方面是线程调度,另一方面是线程协作
- 调度开销
上下文切换
首先看一下线程调度,在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况
缓存失效
由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。
那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。
协作开销
除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能
23. 哪些场景需要注意线程安全问题?
学习重点:注意线程安全的场景
访问共享变量或资源
典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存等,信息会被多个线程同时访问,就可能存在并发读写的情况下发生线程安全问题
依赖时序的操作
如果操作的正确性是依赖时序的,而在多线程下不能保障执行的顺序和我们预想的一致
if (map.containsKey(key)) {
map.remove(obj)
}
检查 map 中有没有 key 对应的元素,如果有则继续执行 remove 操作。此时,这个组合操作就是危险的,因为它是先检查后操作,而执行过程中可能会被打断。如果此时有两个线程同时进入 if() 语句,然后它们都检查到存在 key 对应的元素,于是都希望执行下面的 remove 操作,随后一个线程率先把 obj 给删除了,而另外一个线程它刚已经检查过存在 key 对应的元素,if 条件成立,所以它也会继续执行删除 obj 的操作,但实际上,集合中的 obj 已经被前面的线程删除了,这种情况下就可能导致线程安全问题
对方没有声明自己是线程安全的
对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题
举个例子:ArrayList 本身不是线程安全的,如果多个线程同时对 ArrayList 进行并发读/写,就有可能产生线程安全问题
24. 自定义线程池
学习重点:
- 核心线程数、阻塞队列、线程工厂、拒绝策略
- 测试线程池性能的工具:JMeter
核心线程数
核心线程数和任务类型有关,分为 CPU 密集型和 IO 密集型,这点根据任务类型设置核心线程数即可
阻塞队列
阻塞队列:LinkedBlockingQueue、SynchronousQueue 、DelayedWorkQueue和ArrayBlockingQueue
ArrayBlockingQueue 的最大特点是容量是有限的,如果任务队列放满任务并且线程数达到最大值,就会按照拒绝策略拒绝
线程工厂
对于线程工厂 threadFactory 这个参数,我们可以使用默认的 defaultThreadFactory,也可以传入自定义的有额外能力的线程工厂,因为我们可能有多个线程池,而不同的线程池之间有必要通过不同的名字来进行区分,所以可以传入能根据业务信息进行命名的线程工厂,以便后续可以根据线程名区分不同的业务进而快速定位问题代码。比如可以通过com.google.common.util.concurrent.ThreadFactoryBuilder 来实现,如代码所示
ThreadFactoryBuilder builder = new ThreadFactoryBuilder();
ThreadFactory rpcFactory = builder.setNameFormat("rpc-pool-%d").build();
生成了名字为 rpcFactory 的 ThreadFactory,它的 nameFormat 为 “rpc-pool-%d” ,那么它生成的线程的名字是有固定格式的,它生成的线程的名字分别为"rpc-pool-1",“rpc-pool-2” ,以此类推
拒绝策略
四种拒绝策略:AbortPolicy,DiscardPolicy,DiscardOldestPolicy 或者 CallerRunsPolicy。除此之外,我们还可以通过实现 RejectedExecutionHandler 接口来实现自己的拒绝策略,在接口中我们需要实现 rejectedExecution 方法,在 rejectedExecution 方法中,执行例如打印日志、暂存任务、重新执行等自定义的拒绝策略,以便满足业务需求
private static class CustomRejectionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
//打印日志、暂存任务、重新执行等拒绝策略
}
}