发布于 

3.Java多线程

1、进程和线程

  • 进程:是程序的一次执行过程,是系统运行的基本单位,是操作系统资源分配的基本单位。
  • 线程:比进程更小的执行单位,是处理器任务调度和执行的基本单位,一个进程可以包含多个线程。多个线程可以共享进程的方法区资源。

1.1 从JVM角度理解进程与线程区别

Java 虚拟机的运行时数据区包含堆、方法区、虚拟机栈、本地方法栈、程序计数器

各个进程之间是相互独立的,每个进程会包含多个线程,每个进程所包含的多个线程并不是相互独立的,这个线程会共享进程的堆和方法区,但这些线程不会共享虚拟机栈、本地方法栈、程序计数器。如下图所示,假设某个进程包含三个线程。

运行时数据区
运行时数据区
  • 内存分配:进程之间地址空间和资源是相互独立的,同一个进程之间的线程会共享进程的地址空间和资源。
  • 资源开销:每个进程具备各自的数据空间,进程之间的切换会有较大的开销。属于同一进程的线程会共享堆和方法区,同时具备私有的虚拟机栈、本地方法栈、程序计数器,线程之间切换资源开销较小,但不利于资源的管理和保护。

1.2 虚拟机栈、本地方法栈和程序计数器为什么是私有的?

  • 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。


程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

1.3 什么是堆和方法区?

堆和方法区是所有线程共享的资源,其中是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

1.4 Java中线程与OS中线程的区别

在 JDK 1.2 之前,Java 线程是基于称为 “绿色线程”(Green Threads)的用户级线程实现的,也就是说程序员大佬们为 JVM 开发了自己的一套线程库或者说线程管理机制。

在 JDK 1.2 及以后,JVM 选择了更加稳定且方便使用的操作系统原生的内核级线程,通过系统调用,将线程的调度交给了操作系统内核。而对于不同的操作系统来说,它们本身的设计思路基本上是完全不一样的,因此它们各自对于线程的设计也存在种种差异,所以 JVM 中明确声明了:虚拟机中的线程状态,不反应任何操作系统中的线程状态

简单来说:现今 Java 中线程的本质,其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现

1.4.1 两者不同点

操作系统中线程有五种状态:newreadyrunningwaitingterminated

OS线程五种状态
OS线程五种状态

Java中线程有六种状态:newrunnableblockedwaitingtimed_waitingterminate

Java中将操作系统中的readyrunning两种状态统一称为runnable状态,而不论是timed_waitingwaiting还是blocked,其实本质上都是操作系统中的waiting状态。

2、几个区别

2.1 并发与并行的区别

  • 并发:一个处理器处理多个任务,按时间片轮流处理多个任务。
  • 并行:单位时间多个处理器同时处理多个任务
并行与并发
并行与并发

2.2 同步与异步的区别

  • 同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
  • 异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中,“真实”地执行着。整个过程,不会阻碍调用者的工作。

3、多线程的优缺点

  • 优点:当一个线程进入等待状态或者阻塞时,CPU 可以先去执行其他线程, 提高 CPU 的利用率。

  • 缺点

    1. 上下文切换。频繁的上下文切换会影响多线程的执行速度。

    2. 死锁。多线程并发执行可能会产生死锁。

    3. 受资源限制。程序执行的速度受限于计算机的硬件或软件资源。

4、什么是线程的上下文切换?

即便是单核的处理器也会支持多线程,处理器会给每个线程分配 CPU 时间片来实现这个机制。

时间片是 CPU 分配给每个线程的执行时间,一般来说时间片非常的短,所以处理器会不停地切换线程。

CPU 会通过时间片分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但切换前会保存上一个任务的状态,因为下次切换回这个任务时还要加载这个任务的状态继续执行,从任务保存到再加载的过程就是一次上下文切换

5、守护线程和用户线程区别

  • 用户线程:平时用到的线程均为用户线程。
  • 守护线程:用来服务用户线程的线程,例如垃圾回收线程。

任何线程都可以设置为守护线程和用户线程,通过方法 Thread.setDaemon(boolean on)设置,true 则是将该线程设置为守护线程,false 则是将该线程设置为用户线程。同时,Thread.setDaemon()必须在 Thread.start()之前调用,否则运行时会抛出异常。

守护线程和用户线程的主要区别在于线程结束后 Java 虚拟机是否结束

  • 用户线程:当任何一个用户线程未结束,Java 虚拟机是不会结束的。
  • 守护线程:如果只剩守护线程未结束,Java 虚拟机是会结束的。

6、死锁、活锁、饥饿

  • 死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时被阻塞。

  • 活锁:任务执行时没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败、尝试、失败。例如,线程 1 和线程 2 都需要获取一个资源,但他们同时让其他线程先获取该资源,两个线程一直谦让,最后都无法获取。

  • 饥饿:以打印机打印文件为例,当有多个线程需要打印文件,如果系统按照短文件优先的策略进行打印,但当短文件的打印任务一直不间断地出现,那长文件的打印任务会被一直推迟,导致饥饿。

    产生饥饿的原因:高优先级的线程占用了低优先级线程的CPU时间

6.1 死锁的四个必要条件

  1. 互斥条件:一个资源在同一时刻只由一个线程占用。
  2. 请求与保持条件:一个线程在请求被占资源时发生阻塞,并对已获得的资源保持不放
  3. 循环等待条件:发生死锁时,所有的线程会形成死循环,一直阻塞。
  4. 不可剥夺条件:线程已获得的资源在未使用完的情况下,不能被其他线程剥夺,只能由自己使用完释放资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static Object resource1 = new Object();
