Java并发

1、并行和并发有什么区别?

  1. 并行是指两个或者多个事件在同⼀时刻发⽣;而并发是指两个或多个事件在同⼀时间间隔发⽣;
  2. 并行是在不同实体上的多个事件,并发是在同⼀实体上的多个事件;
  3. 在⼀台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如 Hadoop 分布式集群。所以并发编程的目标是充分的利⽤处理器的每⼀个核,以达到最⾼的处理性能。

    2、线程和进程的区别?

    进程:是程序运行和资源分配的基本单位,⼀个程序至少有⼀个进程,⼀个进程至少有⼀个线程。进程在执行过程中拥有独立的内存单元,而多个线程共享内存资源,减少切换次数,从而效率更⾼。
    线程:是进程的⼀个实体,是 cpu 调度和分派的基本单位,是比程序更小的能独立运行的基本单位。同⼀进程中的多个线程之间可以并发执行。

    3、守护线程是什么?

    用户线程:我们平常创建的普通线程。
    守护线程(即 Daemon thread),是个服务线程,用来服务于用户线程,准确地来说就是服务其他的线程。 在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
    具体看这个—>> 守护线程
    守护线程怎么使用:使用很简单,只是在调用start()方法前,调用 setDaemon(true) 把该线程标记为守护线程。
  • 如何检查一个线程是守护线程还是用户线程:使用isDaemon()方法。
  • Java垃圾回收线程就是一个典型的守护线程,因为我们的垃圾回收是一个一直需要运行的机制,但是当没有用户线程的时候,也就不需要垃圾回收线程了,守护线程刚好满足这样的需求。

    4、创建线程的几种方式?

  1. 继承 Thread 类创建线程;
  2. 实现 Runnable 接口创建线程;
  3. 通过 Callable 和 Future 创建线程;
  4. 通过 线程池 创建线程。

    5、Runnable 和 Callable 有什么区别?

  5. Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行 run() 方法中的代码而已; run方法不可以抛出异常

  6. Callable 接口中的 call() 方法是有返回值的,是⼀个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果。call方法可以抛出异常。

    6、线程状态及转换?

    Thread 的源码中定义了6种状态:

  7. new(新建)

  8. runnnable(可运行)
  9. blocked(阻塞)
  10. waiting(等待)
  11. time waiting (定时等待)
  12. terminated(终止) | 状态名称 | 说明 | | —- | —- | | NEW | 初始状态,线程被创建,但是还没有调用start()方法 | | RUNNABLE | 运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称为“运行中” | | BLOCKED | 阻塞状态,表示线程阻塞与锁 | | WAITING | 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断) | | TIME_WAITING | 超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的 | | TERMINATED | 终止状态,表示当前线程已经执行完毕 |

Snipaste_2021-12-27_15-27-14.png

7、sleep() 和 wait() 的区别?

  1. sleep() 方法正在执行的线程主动让出 cpu(然后 cpu 就可以去执⾏其他任务),在 sleep 指定时间后 cpu 再回到该线程继续往下执行(注意:sleep 方法只让出了 cpu,而并不会释放同步资源锁);而wait() 方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而进行,只有调用了 notify() 方法,之前调用 wait() 的线程才会解除 wait 状态,可以去参与竞争同步资源锁,进而得到执行。(注意:notify 的 作⽤相当于叫醒睡着的⼈,而并不会给他分配任务,就是说 notify 只是让之前调⽤ wait 的线程有权利重新参与线程的调度);
  2. sleep() 方法可以在任何地方使用,而 wait() 方法则只能在同步方法或同步块中使用;

    1. sleep() 是线程类 Thread 的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;
    2. wait() 是 Object 的方法,调用会放弃对象锁,进入等待队列,待调用notify() / notifyAll() 唤醒指定的线程或者所有线程,才会进入锁池,不再次获得对象锁才会进⼊运行状态。

      8、什么是线程死锁?如何避免死锁?

      死锁
      多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
      image.png
      死锁必须具备以下四个条件:
      互斥条件、请求与保持条件不剥夺条件循环等待条件
      死锁.png

      9、线程的 run() 和 start() 有什么区别?

      10、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不
      能直接调用 run() 方法 ?

  3. 每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,run() 方法称为线程体。通过调用 Thread 类的 start() 方法来启动⼀个线程

  4. start() 方法来启动⼀个线程,真正实现了多线程运行。这时无需等待 run() 方法体代码执行完毕,可以直接继续执行下面的代码;这时此线程是处于就绪状态,并没有运行。然后通过此 Thread 类调用方法 run() 来完成其运行状态,这里方法 run() 称为线程体,它包含了要执行的这个线程的内容,run() 方法运行结束,此线程终止。然后 cpu 再调度其它线程;
  5. run() 方法是在本线程里的,只是线程里的⼀个函数,而不是多线程的。如果直接调用 run(),其实就相当于是调用了⼀个普通函数而已,直接调用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有⼀条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法法而不是 run() 方法。

    11、在 Java 程序中怎么保证多线程的运行安全?

    线程安全在三个方面体现:

  6. 原子性:提供互斥访问,同⼀时刻只能有⼀个线程对数据进行操作(atomic,synchronized);

  7. 可见性:⼀个线程对主内存的修改可以及时地被其他线程看到(synchronized、volatile);
  8. 有序性:⼀个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果⼀般杂乱无序(happens before 原则)。

    12、Java 线程同步的几种方法?

  9. 使用 Synchronized 关键字;

  10. wait 和 notify;
  11. 使用特殊域变量 volatile 实现线程同步;
  12. 使用可重⼊锁实现线程同步;
  13. 使用阻塞队列实现线程同步;
  14. 使用信号量 Semaphore。

    13、JMM

    JMM详解 【Java线程】Java内存模型总结
    JMM通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。
    1. 原子性指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
    2. 可见性指当一个线程修改了某一个共享变量的值,其他的线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为我们在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量一定是修改后的值。
    3. 有序性指对于一个线程的执行代码,我们习惯性的认为代码的执行是从前往后,依次执行的。但是在并发时,程序的执行可能会出现乱序。给人直观的感觉就是:写在前面的代码,可能会在后面执行。

