Java并发常见面试题总结

文章目录

主要参考:JavaGuide小林coding,同时加上网上搜索整理和个人理解总结

1 多线程

1.1 进程、线程和协程的区别?🔥

  • 进程是正在运行程序的实例,进程中包含了多个线程,每个线程执行不同的任务
  • 进程是资源分配的基本单位,线程是调度的基本单位
  • 不同的进程使用不同的内存空间,进程下的所有线程共享进程的内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低,但不利于资源的管理和保护
  • 协程是用户态的轻量级线程,是线程内部调度的基本单位,同一时间只能执行一个协程

1.2 Java 线程和操作系统的线程有啥区别?

JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程,也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统,但绿色线程和原生线程比起来在使用时有一些限制。

在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程来实现 Java 线程,由操作系统内核进行线程的调度和管理。也就是就是和操作系统的线程是一样的。

1.3 如何创建线程?🔥

一般来说,创建线程有很多种方式,例如继承 Thread 类、实现 Runnable 接口、实现 Callable 接口、使用线程池等等。通常情况下,我们项目中都会采用线程池的方式创建线程。

不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。

严格来说,Java 就只有一种方式可以创建线程,那就是通过 new Thread().start() 创建。不管是哪种方式,最终还是依赖于 new Thread().start()

1.4 runnable 和 callable 两个接口创建线程有什么不同呢?🔥

最主要的区别是一个是有返回值,一个是没有返回值的。

Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,和 FutureTask 配合可以用来获取异步执行的结果

他们异常处理也不一样。Runnable 接口 run 方法异常只能在内部处理,不能往上继续抛;Callable 接口 call 方法允许抛出异常。

在实际开发中,如果需要拿到执行的结果,需要使用 Callalbe 接口创建线程,调用 FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

1.5 如何停止一个正在运行的线程呢?🔥

  • 异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
  • 使用 return 停止线程:调用 interrupt 标记为中断状态后,在 run 方法中判断当前线程状态,如果为中断状态则 return,能达到停止线程的效果。
  • 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞的线程中断。会抛出中断异常,达到停止线程的效果
  • stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。

1.6 调用 interrupt 是如何让线程抛出异常的?

每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()方法中断时,会根据实际情况做出响应。

  • 如果该线程正在执行低级别的可中断方法(如Thread.sleep()Thread.join()Object.wait()),则会解除阻塞并抛出InterruptedException异常
  • 否则Thread.interrupt()仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。

1.7 说说线程的生命周期和状态?🔥

  • 当一个线程对象被创建,但还未调用 start 方法时处于 NEW(新建) 状态,调用了 start() 方法,就会由NEW(新建) 进入 RUNNABLE(可执行) 状态。
  • 当线程执行 wait() 方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到可执行状态。
  • TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis) 方法或 wait(long millis) 方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到可执行状态。
  • 如果线程获取锁失败后,这个时候线程就会进入 BLOCKED(阻塞) 状态。
  • 线程在执行完了 run() 方法之后将会进入到 TERMINATED(终止) 状态。

相关阅读:线程的几种状态你真的了解么?

1.8 什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

1.9 Thread 的sleep () 方法和 Object 的wait () 方法对比🔥

共同点:两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。

1.10 为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

1.11 可以直接调用 Thread 类的 run 方法吗?🔥

Thread 中 run()start() 有什么不同呢?

  • 调用 start() 方法会启动线程并使线程进入可执行状态,只能调用一次。
  • 直接执行 run() 方法的话会当成普通方法不会以多线程的方式执行,可以被调用多次。

1.12 并发与并行的区别

  • 并发是指一个处理器同时处理多个任务。
  • 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
  • 并发是逻辑上的同时发生,而并行是物理上的同时发生。

1.13 同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前,该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

1.14 为什么要使用多线程?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
  • 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。

1.15 使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

1.16 如何保证 Java 程序在多线程的情况下执行安全呢?🔥

jdk 中也提供了很多的类帮助我们解决多线程安全的问题,比如:

  • synchronized、LOCK 和原子类 可以解决原子性问题
  • synchronized、LOCK 和volatile,可以解决可见性问题
  • volatile 可以解决有序性问题

1.17 如何理解线程安全和不安全?

线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。

  • 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
  • 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

1.18 保证数据的一致性有哪些方案呢?

  • 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
  • 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
  • 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。