private static Object resource2 = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1){
//返回当前正在执行的线程对象
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "wait get resource2");
synchronized (resource2){
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();

new Thread(() -> {
synchronized (resource2){
//返回当前正在执行的线程对象
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "wait get resource1");
synchronized (resource1){
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}

运行结果:

运行结果
运行结果

线程1通过synchronized(resource1)获得resource1的监视器锁,然后通过Thread.sleep(1000),让线程1休眠1s,为的是让线程2行动,然后获取到resource2的监视器锁。线程1和线程2休眠结束后,都开始企图获取对方的资源,然后这两个线程都会陷入互相等待的状态,于是产生了死锁。

6.2 如何预防和避免死锁?

6.2.1 如何预防死锁?

破坏死锁产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它所占有的资源。
  3. 破坏循环等待条件:按照顺序申请资源。比如按某一顺序申请资源,释放资源时则反序释放。

6.2.2 如何避免死锁?

在资源分配时,借助算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
将上面线程2的代码改成下面这样就不会产生死锁了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
new Thread(() -> {
synchronized (resource1){
//返回当前正在执行的线程对象
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "wait get resource2");
synchronized (resource2){
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();
运行结果
运行结果

6.3 死锁、活锁、饥饿的区别

  1. 活锁是在不断尝试,而死锁是一直在等待;
  2. 活锁有可能自行解开,而死锁无法自行解开;
  3. 饥饿可以自行解开,而死锁不行;

7、线程的生命周期

线程的生命周期
线程的生命周期
  1. 线程创建后将处于NEW初始状态,调用start()方法后开始运行,此时线程处于READY就绪状态。
  2. 可运行状态的线程获取CPU时间片后就处于RUNNING运行中状态。
  3. 当线程执行wait()方法后,线程进入WATING等待状态,进入等待状态的线程需要依靠其他线程的通知才能返回到运行状态,而TIMED_WATING超时等待状态相当于在等待状态的基础上增加了超时限制,比如通过sleep(long millis)方法或者wait(long millis)方法可以将Java线程置于超时状态。
  4. 当超时时间到达后Java线程将会回到RUNNABLE状态
  5. 当线程调用同步方法时,在没有获取到锁的情况下,线程会进入BLOCKED阻塞状态,一直到获取到锁。
  6. 线程在执行RUNNABLErun()方法后会进入到TERMINATED终止状态。

8、创建线程的方式?

8.1 继承Thread类创建线程

首先继承Thread类,重写run()方法,在main()函数中调用子类实例的start()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ThreadDemo extends Thread{
@Override
public void run(){
System.out.println(Thread.currentThread().getName() + "run()方法正在执行");
}
}

public class Main{
public static void main(String[] args) {
ThreadDemo threadDemo = new ThreadDemo();
threadDemo.start();
System.out.println(Thread.currentThread().getName() + "main()方法执行结束");
}
}
运行结果
运行结果

8.2 实现Runnable接口创建线程

首先创建实现Runnable接口的类RunnableDemo,重写run()方法;
创建类RunnableDemo的实例对象runnableDemo,以此为参数创建Thread对象,调用start()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class RunnableDemo implements Runnable{
@Override
public void run(){
System.out.println(Thread.currentThread().getName() + "run()方法正在执行");
}
}

public class Main{
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
System.out.println(Thread.currentThread().getName() + "main()方法执行结束");
}
}

8.3 使用Callable和Future创建线程

  1. 创建Callable接口的实现类CallableDemo,重写call()方法。
  2. 以类CallableDemo的实例化对象作为参数创建FutureTask对象。
  3. 以FutureTask对象作为参数创建Thread对象。
  4. 调用Thread对象的start()方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CallableDemo implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println(Thread.currentThread().getName() + "call()方法执行中");
return null;
}
}

public class Main{
public static void main(String[] args) throws ExecutionException, InterruptedException {
CallableDemo callableDemo = new CallableDemo();
FutureTask<Integer> integerFutureTask = new FutureTask<>(callableDemo);
Thread thread = new Thread(integerFutureTask);
thread.start();
System.out.println("返回结果" + integerFutureTask.get());
System.out.println(Thread.currentThread().getName() + "main()方法执行结束");
}
}

8.4 使用线程池

例如Executor框架,可以提供四种线程池:

  1. newCachedThreadPool创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  2. newFixedThreadPool创建一个定长的线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  3. newScheduledThreadPool创建一个定长的线程池,支持定时及周期性任务执行。
  4. newSingleThreadExecutor创建一个单线程化的线程池,只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ThreadDemo extends Thread{
@Override
public void run(){
System.out.println(Thread.currentThread().getName() + " 正在执行");
}
}

public class Main{
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建一个可重用固定长度的线程池
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(2);

//实现接口
ThreadDemo t1 = new ThreadDemo();
ThreadDemo t2 = new ThreadDemo();
ThreadDemo t3 = new ThreadDemo();
ThreadDemo t4 = new ThreadDemo();
ThreadDemo t5 = new ThreadDemo();

//将线程放入池中执行
fixedThreadPool.execute(t1);
fixedThreadPool.execute(t2);
fixedThreadPool.execute(t3);
fixedThreadPool.execute(t4);
fixedThreadPool.execute(t5);

//关闭线程池
fixedThreadPool.shutdown();
}
}
image.png
image.png

9、runnable和callable区别

相同点:

  1. 两者都是接口;
  2. 都需要调动Thread.start()启动线程。

不同点:

  1. callable的核心是call()方法,允许返回值;而runnable的核心是run()方法,没有返回值
  2. call()方法可以抛出异常,但是run()方法不行。
  3. callable和runnable都可以应用于executors,但Thread类只支持runnable。

10、start()和run()

10.1 两者区别

  1. 线程是通过Thread对象所对应的方法run()来完成其操作的,而线程的启动是通过start()方法执行。
  2. run()方法可以重复调用,start()方法只调用一次。

10.2 为什么调用start方法时会执行run方法,而不直接执行run方法?

当 new 一个 Thread 时,线程进入了新建状态。

调用 start()方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行了。start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。

但是,如果直接执行 run() 方法,会把 run() 方法当成一 个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start()方法才可以启动线程并使线程进入就绪状态,直接执行run()方法的话不会以多线程的方式执行。

10.3 一个线程执行两次start()方法会出现什么情况?

Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。

只有处于NEW状态的线程才能够执行start()方法。也就是第二次调用start()方法的时候,线程可能处于终止或者其他非NEW的状态,因此无论如何都是不能够重复调用的。

11、线程同步和调度的方法(对线程的控制方法)

  • wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁
  • sleep():使当前线程进入指定毫秒数的休眠,暂停执行,需要处理异常InterruptedException不会释放持有的锁
  • notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能精准的唤醒某一个等待的线程,而是JVM确定唤醒哪个线程,与优先级无关。
  • notifyAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态
  • join():与sleep()方法一样,是一个可中断的方法,在一个线程中调用另一个线程的join()方法,会使得当前的线程挂起,直到执行join()方法的线程结束。例如在B线程中调用A线程的join()方法,B线程进入了阻塞状态,直到A线程结束或者到达指定时间。
  • yield():提醒调度器当前线程愿意放弃当前的CPU资源,使得当前线程从运行状态切换到就绪状态。

12、Sleep的区别对比

12.1 sleep()和yield()区别

  • sleep()方法会使得当前线程暂停指定的时间,没有消耗CPU时间片。
  • sleep()使得线程进入阻塞状态,yield()方法只是对CPU进行提示,如果CPU没有忽略这个提示,会使得线程上下文的切换,进入到就绪状态。
  • sleep()需要抛出异常,而yield()不需要抛出异常。
  • sleep()会完成给定的休眠时间,yield()不一定。

12.2 sleep()和wait()的区别

相同点:

  1. 都能使线程进入到等待状态。
  2. 都是可中断方法,被中断后都会收到中断异常。

不同点:

  1. wait()是Object方法,sleep()是Thread方法。
  2. wait()必须在同步方法中进行,sleep()不需要。
  3. 线程同步方法中执行sleep()不会释放monitor的锁,而wait()方法会释放monitor的锁。
  4. sleep()方法在短暂的休眠后会主动退出阻塞,而wait()方法在没有指定时间的情况下,需要被其他线程中断才可以退出阻塞。

13、Sleep(0)有意义吗?为什么?

Thread.Sleep(0) 并非是真的要线程挂起0毫秒,这个操作的意义在于这次调用Thread.Sleep(0)的当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread.Sleep(0) 是指当前的线程暂时放弃cpu,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作。

在线程中,调用sleep(0)可以释放cpu时间,让线程马上重新回到就绪队列而非等待队列,sleep(0)释放当前线程所剩余的时间片(如果有剩余的话),这样可以让操作系统切换其他线程来执行,提升效率。

在线程没退出之前,线程有三个状态,就绪态,运行态,等待态。sleep(n)之所以在n秒内不会参与CPU竞争,是因为,当线程调用sleep(n)的时候,线程是由运行态转入等待态,线程被放入等待队列中,等待定时器n秒后的中断事件,当到达n秒计时后,线程才重新由等待态转入就绪态,被放入就绪队列中,等待队列中的线程是不参与cpu竞争的,只有就绪队列中的线程才会参与cpu竞争,所谓的cpu调度,就是根据一定的算法(优先级,FIFO等… …),从就绪队列中选择一个线程来分配cpu时间。

sleep(0)之所以马上回去参与cpu竞争,是因为调用sleep(0)后,因为0的原因,线程直接回到就绪队列,而非进入等待队列,只要进入就绪队列,那么它就参与cpu竞争。

14、线程间通信方式

  1. 共享变量:多个线程通过访问共享变量来实现通信。一般情况下需要使用 synchronized 或者 volatile 来保证共享变量的可见性和原子性。
  2. wait/notify:多个线程之间通过调用对象的 wait()notify() 方法来实现通信。其中,wait() 方法会让当前线程进入等待状态,并且释放对象的锁;而 notify() 方法会唤醒等待队列中的某个线程,让其进入就绪状态。
  3. Condition:Condition 接口提供了类似 wait/notify 的功能,但是相比之下更加灵活。通过 Condition 接口的 await()signal() 方法,可以实现多个线程之间的等待和唤醒操作。
  4. 管道:通过管道实现线程之间的通信。一般情况下,需要使用 PipedInputStreamPipedOutputStream 来实现管道流。
  5. 队列:线程间可以通过队列来传递数据。一个线程将数据放入队列的一端,另一个线程从队列的另一端取出数据。常用的队列实现包括BlockingQueue、ConcurrentLinkedQueue等。

15、如何实现线程同步和互斥?

线程互斥:指某一个资源只能被一个访问者访问,具有唯一性和排他性。但访问者对资源访问的顺序是乱序的。

线程同步:指在互斥的基础上使得访问者对资源进行有序访问

线程同步的实现方法:

  • 同步方法
  • 同步代码块
  • wait()notify()
  • 使用volatile实现线程同步
  • 使用重入锁实现线程同步
  • 使用局部变量实现线程同步
  • 使用阻塞队列实现线程同步

16、线程安全

16.1 什么是线程安全?

线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。

16.2 如何保证线程安全?

  • 使用同步锁。用synchronized关键字或ReentrantLock类对共享资源加锁,保证同一时刻只有一个线程能够访问共享资源。
  • 使用volatile关键字,保证共享变量的可见性禁止指令重排
  • 使用线程安全的类。

16.3 线程安全体现在什么方面?

  1. 原子性:对共享变量互斥访问,同一个时刻只能有一个线程对数据操作;
  2. 可见性:当一个线程修改主内存后,其他变量能及时看到;
  3. 有序性:一个线程中的指令执行是有序的。

17、如何让三个线程T1、T2、T3按顺序执行这类问题?

这是一道面试中常考的并发编程代码题,类似的题还有:

  • 三个线程T1、T2、T3轮流打印ABC,打印n次,如ABCABCABC……
  • 两个线程交替打印1-100的奇偶数
  • N个线程循环打印1-100
  • ……

其实这类问题的本质都是线程通信问题,思路基本上都是一个线程执行完,阻塞该线程,唤醒其他线程,然后按顺序执行下一个线程。

17.1 如何按顺序执行三个线程?

17.1.1 synchronized + wait/notify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private int num;
private static final Object LOCK = new Object();

private void printABC(String name, int targetNum){
synchronized (LOCK){
//让其他线程陷入阻塞状态
while (num % 3 != targetNum){
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(name);
//唤醒所有线程
LOCK.notifyAll();
}
}

public static void main(String[] args) {
Main main = new Main();
new Thread(() -> {
main.printABC("A",0);
}, "A").start();
new Thread(() -> {
main.printABC("B",1);
}, "B").start();
new Thread(() -> {
main.printABC("C",2);
}, "C").start();
}

17.2 三个线程轮流打印n次ABC

这个只需要在上面代码的基础上,加一个n次的循环即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void printABC(String name, int targetNum){
//加个循环
for (int i = 0; i < 10; i++) {
synchronized (LOCK){
while (num % 3 != targetNum){
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(name);
LOCK.notifyAll();
}
}
}
运行结果
运行结果

基本思路:ABC三个线程同时启动,因为变量num的初始值为0,所以线程B、C拿到锁后,进入while()循环,然后执行wait()方法,线程阻塞,释放锁。只有A拿到锁后,不仅如此while()循环,执行num++,打印字符A,最后唤醒其他两个线程。这个时候变量num为1,所以线程B拿到锁后,不被阻塞,执行num++,打印字符B,最后唤醒其他两个线程。后面打印字符C也是相同的流程。

17.2.1 join()方法

join()方法:在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。
基于这个原理,我们可以使得三个线程按照顺序执行,然后循环多次即可。无论三个线程谁先谁后,顺序最后都是A-B-C。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static class printABC implements Runnable{
private Thread beforeThread;
public printABC(Thread beforeThread){
this.beforeThread = beforeThread;
}

@Override
public void run() {
if (beforeThread != null){
try {
beforeThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(Thread.currentThread().getName());
}
}

public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(new printABC(null), "A");
Thread t2 = new Thread(new printABC(t1), "B");
Thread t3 = new Thread(new printABC(t2), "C");
t1.start();
t2.start();
t3.start();
Thread.sleep(10);
}
}

使用join好处就是不论三个线程启动的顺序咋样,线程B只会在A线程执行完后,才会执行,C只会在B执行完后执行。

17.2.2 Lock锁

其实原理一样,使用lock锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private int num;    //当前状态值:保证三个线程之间交替打印
private Lock lock = new ReentrantLock();

private void printABC(String name, int targetNum){
for (int i = 0; i < 10;) {
lock.lock();
if (num % 3 == targetNum){
num++;
i++;
System.out.print(name);
}
lock.unlock();
}
}

public static void main(String[] args) {
Main main = new Main();
new Thread(() -> {
main.printABC("A",0);
}, "A").start();
new Thread(() -> {
main.printABC("B",1);
}, "B").start();
new Thread(() -> {
main.printABC("C",2);
}, "C").start();
}

17.2.3 Lock+Condition精准唤醒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private int num;    //当前状态值:保证三个线程之间交替打印
private static Lock lock = new ReentrantLock();
private static Condition c1 = lock.newCondition();
private static Condition c2 = lock.newCondition();
private static Condition c3 = lock.newCondition();

private void printABC(String name, int targetNum, Condition currentThread, Condition nextThread){
for (int i = 0; i < 10;) {
lock.lock();
try {
while (num % 3 != targetNum) {
currentThread.await();
}
num++;
i++;
System.out.print(name);
nextThread.signal();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}

public static void main(String[] args) {
Main main = new Main();
new Thread(() -> {
main.printABC("A",0, c1, c2);
}, "A").start();
new Thread(() -> {
main.printABC("B",1, c2, c3);
}, "B").start();
new Thread(() -> {
main.printABC("C",2, c3, c1);
}, "C").start();
}

17.3 两个线程交替打印1-100的奇偶数

基本思路:也是用synchronized+wait方法进行打印
当数字是奇数时,打印数字,然后唤醒另一个线程打印偶数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private int num = 1;                                //全局计数器,从1开始
private final Object Lock = new Object(); //用于线程同步的锁

public void print(int targetNum){
synchronized (Lock){
while (num <= 100){
if ((num % 2) == targetNum){ //如果是要求的数,就打印
System.out.print(num + " ");
num++;
Lock.notifyAll(); //打印完然后唤醒另一个线程
}else{ //否则就释放锁,并等待
try {
Lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

public static void main(String[] args) {
Main main = new Main();
new Thread(() -> {
main.print(0);
}).start();
new Thread(() -> {
main.print(1);
}).start();
}

17.4 N个线程循环打印1-100

当N为4时,循环打印1-100,解决方法一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private int num = 1;                                //全局计数器,从1开始
private final Object Lock = new Object(); //用于线程同步的锁

public void print(int targetNum){
synchronized (Lock){
while (num <= 100){
if ((num % 4) == targetNum){ //如果是要求的数,就打印
System.out.print(num + " ");
num++;
Lock.notifyAll(); //打印完然后唤醒另一个线程
}else{ //否则就释放锁,并等待
try {
Lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

public static void main(String[] args) {
Main main = new Main();
new Thread(() -> {
main.print(0);
}).start();
new Thread(() -> {
main.print(1);
}).start();
new Thread(() -> {
main.print(2);
}).start();
new Thread(() -> {
main.print(3);
}).start();
}

18、synchronized关键字

18.1 什么是synchronized关键字

多个线程同时访问共享资源时会出现一些问题,而synchronized关键字是用来保证线程同步的。

18.2 Java内存的可见性问题

在了解 synchronized 关键字的底层原理前,需要先简单了解下 Java 的内存模型,看看 synchronized 关键字是如何起作用的。

内存模型
内存模型

18.2.1 什么是内存不可见问题?

Java中内存不可见问题是指当多个线程访问同一共享变量时,一个线程修改了这个变量的值,其他线程可能无法立即看到修改后的值,从而出现数据不一致的问题。

这是因为每个线程都有自己的工作内存,线程间共享变量时,为了提高性能,JVM会把变量缓存到每个线程的本地工作内存中,而不是直接读取主内存中的值。当一个线程修改了共享变量的值时,修改后的值可能还没有同步到主内存中,其他线程读取的仍是旧值,从而导致数据不一致。

18.2.2 synchronized关键字是怎么解决的?

其实 synchronized 就是把在 synchronized 块内使用到的变量从线程的本地内存中擦除,这样在 synchronized 块中再次使用到该变量就不能从本地内存中获取了,需要从主内存中获取,确保变量的修改和读取都在主内存中进行,避免了了内存不可见问题。

18.3 synchronized关键字三大特性?

  • 原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。
  • 可见性:当一个线程对共享变量进行修改之后,其他线程就可以立刻看到。执行synchronized时,会对应执行lockunlock原子操作,保证可见性。
  • 有序性:程序的执行顺序会按照代码的先后顺序执行。

18.4 synchronized关键字可以实现什么类型的锁?

  • 悲观锁:每次访问共享资源时都会上锁。
  • 非公平锁:线程获取锁的顺序并不一定是按照线程阻塞的顺序。
  • 可重入锁:已经获取锁的线程可以再次获取锁。
  • 独占锁/排他锁:该锁只能被一个线程所持有,其他线程均被阻塞。

18.4.1 可重入锁的原理知道吗?

synchronized是可重入锁的实现原理:

当执行monitorenter指令时,线程试图获取锁,如果锁的计数器为0,则表示锁可以被获取,获取后将锁的计数器设为1。

执行monitorenter
执行monitorenter

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。整个流程与上面获取锁的流程相同。

执行monitorexit
执行monitorexit

简单来说,当线程请求一个由其它线程持有的对象锁时,该线程会阻塞;而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。

可重入锁实现的原理是:每一个锁关联一个线程id重入次数计数器

  • 当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;
  • 当某一线程请求成功后,JVM会记下锁的持有线程id,并且将计数器置为 1;
    • 如果此时其它线程请求该锁,则必须等待;
    • 如果该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时重入次数计数器会递增;
    • 如果该线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。

18.5 使用方式

  1. 修饰普通同步方法:给当前对象实例加锁
  2. 修饰静态同步方法:给当前类加锁
  3. 修饰同步方法块:给指定对象或类加锁

18.5.1 修饰普通同步方法

1
2
3
synchronized void method(){
//业务代码
}

给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

18.5.2 修饰静态同步方法

1
2
3
synchronized static void method(){
//业务代码
}

修饰静态同步方法也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管 new 了多少个对象, 只有一份)。
所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法是允许的,不会发生互斥现象.
因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

18.5.3 修饰同步方法块

1
2
3
synchronized(this){
//业务代码
}

修饰同步方法块,表示对给定对象/类加锁
synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁
synchronized(类.class)表示进入同步代码前要获得当前 class 的锁

18.6 底层原理

synchronized关键字底层原理属于JVM层面的东西。

18.6.1 修饰同步语块的情况

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理
synchronized关键字原理

从上面可以看出synchronized同步语块的实现使用的是monitorentermonitorexit指令,其中前者指向同步代码块的开始位置,后者指向代码块结束的位置。

当执行monitorenter指令时,线程试图获取锁,也就是获取**对象监视器monitor**的持有权。


另外,wait/notify方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用这两个方法,否则会抛出java.lang.IllegalMonitorStateException异常的原因。

18.6.2 修饰方法的情况

synchronized关键字原理
synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

18.6.3 总结

  • synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

  • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器monitor的获取

18.6.4 能具体说一下Monitor吗?

对象监视器monitor存在于每个对象的对象头中,synchronized锁便是通过这种方式获取锁的。

其内部维护了三个变量:

  • WaitSet:保存处于Waiting状态的线程;
  • EntryList:保存处于Blocked状态的线程;
  • Owner:持有锁的线程;

一个线程获取到锁的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner。

在上锁的过程中,如果有其他线程来抢锁,则进入EntryList进行阻塞状态,当获得锁的线程执行完毕,释放了锁,就会唤醒EntryList中等待的线程竞争锁,竞争的过程是非公平的。

18.7 JDK1.6对synchronized做了哪些优化?

在 jdk1.6 中,为了减少获得锁释放锁带来的性能开销,引入了大量优化,如偏向锁轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可一级一级的降级,当锁释放之后会重新回归无锁状态,不会一级一级的往下降,而是直接变成无锁状态,这种策略是为了提高获得锁和释放锁的效率。

  1. 偏向锁(Biased Locking):
    • 偏向锁是在无竞争的情况下对锁进行优化的机制。它的目标是减少无竞争情况下的锁操作的开销
    • 当一个线程获得了偏向锁后,之后再次进入同步块时无需重新获取锁,而是直接进入
    • 偏向锁的撤销机制是当其他线程试图竞争偏向锁时,持有偏向锁的线程会被挂起,偏向锁会升级为轻量级锁。
  2. 轻量级锁(Lightweight Locking):
    • 轻量级锁是在有少量竞争的情况下对锁进行优化的机制。
    • 当第一个线程进入同步块时,它会尝试使用 CAS(Compare and Swap)操作将对象头部的标记字段替换为指向锁记录的指针,这个过程是无锁的。
    • 如果 CAS 操作成功,线程就获得了轻量级锁。如果 CAS 操作失败,表示有竞争,线程会膨胀为重量级锁
  3. 重量级锁(Heavyweight Locking):
    • 重量级锁是在存在激烈竞争轻量级锁膨胀失败的情况下使用的锁机制。
    • 当一个线程尝试获取一个被重量级锁保护的同步块时,它会被阻塞,进入等待状态。
    • 重量级锁使用操作系统的同步原语来实现,比如使用操作系统的互斥量或监视器锁。
优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步代码方法的性能相差无几。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用只有一个线程访问的同步场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程,使用自旋会消耗CPU。 追求响应时间,同步块执行速度非常快。
重量级锁 线程竞争不适用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量,同步块执行时间速度较长。

18.8 synchronized和lock两者的区别

  1. 语法层面
    • synchronized是关键字,源码在JVM中,是由C++实现的,退出同步代码块锁会自动释放;
    • Lock是接口,源码由JDK提供,用JAVA语言实现,需要手动用unlock方法释放锁;
  2. 功能层面
    • 二者都属于悲观锁,都具备基本的互斥、同步、锁重入的功能;
    • Lock提供了更多的功能,比如等待状态、公平锁、可打断、可超时等等,同时Lock可以实现不同的场景,比如ReentrantLock等;
  3. 性能层面
    • 在没有或竞争少时,synchronized做了许多优化,比如偏向锁、轻量级锁;
    • 在竞争激烈时,Lock的性能会更好;

18.9 了解ReentrantLock吗?

ReentrantLock是java并发包下面的Lock接口的一个实现类,其丰富的功能与synchronized对比更加灵活,与synchronized一样都是悲观锁。

ReentrantLock是一个可重入锁,当调用lock方法获取锁之后,再次调用lock时,是不会再阻塞的,内部直接增加重入次数即可,代表这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock的底层实现原理主要是利用CAS+AQS队列来实现的,默认是非公平锁

18.10 ReentrantLock和synchronized的区别是什么?

相同点

  1. 两者都是可重入锁
  2. 两者都是悲观锁

不同点

  1. ReentrantLock是显式锁,需要手动调用lock()方法获取锁,再通过unLock()方法释放锁,而synchronized是隐式锁,通过关键字修饰的代码块或方法时自动获取和释放锁。
  2. ReentrantLock可以设置公平锁和非公平锁,但是synchronized是非公平锁。
  3. ReentrantLock可以在等待锁的过程中响应中断请求,synchronized在等待锁的过程中无法响应中断请求。
  4. ReentrantLock可以绑定多个条件。

19、volatile关键字

作用:保证线程之间的变量可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

注意:volatile关键字能保证数据的可见性有序性,但是不能保证数据的原子性。而synchronized关键字两者都能保证。

19.1 为什么代码会重排?

计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。

代码重排
代码重排

左边这段代码,不断地交替读取a,b,会导致寄存器频繁交替存储a和b,使得代码的性能下降。

但是如果使用右边的这种重排序方式,避免交替读取a和b,这就是重排序的意义。

指令重排序一般分为编译器优化重排、指令并行重排、内存系统重排三种:

  • 编译器优化重排:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序。
  • 指令并行重排:对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
  • 内存系统重排:因为处理器使用缓存和读/写缓冲区,使得加载(load)和 存储(store)看上去像是在乱序执行。

这三种指令重排说明了一个问题,那就是指令重排在单线程下可以提高代码的性能,但是在多线程下会出现问题。

19.2 重排序会引发什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
int a = 0;
boolean flag = false;
public void writer(){
a = 1; //操作1
flag = true; //操作2
}

public void reader(){
if (flag){ //操作3
int i = a + a; //操作4
}
}

假设线程1先执行writer()方法,随后线程2执行reader()方法,最后程序一定能得到正确的结果吗?

不一定。如果操作1和操作2进行了重排序,线程1先执行操作2,也就是flag = true;然后线程2执行操作3和操作4,在执行操作4时,不能正确读取到a的值,导致最终程序运行结果出问题,这说明在多线程代码中,重排序会破坏多线程的语义。

19.3 volatile实现原理

19.3.1 实现可见性原理

导致内存不可见的主要原因:Java内存模型中本地内存和主内存之间的值不一致导致的。

volatile可以保证内存可见性的关键是 volatile的读/写实现了缓存一致性

缓存一致性的主要内容为: 每个处理器会通过嗅探总线上的数据来查看自己的数据是否过期,一旦处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存设为无效状态。此时,如果处理器要获取这个数据,必须得重新从主内存将其读取到本地内存。 当处理器写数据时,如果发现操作的是共享变量,会通知其他处理器将该变量的缓存设为无效状态。

那缓存一致性是如何实现的呢?

可以发现通过 volatile 修饰的变量,在生成汇编指令时会比普通的变量多出一个 Lock指令,这个 Lock 指令就是 volatile 关键字可以保证内存可见性的关键,它主要有两个作用:

(1)将当前处理器缓存的数据刷新到主内存。

(2)刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效。

19.3.2 实现有序性原理

为了实现 volatile 的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。

内存屏障:是一种 CPU 指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

Java内存模型把内存屏障分为4类,分别是LoadLoadStoreStoreLoadStoreStoreLoad

根据Java内存模型对编译器指定的重排序规则为:

重排序规则
重排序规则
  • volatile写操作上面加ss屏障:禁止上面的普通写和下面的volatile写重排序;
  • volatile写操作下面加sl屏障:禁止上面的volatile写和下面可能的volatile读/写重排序;
  • volatile读操作下面加ll屏障:禁止上面的volatile读和下面所有的普通读重排序;
  • volatile读操作下面加ls屏障:禁止上面的volatile读和下面所有的普通写重排序;

通过这四个重排序规则,volatile关键字可以禁止指令重排,实现有序性。

面试题:用双重校验锁的方式实现单例模式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private volatile static Singleton uniqueInstance;
//用private修饰构造函数可以避免被实例化
private Singleton(){}

public static Singleton getUniqueInstance(){
//先判断对象是否已经实例化过
//没有实例化过才进入加锁代码
if (uniqueInstance == null){
//类对象加锁
synchronized (Singleton.class){
if (uniqueInstance == null){
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance = new Singleton();这段代码其实是分三步执行的:

  1. uniqueInstance分配内存空间;
  2. 初始化uniqueInstance;
  3. uniqueInstance指向分配的内存空间。

但是因为JVM具有指令重排的特性,执行的顺序可能会变成1->3->2。在单线程中指令重排不会出问题,但是在多线程的情况下,会导致一个线程获得还没有初始化的实例。比如,线程T1执行了1和3,此时T2调用getUniqueInstance()后发现uniqueInstance不为空,因此返回uniqueInstance,但是此时还进行第2步,也即还未初始化uniqueInstance
使用volatile关键字可以禁止JVM指令重排,从而保证多线程环境下也能正常运行。

20、synchronized和volatile的区别

  • volatile 主要是保证内存的可见性,即变量在寄存器中的内存是不确定的, 需要从主存中读取。synchronized 主要是解决多个线程访问资源的同步性
  • volatile 作用于变量synchronized 作用于代码块或者方法
  • volatile 不能保证数据的原子性。 synchronized 可以保证数据的可见性和原子性。
  • volatile 不会造成线程的阻塞,synchronized 会造成线程的阻塞。

21、乐观锁和悲观锁

乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

悲观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

21.1 如何实现乐观锁?

版本号机制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

CAS算法:CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作(即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成),底层依赖于一条 CPU 的原子指令。

CAS涉及到的三个操作数:

  • V :要更新的变量值(Var)
  • E :预期值(Expected)
  • N :拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了V,则当前线程放弃更新。

21.2 乐观锁存在的问题

ABA问题:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,

循环时间长开销大:CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

22、ThreadLocal

22.1 什么是ThreadLocal?有哪些应用场景?

ThreadLocal 为变量在每个线程中都创建了一个副本,每个线程可以访问自己内部的副本变量, 并且不会和其他线程的局部变量冲突,实现了线程间的数据隔离,同时实现了线程内的资源共享

ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。

应用场景:

  • 使用 ThreadLocal 进行跨函数数据传递(比如传递请求过程中的用户ID、Session、传递HTTP用的请求实例HttpRequest)
  • 使用 ThreadLocal 实现线程间数据隔离
  • 使用 ThreadLocal 实现数据库连接

22.2 ThreadLocal原理

image-20230331170315399
image-20230331170315399

每一个 Thread 线程内部都有一个 Map(ThreadLocalMap),如果给一个 Thread 创建多个 ThreadLocal 实例,然后放置本地数据,那么当前线程的 ThreadLocalMap 中就会有多个“Key-Value 对”,其中 ThreadLocal 实例为 Key,本地数据为 Value。

每一个线程在获取本地值时,都会将 ThreadLocal 实例作为 Key 从自己拥有的 ThreadLocalMap 中获取值,别的线程无法访问自己的 ThreadLocalMap 实例,自己也无法访问别人的 ThreadLocalMap 实例, 达到相互隔离,互不干扰。

ThreadLocal 的操作都是基于 ThreadLocalMap 展开的,而 ThreadLocalMap 是 ThreadLocal 的一个静态内部类,其实现了一套简单的 Map 结构(比 HashMap 简单)。其中get()set()remove() 方法都涉及 ThreadLocalMap 的方法调用。

22.3 什么导致了ThreadLocal内存泄露问题?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。

解决方法:ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会指定清理掉 key 为 null 的value。并且使用完 ThreadLocal方法后最好手动调用remove()方法清理。


弱引用:如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

22.4 为什么ThreadLocalMap中的key是弱引用?

因为如果key是强引用的话,那么就算发生GC,这个keyvalue都不会被回收,更容易造成内存泄漏问题,所以设计成弱引用能很大程度避免内存泄漏。

22.5 那为什么value不设计成弱引用?

假设 key 所引用的 ThreadLocal 对象还被其他的引用对象强引用着,那么这个 ThreadLocal 对象就不会被 GC 回收,但如果 value 是弱引用且不被其他引用对象引用着,那 GC 的时候就被回收掉了,那线程通过 ThreadLocal 来获取 value 的时候就会获得 null,显然这不是我们希望的结果。因为对我们来说,value 才是我们想要保存的数据,ThreadLcoal 只是用来关联 value 的,如果 value 都没了,还要 ThreadLocal 干嘛呢?所以 value 不能是弱引用

23、线程池

23.1 什么是线程池?为什么使用线程池?

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

使用线程池的好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

23.2 如何创建线程池?

方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。

方式二:通过 Executor 框架的工具类 Executors 来创建。

  1. newCachedThreadPool创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程;
  2. newFixedThreadPool创建一个定长的线程池,可控制线程最大并发数,超出的线程会在队列中等待;
  3. newScheduledThreadPool创建一个可以执行延迟任务的线程池,支持定时和周期性任务;
  4. newSingleThreadExecutor创建一个单线程化的线程池,只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。

23.2.1 为什么不推荐使用Executors方式创建线程池?

  • FixedThreadPoolSingleThreadExecutor : 使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool :使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

23.2.2 线程池核心参数有哪些?如何解释?

ThreadPoolExecutor 7 个核心的参数:

  • corePoolSize 核心线程数目: 线程池中会保留的最多线程数;
  • **maximumPoolSize最大线程数目 :**核心线程+救急线程的最大数目。
  • workQueue阻塞队列: 当没有空闲的核心线程时,新任务会进入次队列排队等待,队列满的话就会创建救急线程执行任务;
  • keepAliveTime生存时间:救急线程的存活时间,如果生存时间内没有新任务,线程资源就会被释放;
  • unit时间单位:救急线程生存时间的单位。
  • threadFactory线程工厂:定制线程对象的创建。
  • handler拒绝策略: 当所有线程都在繁忙、阻塞队列也放满时,会触发拒绝策略。
image-20230410153830053

23.2.3 线程池的拒绝策略有哪些?

  • ThreadPoolExecutor.AbortPolicy 直接抛出异常来拒绝新任务的处理,默认策略
  • ThreadPoolExecutor.CallerRunsPolicy 用调用者所在的线程来执行任务;
  • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy 丢弃阻塞队列中最靠前的任务,并执行当前任务。

23.2.4 线程池常见的阻塞队列有哪些?

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • ArrayBlockingQueue(有界队列):基于数组结构的有界阻塞队列,FIFO,强制有界,只有一把锁;
  • LinkedBlockingQueue(无界队列):基于链表结构的无界阻塞队列,FIFO,可以有界,头尾都有锁。
  • SynchronousQueue(同步队列) :不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
  • DelayedWorkQueue(延迟阻塞队列):是一个优先级队列,可以保证每次出队的任务都是当前队列中执行时间最靠前的;

23.2.5 线程池处理任务的流程(线程池执行原理是什么?)

image-20230410154615916
image-20230410154615916
  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么会按照当前的拒绝策略来执行。

23.3.6 如何给线程命名?

初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。

给线程池里的线程命名通常有下面两种方式:

1、利用 guava 的 ThreadFactoryBuilder

1
2
3
4
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat(threadNamePrefix + "-%d")
.setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)

2、自己实现ThreadFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 线程工厂,它设置线程名称,有利于我们定位问题。
*/
public final class NamingThreadFactory implements ThreadFactory {

private final AtomicInteger threadNum = new AtomicInteger();
private final ThreadFactory delegate;
private final String name;

/**
* 创建一个带名字的线程池生产工厂
*/
public NamingThreadFactory(ThreadFactory delegate, String name) {
this.delegate = delegate;
this.name = name; // TODO consider uniquifying this
}

@Override
public Thread newThread(Runnable r) {
Thread t = delegate.newThread(r);
t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
return t;
}

}

23.3.7 如何合理配置线程池大小?

  • 如果业务是 CPU 密集型任务(N+1):这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • 如果业务是 IO 密集型任务(2N):这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N

23.3.8 如何判断任务时CPU密集型还是IO密集型?

CPU 密集型简单理解就是利用 CPU 计算能力的任务,比如在内存中对大量数据进行排序

但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

23.3.9 线程池回收线程的方法有哪些?

  1. 对于核心线程来说:可以设置参数allowCoreThreadTimeOuttrue,等待keepAliveTime时间后,线程就会被回收。
  2. 对于救急线程来说:等到keepAliveTime后,线程自动会被回收,也可以执行executor.shutdown()方法,立马回收救急线程。

23.3.10 如何判断线程池已经执行完所有任务?

  1. isTerminated()方法判断线程池的状态,循环判断该方法的返回值,了解线程池的运行状态;使用该方法的前提是线程池需要调用shutdown方法,不然就会一直运行;
  2. 通过判断线程池中的计划执行任务数和已完成任务数来判断任务是否执行完,当两者相等时,说明线程池的人物全部都执行完毕了;
  3. CountDownLatch赋一个值为N的计数器,N为其中的任务数,每执行完一个任务,就让计数器-1,当计数器为0时,就说明任务都执行完毕。

23.3 线程池原理分析

首先创建一个 Runnable 接口的实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class MyRunnable implements Runnable{

private String command;

public MyRunnable(String s){
this.command = s;
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "Start. Time = " + new Date());
processCommand();
System.out.println(Thread.currentThread().getName() + "End. Time = " + new Date());
}

private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString(){
return this.command;
}
}

编写测试程序,使用ThreadPoolExecutor构造函数自定义参数的方式来创建线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ThreadPoolExecutorDemo {

private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final long KEEP_ALIVE_TIME = 1L;

public static void main(String[] args) {
//使用阿里巴巴推荐的创建线程池的方式
//通过ThreadPoolExecutor构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy()
);

for (int i = 0; i < 10; i++) {
//创建workerThread对象
MyRunnable worker = new MyRunnable("" + i);
//执行Runnable
executor.execute(worker);
}
//终止线程池
executor.shutdown();
while (!executor.isTerminated()){

}
System.out.println("Finished all thread");
}
}

可以看到上面的代码中,指定了:

  • corePoolSize: 核心线程数为 5。

  • maximumPoolSize :最大线程数 10

  • keepAliveTime : 等待时间为 1L。

  • unit: 等待时间的单位为 TimeUnit.SECONDS。

  • workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100;

  • handler:饱和策略为 CallerRunsPolicy

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pool-1-thread-1Start. Time = Mon Apr 10 16:04:32 CST 2023
pool-1-thread-2Start. Time = Mon Apr 10 16:04:32 CST 2023
pool-1-thread-4Start. Time = Mon Apr 10 16:04:32 CST 2023
pool-1-thread-5Start. Time = Mon Apr 10 16:04:32 CST 2023
pool-1-thread-3Start. Time = Mon Apr 10 16:04:32 CST 2023
pool-1-thread-5End. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-2End. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-1End. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-2Start. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-1Start. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-4End. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-4Start. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-5Start. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-3End. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-3Start. Time = Mon Apr 10 16:04:37 CST 2023
pool-1-thread-5End. Time = Mon Apr 10 16:04:42 CST 2023
pool-1-thread-1End. Time = Mon Apr 10 16:04:42 CST 2023
pool-1-thread-2End. Time = Mon Apr 10 16:04:42 CST 2023
pool-1-thread-3End. Time = Mon Apr 10 16:04:42 CST 2023
pool-1-thread-4End. Time = Mon Apr 10 16:04:42 CST 2023
Finished all thread

线程池首先会执行5个任务,然后这些任务中,如果有被执行完的任务,就会被拿去新的任务执行。

首先分析一下 execute方法。 在示例代码中,我们使用 executor.execute(worker)来提交一个任务到线程池中去。

这个方法非常重要,下面我们来看看它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

private static int workerCountOf(int c) {
return c & CAPACITY;
}
//任务队列
private final BlockingQueue<Runnable> workQueue;

public void execute(Runnable command) {
// 如果任务为null,则抛出异常。
if (command == null)
throw new NullPointerException();
// ctl 中保存的线程池当前的一些状态信息
int c = ctl.get();

// 下面会涉及到 3 步 操作
// 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize
// 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
if (!isRunning(recheck) && remove(command))
reject(command);
// 如果当前工作线程数量为0,新创建一个线程并执行。
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
// 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
//如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
else if (!addWorker(command, false))
reject(command);
}

execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。

23.4 几个常见的对比

23.4.1 Runnable和Callable

Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例

Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。

工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。(Executors.callable(Runnable task)Executors.callable(Runnable task, Object result))。

23.4.2 excute()和submit()

execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Futureget()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 java.util.concurrent.TimeoutException

23.4.3 shutdown()和shutdownNow()

shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕

shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List

23.4.4 isTerminated()和isShutdown()

  • isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
  • isShutDown 当调用 shutdown() 方法后返回为 true。

24、CAS

CAS(Compare And Swap)是CPU指令级的原子操作,并且处于用户态下,所以其开销较小,性能更高。

是一种乐观锁技术,属于非阻塞算法。

24.1 CAS的自旋过程?

CAS 是一种无锁算法,该算法关键依赖三个值——当前值期望值更新值,底层 CPU 利用原子操作判断当前值期望值是否相等,如果相等就给内存地址赋更新值,否则不做任何操作。使用 CAS 进行无锁编程的步骤大致如下:

  1. 获得字段的当前值期望值
  2. 计算更新值
  3. 通过CAS将更新值替换成当前值上,如果CAS失败,就重复第1步,一直到CAS成功,这就是CAS自旋。

24.2 CAS存在什么问题?如何解决?

  • ABA问题,在乐观锁中有介绍。可以通过版本号来判断变量的值是否发生了变化,在CAS操作时同时比较版本号是否一致。
  • 高并发下,自旋时间过长导致时间开销太大,性能很低。可以通过限制自旋次数来解决。
  • 只能保证一个共享变量的原子操作。可以结合ReentrantLock和synchronized关键字来保证多个变量的原子性。

24.3 CAS操作为什么是原子操作?

CAS操作最重要的两个动作:比较修改,在这两个操作时,可能在比较的时候它是一样的,当我修改的时候它却被别的线程修改了。

这就涉及到CAS本身这个操作是原子的,也就是不被其他线程所干扰的。因为CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

25、Atomic原子类

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic

根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类

25.1 基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

25.2 数组类型

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

25.3 引用类型

  • AtomicReference:引用类型原子类

  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

  • AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来。

25.4 对象的属性修改类型

  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器

  • AtomicLongFieldUpdater:原子更新长整形字段的更新器

  • AtomicReferenceFieldUpdater :原子更新引用类型里的字段的更新器

要想原子地更新对象的属性需要两步。

第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。

第二步,更新的对象属性必须使用 public volatile 修饰符。

26、AQS

26.1 什么是AQS?

AQS 的全称是 AbstractQueuedSynchronizer,是一个用来构建锁和同步器的框架,像 ReentrantLock,Semaphore,FutureTask 都是基于 AQS 实现的。

26.2 AQS的原理

简单来说,AQS 就是维护了一个共享资源,然后使用队列来保证线程排队获取资源的一个过程

AQS 的原理图如下

image-20230410171701107
image-20230410171701107

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。

CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

1
2
// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;

状态信息 state 可以通过 protected 类型的getState()setState()compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
protected final int getState() {
return state;
}
//设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

26.3 AQS资源共享方式

  • Exclusive:独占,只有一个线程可以执行,如ReentrantLock
  • Share:共享,可以多个线程同时执行,如Semaphore,CountDownLatch

26.4 AQS是如何实现独占锁和共享锁的?

  • 独占式的 ReentrantLock 为例

    • AQS使用双向队列(CLH队列)来保存等待获取锁的线程。当一个线程尝试获取锁时,如果锁已被其他线程占用,该线程会被加入到等待队列中,等待获取锁。
    • 在独占锁模式下,AQS会按照FIFO顺序唤醒等待队列中的线程,先到先得。
    • 当持有锁的线程释放锁时,AQS会唤醒等待队列中的第一个线程,使其有机会获取锁。
  • 共享式的 CountDownLatch 以例

    • 在共享锁模式下,多个线程可以同时持有同一个锁,实现资源的共享访问。
    • AQS使用计数器来记录锁的持有情况。当一个线程尝试获取锁时,如果失败,则会进入队列等待,如果获取成功,则会让计数器+1,并通知队列中下一个线程,以实现共享的功能。
    • 持有共享锁的线程在释放锁时,会递减计数器。当计数器归零时,表示没有线程持有该锁,其他线程可以尝试获取锁。

AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock


本站由 Cccccpg 使用 Stellar 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。