14、源代码与指令间的重排序

为了提高性能,编译器和处理器常常会对指令做重排序。
重排序有3种类型,其中后2种都是处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。

  1. 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

image.png
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保持一致性是无法确定的,结果无法预测。

15、重排序对可见性的影响

参考下表,虽然处理器执行的顺序是A1->A2,但是从内存角度来看,实际发生的顺序是A2->A1。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与实
际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此它们都会允许对写-读操作执行
重排序。
Snipaste_2021-12-28_20-14-42.png
Snipaste_2021-12-28_20-57-20.png

int a, b, x, y = 0;
线程a 线程2
x = a; y = b;
b = 1; a = 2;
x = 0, y = 0

如果编译器对这段程序代码执行重排优化后,可能出现下列情况。

int a, b, x, y = 0;
线程a 线程2
b = 1; a = 2;
x = a; y = b;
x = 0, y = 0

16、如何解决重排序带来的问题

重排序的解决方法
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则会要求编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barries / Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
由于常见的处理器内存模型比JMM要弱,Java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM在不同的处理器中需要插入的内存屏障的数量和种类也不同。
CPU内存屏障:

  1. LoadLoad:禁止读和读的重排序;
  2. StoreStore:禁止写和写的重排序;
  3. LoadStore:禁止读和写的重排序;
  4. StoreLoad:禁止写和读的重排序。

Java内存屏障:

  1. public final class Unsafe {
  2. // LoadLoad + LoadStore
  3. public native void loadFence();
  4. // StoreStore + LoadStore
  5. public native void storeFence();
  6. // loadFence() + storeFence() + StoreLoad
  7. public native void fullFence();
  8. }

image.png
image.png

17、happens-before && as-if-serial

Java并发编程之happens-before和as-if-serial语义
JMM使用happens-before规则来阐述操作之间的内存可见性,以及什么时候不能重排序。
在JMM中, 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
换个角度来说,如果A happens-before B,则意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。
其中,前4条规则与程序员密切相关。

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
  2. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
  3. synchronized规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
  4. 传递性:若 A happens-before B,且 B happens-before C,则 A happens-before C;
  5. start()规则:若线程A执行ThreadB.start(),则线程A的这个操作happens-before于线程B中的任意操作;
  6. join()规则:若线程A执行ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()的成功返回。

举个例子:
假设线程A执行writer()方法之后,线程B执行reader()方法。根据 happens-before规则,这个过程建立的happens-before关系可以分为3类:

  1. 根据顺序规则,1 happens-before 2,3 happens-before 4;
  2. 根据volatile规则,2 happens-before 3;
  3. 根据happens-before的传递性规则,1 happens-before 4。

    1. public class Test2 {
    2. int a = 0;
    3. volatile boolean flag = false;
    4. public void writer() {
    5. // 1
    6. a = 1;
    7. // 2
    8. flag = true;
    9. }
    10. public void reader() {
    11. // 3
    12. if (flag) {
    13. // 4
    14. int i = a;
    15. }
    16. }
    17. }

    18、谈谈volatile的使用及其原理

    volatile 关键字是用来保证有序性可见性的。
    这跟 Java 内存模型有关。我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了了减少流水线阻塞,提高 CPU 的执行效率。这就需要有一定的顺序和规则来保证,不然程序员自己写的代码都不不知道对不对了,所以有 happens-before 规则。