1.19 单核 CPU 上运行多个线程效率一定会高吗?

单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:CPU 密集型和 IO 密集型。CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。

在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。

因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。

1.20 你谈谈 JMM(Java 内存模型)🔥

JMM 可以看作是 Java 定义的并发编程相关的一组规范,抽象了线程和主内存之间的关系,比如说线程之间的共享变量必须存储在主内存中。其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序的可移植性。

  • 主内存:所有线程创建的实例对象都存放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • 本地内存:每个线程都有一个私有的本地内存,本地内存存储了共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在。

并发编程三个特性

  • 原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。借助 synchronized、各种 Lock 以及各种原子类实现原子性。
  • 可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。可以借助 synchronizedvolatile 以及各种 Lock 实现可见性。
  • 有序性:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

1.21 请谈谈你对 volatile 的理解🔥

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile 关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行的有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

volatile不能保证原子性,所以不能保证线程安全。对于复合操作,需要使用synchronized关键字或者Lock来保证原子性和线程安全。

1.22 死锁产生的条件是什么?🔥

  1. 互斥:一个资源在任意时刻只能由一个线程占用。
  2. 请求与保持:一个线程在请求被占用资源时,对已经获得的资源保持不放。
  3. 不可剥夺:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只能自己释放资源。
  4. 循环等待:若干线程之间形成一种头尾相接的循环等待资源关系。

1.23 如何进行死锁诊断?🔥

  • 使用 jps 查看当前 java 程序运行的进程 id,使用 jstack 命令查看进程内线程的堆栈信息。如果有死锁,jstack 的输出中通常会有 Found one Java-level deadlock: 的字样,后面会跟着死锁相关的线程信息。
    • 另外,实际项目中还可以搭配使用 topdffree 等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。
  • 也可以采用可视化工具 VisualVM、jconsole 等工具进行排查。

2 乐观锁和悲观锁

2.1 乐观锁和悲观锁的区别?使用场景?🔥

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题,所以每次在获取资源操作的时候都会上锁,也就是共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

  • 写多读少的场景:冲突发生概率较高,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费 CPU 资源。

乐观锁总是假设最好的情况,认为冲突的概率很低,先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作(具体方法可以使用版本号机制或 CAS 算法)。

  • 读多写少的场景:冲突发生概率较低,乐观锁更有优势,因为悲观锁会锁住代码块,加锁和释放锁都需要消耗额外的资源。

2.2 如何实现乐观锁?

版本号控制: 增加一个版本号字段记录数据更新时候的版本,每次更新时递增版本号。在更新数据时,同时比较版本号,若当前版本号和更新前获取的版本号一致,则更新成功,否则失败。

CAS :全称是 Compare And Swap(比较再交换)。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

  • CAS 使用到的地方很多:AQS 框架、AtomicXXX 类
  • 在操作共享变量的时候使用的是自旋锁,效率上更高一些
  • CAS 的底层是调用的 Unsafe 类中的方法,都是操作系统提供的,其他语言实现

2.3 CAS 算法存在哪些问题?

2.3.1 ABA 问题

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

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2.3.2 循环时间长开销大

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

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
2.3.3 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

2.4 为什么不能所有的锁都用CAS?

CAS操作是基于循环重试的机制,如果CAS操作一直未能成功,线程会一直自旋重试,占用CPU资源。在高并发情况下,大量线程自旋会导致CPU资源浪费。

3 synchronized 关键字和 ReentrantLock

3.1 synchronized 是什么?有什么用?

synchronized 同步锁,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

3.2 如何使用 synchronized?

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 加到代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

3.3 构造方法可以用 synchronized 修饰么?

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

3.4 synchronized 底层原理了解吗?🔥

synchronized 同步代码块的情况

synchronized 会在同步的代码块前后加上monitorenter和monitorexit指令。

  • 当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权,锁的计数器+1。如果获取锁失败,那当前线程就要阻塞等待。
  • 对象锁的的拥有者才可以执行 monitorexit 指令来释放锁。执行monitorexit指令时则会把计数器-1。当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized 修饰方法的的情况

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

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

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

monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因。

monitor 内部维护了三个变量

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

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

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

