Java并发常见面试题总结

263 阅读34分钟

什么是线程(轻量级进程),进程和协程?

进程是程序的一次执行过程,是系统运行程序的基本单位。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。协程(Coroutine)是比线程更轻量级的执行单元。
现在的 Java 线程的本质其实就是操作系统的线程。Java 线程采用的是一对一的线程模型

线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

协程由程序自身控制调度,而不是操作系统。协程可以在某个函数执行到一半时暂停执行,然后在需要的时候继续执行。
用户态切换:协程的切换在用户态进行,不需要内核的参与,因此切换开销更小非抢占式调度:协程的调度由程序显式控制,是主动出让资源而不是由操作系统抢占。
适用于I/O密集型任务:协程非常适合I/O密集型任务,因为它们可以在等待I/O时主动让出CPU,切换到其他协程执行。不阻塞

  • 协程可以在一个线程内运行,因此协程可以被认为是对线程的一种细粒度划分

  • 协程可以实现比线程更高效的并发执行,但它们不能利用多核CPU的并行计算能力(除非结合多线程或多进程)。

一句话简单了解堆和方法区

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

进程之间如何通信(ICP)

1. 管道(Pipes)

管道是一种最基本的IPC机制,适用于父子进程之间的通信。

1.1 无名管道(Unnamed Pipes)

无名管道只能用于具有亲缘关系的进程(如父子进程)。

  • 特点

    • 单向通信:数据只能沿一个方向流动。
    • 继承关系:只有父进程和子进程可以通信。

1.2 命名管道(Named Pipes 或 FIFOs)

命名管道可以用于无亲缘关系的进程之间的通信。

  • 特点

    • 双向通信:数据可以双向流动。
    • 持久存在:在文件系统中存在,可以被任何进程使用。

2. 消息队列(Message Queues)

消息队列允许进程以消息的形式进行通信,每条消息都有一个标识符。

  • 特点

    • 异步通信:发送方和接收方不需要同步。
    • 消息标识:每条消息可以带有一个类型标识。

3. 共享内存(Shared Memory)

共享内存允许多个进程共享同一块内存区域,适用于需要高效通信的场景。

  • 特点

    • 高效通信:数据在内存中直接共享,速度快。
    • 同步问题:需要使用同步机制(如信号量)来避免竞争条件。

4. 信号(Signals)

信号是一种异步通知机制,用于通知进程某个事件的发生。

  • 特点

    • 异步通知:可以在任何时间打断进程的执行。
    • 处理函数:进程可以定义信号处理函数来处理特定信号。

5. 套接字(Sockets):实现不同设备系统之间的通信。

套接字是一种网络通信机制,支持本地和远程进程间的通信。

  • 特点

    • 灵活性强:支持TCP、UDP等多种协议。
    • 网络通信:不仅支持同一台机器上的进程通信,还支持不同机器之间的通信。

进程同步方式

  1. 信号量(Semaphore) :用于控制多个进程对共享资源的访问。
  2. 互斥锁(Mutex) :确保同一时间只有一个进程访问资源。
  3. 条件变量(Condition Variable) :用于进程间的复杂同步

进程调度策略

  1. 先来先服务(FCFS) :按进程到达的顺序调度。
  2. 短作业优先(SJF) :优先调度预计运行时间短的进程。
  3. 优先级调度(Priority Scheduling) :根据进程优先级调度。
  4. 时间片轮转(Round Robin) :每个进程分配一个时间片,轮流执行。
  5. 多级反馈队列(Multilevel Feedback Queue) :根据进程的行为动态调整优先级

线程之间的通信

image.png

如何创建线程?

一般来说,创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等

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

线程的生命周期和状态

  • NEW: 初始状态,线程被创建出来但没有被调用 start()

  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。start再调用run方法执行

  • BLOCKED:阻塞状态,需要等待锁释放。

  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。

  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

  • TERMINATED:终止状态,表示该线程已经运行完毕。