其中有条就是 volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作、有序性实现的是通过插入内存屏障来保证的。
被 volatile 修饰的共享变量量,就具有了以下两点特性:
1 . 保证了不同线程对该变量操作的内存可见性;
2 . 禁止指令重排序。
volatile的原理:
在JVM底层 volatile 是采用“内存屏障”来实现的。观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

可见性与Java的内存模型有关,模型采用缓存与主存的方式对变量进行操作,也就是说,每个线程都有自己的缓存空间,对变量的操作都是在缓存中进行的,之后再将修改后的值返回到主存中,这就带来了问题,有可能一个线程在将共享变量修改后,还没有来的及将缓存中的变量返回给主存中,另外一个线程就对共享变量进行修改,那么这个线程拿到的值是主存中未被修改的值,这就是可见性的问题。
volatile很好的保证了变量的可见性,变量经过volatile修饰后,对此变量进行写操作时,汇编指令中会有一个LOCK前缀指令,这个不需要过多了解,但是加了这个指令后,会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存;
  • 这个写回内存的操作会使得在其他处理器缓存了该内存地址无效;

什么意思呢?意思就是说当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值,这就保证了可见性。

18.1、volatile为什么不能保证原子性

一个变量 i 被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取 i 的值,其次对 i 的值进行加1,最后将得到的新值写会到缓存中。
线程A首先得到了 i 的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也得到了 i 的值,由于 i 的值未被修改,即使是被 volatile 修饰,主存的变量还没变化,那么线程 B 得到的值也是100,之后对其进行加 1 操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
问题来了,线程A已经读取到了 i 的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程 A 阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。

19、volatile的基本特性

Volatile三大特性概念详解
深入理解Volatile关键字及其实现原理
深入分析volatile原理

  1. 可见性:对一个volatile变量的读,总是能看到对这个volatile变量最后的写入;
  2. 原子性:对任意单个volatile变量的读/写具有原子性,但类似volatile++这种复合操作不具有原子性。

举例:

  1. import java.util.concurrent.TimeUnit;
  2. /**
  3. * 实体类,观察num值的可见性,此时没有volatile
  4. */
  5. class Volatile {
  6. // volatile int num = 0; 加上volatile关键字
  7. int num = 0; // 不加volatile关键字
  8. public void addTo60() {
  9. this.num = 60;
  10. }
  11. }
  12. //测试类
  13. public class Test3 {
  14. public static void main(String[] args) {
  15. //测试可见性
  16. seeVolatileOk();
  17. }
  18. /**
  19. * aaa线程修改num值为60后,main线程拿到的num=0,死循环。说明线程之间共享变量不可见。
  20. */
  21. private static void seeVolatileOk() {
  22. Volatile v = new Volatile();
  23. new Thread(() -> {
  24. System.out.println(Thread.currentThread().getName() + "\t come in ");
  25. try {
  26. TimeUnit.SECONDS.sleep(2);
  27. } catch (InterruptedException e) {
  28. e.printStackTrace();
  29. }
  30. v.addTo60();
  31. System.out.println(Thread.currentThread().getName() + "\t updated num value:" + v.num);
  32. }, "aaa").start();
  33. while (v.num==0){
  34. }
  35. System.out.println(Thread.currentThread().getName() + "\t mission is over,updated num value:" + v.num);
  36. }
  37. }

20、volatile 的内存语义

  1. 写内存语义:当写一个 volatile 变量时,JMM会把该线程本地内存中的共享变量的值刷新到主内存;
  2. 读内存语义:当读一个 volatile 变量时,JMM会把该线程本地内存置为无效,使其从主内存中读取共享变量。

    21、volatile 的实现机制

    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能
    得到正确的volatile内存语义。

  3. 在每个volatile写操作的前面插入一个StoreStore屏障;

  4. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  5. 在每个volatile读操作的后面插入一个LoadLoad屏障;
  6. 在每个volatile读操作的后面插入一个LoadStore屏障;

    22、volatile 与锁的对比

    volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上锁比 volatile 更强大,在可伸缩性和执行性能上 volatile 更有优势。

    23、了解Fork/Join框架吗?

    Fork/Join框架是Java7提供的一个用于并行执行任务的框架是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
    Fork/Join框架需要理解两个点,「分而治之」和「工作窃取算法」。
    「分而治之」
    以上Fork/Join框架的定义,就是分而治之思想的体现。
    image.png「工作窃取算法」
    把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时。有的线程优先把自己负责的
    任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算
    法。
    image.png

    工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。

    24、CAS?

    CAS
    CAS:全称 Compare and swap ,即比较并交换,它是一条 CPU 同步原语。是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。
    CAS 是一种无锁的非阻塞算法的实现。
    CAS 包含了 3 个操作数:需要读写的内存值 V,旧的预期值 A,要修改的更新值 B;
    当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的 值,否则不会执行任何操作(他的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。)
    CAS 并发原语体现在 Java 语言中的 sum.misc.Unsafe 类中的各个方法。调用 Unsafe 类中的 CAS 方,JVM 会帮助我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。
    再次强调,由于 CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,CAS 是一条 CPU 的原子指令,不会造成数据不一致问题。

    25、CAS有什么缺陷?

    ABA 问题
    并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可
    能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
    可以通过AtomicStampedReference解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值
    的版本来保证CAS的正确性。

    内存值V=100;threadA 将100,改为50;threadB 将100,改为50;threadC 将50,改为100;**