3.5 synchronized 锁升级原理了解吗?🔥

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。并且锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

  • 无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动一段时间之后才能开启。时间和是否开启偏向锁可以通过JVM参数进行设置。
  • 偏向锁:一段很长的时间内都只被一个线程使用锁。在第一次获得锁时,会有一个 CAS 操作,之后该线程再获取锁,只需要判断 MarkWord 当中存储的线程 ID 是否是自己的线程 ID 即可,而不是开销相对较大的 CAS 命令
  • 轻量级锁:线程加锁的时间是错开的(也就是没有竞争)。轻量级锁修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是 CAS 操作,保证原子性。
  • 重量级锁:当有两个以上的线程竞争锁的时候轻量级锁就会升级为重量级锁。底层使用 Monitor 实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

3.6 JVM对Synchornized的优化?

  • 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。
  • 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。

3.7 synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

3.8 ReentrantLock 是什么?

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁。 ReentrantLock 的底层是由 AQS 来实现的。ReentrantLocksynchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

3.9 公平锁和非公平锁有什么区别?

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

3.10 synchronized 和 Lock(ReentrantLock) 有什么区别?🔥

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,锁会自动释放
  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时 Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁
  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

3.11 synchronized 和ReentrantLock 应用场景

synchronized

  • 简单同步需求: 当需要对代码块或方法进行简单的同步控制时,synchronized是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。
  • 代码块同步: 如果你想对特定代码段进行同步,而不是整个方法,可以使用synchronized代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。
  • 内置锁的使用synchronized关键字使用对象的内置锁(也称为监视器锁),这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。

ReentrantLock:

  • 高级锁功能需求ReentrantLock 提供了 synchronized 所不具备的高级功能,如获取等待状态、公平锁、可打断、可超时、多条件变量。当需要这些功能时,ReentrantLock 是更好的选择。
  • 性能优化: 在高度竞争的环境中,ReentrantLock可以提供比synchronized更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。

3.12 可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

4 ThreadLocal

4.1 ThreadLocal 有什么用?🔥

ThreadLocal是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。

4.2 ThreadLocal 原理了解吗?🔥

ThreadLocal 的实现依赖于 ThreadLocalMap,这是一个存储 ThreadLocal 变量本身和对应值的映射。每个线程都有自己的 ThreadLocalMap,用于存储该线程所持有的所有 ThreadLocal 变量的值。

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

4.3 ThreadLocal 内存泄露问题是怎么导致的?🔥

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。

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

5 线程池

5.1 什么是线程池?

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

5.2 为什么要用线程池?

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

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

这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处

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

5.3 线程池的种类有哪些?🔥

也即是问通过 Executors 工具类可以创建哪些类型的线程池

  • SingleThreadExecutor: 只有一个线程的线程池。多余的任务被提交到该线程池时,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • FixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变,超出的线程会在队列中等待。
  • CachedThreadPool: 可根据实际情况调整线程数量的线程池。如果线程池数量超过需要的线程数,可灵活回收空闲线程,若无空闲线程可用,则新建线程。
  • ScheduledThreadPool:支持定时及周期性任务执行的线程池。

5.4 为什么不推荐使用内置线程池?🔥

在《阿里巴巴 Java 开发手册》中提到过

  • SingleThreadExecutorFixedThreadPool :阻塞队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPoolScheduledThreadPool:允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量的线程,从而导致 OOM。
  • 一般推荐使用 ThreadPoolExecutor 来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

5.5 线程池的核心参数有哪些?🔥

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核心线程数,线程池中最小的线程数量。即使是空闲状态,核心线程也不会被回收。
  • maximumPoolSize : 最大线程数,线程池中最大的线程数量。当任务量增加时,线程池会动态地创建新的线程,直到达到最大线程数。
  • workQueue: 阻塞队列,用于存放待执行任务的队列。当线程池中的线程都在忙于执行任务时,新的任务会被放入队列中等待执行。

ThreadPoolExecutor 其他常见参数 :

  • keepAliveTime:空闲线程存活时间,当线程池中的线程数量超过核心线程数时,多余的空闲线程的存活时间。超过这个时间,空闲线程会被回收。
  • unit : 时间单位, keepAliveTime 参数的时间单位。
  • threadFactory :线程工厂,用于创建新的线程。
  • handler :饱和(拒绝)策略。当线程池无法接收新的任务时,采取的处理方式。

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

  • AbortPolicy:直接抛出一个任务被线程池拒绝的异常。
  • DiscardPolicy:直接抛弃提交的任务,但不抛出异常。
  • DiscardOldestPolicy:抛弃最早排队的任务,然后执行该任务。
  • CallerRunsPolicy:由调用者所在的线程去执行任务。
  • 自定义拒绝策略:通过实现接口可以自定义任务拒绝策略。

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