什么是线程上下文切换?

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

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

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

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

Thread#sleep() 方法和 Object#wait() 方法对比

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

区别

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

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

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作

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

多线程

并发与并行的区别

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

同步和异步的区别

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

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

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

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

什么是线程死锁?

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

产生死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。(一山不容二虎
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。(我得不到,我不释放
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。(抢不过你
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系(都需要A+B资源,我等你释放A,你等我释放B

如何检测死锁?

  • 使用jmapjstack等命令查看 JVM 线程栈和堆内存的情况。

  • 实际项目中还可以搭配使用topdffree等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有的资源。若申请不到则不获取任何资源。避免获取部分资源后等其他资源的情况。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。规定好资源分配顺序,避免出现互相等待的情况。
  4. 互斥条件不可破坏

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

volatile 关键字

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。

如何禁止指令重排序(语句顺序改变)?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

Java的可见性

在Java多线程编程中,可见性问题指的是一个线程对共享变量的更新对其他线程可见的程度。由于Java虚拟机和硬件架构的优化,变量更新的可见性不是总能保证的。具体来说,线程可能会在自己的缓存中维护一个变量的副本,而不是直接从主内存中读取和写入。如果一个线程更新了一个变量,其他线程可能不会立即看到这个更新

为什么volatile可以保证可见性

volatile关键字是Java提供的一种轻量级的同步机制,它用于修饰变量,以确保对该变量的读写操作是直接进行(不再缓存到线程自己的副本)的,并且对所有线程立即可见。

锁升级机制

在 JVM 中,锁升级机制主要涉及以下几种锁状态:

  1. 无锁(No Lock) :没有线程持有锁,所有线程都可以自由访问。
  2. 偏向锁(Biased Locking) :偏向于第一个获得锁的线程,如果同一个线程再次请求锁,不需要进行同步操作。
  3. 轻量级锁(Lightweight Lock) :如果偏向锁失败,会升级为轻量级锁,通过 CAS 操作实现自旋锁。
  4. 重量级锁(Heavyweight Lock) :如果轻量级锁自旋失败(自旋次数达到上限或线程数超过阈值),会升级为重量级锁,通过操作系统的 Mutex 实现阻塞和唤醒。

锁升级流程

  1. 无锁 -> 偏向锁

    • 当一个线程第一次请求锁时,锁处于无锁状态,会偏向该线程,将锁对象的标记设置为偏向锁,并记录持有锁的线程 ID。
  2. 偏向锁 -> 轻量级锁

    • 当另一个线程请求持有偏向锁的对象锁时,偏向锁会撤销,将锁升级为轻量级锁,并尝试通过自旋获取锁。
  3. 轻量级锁 -> 重量级锁

    • 如果自旋次数达到上限或者有多个线程竞争轻量级锁,则轻量级锁会升级为重量级锁。
    • 重量级锁通过操作系统的 Mutex 机制实现,阻塞和唤醒线程。

什么是悲观锁?

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

synchronizedReentrantLock等独占锁就是悲观锁思想

什么是乐观锁?

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

Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

版本号机制

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

CAS 算法

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

ABA 问题是 CAS 算法最常见的问题。

ABA 问题

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

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

CAS循环时间长开销大

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

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

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

只能保证一个共享变量的原子操作

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

synchronized 是什么?有什么用?

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

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法(对象锁):当前对象实例的锁,锁住实例对象this,不能实例被共享
  2. 修饰静态方法static(类锁):当前 class 的锁,对类加锁,会被实例共享
  3. 修饰代码块:给定对象的锁+给定 Class 的锁

synchronized 和 volatile 有什么区别?

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

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

ReentrantLock 是什么(Reentrant是重入的意思)

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。 ReentrantLock 里面有一个内部类 SyncSync 继承 AQSAbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。 ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

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

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

synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。可重入锁可以基于计数的原理,加锁时计数器+1,释放锁时计数器-1,计数器为0时完全释放。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于(JDK)API

相比synchronizedReentrantLock增加了一些高级功能。主要来说主要有三点:

  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件) : synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。可以灵活地有选择性地通知想通知的线程。

可中断锁和不可中断锁有什么区别(获取锁只能一直排队或者可以自行离开)?

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

共享锁和独占锁有什么区别?

  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。

ReentrantReadWriteLock 是什么?

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

线程持有读锁的情况下,该线程不能取得写锁,线程持有写锁的情况下,该线程可以继续获取读锁,即读不能升级为写,但写可以降级为读:读锁升级为写锁会引起线程的争夺

StampedLock 是什么?

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition

ThreadLocal 有什么用(key,value存储)?

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

JDK 中自带的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。实现原理主要依赖于 ThreadLocalMap。键为 ThreadLocal 对象,值为实际的局部变量值。get()查询,set()存储

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

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

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法

什么是线程池?

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

为什么要用线程池?

池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

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

  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

如何创建线程池?

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

**方式二:通过 Executor 框架的工具类 Executors 来创建。**有以下四种:

  • FixedThreadPool:固定线程数量的线程池。--有空就用,没空排队
  • SingleThreadExecutor: 只有一个线程的线程池。--排队
  • CachedThreadPool: 可根据实际情况调整线程数量的线程池。--没空创建新的
  • ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors(默认设置会导致OOM) 去创建,而是通过 ThreadPoolExecutor (可以自定义线程池参数避免OOM)构造函数的方式。 Executors 返回线程池对象的弊端如下:

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM(Out of memory内存溢出)
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM
  • ScheduledThreadPoolSingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量,(至少会一直维护池内有这些线程一直可用)。

  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数

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

  • 新任务提交

    • 如果当前线程数小于corePoolSize,创建一个新线程来处理任务。
    • 否则,将任务加入到workQueue
  • 任务队列workQueue已满

    • 如果当前线程数小于maximumPoolSize,创建一个新线程来处理任务。
    • 否则,任务被拒绝处理,并调用RejectedExecutionHandler来处理被拒绝的任务。
  • 任务完成

    • 线程池中的线程完成一个任务后,将从workQueue中取出下一个任务来处理。
    • 如果没有等待处理的任务且当前线程数超过corePoolSize,多余的线程将被终止。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过keepAliveTime才会被回收销毁。核心线程有其他的关闭方式。
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :拒绝策略

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

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。(不丢弃风险:耗时增加,阻塞,OOM,可以增大队列,增加最大线程数量。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

如果服务器资源已经达到可利用的极限(CallerRunsPolicy问题无法避免),这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?

这里提供的一种任务持久化的思路,这里所谓的任务持久化,包括但不限于:

  1. 设计一张任务表间任务存储到 MySQL 数据库中。
  2. Redis缓存任务。
  3. 将任务提交到消息队列中。

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

  • 容量为 Integer.MAX_VALUELinkedBlockingQueue无界队列):FixedThreadPoolSingleThreadExectorFixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满

  • SynchronousQueue同步队列):CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。

  • DelayedWorkQueue延迟阻塞队列):ScheduledThreadPoolSingleThreadScheduledExecutorDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

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

线程池处理任务的流程了解吗?

图解线程池实现原理

图解线程池实现原理

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

线程池中线程异常后,销毁还是复用?

使用execute()时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()时,异常被封装在Future中,线程继续复用。

Future 类有什么用(获取之前交给其他线程处理的任务结果)?

Future 类是异步思想的典型运用,当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。 Future 类只是一个泛型接口,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。

AQS 是什么(一种锁排队机制)?

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。AQS 就是一个抽象类,主要用来构建锁和同步器ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueueSemaphore等等皆是基于 AQS 的。提供排他锁和共享锁两种。

Semaphore 有什么用?

synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。 Semaphore 有两种模式:。

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

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

fail-safe 和 fail-fast的区别

fail-safe 和 fail-fast ,是多线程并发操作集合时的一种失败处理机制。
Fail-fast : 表示快速失败,在集合遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出 ConcurrentModificationException 异常,从而导致遍历失败
Fail-safe,表示失败安全,也就是在这种机制下,出现集合元素的修改,不会抛出ConcurrentModificationException。原因是采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到。

分布式事务实现方案

分布式事务的特点

  1. 跨多个节点:事务涉及的操作分布在不同的节点上,每个节点都可能有自己的数据存储和业务逻辑。
  2. ACID特性:分布式事务需要满足原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),简称ACID特性。
  3. 协调机制:需要一个协调者来管理和协调各个节点上的事务,确保所有节点上的操作一致性。