循环时间长开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。
只能保证一个变量的原子操作
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。
可以通过这两个方式解决这个问题:

  1. 使用互斥锁来保证原子性;
  2. 将多个变量封装成对象,通过AtomicReference来保证原子性。

    26、锁

  3. 《吊打面试官》系列-乐观锁、悲观锁

  4. Java锁详解
  5. synchronized底层如何实现?什么是锁的升级、降级?
  6. 妹妹问我:互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景
  7. Synchronized关键字及锁升级的讲解

    27、AQS

    AbstractQueuedSynchronizer源码解析(上)
    AbstractQueuedSynchronizer源码解析(下)
    一行一行源码分析清楚AbstractQueuedSynchronizer

    28、你知道什么叫做公平和非公平锁吗

    公平锁:在竞争环境下,先到临界区的线程比后到的线程一定更快地获取得到锁
    非公平锁:先到临界区的线程未必比后到的线程更快地获取得到锁

    29、说一说什么是AQS?

    AQS(AbstractQueuedSynchronizer)抽象队列同步器, 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我们在lock 的时候,AQS 开放出 state 字段,让子类可以根据state 字段来决定是否能够获得锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内部机制,封装的很好,又暴露出子类锁需要扩展的地方;
    基于AQS实现的组件,诸如:
  • ReentrantLock 可重入锁(支持公平和非公平的方式获取锁);
  • Semaphore 计数信号量;
  • ReentrantReadWriteLock 读写锁。

内部实现的关键就是维护了一个先进先出的队列以及state状态变量。先进先出队列存储的载体叫做Node节点,该节点标识着当前的状态值、是独占还是共享模式以及它的前驱和后继节点等信息。
AQS 底层是由同步队列 + 条件队列联手组成同步队列管理着获取不到锁的线程的排队和释放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据,肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;
AQS支持两种模式:独占(锁只会被一个线程独占)和共享(多个线程可同时执行)

30、AQS使用了哪些设计模式?

AQS同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很
经典的一个应用):

  1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对
    于共享资源state的获取和释放)
  2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的
    方法。

这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

31、了解AQS中同步队列的数据结构吗?

image.png
当前线程获取同步状态失败,同步器将当前线程机等待状态等信息构造成一个Node节点加入队列,放在队尾,同步器重新设置尾节点;加入队列后,会阻塞当前线程;同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中第一个节点,让其再次获取同步状态 。

32、AQS 组件了解吗?

Semaphore(信号量):允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
CyclicBarrier(循环栅栏):CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

33、介绍一下 Atomic 原子类

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被
其他线程干扰。
所以,所谓原子类说简单点就是具有原子 / 原子操作特征的类。
并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下。

34、简单介绍一下 AtomicInteger 类的原理

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

35、说⼀说自己对于 synchronized 关键字的了解?

Synchronized关键字及锁升级的讲解
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有⼀个线程执行。
另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,而**操作系统实现线程之间的切换时需要从⽤户态转换到内核态**,这个状态之间的转换需要相对比较长的时间,时间成本相对较⾼,这也是为什么早期的synchronized 效率低的原因。庆幸的是在 JDK6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
synchronized 关键字底层原理属于 JVM 层。

36、synchronized底层原理

synchronized 修饰同步代码块
通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰的方法
并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

37、如何在项目中使用 synchronized 的?

image.png
synchronized 关键字最主要的三种使用方式:

  1. 修饰实例方法:作用于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁;
  2. 修饰静态方法:作用于当前类对象(class对象)加锁,进⼊同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员(static 表明这是该类的⼀个静态资源,不管 new了多少个对象,只有⼀份,所以对该类的所有对象都加了锁)。所以如果⼀个线程 A 调⽤⼀个实例,对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized⽅法占⽤的锁是当前实例对象锁;
  3. 修饰代码块:指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。和 synchronized 方法⼀样,synchronized(this) 代码块也是锁定当前对象的。synchronized 关键字加到 static 静态⽅法和synchronized(class) 代码块上都是是给 Class 类上锁。这⾥再提⼀下:synchronized 关键字加到⾮ static 静态发法上是给对象实例上锁。另外需要注意的是:尽量不要使⽤ synchronized(String a) 因为 JVM 中,字符串常量池具有缓冲功能。