比较常见的有4个,用的最多是 ArrayBlockingQueue 和 LinkedBlockingQueue

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

其中,LinkedBlockingQueue 默认无界,支持有界,底层是链表。而 ArrayBlockingQueue 强制有界,底层是数组。ArrayBlockingQueue 只有一把锁,读和写公用,而 LinkedBlockingQueue 读和写各有一把锁,性能相对更好一点

5.8 如何确定核心线程数呢?🔥

  • 如果我们设置的线程数量太小的话,如果同一时间有大量任务需要处理,可能会导致大量的任务在阻塞队列中排队等待执行,CPU 根本没有得到充分利用。

  • 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

  • I/O 密集型任务(2N): 这种任务系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

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

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

5.9 线程池处理任务的流程(执行原理)了解吗?🔥

图解线程池实现原理

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果核心线程都在执行任务,则判断阻塞队列是否已满,如果阻塞队列没有满,则将新提交的任务存储在这个阻塞队列里。
  3. 如果阻塞队列已经满了,但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果线程数量达到最大线程数,根据配置的拒绝策略来处理新的任务。
  5. 当线程池中的线程执行完任务后,会继续从阻塞队列中取出任务来执行,直到线程池关闭或者没有待执行的任务

5.10 如何给线程池命名?

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

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

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

1、利用 guava 的 ThreadFactoryBuilder

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

2、自己实现 ThreadFactory

// 线程工厂,它设置线程名称,有利于我们定位问题。
public final class NamingThreadFactory implements ThreadFactory {

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

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

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

5.11 如何设计一个能够根据任务的优先级来执行的线程池?

假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。

要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:

  1. 提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。
  2. 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则(推荐)。

不过,这存在一些风险和问题,比如:

  • PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。
  • 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
  • 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。

对于 OOM 这个问题的解决比较简单粗暴,就是继承 PriorityBlockingQueue 并重写一下 offer 方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。

饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。

对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。

5.12 线程池中shutdown (),shutdownNow()这两个方法有什么作用?

  • shutdown使用了以后会置状态为SHUTDOWN,正在执行的任务会继续执行下去,没有被执行的则中断。此时,不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常
  • shutdownNow 会置状态为STOP,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,它会返回那些未执行的任务。它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。

5.13 提交给线程池中的任务可以被撤回吗?

可以,当向线程池提交任务时,会得到一个Future对象。这个Future对象提供了几种方法来管理任务的执行,包括取消任务。

取消任务的主要方法是Future接口中的cancel(boolean mayInterruptIfRunning)方法。这个方法尝试取消执行的任务。参数mayInterruptIfRunning指示是否允许中断正在执行的任务。如果设置为true,则表示如果任务已经开始执行,那么允许中断任务;如果设置为false,任务已经开始执行则不会被中断。

6 Future

6.1 Future 类有什么用?

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。

这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
    // 取消任务执行
    // 成功取消返回 true,否则返回 false
    boolean cancel(boolean mayInterruptIfRunning);
    // 判断任务是否被取消
    boolean isCancelled();
    // 判断任务是否已经执行完成
    boolean isDone();
    // 获取任务执行结果
    V get() throws InterruptedException, ExecutionException;
    // 指定时间内没有返回计算结果就抛出 TimeOutException 异常
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutExceptio

}

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

6.2 Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 CallableFuture 之间的关系。

FutureTask 提供了 Future 接口的基本实现,常用来封装 CallableRunnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask

<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);

FutureTask 不光实现了 Future 接口,还实现了 Runnable 接口,因此可以作为任务直接被线程执行。

FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为 Callable 对象。

public FutureTask(Callable<V> callable) {
    if (callable == null)
        throw new NullPointerException();
    this.callable = callable;
    this.state = NEW;
}
public FutureTask(Runnable runnable, V result) {
    // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象
    this.callable = Executors.callable(runnable, result);
    this.state = NEW;
}

FutureTask 相当于对 Callable 进行了封装,管理着任务执行的情况,存储了 Callablecall 方法的任务执行结果。