分布式事务的实现方式

常见的分布式事务实现方式包括两阶段提交(2PC)、三阶段提交(3PC)、基于消息队列的最终一致性等。

1. 两阶段提交(2PC)

两阶段提交是实现分布式事务的一种经典方法,分为准备阶段(Prepare Phase)和提交阶段(Commit Phase):

  • 准备阶段:协调者向所有参与节点发送准备请求,所有节点执行预操作并记录日志,但不提交。如果所有节点都返回成功,进入提交阶段,否则进入回滚阶段。
  • 提交阶段:如果准备阶段所有节点都成功,协调者向所有节点发送提交请求,所有节点正式提交操作。如果有节点失败,协调者向所有节点发送回滚请求,所有节点回滚操作。

优点:简单易实现。 缺点:如果协调者失败,可能会导致阻塞。

2.三阶段提交(3PC)

三阶段提交(Three-phase commit protocol, 3PC)是对两阶段提交(2PC)的改进,它通过引入一个中间阶段来减少协调者和参与者之间的阻塞时间,增强了系统的可靠性。3PC 分为三个阶段:

  1. CanCommit 阶段(投票阶段)

    • 协调者向所有参与者发送 CanCommit 请求,询问是否可以提交事务。
    • 参与者收到请求后,做出初步判断(如检查资源是否可用),并返回 Yes 或 No。
  2. PreCommit 阶段(预提交阶段)

    • 如果所有参与者都返回 Yes,协调者进入 PreCommit 阶段,向所有参与者发送 PreCommit 请求。
    • 参与者收到 PreCommit 请求后,执行事务操作并记录日志,但不提交事务,仅做预提交。
    • 参与者返回 ACK 确认预提交完成。
  3. DoCommit 阶段(提交阶段)

    • 如果协调者在预提交阶段收到所有参与者的 ACK,则进入 DoCommit 阶段,向所有参与者发送 DoCommit 请求,要求正式提交事务。
    • 参与者收到 DoCommit 请求后,正式提交事务并释放资源。
    • 如果在任何阶段遇到失败,协调者会发送 Abort 请求,要求所有参与者回滚事务。

3. 基于消息队列的最终一致性

使用消息队列可以实现分布式系统的最终一致性。具体步骤如下:

  • 事务消息:在执行数据库操作时,先发送一条半事务消息到消息队列,数据库操作和发送消息在同一个本地事务中完成。
  • 确认消息:数据库操作成功后,确认消息发送成功,消息队列将消息发送给消费者。
  • 处理消息:消费者收到消息后,执行缓存操作。

使用消息队列实现数据库和缓存的一致性

通过消息队列,可以实现数据库和缓存操作的一致性。具体步骤如下:

  1. 数据库操作:在事务中执行数据库操作,并发送半事务消息到消息队列。
  2. 消息队列:消息队列暂存半事务消息,等待数据库事务提交。
  3. 提交事务:数据库事务提交后,确认消息发送成功,消息队列将消息发送给消费者。
  4. 更新缓存:消费者收到消息后,执行缓存操作,确保数据库和缓存一致。