补充:双重校验锁实现单例模式

  1. public class Singleton {
  2. public volatile static Singleton uniqueInstance;
  3. public Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. // 先判断对象是否已经实例过,没有实例化才进入加锁代码
  7. if (uniqueInstance == null) {
  8. // 类对象加锁
  9. synchronized (Singleton.class) {
  10. if (uniqueInstance == null) {
  11. uniqueInstance = new Singleton();
  12. }
  13. }
  14. }
  15. return uniqueInstance;
  16. }
  17. }

另外,需要注意 uniqueInstance 采⽤ volatile 关键字修饰也是很有必要。采⽤ volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执⾏:

  1. 为 uniqueInstance 分配内存空间;
  2. 初始化 uniqueInstance;
  3. 将 uniqueInstance 指向分配的内存地址

    38、说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍⼀下这些优化吗?

    锁升级 Synchronized关键字及锁升级的讲解

    39、synchronized 和 Lock 有什么区别?

  • synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

    40、谈谈 synchronized 和 ReenTrantLock 的区别?

  1. synchronized 是关键字,ReentrantLock 是类,这是⼆者的本质区别。
  2. synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API。synchronized 是依赖于 JVM 实现的,JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层⾯实现的,并没有直接暴露给我们。 ReenTrantLock 是 JDK 层⾯实现的(也就是 API 层⾯,需要 lock() 和 unlock ⽅法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

    41、synchronized 和 volatile 的区别是什么?

  3. volatile 本质是在告诉 JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

  4. volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的。
  5. volatile 仅能实现变量的修改可见性,不能保证原子性; synchronized 则可以保证变量的修改可见性和原子性。
  6. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
  7. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

    42、谈⼀下你对 volatile 关键字的理解?

    volatile 关键字是⽤来保证有序性和可见性。这跟 Java 内存模型有关。我们所写的代码,不⼀定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流⽔线阻塞,提⾼ CPU 的执行效率。这就需要有⼀定的顺序和规则来保证,不然程序员⾃⼰写的代码都不知道对不对了,所以有 happens-before 规则,其中有条就是 volatile 变量规则:对⼀个变量的写操作先⾏发⽣于后⾯对这个变量的读操作、有序性实现的是通过插⼊内存屏障来保证的。
    被 volatile 修饰的共享变量,就具有了以下两点特性:

  8. 保证了不同线程对该变量操作的内存可见性;

  9. 禁⽌指令重排序。

    备注:这个题如果扩展了答,可以从 Java 的内存模型⼊⼿,下⼀篇 Java 虚拟机⾼频⾯试题中会讲到,这⾥不做过多赘述

43、ThreadLocal是什么?

ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
可以理解成每个线程都有自己专属的存储容器,它用来存储线程私有变量,其实它只是一个外壳,内部真正存取是一个Map。每个线程可以通过set()和get()存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可访问的,线程被终止后,它的所有实例将被垃圾收集。总之记住一句话:ThreadLocal存储的变量属于当前线程。

  1. //创建一个ThreadLocal变量
  2. static ThreadLocal<String> localVariable = new ThreadLocal<>();

ThreadLocal的应用场景有

  • 数据库连接池:ThreadLocal经典的使用场景是为每个线程分配一个 JDBC 连接 Connection,这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B 线程正在使用的 Connection。
  • 会话管理中使用:将Session保存在ThreadLocal中,使线程处理多次处理会话时始终是同一个Session。

    44、ThreadLocal的实现原理

    Thread类中有个变量threadLocals,它的类型为ThreadLocal中的一个内部类ThreadLocalMap,ThreadLocal是一个壳子,真正的存储结构是ThreadLocal里有ThreadLocalMap这么个内部类,而有趣的是,ThreadLocalMap的引用是在Thread上定义的,实现的类似map的功能。每个线程都有自己的一个map,map是一个数组的数据结构存储数据,每个元素是一个Entry,entry的key是ThreadLocal的引用,也就是当ThreadLocal本身并不存储值,它只是作为key来让线程从ThreadLocalMap获取value。所以,得出的结论就是ThreadLocalMap该结构本身就在Thread下定义,而ThreadLocal只是作为key,存储set到ThreadLocalMap的变量当然是线程私有的。
    image.png
    ThreadLocal中的set方法的实现逻辑,先获取当前线程,取出当前线程的ThreadLocalMap,如果不存在就会创建一个ThreadLocalMap,如果存在就会把当前的threadlocal的引用作为键,传入的参数作为值存入map中。代码如下所示:
    public void set(T value) {
      Thread t = Thread.currentThread();
      ThreadLocalMap map = getMap(t);
      if (map != null) {
          map.set(this, value);
      } else {
          createMap(t, value);
      }
    }
    
    ThreadLocal中get方法的实现逻辑,获取当前线程,取出当前线程的ThreadLocalMap,用当前的threadlocal作为key在ThreadLocalMap查找,如果存在不为空的Entry,就返回Entry中的value,否则就会执行初始化并返回默认的值。代码如下所示:
    public T get() {
      Thread t = Thread.currentThread();
      ThreadLocalMap map = getMap(t);
      if (map != null) {
          ThreadLocalMap.Entry e = map.getEntry(this);
          if (e != null) {
              @SuppressWarnings("unchecked")
              T result = (T)e.value;
              return result;
          }
      }
      return setInitialValue();
    }
    
    ThreadLocal中的hash code非常简单,就是调用AtomicInteger的getAndAdd方法,参数是个固定值0x61c88647。上面说过ThreadLocalMap的结构非常简单只用一个数组存储,并没有链表结构,当出现Hash冲突时采用线性查找的方式,所谓线性查找,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。如果产生多次hash冲突,处理起来就没有HashMap的效率高,为了避免哈希冲突,使用尽量少的threadlocal变量。

    45、为啥我要把ThreadLocal做为key,而不是Thread做为key?

    理论上是可以,但没那么优雅。
    一个线程是可以拥有多个私有变量的嘛,那key如果是当前线程的话,意味着还点做点「手脚」来唯一标识set进去的value。
    假设上一步解决了,还有个问题就是;并发量足够大时,意味着所有的线程都去操作同一个Map,Map体积有可能会膨胀,导致访问性能的下降。
    这个Map维护着所有的线程的私有变量,意味着你不知道什么时候可以「销毁」,现在JDK实现的结构就不一样了。线程需要多个私有变量,那有多个ThreadLocal对象足以,对应的Map体积不会太大。只要线程销毁了,ThreadLocalMap也会被销毁。
    Java并发 - 图15

    46、ThreadLocal 内存泄露问题吗?

    内存泄露你申请完内存后,你用完了但没有释放掉,你自己没法用,系统又没法回收。
    image.png
    ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用,如下:
    image.png
    弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。
    Java并发 - 图18
    弱引用比较容易被回收。因此,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是因为ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:
    ThreadLocalMap的key没了,value还在,这就会「造成了内存泄漏问题」。
    如何「解决内存泄漏问题」?使用完ThreadLocal后,及时调用remove()方法释放内存空间。

    Java并发 - 图19

    47、为什么要将ThreadLocalMap的key设置为弱引用呢?强引用不香吗?

    外界是通过ThreadLocal来对ThreadLocalMap进行操作的,假设外界使用ThreadLocal的对象被置null了,那ThreadLocalMap的强引用指向ThreadLocal也毫无意义啊。
    弱引用反而可以预防大多数内存泄漏的情况。
    毕竟被回收后,下一次调用set/get/remove时ThreadLocal内部会清除掉。

    48、建议把ThreadLocal修饰为static,为什么?

    ThreadLocal能实现了线程的数据隔离,不在于它自己本身,而在于Thread的ThreadLocalMap。所以,ThreadLocal可以只初始化一次,只分配一块存储空间就足以了,没必要作为成员变量多次被初始化。

    49、说下对 ReentrantReadWriteLock 的理解?

    ReentrantReadWriteLock 允许多个读线程同时访问,但是不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁:⼀个是⽤于读操作的 ReadLock,⼀个是⽤于写操作的 WriteLock。读写锁
    ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远⼤于写操作的时候,读写锁就⾮常有⽤了。
    ReentrantReadWriteLock 基于 AQS 实现,它的⾃定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和⼀个写线程,该状态的设计成为实现读写锁的关键。ReentrantReadWriteLock 很好的利⽤了⾼低位。来实现⼀个整型控制两种状态的功能,读写锁将变量切分成了两个部分,⾼ 16 位表示读,低 16 位表示写。
    ReentrantReadWriteLock 的特点:
  1. 写锁可以降级为读锁,但是读锁不能升级为写锁;
  2. 不管是 ReadLock 还是 WriteLock 都⽀持 Interrupt,语义与 ReentrantLock ⼀致;
  3. WriteLock ⽀持 Condition 并且与 ReentrantLock 语义⼀致,⽽ ReadLock 则不能使⽤ Condition,否则抛出UnsupportedOperationException 异常;
  4. 默认构造⽅法为⾮公平模式 ,开发者也可以通过指定 fair 为 true 设置为公平模式 。

升降级

  1. 读锁⾥⾯加写锁,会导致死锁;
  2. 写锁⾥⾯是可以加读锁的,这就是锁的降级

    50、说下对悲观锁和乐观锁的理解?

    悲观锁
    总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁,这样别⼈想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给⼀个线程使⽤,其它线程阻塞,⽤完后再把资源转让给其它线程)。传统的关系型数据库⾥边就⽤到了很多这种锁机制,⽐如:⾏锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
    乐观锁
    总是假设最好的情况,每次去拿数据的时候都认为别⼈不会修改,所以不会上锁,但是在更新的时候会判断⼀下在此期间别⼈有没有去更新这个数据,可以使⽤版本号机制和 CAS 算法实现。乐观锁适⽤于多读的应⽤类型,这样可以提⾼吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下⾯的原⼦变量类就是使⽤了乐观锁的⼀种实现⽅式 CAS 实现的。
    两种锁的使用场景
    从上⾯对两种锁的介绍,我们知道两种锁各有优缺点,不可认为⼀种好于另⼀种,像乐观锁适⽤于写⽐较少的情况下(多读场景),即冲突真的很少发⽣的时候,这样可以省去了锁的开销,加⼤了系统的整个吞吐量。但如果是多写的情况,⼀般会经常产⽣冲突,这就会导致上层应⽤会不断的进⾏ retry,这样反倒是降低了性能,所以⼀般多写的场景下⽤悲观锁就比较合适。

    51、乐观锁常见的两种实现方式是什么?(看博客)

    乐观锁⼀般会使用版本号机制或者 CAS 算法实现。
    版本号机制
    ⼀般是在数据表中加上⼀个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的version 值为当前数据库中的 version 值相等时才更新,否则᯿试更新操作,直到更新成功。
    CAS 算法
    即 compare and swap(比较与交换),是⼀种有名的⽆锁算法。⽆锁编程,即不使⽤锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫⾮阻塞同步(Non
    blocking Synchronization)。

    52、乐观锁的缺点有哪些?

  3. ABA 问题

  4. 循环时间长开销大
  5. 只能保证⼀个共享变量的原子操作

    53、CAS 和 synchronized 的使用场景?

    简单的来说 CAS 适⽤于写⽐较少的情况下(多读场景,冲突⼀般较少),synchronized 适⽤于写⽐较多的情况下(多写场景,冲突⼀般较多)。

  6. 对于资源竞争较少(线程冲突较轻)的情况,使⽤ synchronized 同步锁进⾏线程阻塞和唤醒切换以及⽤户态内核态间的切换操作额外浪费消耗 cpu 资源;⽽ CAS 基于硬件实现,不需要进⼊内核,不需要切换线程,操作⾃旋⼏率较少,因此可以获得更⾼的性能。

  7. 对于资源竞争严重(线程冲突严重)的情况,CAS ⾃旋的概率会⽐较⼤,从⽽浪费更多的 CPU 资源,效率低于synchronized。

    54、了解ReentrantLock吗?

    ReetrantLock是一个可重入的独占锁,主要有两个特性,一个是支持公平锁和非公平锁,一个是可重入。
    ReetrantLock实现依赖于AQS(AbstractQueuedSynchronizer)。
    ReetrantLock主要依靠AQS维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。
    等待唤醒,重新尝试加锁。

    55、ReadWriteLock是什么?

    首先ReentrantLock某些时候有局限,如果使用ReentrantLock,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。
    因为这个,才诞生了读写锁ReadWriteLock。ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。

    59、ReentrantLock加锁和解锁的过程

    加锁的过程
    以非公平锁为例,我们在外界调用lock方法的时候,源码是这样实现的

  8. CAS尝试获取锁,获取成功则可以执行同步代码;

  9. CAS获取失败,则调用acquire方法,acquire方法实际上就是AQS的模板方法;
  10. acquire首先会调用子类的tryAcquire方法(又回到了ReentrantLock中);
  11. tryAcquire方法实际上会判断当前的state是否等于0,等于0说明没有线程持有锁,则又尝试CAS直接获取锁;
  12. 如果CAS获取成功,则可以执行同步代码;
  13. 如果CAS获取失败,那判断当前线程是否就持有锁,如果是持有的锁,那更新state的值,获取得到锁(这里其实就是处理可重入的逻辑);
  14. CAS失败&&非重入的情况,则回到tryAcquire方法执行「入队列」的操作
  15. 将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用CAS尝试获取锁
  16. 如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
  17. 没获取得到锁,则判断前驱节点的状态是否为SIGNAL,如果不是,则找到合法的前驱节点,并使用CAS将状态设置为SIGNAL
  18. 最后调用park将当前线程挂起