6.3 CompletableFuture 类有什么用?

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入 CompletableFuture 类可以解决 Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

下面我们来简单看看 CompletableFuture 类的定义。

public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
}

可以看到,CompletableFuture 同时实现了 FutureCompletionStage 接口。

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。

CompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。

7 AQS

7.1 AQS 的原理是什么?🔥

AQS 的全称为 AbstractQueuedSynchronizer ,AQS 就是一个抽象类,主要用来构建锁和同步器。

AQS 为构建锁和同步器提供了一些通用功能的实现,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueue 等等皆是基于 AQS 的。

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

内部有一个Volatile的int类型的 state 属性来表示资源状态,通过 cas 设置 state 状态保证原子性。
通过内置的FIFO队列来完成资源获取的排队工作。

7.2 Semaphore 有什么用?

类似问题:如果控制某一个方法允许并发访问线程的数量?

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而 Semaphore (信号量)可以用来控制同时访问特定资源的线程数量。

Semaphore 的使用简单,我们这里假设有 N(N>5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。

// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();

当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。

Semaphore 有两种模式:。

  • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的。

Semaphore 对应的两个构造方法如下:

public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。

Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。

7.3 Semaphore 的原理是什么?

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。

调用 semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。

/**
 *  获取1个许可证
 */
public void acquire() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}
/**
 * 共享模式下获取许可证,获取成功则返回,失败则加入阻塞队列,挂起线程
 */
public final void acquireSharedInterruptibly(int arg)
    throws InterruptedException {
    if (Thread.interrupted())
      throw new InterruptedException();
        // 尝试获取许可证,arg为获取许可证个数,当可用许可证数减当前获取的许可证数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。
    if (tryAcquireShared(arg) < 0)
      doAcquireSharedInterruptibly(arg);
}

调用 semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。

// 释放一个许可证
public void release() {
    sync.releaseShared(1);
}

// 释放共享锁,同时会唤醒同步队列中的一个线程。
public final boolean releaseShared(int arg) {
    //释放共享锁
    if (tryReleaseShared(arg)) {
      //唤醒同步队列中的一个线程
      doReleaseShared();
      return true;
    }
    return false;
}

7.4 CountDownLatch 有什么用?

CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

7.5 CountDownLatch 的原理是什么?

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了 tryReleaseShared 方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到 count 个线程调用了 countDown() 使 state 值被减为 0,或者调用 await() 的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。

7.6 用过 CountDownLatch 么?什么场景下用的?

CountDownLatch 的作用就是允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:

我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。

为此我们定义了一个线程池和 count 为 6 的 CountDownLatch 对象。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用 CountDownLatch 对象的 await() 方法,直到所有文件读取完之后,才会接着执行后面的逻辑。

伪代码是下面这样的:

public class CountDownLatchExample1 {
    // 处理文件的数量
    private static final int threadCount = 6;

    public static void main(String[] args) throws InterruptedException {
        // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建)
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            final int threadnum = i;
            threadPool.execute(() -> {
                try {
                    //处理文件的业务操作
                    //......
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //表示一个文件已经被完成
                    countDownLatch.countDown();
                }

            });
        }
        countDownLatch.await();
        threadPool.shutdown();
        System.out.println("finish");
    }
}

有没有可以改进的地方呢?

可以使用 CompletableFuture 类来改进!Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。

CompletableFuture<Void> task1 =
    CompletableFuture.supplyAsync(()->{
        //自定义业务操作
    });
......
CompletableFuture<Void> task6 =
    CompletableFuture.supplyAsync(()->{
    //自定义业务操作
    });
......
CompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);

try {
    headerFuture.join();
} catch (Exception ex) {
    //......
}
System.out.println("all done. ");

上面的代码还可以继续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。

//文件夹位置
List<String> filePaths = Arrays.asList(...)
// 异步处理所有文件
List<CompletableFuture<String>> fileFutures = filePaths.stream()
    .map(filePath -> doSomeThing(filePath))
    .collect(Collectors.toList());
// 将他们合并起来
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
    fileFutures.toArray(new CompletableFuture[fileFutures.size()])
);

7.7 CyclicBarrier 有什么用?

CyclicBarrierCountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。

CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock (ReentrantLock 也属于 AQS 同步器)和 Condition 的。

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。

7.8 CyclicBarrier 的原理是什么?

CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。

//每次拦截的线程数
private final int parties;
//计数器
private int count;

下面我们结合源码来简单看看。

1、CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

public CyclicBarrier(int parties) {
    this(parties, null);
}

public CyclicBarrier(int parties, Runnable barrierAction) {
    if (parties <= 0) throw new IllegalArgumentException();
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}

其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。

2、当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L) 方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。

public int await() throws InterruptedException, BrokenBarrierException {
  try {
      return dowait(false, 0L);
  } catch (TimeoutException toe) {
      throw new Error(toe); // cannot happen
  }
}

dowait(false, 0L)方法源码分析如下:

    // 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。
    private int count;
    /**
     * Main barrier code, covering the various policies.
     */
    private int dowait(boolean timed, long nanos)
        throws InterruptedException, BrokenBarrierException,
               TimeoutException {
        final ReentrantLock lock = this.lock;
        // 锁住
        lock.lock();
        try {
            final Generation g = generation;

            if (g.broken)
                throw new BrokenBarrierException();

            // 如果线程中断了,抛出异常
            if (Thread.interrupted()) {
                breakBarrier();
                throw new InterruptedException();
            }
            // cout 减 1
            int index = --count;
            // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行 await 方法之后的条件
            if (index == 0) {  // tripped
                boolean ranAction = false;
                try {
                    final Runnable command = barrierCommand;
                    if (command != null)
                        command.run();
                    ranAction = true;
                    // 将 count 重置为 parties 属性的初始化值
                    // 唤醒之前等待的线程
                    // 下一波执行开始
                    nextGeneration();
                    return 0;
                } finally {
                    if (!ranAction)
                        breakBarrier();
                }
            }

            // loop until tripped, broken, interrupted, or timed out
            for (;;) {
                try {
                    if (!timed)
                        trip.await();
                    else if (nanos > 0L)
                        nanos = trip.awaitNanos(nanos);
                } catch (InterruptedException ie) {
                    if (g == generation && ! g.broken) {
                        breakBarrier();
                        throw ie;
                    } else {
                        // We're about to finish waiting even if we had not
                        // been interrupted, so this interrupt is deemed to
                        // "belong" to subsequent execution.
                        Thread.currentThread().interrupt();
                    }
                }

                if (g.broken)
                    throw new BrokenBarrierException();

                if (g != generation)
                    return index;

                if (timed && nanos <= 0L) {
                    breakBarrier();
                    throw new TimeoutException();
                }
            }
        } finally {
            lock.unlock();
        }
    }
  • 19
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java中,高并发面试题通常关注线程安全、并发控制、锁机制、多线程设计模式和性能优化等内容。以下是一些常见的面试问题: 1. **什么是线程安全?** - 线程安全是指多个线程同时访问共享资源时,不会出现数据不一致或程序行为不可预测的情况。 2. **Java中哪些集合类是线程安全的?** - Java集合框架中的`ConcurrentHashMap`、`CopyOnWriteArrayList`、`CopyOnWriteArraySet`等是线程安全的。 3. **什么是`synchronized`关键字?** - `synchronized`用于同步代码块或方法,防止多个线程同时执行,保证了对共享资源的互斥访问。 4. **区别`synchronized`和`ReentrantLock`?** - `synchronized`更简单直接,但没有提供锁的粒度控制;`ReentrantLock`提供了更多的灵活性,比如可中断锁和公平锁。 5. **什么是阻塞队列?** - 如`BlockingQueue`,如`ConcurrentLinkedQueue`、`ArrayBlockingQueue`等,它们在并发场景下用于任务的生产和消费。 6. **`volatile`关键字的作用是什么?** - 它保证了多线程环境下的可见性和内存一致性,尤其在变量可能被多个线程修改的情况下。 7. **什么是死锁?如何避免死锁?** - 死锁是指两个或多个线程相互等待对方释放资源而造成的一种僵局。避免方法包括避免循环等待资源、设置超时等。 8. **什么是线程池?** - 线程池可以复用已有线程,控制并发数,提高系统性能和资源利用率。 9. **Spring框架中的`@Async`是什么?** - Spring的异步处理注解,用于将任务提交给后台执行,避免阻塞主线程。 10. **`ExecutorService`和`Future`接口的区别?** - `ExecutorService`负责执行任务,`Future`则提供了对任务执行结果的访问和状态检查。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值