麻烦使用压缩算法压缩下加锁的过程
候选者:压缩后:当线程CAS获取锁失败,将当前线程入队列,把前驱节点状态设置为SIGNAL状态,并将自己挂起。
Java并发 - 图20
解锁的过程

  1. 外界调用unlock方法时,实际上会调用AQS的release方法,而release方法会调用子类tryRelease方法(又回到了ReentrantLock中)
  2. tryRelease会把state一直减(锁重入可使state>1),直至到0,当前线程说明已经把锁释放了
  3. 随后从队尾往前找节点状态需要 < 0,并离头节点最近的节点进行唤醒
  4. 唤醒之后,被唤醒的线程则尝试使用CAS获取锁,假设获取锁得到则把头节点给干掉,把自己设置为头节点

解锁的逻辑非常简单哈,把state置0,唤醒头结点下一个合法的节点,被唤醒的节点线程自然就会去获取锁。
为什么要设置前驱节点为SIGNAL状态,有啥用?
其实归终结底就是为了判断节点的状态,去做些处理。
Node 中节点的状态有4种,分别是:CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)和0
在ReentrantLock解锁的时候,会判断节点的状态是否小于0,小于等于0才说明需要被唤醒,另外一提的是:公平锁的实现与非公平锁是很像的,只不过在获取锁时不会直接尝试使用CAS来获取锁。
只有当队列没节点并且state为0时才会去获取锁,不然都会把当前线程放到队列中。
Java并发 - 图21

线程池

ThreadPoolExecutor(1)
ThreadPoolExecutor(2)
ThreadPoolExecutor(3)
ThreadPoolExecutor(4)
ThreadPoolExecutor(5)
ThreadPoolExecutor(6)
ThreadPoolExecutor(7)

ThreadPoolExecutor 线程池相关介绍

1、shutdown() VS shutdownNow()

  • shutdown():关闭线程池,线程池的状态变为 SHUTDOWN线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow():关闭线程池,线程的状态变为 STOP线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。

    2、isTerminated() VS isShutdown()

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

    3、为什么要用线程池?

    线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
    使用线程池的好处:

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

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

    4、执行execute()方法和submit()方法的区别是什么呢?

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

  • submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

    5、你说下线程池核心参数?

  • corePoolSize : 核心线程大小。线程池一直运行,核心线程就不会停止。

  • maximumPoolSize :线程池最大线程数量。非核心线程数量=maximumPoolSize-corePoolSize
  • keepAliveTime :非核心线程的心跳时间。如果非核心线程在keepAliveTime内没有运行任务,非核心线程会消亡。
  • workQueue :阻塞队列。ArrayBlockingQueue,LinkedBlockingQueue等,用来存放线程任务。
  • defaultHandler :饱和策略。ThreadPoolExecutor类中一共有4种饱和策略。通过实现RejectedExecutionHandler接口。
    • AbortPolicy : 线程任务丢弃报错。默认饱和策略。
    • DiscardPolicy : 线程任务直接丢弃不报错。
    • DiscardOldestPolicy : 将workQueue队首任务丢弃,将最新线程任务重新加入队列执行。
    • CallerRunsPolicy :线程池之外的线程直接调用run方法执行。
  • ThreadFactory :线程工厂。新建线程工厂。

    6、线程池执行任务的流程

    Java并发 - 图22

  1. 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
  2. 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务。
  3. 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。

    7、常用的JAVA线程池有哪几种类型?

    1、newCachedThreadPool
    创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    这种类型的线程池特点是:
    工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。
    如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。
    在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统OOM。
    2、newFixedThreadPool
    创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。
    FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
    3、newSingleThreadExecutor
    创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。
    4、newScheduleThreadPool
    创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。
    image.png

    8、线程池常用的阻塞队列有哪些?

    image.png
    表格左侧是线程池,右侧为它们对应的阻塞队列,可以看到 5 种线程池对应了 3 种阻塞队列

  4. LinkedBlockingQueue 对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。

  5. SynchronousQueue 第二种阻塞队列是 SynchronousQueue,对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。 我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。
  6. DelayedWorkQueue 第三种阻塞队列是DelayedWorkQueue,它对应的线程池分别是ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。

DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。

9、源码中线程池是怎么复用线程的?

源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,worker线程数量和参数有关,每个worker会while死循环从阻塞队列中取数据,通过置换worker中Runnable对象,运行其run方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。

10、如何合理配置线程池参数?

自定义线程池就需要我们自己配置最大线程数 maximumPoolSize ,为了高效的并发运行,这时需要看我们的业务是IO密集型还是CPU密集型。
CPU密集型 CPU密集的意思是该任务需要最大的运算,而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才能得到加速(通过多线程)。而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那么多。
IO密集型 IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致大量的CPU运算能力浪费在等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上这种加速主要就是利用了被浪费掉的阻塞时间。
IO 密集型时,大部分线程都阻塞,故需要多配制线程数。公式为:

CPU核数*2 CPU核数/(1-阻塞系数) 阻塞系数在0.8~0.9之间 查看CPU核数: System.out.println(Runtime.getRuntime().availableProcessors());

当以上都不适用时,选用动态化线程池,看美团技术团队的实践:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html

11、Executor和Executors的区别?

Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。
Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。
使用ThreadPoolExecutor 可以创建自定义线程池。Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用get()方法获取计算的结果。