Skip to content

Java 并发笔记

什么是 Java 中的线程同步?

线程同步就是针对临界资源的访问控制,临界资源就是可以被多个线程共同访问的资源,这种资源因为竟态问题需要被限制访问,也就是同一时间,这种资源只能被一个线程访问 3 比如我们现在有一个票,A 线程来读,B 线程来读,A 线程 -1,B线程 -1,A 线程写回,B 线程写回,这就是超卖现象,对临界资源管理不到位产生的问题,这在生产上是很严重的

线程的生命周期在 Java 中是如何定义的?

线程在 Java 的 Thread.State 枚举中一共有六中状态:

  • NEW:线程刚刚被创建出来,还没开始调用 start
  • RUNNABLE:线程刚调用 start,这里包含操作系统层面的就绪和正在运行两个状态,没被阻塞,没挂就算 RUNNABLE
  • BLOCKED:线程拿取 Synchronized 锁没拿到,在同步块外面等待
  • WAITING:调用 Object.wait、Thread.join 或 LockSupport.park 进入无限期的等待,这个时候必须有显式的操作将其唤醒
  • TIMED_WAITING:有限期的等待,比如:Thread.sleep、Object.wait(timeout)、Thread.join(timeout)
  • TERMINATED:线程跑完任务,被干掉,自己挂了都算

BLOCKED 和 WAITING 的区别

区别就是是否是主动触发的,前者是抢锁没抢到就等待,被动的,后者是主动阻塞的,需要被唤醒

都不占用时间片就是了

RUNNABLE 状态的线程一定在执行吗?为什么 Java 不区分就绪和运行状态?

不一定,有可能还就绪状态,在等待时间片,因为运行和就绪之间就差一个时间片,切换速度很快,区分了也不准,所以就不切换了,而且,知道是就绪还是在运行对于程序没啥意义

线程从 WAITING 状态被唤醒后是直接变成 RUNNABLE 还是 BLOCKED?

不一定,主要看被唤醒后要干啥,如果要去竞争锁的话就转换为:BLOCKED,抢到锁了就转换为 RUNNABLE,不需要的话就去 RUNNABLE

总结下来就是,如果需要抢锁的话就增加一个 BLOCKED 进行抢锁操作

Java 中 Thread.sleep 和 Thread.yield 的区别?

都可以中断当前线程,但是机制不同

Thread.sleep 会让线程进入 TIMED_WAITING状态,在这个时间段内,不会去抢占 CPU 时间片,等到醒来之后变为 RUNNABLE 状态,因为自己睡觉的时候也会占用锁,所以并不会转为 BLOCKED去竞争锁

Thread.yield 只是告诉 CPU 自己愿意让出时间片,如果让出了,自己就转为就绪态,没人用还是在运行态

最直观的区别就是状态的切换,前者是 RUNABLE -> TIMED_WAITING -> RUNNABLE,后者不变

底层实现差别:

  • Thread.sleep会将自己放到等待队列中,时间到了放到就绪队列,在这个期间不会被调度
  • Thread.yield就是把自己放到就绪队列末尾,如果队列没人,还是自己用

Java 中 Thread.sleep(0) 的作用是什么?

作用就是让线程真正的放弃自己的时间片,从运行状态转为就绪状态,如果 Thread.yield 是百分百让出的话就不需要这玩意了

典型的场景就是防止大循环长期霸占时间片

sleep(0) 和 sleep(1) 的区别大吗?为什么有些代码用 sleep(1)?

在于精度和性能,Windows 精度低,会睡个 15 毫秒左右,Linux 会睡眠 1 毫秒,用处就是真的让线程停一下,让其他线程执行,而不是走个过场,如果只是想触发一下调度,前者效率高一点

在高并发场景下频繁调用 sleep(0) 会不会有性能问题?

会有的,因为这个涉及到用户态和内核态的切换,在几微妙的级别,合理的方法是每个一定数目的循环就调用,让出时间片给其他线程

知道 LockSupport.parkNanos(1) 吗?它和 sleep(0) 有什么区别?

不知道

开玩笑的,这个是 JUC 的原语,底层使用 park/unpark,响应中断的方式不同,不会抛出 InterruptedException,而且可以被 unpark 唤醒

单纯想要让出 CPU 的话,使用 sleep 更好

sleep 能被中断吗?中断后会发生什么?

可以的,如果其他线程调用他的 interrupt 他会立刻报错 InterruptedException 并清除中断标志位 ,所以 sleep 应该在 try-catch 中,用于捕获异常之后重新设置中断标志,让上层代码知道发生冲突了

为什么说 yield 在生产环境用得少?

主要是他的不可靠性,想要让他让出线程,结果没让出,代码可能出问题。你如果想要线程让出 CPU 还是使用 sleep(0) 更好

如果想让线程精确地暂停一段时间,有什么好办法?

普通的 Thread.sleep 在 Windows 可能有误差,如果想要高精度的话,可以使用 LockSupport.parkNanos(),再牛逼一点可以使用 System.nanoTime(),但是这样会占用 CPU,如果真有这样的高频场景,建议用硬件时钟

如果一个线程在 Java 中被两次调用 start() 方法,会发生什么?

直接抛出 IllegalThreadStateException 异常,表示线程被复用了,Java 规定线程是一次性的,start 会检测是否为 NEW 状态,线程离开 NEW 状态就回不去了,另外,线程跑完之后,栈空间,寄存器都会被回收,更是没办法复用

如果想要复用线程,可以使用线程池

线程池里的线程执行任务时抛了异常,这个线程会怎样?

看任务提交方式了:

  • execute():异常会直接爆出来,线程直接死掉,线程池再补充
  • submit():不会发生变化,线程还可以用,报错被保存到 Future,想要查看得调用 get 方法

什么情况下会出现死锁?如何避免死锁?

死锁需要同时满足四个条件:

  • 互斥条件:线程对分配的资源有排他性访问,即每一个资源要么分配给一个线程,要么是可用的。
  • 占有且等待:一个线程已经占有至少一个资源,但又在等待另一个资源,而此时该资源被其他线程占有。
  • 不可剥夺:线程占有的资源不能被剥夺,资源只能在使用完后由线程自行释放。
  • 环路等待:存在一种资源等待的环形链,即线程A在等待线程B占有的资源,而线程B在等待线程C占有的资源,....,直到最后一个线程等待线程A占有的资源,从而形成一个等待环路。

所以只需要针对这四个采取破坏操作就行:

  • 避免互斥条件:尽量减少资源的独占性,使用非阻塞同步机制。
  • 破坏占有且等待:采用资源预分配策略,即进程一次性请求所需的所有资源。
  • 破坏不可剥夺:如果一个进程得不到所需的资源,应释放它所持有的资源,或者使用优先级来剥夺资源。
  • 破坏环路等待:对系统中的资源进行排序,每个线程按序请求资源,避免形成环路。

Java 中 wait() 和 sleep() 的区别?

  • 所属类不同,wait() 属于 Object,Sleep 属于 Thread,
  • 对锁的操作不同,wait() 在等待的时候释放锁,Sleep 在等待的时候不释放锁
  • 调用区域不同:wait 必须在 synchronized 中调用,Sleep 随便

wait() 为什么必须在 synchronized 块里

为了避免静态条件,通常下,阻塞的条件和阻塞的操作不是原子的,可能在中间条件又被更改了,这样就丢失信号了,所以要加锁保证信号安全

为什么 wait() 要放在 while 循环里

  • JVM 允许 wait() 在没有 notify 的情况下返回,虽然很少见,但规范允许
  • 多个线程等在同一个锁上,notifyAll() 唤醒了所有线程,但只有一个能满足条件,其他的醒来发现条件又不满足了

wait() 被唤醒后是直接执行还是要重新竞争锁?

需要,线程在被 wait 后会释放锁,加入到对象头的等待队列中,被唤醒后要重新竞争锁

notify() 和 notifyAll() 怎么选?

使用 notifyAll,因为被唤醒的线程可能还是不满足条件,这样就白白唤醒了,还不如直接都唤醒,谁满足谁去工作,效率更高

sleep(0) 和 yield() 有什么区别?

都是让出 CPU 的意思,前者直接从运行状态进入到就绪状态,但是后者不一定被调度器响应,性能差不多

wait() 能不带参数吗?不带参数会怎样?

可以的,不带参数需要手动唤醒,带了可以自动唤醒

Java 中 volatile 关键字的作用是什么?

其实核心作用就两个:保持变量可见性,防止指令重排

保证可见性就是被 volatile 修饰的变量会在被修改之后立刻被刷新到主存,其他核心在总线监听到消息之后会放弃自己的缓存,转而去更新,这样可以保证其他线程看到的都是最新值

防止指令重排:编译器和 CPU 会对指令进行排序,这个在单线程没什么问题,但是会在多线程有问题,volatile 会插入内存屏障,限制重排

内存屏障就是防止两侧的指令跨越进行重排的

volatile 能保证 64 位 long 和 double 的原子性读写吗?

可以的,在 32 位虚拟机中,对 64 位的操作会被分成上下两部分,还不是原子性的,在这之间读取就会读取到半成品,但是加上 volatile 后,JVM 必须保证 64 位变量的读写是原子的。不过现在主流都是 64 位 JVM,这个问题基本不存在了

volatile 数组能保证数组元素的可见性吗?

不能,volatile 修饰数组只能保证数组引用的可见性,比如把数组指向另外一个地方去,其他线程会马上看到

为什么说 volatile 比 synchronized 轻量?

因为其不用调用 Monitor,Monitor的修改会涉及用户态和内核态的切换,volatile 只是增加内存屏障的指令而已,性能开销不大,因为本质上他是不会去加锁的,非原子操作还是会线程不安全

什么是协程?Java 支持协程吗?

协程是用户态的轻量级线程,不由操作系统调度,由 JVM 进行管理和调度,创建,切换,撤销成本低,内存占用小

从 21 开始支持协程

虚拟线程的实现原理?

首先有三个概念需要明确:

  • 虚拟线程,他就是 Java 堆里面的普通对象,几百万个没有压力,自己没有执行代码的能力
  • 平台线程:Java 传统的线程,他一比一绑定操作系统线程,数量有限
  • 载体线程:当平台线程正在执行虚拟线程的代码的时候,这个就叫做载体线程

调度器持有和 CPU 核心数相同的线程,负责将虚拟线程分配给空闲的平台线程执行

强大并发的原因就是挂载和卸载:

  • 当虚拟线程需要执行的时候,调度器寻找一个空闲的平台线程将其挂载,平台线程开始执行其代码
  • 当虚拟线程阻塞的时候,调度器将其拉下来,从栈放到堆里,然后挂载其他的虚拟线程进行工作
  • 当虚拟线程结束阻塞的时候,调度器会寻找空闲的平台线程去挂载虚拟线程进行工作。

虚拟线程阻塞了也不占用资源,直接下去等了,其他的虚拟线程再上来干活,这样并发就高了

虚拟线程和 CompletableFuture 异步编程有什么区别?什么时候该用哪个?

CompletableFuture 是异步回调工具,侧重的是线程之间的并行配合,虚拟线程则是减少了线程的开销,本质上不一样

CompletableFuture 在任务比较多的时候显得复杂,可读性低,虚拟线程使用同步阻塞的方式也可以做到异步回调的效果,代码更易读。

如果是多个任务并行的复杂的场景,使用 CompletableFuture 效果更好

虚拟线程能完全替代线程池吗?

不能,虚拟线程解决的问题是阻塞带来的浪费问题,如果一个任务大部分靠 CPU 来进行,没有阻塞的话,虚拟线程还会因为调度器性能偏低,所以相对而言,虚拟线程更适合 IO 密集型任务,线程池更适合 CPU 密集型任务

虚拟线程里用 ThreadLocal 会有什么问题?

首先要明确的是,每一个线程 ThreadLocal 都会被保存为一个副本,如果是普通的线程,几十个就几十个了,但是虚拟线程动辄几十万,上百万,内存直接爆炸了,21 可以使用 ScopedValue 来进行资源共享

怎么判断一个虚拟线程有没有被 pin 住?

可以加 JVM 参数 -Djdk.tracePinnedThreads=full,运行时会打印被 pin 住的虚拟线程堆栈。一般是 synchronized 块里做了阻塞操作导致的,把 synchronized 换成 ReentrantLock 就能解决。生产环境可以用 JFR 事件 jdk.VirtualThreadPinned 来监控。

什么是 Java 的 CAS(Compare-And-Swap)操作?

CAS 是并发操作的基石,是一种原子操作,他会检测目标内存是否是预期值,如果是的话就更改为新值,如果不是的话就自旋重试

这种操作优势很明显,他不需要做加锁这种开销大的操作就能保证线程安全,缺点也很明显:

  • ABA问题:如果一个A值被修改成了B,然后又被改成了A,这时候再去操作是发现不了的,因为值已经变回去了,但是确是被操作过的
  • 自旋开销:在搞并发情况下,自旋会发生的比较频繁,CPU的开销比较大
  • 只能保证单个变量操作的原子性,多个不能保证

CAS 总线风暴是什么?怎么优化的?

lock 前缀的指令会将写缓冲区的数据立刻刷新到缓存,在多处理器架构下,处理器会通过嗅探总线的方式来判断自己的缓存是否过期。如果有一个CPU刷新主存就会通知其他的CPU缓存过期

在高并发场景下,大量的 CAS 去修改一个变量,总线上的缓存一致性流量就会暴涨,这个时候总线就会成为瓶颈,这个就是总线风暴

常用的解决思路就是维护一个 Cell,每个线程更新不同的 Cell,减少竞争,最后汇总

ABA问题是什么,怎么避免的?

ABA 问题是 CAS 在并发的时候遇到的常见问题,具体来说就是一个变量从A变成B再变为A,CAS无法检测到这种变化,误以为是原值,然后产生错误操作的情况

常用解决思路是引入版本号,这样即使值相同,也会因为版本号的不同区分出来。

为什么说 Unsafe 类不推荐直接使用?

Unsafe 的操作可以直接操作内存,绕过 JVM 的类型检查,相当于在没有保护措施的情况下去干活,用得不好会导致内存泄漏,而且这玩意不是在 JDK 的标准 API 里面,随时可能改动,可以使用 VarHandle 作为替代方案

什么是 Java 中的锁自适应自旋?

自适应自旋是针对 synchronized 的重型锁的一种优化手段,核心目的就是为了减少阻塞唤醒带来的成本,具体来说就是 JVM 维护了上一次自旋的结果,如果这次发生了竞争失败的情况,就去查询这个,如果上次很容易就自旋成功了,那么这次也去自旋,如果上次失败了,就减少自旋的次数,如果一直失败就直接进入阻塞状态,防止浪费 CPU 资源

自适应自旋只发生在重量级锁上,轻量级锁 CAS 失败之后不会发生自旋,而是调用 inflate 进行膨胀

本质上就是通过历史数据预测未来,如果 CAS 在一个锁上很快就成功,那么就说明这个锁很快会被释放,CAS 带来的开销小于阻塞 + 唤醒的成本,就多自旋几圈。反过来也一样,本质上和怠速停车一样

自旋次数太多会有什么问题?

CPU 会空转,在这段空转的时间内,处理不了其他的工作,白白浪费性能,还会耽误其他线程执行,因此可以设置最大自旋次数,防止一直自旋浪费性能

自适应自旋怎么判断上次自旋是否成功的?

JVM 在 ObjectMonitor 维护自旋的统计信息,最近几次自旋是否拿到锁了,如果最近的情况不乐观,就减少甚至不让线程自旋

Java的锁升级机制是什么

1.6 给 Synchronized 增加了锁的升级机制,根据不同的场景使用不同的锁,有意思的是,并没有锁的降级机制

  • 偏向锁:偏向锁的使用场景就是"只有一个线程访问",线程首次访问的时候,JVM记录下这个线程的 ID 到 Mark Word 里面,之后,这个线程再来访问,就可以直接获取控制权,而不需要 CAS 操作,如果有其他的线程来竞争资源,偏向锁就不适合该场景了,就会升级成轻量级锁 值得一提的是,现代应用场景下,并发度高,偏向锁命中概率持续降低,15 默认禁用偏向锁,18 移除偏向锁
  • 轻量级锁:轻量级锁的使用场景是"多个线程交替访问,不产生竞争"。创建的时候要在线程的私有栈内创建一个 Lock Record,他会备份 MarkWord 的数据和锁对象的引用,之后 CAS 将对象头指向这个 MarkWord,如果成功就说明设置成功,如果失败就说明发生竞争,轻量级锁将不适用于当前场景,将会膨胀成重量级锁
  • 重量级锁:当轻量级锁的 CAS 失败之后,锁膨胀为重量级锁,这个时候使用互斥量来保证运行,其核心是 ObjectMonitor,底层就是使用互斥量来实现线程的阻塞和唤醒

Java 的 synchronized 是怎么实现的?

Synchronized 主要依赖于对象头 Mark Word 和 Monitor 对象监视器

其对于方法和代码块的实现方式不同

  • 修饰方法的时候会在方法的访问标志加上 ACC_SYNCHRONIZED 标志,当线程进入被 ACC_SYNCHRONIZED 标记的方法前,会尝试去获取这个对象的监视器锁,成功了才会继续执行,失败就去等待了
  • 修饰代码块时,编译器会在上下分别加上 monitorenter 和 monitorexit 表示监视器锁获取和监视器锁释放,monitorexit 会在正常退出和异常退出分别被加入,以保证所有情况都能正常解锁。当线程执行的时候读取到 monitorenter 时尝试获取对象的 Monitor 锁,成功就进入临界区工作,失败就进入阻塞队列,执行 monitorexit 进行锁的释放并且唤醒一部分在等待队列的线程

不管作用于方法还是代码块,本质上都是获取某个对象的 Monitor 锁

  • 代码块加锁:获取锁对象的 Monitor 锁
  • 实例方法:获取实例对象的 Monitor 锁
  • 静态方法:获取类对象的 Monitor 锁

synchronized 锁的是什么?锁对象和锁 Class 有什么区别?

本质上是锁对象,如果修饰实例方法,锁的就是实例对象,如果是静态方法,锁的就是 class 对象,锁实例是相互独立的,性能没影响,锁 class 对象是全局的,所以 静态方法要慎用

为什么 synchronized 不需要手动解锁,底层是怎么保证的?

编译器在生成字节码文件的时候会自动加上 monitorenter 和 monitorexit,并且还在异常出口处也加上了 monitorexit,保证了在执行的时候会正常的获取锁和释放锁,不会缺漏

锁升级过程中,偏向锁撤销为什么要在安全点进行?

因为 撤销偏向锁要遍历栈,找到持有这个锁的栈帧,修改他的 Lock Record,如果这个时候有线程在运行,数据就会乱掉,得在大家都安全的时候进行更改,这也是废弃的原因之一,撤销的开销太大

轻量级锁的 Lock Record 里存的是什么?为什么要拷贝 Mark Word?

存放的是 Lock Record 里面主要是 Mark Word 的拷贝和锁对象的指针,拷贝 Mark Word 的原因是撤销锁的时候会用到,在重入的时候,也会创建一个新的 Lock Record,但是不会去拷贝 Mark Word,如果撤销锁的时候发现 Mark Word 是 null,就直接释放,以此判断是否重入

synchronized 能降级吗?比如从重量级锁降回轻量级锁?

并不能,这套体系设计上没考虑过降级的情况,但是 GC 可以在一个重量级锁的队列中没有线程等待的时候进行降级,但这个也并不是运行时的降级

Java 中 ReentrantLock 的实现原理是什么?

底层就是简单的 CAS + AQS,里边还是一个 state 用于计数 + 一个队列用于等待

加锁的时候线程先去看看这个锁是公平还是非公平,然后决定是去检测阻塞队列还是去 CAS state

CAS state 的时候,会将 0 改成 1,表示这个锁已经被持有,并将 exclusiveOwnerThread 设置为自己,失败了就去排队,park 挂起

ReentrantLock 的 lock 方法忘记 unlock 会怎样?

线程会一直持有锁,其他线程竞争不到锁,程序会卡死一般情况下必须用 try-finally 包起来,finally 里面 unlock,保证任何情况下都能释放锁,这就不得不提到 synchronized 了,这玩意自动释放,简单高效

说说 AQS 吧?

AQS 是 JUC 里面锁和同步器的基础框架

核心就是两样东西:volatile int 的 state 变量,一个 CLH 队列锁的变种

state 表示同步的状态,具体功能由子类定义:

  • 独占锁实现:state 可以代表持有状态,0 表示未被持有,1 表示持有
  • 共享锁实现:state 表示当前持有的数量

比如 ReentrantLock 使用其表示独占锁,Semaphore 表示锁还剩多少个

AQS 使用 CLH 队列锁的变种,具体来说是将原来的单向链表变成了双向链表,每个 QNode 不去自己的前驱上面自旋查看锁的使用状态,而是在自己使用完毕之后去自己的后继去唤醒线程,这会节省大量线程空转消耗的 CPU 资源

获取锁的时候,线程尝试 CAS state,如果成功,说明拿到了资源,如果失败就说明锁正在被使用,自己就把自己包装成 QNode 节点加入到队列尾部,然后 park 挂起等待。释放锁的节点会唤醒自己的后继节点

AQS 为什么用 CLH 队列的变体而不是普通队列?

CLH 队列采用自旋等待前驱节点释放锁的策略,避免大量线程去 CAS 同一个变量产生总线风暴的问题,AQS 将线程从原来的前驱节点自旋改成了 park 挂起,进一步减少线程烧 CPU 的问题,但是线程挂起就不能检测前驱的状态,改成双向链表去唤醒后继节点来让队列正常工作

AQS 里的 Node 有哪些等待状态?

5 种:

  • CANCELLED 是 1,节点被取消了要从队列摘掉
  • 0 是初始状态;
  • SIGNAL 是 -1,表示后继节点需要被唤醒
  • CONDITION 是 -2,节点在条件队列里等着
  • PROPAGATE 是 -3,共享模式下用来传播唤醒信

正数只有 CANCELLED 一种,所以源码里经常用 waitStatus > 0 来判断节点是否取消。

什么是 Java 的 CountDownLatch?

其主要实现了 后置线程等待前置线程完成工作后继续工作的机制,其底层是 AQS,state 的含义是还需要等待的线程数,初始化 CountDownLatch 时需要指定 state

  • await():后置线程使用,他会检查 state 的状态,如果不是 0,就进入阻塞队列等待
  • countDown():前置线程使用,会减小 state 的数量

不过这玩意除了 countDown() 以外并没有提供额外的操作 state 值的操作,也就是说这玩意是一次性的,一次被使用完成之后就会废掉

CountDownLatch 的 countDown 调用次数超过初始值会怎样?

啥事没有,因为 await() 在 state 为 0 的时候已经让后置线程起跑了,这玩意也不能重复使用,所以不会有问题的,但是如果设定值和调用值不一样的话,说明逻辑有问题,要检查一下

如果子任务抛异常了,没调用 countDown,怎么办?

后置线程直接卡柱,所以 countDown 应该放到 finally 中,或者调用自带超时的 await,

能不能让 CountDownLatch 的计数器重置?比如用反射改 state?

可以的,但是不建议使用,修改的 state 并不会触发线程起跑,如果想要多轮使用,可以使用 CyclicBarrier 来实现

await 方法可以被多个线程同时调用吗?

可以的,底层是AQS,后置线程没等到直接进入 CLH 队列排队了,等到 state 为 0 的时候直接一起起跑

什么是 Java 的 CyclicBarrier?

cyclicBarrier 主要实现的是一组线程一起起跑的操作,底层是 ReentrantLock ,每一个线程调用 await() 阻塞,直到计数器为 0,使用 Condition 批量唤醒,然后重置计数器,等下一次

内部如果有线程失败,就将这一次的 Generation 设置为 true 表示屏障损坏,然后唤醒其他线程,其他线程被唤醒之后就检查原因,如果是正常唤醒, 就继续,如果是因为失败导致的唤醒,就报错,所以会保证一起失败一起成功

另外,这个是可以重复使用的,可以用于多轮的屏障操作

如果我把 CyclicBarrier 的 parties 设成 5,但只有 4 个线程调用 await,会发生什么?

大家一起阻塞呗,所以实际中,parties 必须和线程数相等,不然就卡

CyclicBarrier 的 barrierAction 在哪个线程执行?

最后一个线程执行,不是固定的,barrierAction 的逻辑不能太重,不然会拖累进度

CountDownLatch 和 CyclicBarrier 都能实现线程等待,有什么区别?

第一个区别就是 CountDownLatch 更适用于前置线程和后置线程的关系,等待前置线程完成之后,后置线程才能完成工作,而 CyclicBarrier 更适用于一组线程相互等待的场景

第二个区别就是 CountDownLatch 只能是一次性使用,CyclicBarrier 则提供了重置计数的操作,这就让其天然适用于多轮操作的场景

其次:CountDownLatch 是基于 AQS 实现的,而 CyclicBarrier 是 ReentrantLock + Condition

什么是 Java 的 Semaphore?

它是基于 AQS 实现的信号量计数器,将 AQS 的 state 定义为信号量,获取信号量时如果state > 0,state-1,反之去队列阻塞,返还信号量时,state+1,然后去队列唤醒头部等待线程

什么是公平锁、非公平锁?

其最大的区别就是新来的线程的行为:

  • 公平锁:新来的线程会去检测阻塞队列,如果里面有东西,他会去排队,并不会去竞争锁,这样可以保证每个线程都有机会抢到锁,不会被饿死,但是吞吐量低,效率不高,适合顺序敏感性场景
  • 非公平锁:新到的线程直接去竞争锁,失败了才将自己插入阻塞队列。吞吐量高,但是线程可能被饿死

ReentrantLock 的公平锁和非公平锁有什么区别?性能差多少?

区别就是新来的线程是否可以直接去访问 AQS 的 state,非公平锁直接尝试获取锁,公平锁去排队

非公平锁省去了唤醒的步骤,直接来抢,性能差距大约在 10%以上,高并发更加明显

说说 Semaphore 与 synchronized、Lock 的区别

他们的语义不一样,后者是互斥锁,同时只能有一个线程去进入临界区,但是 semaphore 可以同时允许多个线程进入临界区

前者适用于做控制连接池,并发流量控制场景,后者更多用来保护共享数据读写安全

Semaphore 能实现互斥锁的效果吗?怎么做?

可以的,互斥锁的本质就是只能有一个线程去访问临界区,只需要把 state 设置成 1 就能实现互斥锁的效果,但是 Semaphore 本身不支持重入,所以会产生重入死锁的情况

如果我 release 调用次数比 acquire 多会怎样?

因为 semaphore 并不会检测线程归还动作的合法性,如果 release 比 acquire 多的话,state 就会变多,这样就会脱离 semaphore 本身的限流作用,所以一般要使用 try-finally 保证 state 总数不变

Semaphore 的 acquire 方法有几种变体?各自什么场景用?

四种:

  • acquire():阻塞等待,可被中断
  • acquireUninterruptibly():阻塞等待,忽略中断信号
  • tryAcquire():非阻塞,立刻返回成功或失败
  • tryAcquire(timeout, unit):带超时的阻塞,超时返回 false

说说 CLH 队列锁?

CLH 队列锁是为了解决大量线程去自旋竞争锁的时候产生的总线风暴问题的。

CLHLock 作为 Java 中一种可行的 CLH FIFO队列锁被广泛应用,其维护着一个数据结构:QNode

QNode 里面只有一个布尔类型的变量 locked,他表示当前线程是否在等待(使用)锁,也就是说,当他处于 true 的时候,这个线程是没有完成工作的,也就不能释放锁。

QNode 会扮演三种角色

  • tail:指向该队列的尾节点,用于新节点的插入操作,使用 AtomicReference 包裹,保证修改的原子性
  • myPred:本线程私有的属性,表示前驱节点的锁的持有状态
  • myNode:本线程的私有属性,表示自己锁的持有状态

工作流程:

  • 加锁:当一个线程想要锁的时候,他会把自己的 locked 设置成 true,然后利用 tail 获得自己的前驱并把 tail 修改为自己,然后自己就盯着自己的前驱的 QNode 属性就行了。
  • 解锁:解锁的步骤可以总结为:获取自己的 QNode,将自己的 QNode 设置为 false,表示自己已经退出临界区,释放锁,然后他会更新自己的 myNode 引用,将其指向自己的前驱 QNode,防止死锁

至于为什么必须在释放锁的时候更新一下 myNode 的引用: 我们假设这样一种情况:

  • 我们的最后一个A节点忙完了自己的工作,将自己的 QNode 置为 false,表示自己不持有锁了,下班了。
  • 突然发现自己还有工作,回来了,又要加锁
  • 这个时候,他会获取到 tail 指向的 QNode,然后将自己的前驱引用这个 QNode,将然后将 tail 的引用标更新为自己的 QNode
  • 然后将自己的 QNode 的值设置为 true,表示自己想要锁,然后自己就去等待前驱的 QNode

问题来了,当 A 节点第一次忙自己的工作的时候,tail 就是指向他的 QNode 的,因此,他下一次来的时候,获取的队尾的 QNode 引用就是自己的,他以为他的前驱就是他自己!然后他自己需要锁,他又在等自己释放锁!这不就跟自己拿着手机找手机一样吗!

所以要更改自己的 Node 引用为一个新的引用啦~

Synchronized 和 ReentrantLock 有什么区别?

Synchronized 是 JAVA 提供的关键字,用起来很简单,全权由 JVM 管理,而 ReentrantLock 需要自己手动获取释放锁,相对应的 Synchronized 的自由度就低很多,包括但不限于只支持非公平锁,不可中断,超时获取不支持,但是方便。

其实性能方面差不多,Synchronized 在一开始的情况下性能不如 ReentrantLock,但是经过优化之后就差不多了,使用的时候只用考虑场景问题而不用考虑性能问题

ReentrantLock 和 synchronized 怎么选?

在性能上二者都差不多,Synchronized 比较简便,适用于大多数场景,而 ReentrantLock 比较适合于高级的应用场景,比如超时,条件获取等操作

为什么 AQS 用双向链表而不是单向链表?

  • 首先,AQS 的 CLH 队列锁中的线程并不会去一直在自己的前驱自旋,而是采用唤醒的方式,双向链表可以完成这个功能
  • 其次,如果节点被撤销的话,单向链表只能从头部进行遍历找到后继,而双向链表可以直接定位后继

tryLock 和 lock 有什么区别?

Lock 是尝试获取锁,如果获取不到就等待,一直等,tryLock 是尝试获取锁,获取不到就返回false,适合快速失败的场景,他还有一个重载,可以传入时间,超时就放弃,更加灵活

前者可以避免死锁,如果一方获取不到锁就释放掉持有的锁

state 变量为什么用 int 不用 boolean?

首先是为了灵活性,state 如果只有 true 和 false 两种状态就不能很灵活的应用到各种场景,比如锁的重入机制就是通过累加 state 来进行实现的,比如 CountDownLatch 使用 state 表示还剩余多少前置线程没完成工作,这都是灵活性的表现

什么是可重入锁,怎么实现的?

可重入锁为了解决同一线程再次获取同一个锁的时候产生的锁死现象,解决方式就是采用可重入锁机制

可重入锁维护一个计数器,当同一个线程再次获取同一个锁的时候就去让这个计数器+1,退出的时候计数器就-1,直到计数器归零就释放这个锁,防止自己跟自己抢的情况出现

AtomicLong 是什么?干什么用的?

AtomicLong 简单来说就是一个支持原子操作的 Long 类型

首先,普通的Long会有两个致命问题:

  • 累加并非原子性操作:普通的Long的累加操作(++)分为三个步骤,读取,+1,写回。很显然不能保证线程安全
  • 撕裂问题:普通的Long是64位的,写入操作也是分为两部分:高32位和低32位写入,这两个步骤是分开的,如果中间有线程进来读一下,就会读到错误的数据

AtomicLong 解决了这两个问题,使用 Unsafe + CAS 保证了操作的原子性和数据可见性

也就是说对于 ++操作:获取值,+1,CAS(查询内存,看看是否和旧值相同,如果相同就更新,如果不同就重试)这样就能保证安全

但是这种实现方式对于极高并发场景下会出现性能抖动:会出现大量等待的线程在自旋,这会消耗掉大部分的性能,无所谓,后面会有 LongAdderDoubleAdder 出手

你使用过 Java 的累加器吗?

累加器常用的就是 LongAdder 和 DoubleAdder,相对于 Atomic,他解决了高并发下线程都在自旋的问题。

LongAdder 选择不去让线程去抢占一个变量的使用权,而是维护了一个 BaseCount 变量和一个 Cell 数组,在竞争不激烈的时候,线程会去更改 BaseCount 的值达到修改的目的,反之会在 Cell 数组找一块地方进行更新,统计的时候将所有的值加起来就行

如果想追求灵活性的话,使用 LongAccumulator 更加的灵活

LongAdder 采用的 Cell 数组是怎么样的?讲一讲?

Cell 数组是用于防止并发场景下大家都去更新一个变量做出的缓和操作,具体来说是如果更新变量失败,就会去这个数组中找一个地方去更新,保证并发性

这个数组一开始不会创建好,只有 BaseCount 访问失败的时候才会去创建,初始长度是二,如果之后还是不够,还会触发扩容,但是不会超过CPU的核心数,因为同一时间执行的线程就这么多,过了就没意义了

另外,其用 Contended 保证不出现伪共享

LongAdder 的 sum 方法为什么不是原子的?有没有办法拿到精确值?

因为 sum 的计算方式是将 BaseCount 和 Cell 数组的所有值求和,在高并发场景下,去对这个操作进行加锁是不利于并发的,所以得到的值也不是精确的,如果想得到精确的值可以试着自己加锁,sumThenReset 方法可以拿到精确的值,但是会清零其余的地方,适合周期总结使用

为什么 Cell 数组长度是 2 的幂次?

这就涉及到线程如何寻找到适合自己的 Cell 了,线程在 BaseCount 繁忙的时候去访问 getProbe 方法,去获取探针值,然后去做 probe & (size -1)到这里就很明确了,如果是二的幂次的话,这样就比取模快很多了

高并发下 LongAdder 一定比 AtomicLong 快吗?

看读写

  • 写操作是比 AtomicLong 快的,因为其 BaseCount + Cell 的设计不会发生太多竞争
  • 读操作就不一定了,因为 LongAdder 需要计算 BaseCount + Cell 的值,Cell 越多越慢

所以读多写少适合 AtomicLong 反之 LongAdder 合适

LongAdder 能保证可见性吗?

BaseCount 和 Cell 的每个值都是 volatile 的,所以这些值保证可见性没问题,但是求和操作就不能保证了,因为其不是原子操作

ThreadLocal 的缺点?

主要缺点其实就是容易造成内存泄漏,由于 ThreadLocalMap 的 Key 是用 WeakReference 包裹的 ThreadLocal,在外部强引用消失之后,会被回收,但是 Value 是强引用的,所以不会被 GC 回收,造成内存泄露,正确的做法就是注意 remove。

其次就是效率问题,ThreadLocalMap 采用开放寻址法,处理哈希冲突的效率较低,

最后还有清理效率不可控,由于 ThreadLocal 使用的是启发式清理,如果在 get、set、remove 操作中碰到了大量需要被清理的对象,这次调用的效率就会低下

Java 中的 ThreadLocal 是如何实现线程资源隔离的?

简单来说,ThreadLocal 让每个线程拥有自己的资源,每个 Thread 中有一个 Map:ThreadLocalMap,叫做threadLocals,用来存放自己的资源。threadLocal.get()方法本质上就是让这个线程去自己的 ThreadLocalMap 中去找,找到了拿来用。

你说 Entry 的 key 是弱引用,那为什么不把 value 也设计成弱引用,这样不就不会内存泄漏了?

value 如果也弱引用,如果发生GC,就给清除了,后续就找不到数据了,ThreadLocalMap 里面的数据本来就是要给别的地方使用的,如果莫名其妙消失了,就违背了设计的初衷,所以使用者得注意 remove 的使用,防止堆积

为什么 ThreadLocalMap 用线性探测法而不是链地址法?

在实际开发中 ThreadLocalMap 中存放的东西数量一般很少,冲突概率不高,线性探测法简单,效率高,内存连续所以缓存友好

Java 中的 InheritableThreadLocal 是什么?

InheritableThreadLocal 是 ThreadLocal 的子类,解决的是子线程继承父线程的资源问题,如果使用 ThreadLocal 作为存储,子线程就无法和父线程进行值传递,父线程设置的值子线程就拿不到,在 InheritableThreadLocal 被创建的时候,会拷贝一份父线程的值,之后父线程的更改与子线程无关

InheritableThreadLocal 是怎么实现父子线程传值的?

inheritableThreadLocals 是除了 threadLocals 的另外一个 ThreadLocalMap 字段,在子线程被创建的时候构造函数会去扫描父线程的 inheritableThreadLocals 字段,如果里面有东西,就会把这个东西拷贝下来到子线程的 inheritableThreadLocals 中

不过这个拷贝仅仅发生在子线程刚被创建的时候,对于线程池来说,这玩意就废了,可以试一下 TransmittableThreadLocal

什么是 Java 的 TransmittableThreadLocal?

TransmittableThreadLocal 是阿里开发的工具类,专门解决线程池中线程传递值问题。其实 Thread 提供了一种解决方案:InheritableThreadLocal,但这种传递仅仅发生在子线程刚被创建的时候,这种模式不适用于线程池,因为在任务被提交到线程池的时候,线程往往已经被创建了,压根不起作用 TransmittableThreadLocal 的工作原理就是当任务提交的时候,任务对象将主线程的 ThreadLocal 作为快照保存下来,线程池的线程执行任务的时候再将这个快照还原,执行完毕之后再清理

为什么 InheritableThreadLocal 在线程池不管用

InheritableThreadLocal 仅仅在创建线程的时候执行构造方法,拷贝父线程的 inheritableThreadLocals,后续提交的任务只是复用已有的线程,并不会执行构造方法,父值无法传递

TTL 和 InheritableThreadLocal 能一起用吗?会有什么问题?

可以使用,TTL本身继承自 InheritableThreadLocal ,new Thread 是 InheritableThreadLocal 的机制,而线程池则是使用 TTL 的 CRR 机制,但是,如果线程池的线程是通过 new Thread 创建的,会继承父线程的 inheritableThreadLocals ,后续有任务传递会 replay 覆盖一次,两次值可能不一样,最好统一使用 TTL

TTL 的 holder 用 WeakHashMap 为什么能防止内存泄漏?

holder 的 key 是 TTL,也就是说,当外部对 TTL 的强引用消失之后,WeakHashMap 就能够清理掉这个key,对应的 Value 也会被清理掉,如果使用 HashMap,key 就不能正常回收,就会发生泄露,但是,弱引用还是兜底,及时 remove 才是好习惯

为什么 Java 中的 ThreadLocalMap 对 key 的引用为弱引用?

首先要明白 每一个 Thread 中维护一个 ThreadLocalMap,ThreadLocalMap 中有一个 Entity 数组,Entity 这个东西继承了用弱引用包装的 ThreadLocal 对象,然后内部有一个 Value 变量,初始化的时候将 ThreadLocal 作为弱引用,Value作为强引用

这样一来,当栈内失去了 ThreadLocal 这个 key的时候,GC会及时回收掉没有被强引用的它,然后当 ThreadLocalMap 被操作的时候,比如查找的时候,如果发现失效的Entity,或者是扩容的时候,又会清理掉失效的 Entity,这样就完成了对内存的清理

即使是这样,也只是尽力而为,也不能保证所有的 Entity 被及时的清理,所以还是记得使用 remove 更彻底

既然弱引用Key会导致内存泄漏,为什么不直接用强引用然后在某个时机统一清理?

强引用手动清理的最大问题就是无法保证时机,万一清理完毕之后,马上又要使用刚刚被清理的 ThreadLocal,就麻烦了,其次就是,如果使用弱引用,key 是会被及时清理的,发生短暂泄露的只是 Value 罢了,即使是这样,也比强引用好得多

ThreadLocalMap 为什么用线性探测法而不是链表法处理哈希冲突?

ThreadLocalMap 在实际使用中,通常不会存放太多的值,在如此稀少的情况下,使用前者的好处就大于后者,首先就是缓存命中率高,其次就是清理过期的 Entity 的时候可以顺便整理数组

如果我把 ThreadLocal 定义成 static 的,还会有内存泄漏问题吗?

static 的对象会被类加载器强引用,因此不会被回收,也就不会出现 key 为 null 的情况,也就不会发生泄露,但是如果 Value 很大,就会一直待在内存里,所以,及时 remove 才是上上策。

InheritableThreadLocal 的引用设计和 ThreadLocal 一样吗?

一样的,因为 InheritableThreadLocal 继承 ThreadLocal 存放的位置还是 ThreadLocalMap,Entity 也没有被修改引用方面,区别只是当 Thread 被创建的时候,会扫描父线程的 inheritableThreadLocals,拷贝给子线程

为什么不直接使用 WeekHashMap 替代 ThreadLocalMap ?

  • 性能:ThreadLocalMap 的 key 永远是 ThreadLocal,不需要处理复杂的 Key 类型,也不需要实现复杂的 equals 判断,他的哈希算法是为 ThreadLocal 量身定做的,分布更加均匀
  • 由于实际使用中 ThreadLocal 存量比较小,使用开放寻址法更为优秀,空间利用率和缓存命中率都比较优秀

InheritableThreadLocal 的拷贝是深拷贝还是浅拷贝?

浅拷贝,也就是说对于可变对象,子线程拿到的也是引用,如果想改为深拷贝,可以重写 childValue 方法

为什么 JDK 不直接在 ThreadLocal 里支持父子线程传递?

ThreadLocal 被发明的本意就是线程隔离,继承关系,在很多场景下是不需要的,而且如果支持的话,使用不当可能造成数据污染,所以选择单独提供一个 InheritableThreadLocal 用于继承数据

TransmittableThreadLocal 的性能开销大吗?

有的有的,因为在每一次任务提交的时候都要遍历所有的 TransmittableThreadLocal 对象制作快照,任务执行的时候需要进行快照的恢复和清理,所以会有开销

如果子线程修改了 InheritableThreadLocal 的值,父线程能感知到吗?

  • 如果子线程获得的是非引用对象,就不会感知,因为本质上是两个对象
  • 如果获得的是引用对象,如果修改引用,则不会有感知,如果是修改的是对象的内容,则能感知到,因为 InheritableThreadLocal 使用的是浅拷贝来获取父对象的值

线程池场景下 InheritableThreadLocal 有什么问题?

有大问题,InheritableThreadLocal 的机制是在被创建的时候去扫描父线程的 inheritableThreadLocals 字段,并拿到这里面东西的副本,后续不再访问,线程池里面的线程是提前创建好的,执行任务时所用的线程只是对线程池中线程的复用,并不会达到继承当时父线程数据的效果,可以使用 TransmittableThreadLocal 来实现对线程池的友好

一个对象所有字段都是 final 的,它就一定是线程安全的吗?

不是的,final 只是保证对象的引用不变,对象值还是不能保证线程安全的

Java 中线程之间如何进行通信?

线程通信的本质就是让线程之间可以相互协调工作,核心手段有两种:共享内存,消息传递

  • 共享内存很好理解,设置一块公共内存区域,让每个线程都可以过去读写,同时保证可见性,原子性,有序性,使用 volatile 保证可见性,synchronized 和 Lock 可以解决这三个问题,但是缺点就是线程多的时候会阻塞排队
  • 消息传递就是使用 wait/notify,Condition,BlockingQueue 等,比较适合生产者 - 消费者模型

常用方式:

  • wait/notify:在 Synchronized 中使用,线程调用 wait 进行阻塞,调用 notify 唤醒其他线程打配合
  • Lock + Condition:比如 ReentrantLock 中的 newCondition 可以创建等待队列,更灵活
  • BlockingQueue:生产者-消费者 模型。生产者 put 消息,消费者 take,二者都带有阻塞
  • volatile:保证可见性,适合单线程写,多线程读的场景

wait 为什么必须放在 synchronized 块里调用?

wait 的底层需要操作对象的 Monitor,用于将当前线程加入到等待队列中, 这个需要获取对象的锁,如果没有的话,JVM 就抛出IllegalMonitorStateException,notify 一样,也需要操作对象的 Monitor

生产者消费者模型,你会选 wait/notify 还是 BlockingQueue?

肯定选 BlockingQueue,首先这种队列的方式很适合生产-消费模型,其次,BlockingQueue 封装了很多方法可以使用,使用 wait/notify 还得自己实现很多方法,不如直接使用 BlockingQueue

Java 中如何创建多线程?

  • 实现 Runnable 的 run 接口,然后将 Runnable 对象传入到 Thread 的构造函数中,然后调用 run 方法就直接创建线程了
  • 直接继承 Thread 类,然后重写 run 方法,但是不灵活,因为只能继承一个对象
  • 使用 Callable 和 FutureTask 也可以,并且他能异步获取运行结果
  • 使用线程池:ExecutorService,复用线程,降低创建/销毁线程开销
  • Java 8 引入 CompletableFuture 可以执行异步任务,自带 ForkJoinPool 作为默认线程池

你了解 Java 线程池的原理吗?

线程池的核心目的就是为了复用线程,减小创建/销毁线程的消耗

线程池包含下面几个参数:

  • 核心线程数:线程池默认持有的线程数,这些线程即使空闲,也不会被回收
  • 最大线程数:线程池能够容纳的最大线程数
  • 空闲存活时间 + 时间单位:表示非核心线程在没有任务执行的时候可以存在多少时间
  • 工作队列:如果当前没有线程能去工作,新来的任务就会被放到工作队列中等待
  • 线程工厂:线程池用于创建线程的工具类
  • 拒绝策略:在线程数达到最大之后,工作队列满员之后,针对新来的任务的策略

一般来说,核心线程数要小于最大线程数,而且核心线程是懒创建的,如果有新任务来,他会去创建一个核心线程去接任务,如果核心线程满了,就去等待队列中等待,如果等待队列也满了,就创建非核心线程来进行工作,如果非核心线程也满了,就会调用拒绝策略进行任务拒绝,如果线程没有工作,并且线程数量大于核心线程数量,就会回收掉一部分线程,直到线程数目等于核心线程数目

为什么线程池是先排队而不是先加线程

线程的创建非常消耗资源,如果一大波请求打过来就创建对应数量的线程,那么系统资源会被耗尽,切换上下文的开销就巨大了,

所以加入了工作队列,目的是削峰填谷,让任务在工作队列待一会,线程慢慢处理,如果工作队列也爆了,就说明真的扛不住了,就要创建线程了

如何合理地设置 Java 线程池的线程数?

看任务类型:

  • 如果是 CPU 密集型,就不需要设置太多的线程,因为 CPU 密集型任务发生阻塞的概率很少,太多线程没什么意义,一般设置为核心数量 + 1
  • 如果是 IO 密集型任务,频繁发生阻塞,CPU 空闲时间增多,线程切换就有了意义,一般设置为 核心数 * 2

实际情况可能复杂得多,需要不断测试,观察 CPU 运行时间,等待时间,工作队列任务数量依此调整线程个数

当然也可以使用动态线程池,ThreadPoolExecutor 提供了类似的的方法,可以在运行时设置线程池的最大连接数和核心线程数,配合 DCC 可以进行不停机扩容/缩减线程池,非常好用

虚拟线程:这玩意在 21 加入 Java,拥有极低的创建成本,当阻塞的时候自动挂起,快速切换线程工作,在 IO 密集型场景中作用明显,但是 CPU 密集型任务作用不明显,因为该场景发生阻塞的概率很低,线程不需要被切换,甚至可能被调度器拖累,影响效率

你说 CPU 密集型任务线程数设成核心数加 1,为什么要加 1?

为了防止线程在工作的时候发生缺页中断的现象,这个时候会从硬盘调取信息,发生阻塞,因此要留一个线程保底,不让核心闲着,这种情况也比较少

怎么判断一个任务是 CPU 密集还是 I/O 密集?

可以通过观察任务执行期间 CPU 干不干活来判断,用 top 或者 jstack 看线程状态,如果大部分时间是 RUNNABLE 说明 CPU 在算,是 CPU 密集型;如果大部分时间是 WAITING 或 TIMED_WAITING 说明在等 I/O,是 I/O 密集型。还可以算等待时间和计算时间的比值,比值越大越偏 I/O 密集。

线程池线程数设太大会有什么问题?

  • 内存压力大:一个线程就要占用 1M 的栈空间,过多的线程会占用过多的内存,内存都被线程占用了
  • CPU 上下文切换开销大:过多的线程频繁切换会导致 CPU 一直在处理上下文切换的工作,实际干活的时间反而少了
  • 竞争加剧:线程可以创建很多,但是数据库连接就这么多,线程都去抢占稀有资源,阻塞的反而更多

压测时 CPU 利用率一直上不去,可能是什么原因?

  • IO慢,大家都在等IO
  • 竞争锁,大家都在等待队列等锁
  • 线程不够,没人干活
  • 可能是压测工具配置有问题或者压测工具本身不大行

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

拒绝策略是线程池在工作线程达到上限的时候,工作队列爆炸的时候,对新来的任务产生的拒绝方式,主要有四种:

  • AbortPolicy:报错:RejectedExecutionException,直接进行报错,让上游业务去处理
  • CallerRunsPolicy:谁提交,谁执行,线程池不干了,让提交任务的线程去跑,然后他就不能提交新任务了,自然就不会爆炸了
  • DiscardOldestPolicy:剔除队列等待时间最多的任务,这个听起来很不靠谱,因为无法判断老任务是否比新任务重要
  • DiscardPolicy:静默丢弃,非暴力不合作属于是,适合无所谓的任务

当然也可以自定义,通过实现 RejectedExecutionHandler 可以实现自定义拒绝策略,通常有三种方法:

  • 阻塞等待,直到入队,适合必须及时完成的任务场景
  • 写入日志,发布警告,等待人工,适合人工介入场景
  • 降级处理,补偿执行,适用于任务时效性不强的场景

如果我想在线程池任务被拒绝时把它持久化到 Redis 或数据库,后续再补偿执行,怎么设计?

首先,是任务的序列化,Runnable 本身不支持被序列化,需要单独提取出任务参数,之后重构一个任务对象用来执行任务

其次:任务要做幂等,防止重复执行任务导致业务出错

最后,使用合适的方式进行步补偿,比如任务写入库表定时任务扫描,或者写入 MQ 延迟队列等待处理

CallerRunsPolicy 会导致什么问题?有没有更好的替代方案?

一般来说,提交到线程池的任务都是耗时很高的,丢给提交线程自己干会导致这个线程阻塞,如果是正在负责 HTTP 之类的线程长时间阻塞的话会被判定为超时导致任务失败

一般的解决方案是设置最大超时时间,超时了直接返回,防止前端超时,另外一种就是将任务降级,等待后续处理,不过这种情况适合时效性不高的任务

线程池 shutdown 之后再提交任务,走的是什么拒绝策略?

也是配置好的拒绝策略,无论这个时候线程池内部是什么状态,线程满没满,队列满没满,都拒绝

Java 并发库中提供了哪些线程池实现?它们有什么区别?

Executors 提供了多种线程池模板,本质都是 ThreadPoolExecutor 和 ForkJoinPool 的不同的参数配置,开箱即用

线程池类型corePoolSized
核心线程
maxPoolSize
最大线程
keepAliveTime
空闲线程存活时间
workQueue
FixedThreadPoolnn0LinkedBlockingQueue
CachedThreadPool0MAX_VALUE60sSynchronousQueue
SingleThreadExecutor110LinkedBlockingQueue
ScheduledThreadPoolnMAX_VALUE0DelayedWorkQueue
WorkStealingPoolCPU 核心数CPU 核心数0Deque
  • FixedThreadPool:线程数固定,多余任务去排队,适合任务量可以预估的场景,比如数据批处理
  • CachedThreadPool:很明显,来一个任务创建一个线程,60秒后回收,适合突发任务
  • SingleThreadExecutor:只有一个线程,提交的任务被放在队列中,也就是说任务会被严格按照提交顺序执行,适合顺序敏感场景
  • ScheduledThreadPool:核心线程指定,最大线程无上限(,适合周期性的任务,比如报表生成,心跳检测之类的
  • WorkStealingPool:这个线程池里面每一个线程都有一个自己的双端队列,自己的任务干完之后就可以去帮助别的线程去做工作,因此设置成 CPU 核心数是合理的

为什么阿里规范禁止用 Executors?

因为这玩意不安全:

  • 拿使用了 LinkedBlockingQueue 这种无界队列的线程池来说,任务会一直堆积到队列中,直接 OOM 了,非常的不安全
  • 再说最大线程数不设上限的队列,一个线程就要 1MB,一千个就是 1G,高并发情况下就给内存打爆了
  • 然后就是 WorkStealingPool 这种互助的线程池了,这玩意默认底层使用 commonPool,整个 JVM 共享这个,如果有一大堆并行执行的任务很长,就会影响到其他的并行流,

正确的做法还是创建 ThreadPoolExecutor 来使用,不能避免去设置这些线程数,队列和处理策略

ScheduledThreadPool 和 Timer 有什么区别?为什么推荐用 ScheduledThreadPool?

最大的区别就是 Timer 是单线程的,如果任务阻塞了,大家就一起阻塞,一个任务抛异常,会导致 timer 也挂掉,后面的任务直接没了,scheduledThreadPoll 使用多线程处理任务,任务之间相互隔离,一个任务挂了不影响其他任务

CachedThreadPool 适合什么场景?我在高并发接口里用它可以吗?

cachedThreadPool 的核心线程为 0,最大线程为 Integer.MAX_VALUE,每个线程最多存在 60 秒,适合偶发性的,短期内迎来大量并发请求的场景,比如每天的定时任务,需要一瞬间处理金挨个请求值了ID,高并发可以用 FixedThreadPool,不过还是建议手动创建一个

线程池的线程是怎么复用的?执行完一个任务后为什么不销毁?

线程池里面的线程在执行完毕任务之后并不会退出,而是会在 while 循环中不断的 take 任务队列,take 会在任务没在队列中而阻塞,直到下一个任务到来就继续工作,所以并不是执行完任务就没了,而是继续等下一个任务,核心代码在 Worker.runWorker 中,总的来说,线程池里面的线程的任务就是接收别的任务,如果没有就阻塞,没有执行完毕一说

submit 和 execute 有什么区别?

execute 只能提交 Runnable,没有返回值,异常直接打到堆栈,submit 可以提交 Runnable 和 Callable,返回一个 Future 对象,可以在里面获取结果,或者异常都可以获取

线程池里的线程抛异常了会怎样?

分提交方式,使用 execute 提交的任务爆了会破坏线程,然后线程池补上一个线程,submit 提交的任务爆了不会破坏线程,报错会被锁在 future 中,注意处理就好

Java 线程池内部任务出异常后,如何知道是哪个线程出了异常?

线程池并不会主动的处理异常,通常有三个方法:

  • 给线程自定义异常处理类:自定义 ThreadFactory,设置 UncaughtExceptionHandler,挂异常处理器之后遇到异常在这里触发回调,记录线程名和异常堆栈
  • 使用 submit 替代 execute:submit 返回的 Future 不仅仅包含处理结果,还包含报错信息,可以通过这个来进行异常处理
  • 使用 try-catch 包裹任务;直接让任务负责异常的处理,写入日志然后处理

为什么 submit 提交的任务,即使抛了异常,线程也不会被销毁?

因为异常不会一直往上抛,submit 会把任务包装成 FutureTask,其中含有 try-catch,异常被捕获了调用 setException(),将异常存储了,不会上抛,线程自然不会销毁,只是要注意返回的 Future 中的异常处理而已,不能无视

如果我用 submit 提交任务,但不调用 get,异常是不是就完全丢失了?

是的,因为既没有堆栈打出来,线程也没有挂掉。一切都是如此的美好,只是因为异常被存放在了 Future 中,一定要处理,不然任务失败了也没人知道,

线程池中一个线程抛异常死掉了,对线程池有什么影响?

如果只是一个线程死掉了,影响其实不大,直接创建新的就好,重要的是任务执行一半挂了,所以一定要做好幂等,防止数据不一致,生产环境一定要注意,不能让异常被吞了

Java 线程池中 shutdown 与 shutdownNow 的区别是什么?

两个方法都用于关闭线程池,区别是烈度不同

  • shutdown 会首先拒绝新任务,调用拒绝策略,然后等待队列的任务都被线程处理完,然后再关闭
  • shutdownNow 也先拒绝新任务,然后直接尝试中断线程池的任务,将队列中的任务打包返回,然后关闭

这个尝试中断,也不一定会中断,取决于任务是否响应中断信号

shutdown 之后再提交任务会怎样?

会直接根据拒绝策略做出行动,该报错报错,该返回返回,所以提交任务要做好任务被拒的准备,防止异常打到堆栈

如果我想等线程池彻底关闭再继续执行后面的逻辑,应该怎么做?

提供了 awaitTermination 方法,他会阻塞当前线程,除非线程池进入 TERMINATED 状态或者超时,可以使用这个玩意进行线程池的关闭操作,先用 shutdown 关闭,然后用 awaitTermination 检测,如果超时返回 false,直接进行 shutdownNow,强制关闭

shutdownNow 中断不了任务,有什么办法能强制停止?

理论上没有办法强制让一个不想中断的线程中断,Thread.stop() 早就废弃了,会导致数据不一致,唯一的办法就是编码的时候对中断位进行检查,或者是在阻塞的时候响应 InterruptedException,代码不配合就没办法了,这是规范问题

实现 Runnable 和继承 Thread,哪种方式更好?为什么?

很明显是实现 Runnable 更好一点,首先集成在 Java 属于比较真贵的资源,Java 只允许单继承,也就意味着继承了 Thread 就没办法再继承别的了

其次,实现 Runnable 后,任务和线程就解耦了,可以将这个任务给不同的线程执行,也可以提交到线程池,这意味着更高的灵活性

最后,继承 Thread 之后也是为了实现 run 方法,本质是一样的,没有性能上的差距

Java 线程池核心线程数在运行过程中能修改吗?如何修改?

可以修改,ThreadPoolExecutor 提供了对应的方法修改核心线程数和最大线程数,可以在运行时修改线程池对应的线程数

但是修改核心线程的时候会根据调大还是调小进行区分:

  • 调小:会根据线程的空闲等待时间来逐步销毁线程
  • 调大:如果有新任务提交,就检查当前的线程数是否小于核心线程数,如果小于就创建线程,大于就丢到队列中

实际中用到的地方更多的是对于流量高峰期增加线程用的,配合DCC来实现动态控制线程数的大小,提升高峰期的承载能力

setCorePoolSize 调大后,线程是立即创建还是等有任务才创建?

等待任务然后创建,从这里也可以看出核心线程并不是用于形容线程的,而是形容线程池的线程数量的,如果想马上创建核心线程的话,使用 prestartAllCoreThreads() 可以预热核心线程

运行中修改线程池参数会不会影响正在执行的任务?

不会,无论是修改核心线程数目还是最大线程数目都不会影响,调整小的话也不会立刻杀死线程,也是会等待线程结束工作时候,超过等到时间才被删除

生产环境怎么实现线程池参数的动态调整?

一般是 DCC + 监控中心,DCC 纳入 线程池线程数目,监控告警纳入队列等参数,设置告警规则,监控报警之后去通过 DCC 线上调整线程数量,尽量微调,不然容易抖动

Java 中的 DelayQueue 和 ScheduledThreadPool 有什么区别?

首先,DelayQueue 是队列,ScheduledThreadPool 是线程池,不是一个东西,DelayQueue 有一个延迟出队的功能,得自己写消费逻辑和成产逻辑,ScheduledThreadPool 不仅仅可以延迟执行任务,还有周期执行,

如果让你实现一个分布式的延迟任务系统,会怎么设计?

首先得让任务序列化到外部存储,才能实现分布式,可以使用 Redis 的 ZSet,score 使用时间戳。Redisson 有封装好的轮子,也可以使用现成的中间件,比如 RockMQ,支持延迟任务,kafka 使用时间轮来实现定时任务

DelayQueue 的 take 方法会一直阻塞吗?如果队列一直为空怎么办?

会的,直到有元素进入之前,都会阻塞,poll(timeout) 可以设置等待时间

ScheduledThreadPool 的核心线程数设置成多少合适?

根据情况来判断,如果是轻量级的小任务 2 - 4 足以,剩下的可以根据任务堆放程度来增加线程数

什么是 Java 的 Timer?

Java 早期的延迟执行任务和定时执行任务的方案,核心是 优先队列 + 任务线程

Timer 接受 TimerTask 中的任务进行执行,将这个任务到优先队列中,最先被执行的任务会在顶部,任务线程不断的将其拿出来看看到没到时间,如果到的话就去执行,并且看看是不是周期的,如果是的话就调整下一次的执行时间,不到的话继续等

这个也是有缺点的:

  • timer 是单线程的:这就意味着如果前面有个大任务堵着,后面的任务就只能等待
  • timer 的任务不隔离:如果一个任务报错了,他会直接给 timer 炸没,后面的任务直接失效
  • timer 时间漂移问题:timer 的 schedule 是根据任务执行完毕的时间来进行计算下一次的时间的,如果任务执行的很慢,下一次的时间就会越来越漂。scheduleAtFixedRate 是根据预期来计算的,并不会被这个影响,如果任务执行时间很长的话,就可能连续触发这个任务来执行

可使用 scheduleAtFixedRate 或者更高级的 kafka 来实现定时任务之类的操作

Timer 的 schedule 和 scheduleAtFixedRate 有什么区别?

都是用来计算周期性任务的下一次时间的,区别就是计算方式不同,前者是 任务执行完毕的时间 + 间隔,也就是会发生漂移,后者是严格按照设定时间来执行的,如果任务执行时间过长,会出现连续执行的现象

为什么说 Timer 一个任务异常会影响其他任务?

Tinner 执行任务的是一个线程,源码中没有任何处理异常的操作,有异常直接给 timer 炸没了,其他的任务自然也没有机会执行

生产环境用 ScheduledExecutorService 要注意什么?

线程要合理,CPU 密集型设置成核心数 + 1,IO 密集型要大一点,任务异常记得 catch 处理,异常被吞了是很严重的事故,关闭线程池要使用 shutdown 再 awaitTermination,给正在跑的任务时间

你了解时间轮(Time Wheel)吗?有哪些应用场景?

用于调度任务的算法,他维护了一个环形数组,每个位置称之为一个槽,里面可以放任务,任务被用链表的形式组织起来,还维护着一个指针,这个指针以一定的速度跟钟表一样不断的切换指向的槽位

优势显而易见,不使用堆来做任务的计时让他可以承担很多任务的调度,毕竟只是靠一个指针来调度任务,任务本身放在槽里面是不动的,存取都是 O(1),开销低

存放任务的时候直接根据当前指针的位置计算出这个任务该放到什么地方,然后直接挂到链表就可以了,等到指针转过来就直接执行了

应用场景:

  • Netty 使用时间轮来处理网络连接的超时,心跳检测等,有时候拖垮系统的不一定是任务的执行,也可能是五十万个任务的检测
  • 本地缓存过期:可以用时间轮来处理缓存过期操作,性能也不错
  • kafka 延迟任务队列执行,kafka 内部使用多层时间轮,支持很长时间的任务排布

不过这个也是有缺点的:由于每个数组的槽位都代表着一个时间,所以时间的粒度不可能很小,如果是毫秒级的话,就需要创建巨量的数组了,这对内存来说是毁灭性的打击,也就意味着,时间轮天生不适合做高时间精度的任务排列

对于有的任务时间过长,超过了时间轮的最大时间,可以使用轮次法和多次时间轮,

  • 轮次法:其实就是多维护了一个轮次参数,表示还能转几轮,每次转到这个槽的时候先检测轮数是否归零,如果不是的话,就 -1,然后去下一个
  • 多层时间轮:和钟表一样,维护了多个时间轮,最快的时间轮一圈之后,外面一层时间轮就动一下,还可以通过降级来控制执行时间

时间轮相比于优先队列来说,能够同时承载的任务多,但是精度不高,合理使用

时间轮的单线程执行会不会成为瓶颈?怎么解决?

会的,一般来说,如果单线程去负责这种多任务的场景,都会成为瓶颈的,所以一般让这个线程做触发的作用,任务不给这个线程执行,而是放到线程池让别的线程执行,

时间轮的槽数量怎么定比较合适?

看任务时间,尽量选择 2 的幂次来,这样计算时间取模的时候比较快,尽量让槽数覆盖任务时间,再者就是精度,对精度要求越高,槽数就越多就行

你使用过哪些 Java 并发工具类?

并发工具类主要在 JUC 下面,

  • 线程安全的集合类:ConcurrentHashMapCopyOnWriteArrayList、ConcurrentLinkedQueue

    • ConcurrentHashMap:常用的线程安全的并发哈希,1.7 之前采用 16 个分段式锁进行提高并发效率,之后使用 CAS + synchronized 单独锁住每一个哈希槽,进一步降低锁的粒度,提升并发性能
    • CopyOnWriteArrayList:写时复制数组,写的时候 copy 出一个数组用于写,读操作依然读旧的,写操作完成之后再更新引用,通过写时复制实现读写分离,增加并发,但是写操作需要复制出整个数组,开销比较大, 适合读多写少
  • 原子操作类:AtomicIntegerAtomicLongAtomicReferenceLongAdder

    • AtomicInteger、AtomicLong、AtomicReference:底层都是 CAS,大家都去修改一个变量,如果失败就自旋
    • LongAdder:底层维护了一个 BaseCount 和一个 Cell 数组,当线程去操作 BaseCount 失败时,就去 Cell 数组去操作,计算的时候求出来总体的变化就行,效率高,但是不严格准确
  • 同步协调工具:CountDownLatchCyclicBarrierSemaphore、Phaser

    • CountDownLatch:用于一组线程去等待另一组线程工作,底层是 CAS,state 表示还差多少线程完成工作,不可复用
    • CyclicBarrier:用于一组线程互相等待的场景,底层是 ReentrantLock,内部主要维护了一个屏障,各个线程到达屏障相互等待,直到都到了或者屏障失效,可以复用,适合周期性任务
    • Semaphore:用于限流的场景,底层依然是 CAS,state 含义为临界资源的数量,资源为 0,新线程就去等待,有人归还资源就去唤醒等待队列的线程。支持公平/非公平锁
  • 阻塞队列:ArrayBlockingQueueLinkedBlockingQueue、PriorityBlockingQueue

    • ArrayBlockingQueue:数组有界阻塞队列,缺点是生产消费使用一把锁,吞吐不高
    • LinkedBlockingQueue:链表可选有界队列,创建的时候不指定大小就是无界的,维护两把锁:生产锁,消费锁,所以吞吐量更高
  • 锁相关:StampedLock、ReentrantReadWriteLock、StampedLock

    • StampedLock:引入乐观锁来增加读并发

什么是 Java 的 StampedLock?

stampedLock 是 Java 8 加入的新锁,适用于读多写少的场景,传统的锁读取的时候也需要加锁,stampedLock 的读锁是乐观锁,一开始读取的时候不需要获取锁,只需要获取当前锁的版本号,等待读取完毕之后再去获取一次版本号,如果两次版本号一致的话就说明这次没有线程写入,数据是对的,如果版本号不同就降级成悲观读锁

和其他锁一样,读锁可以被持有多份,写锁只能有一个,读锁是支持进化成写锁的,但是不能有别的读锁被持有

stampedLock 底层使用 64 位的 Long 来管理锁的,具体来说,使用 Long 的低 7 位来标记读锁的数量,第八位用来表示写锁的状态位,剩余的 56 位用来表示版本号,每次写锁释放,版本号+1,用于乐观锁的检测

这玩意不支持重入,原因很简单,既然 stampedLock 使用一个 64 位的数据管理锁,就没必要再去增加开销去维护重入次数了,因为不可重入在大多数场景下是可以接受的

StampedLock 的乐观读的 validate 方法底层是怎么判断有没有写操作的?

可以通过判断 StampedLock 的维护的 long 的版本号来判断是否有写操作插入,这个版本号会在写锁释放的时候 +1,如果在读操作前后这个版本号改变了,就不能保证读到的数据准确了

StampedLock 的锁升级是怎么实现的?

调用 tryConvertToWriteLock。他会首先检测是否持有写锁,如果有的话返回失败,接下来,他会检测是否还存在其他的读锁,如果有的话,也失败,然后才能成功,这样可以省去一个窗口期

为什么 StampedLock 不支持可重入?

stampedLock 使用 Long 来维护锁的状态,如果还需要支持重入的话,就得单独为每个线程去维护重入次数之类的数据,代码复杂度和逻辑开销就会高不少,并且 Doug Lea 认为不可重入大多数情况下是可以接受的,所以就没做

高并发写的场景下 StampedLock 表现怎么样?

不怎么样,因为写锁的存在,乐观读锁会一直检测失败,然后重新升级为悲观锁,还不如一开始就升级为读锁,白白浪费 CPU。而且写锁竞争场景,stampedLock 也没做什么优化

什么是 Java 的 CompletableFuture?

CompletableFuture 是 java 8 引入的一个异步编程工具,属于是 Future 的上位替代,解决了 Future 的问题,比如 get 方法阻塞,没办法串联任务等

completableFuture 支持链式调用,可以让任务执行完毕之后自动执行后续操作,也可以自动处理异常和最后的结果,不用手动等待结果去阻塞

默认情况下,completableFuture 使用 ForkJoinPool.commonPool 线程池执行异步任务,如果有大任务占用线程池的线程,就会导致别的任务的阻塞,所以一般情况下,都建议传入一个线程池来支持异步任务的执行

注意,如果不加 exceptionally 或 handle 异常会直接被吞掉,导致调试一脸懵逼

thenApply 和 thenApplyAsync 有什么区别?

调用线程的区别,thenApply 调用的是之前线程池中的线程,如果线程已经完成了工作回去了,就使用调用 thenApply 的线程执行后续的操作。thenApplyAsync 会把后续的操作提交给线程池执行,如果回调用耗时操作,还是传回线程池更好

如何使用 Java 的 CompletableFuture 实现异步编排?

主要是启动异步任务,串联任务,任务组合,异常处理

启动任务:supplyAsync()(有返回值)或 runAsync() 来启动任务

串联任务:

  • thenApply():获取上一步的结果,并且返回一个新的结果
  • thenAccept():获取上一步的结果,处理掉
  • thenRun():不关心上一步结果,直接执行下一个逻辑
  • thenCompose():如果下一个操作是一步操作,可以将两个操作扁平化一层

任务组合:

  • thenCombine():两个任务都完成后处理结果
  • allOf():等待所有任务完成(无返回值)
  • anyOf():任一任务完成就返回

异常处理:

  • exceptionally()
  • handle()

异常处理挺重要,不注意的话异常直接被吞掉了

注意使用过程中,CompletableFuture 默认使用一个线程池,整个 JVM 都使用一个,所以记得传入自定义的线程池用于跑任务

JDK 9 CompletableFuture 提供了超时机制,写代码更加方便了

CompletableFuture 的 join 和 get 有什么区别?

都是阻塞拿到结果,区别就是抛出的异常不同,join 抛出的是受检异常,必须写上 try-catch 或者 throw 去处理,而 get 不用,代码不是很臃肿

生产环境用 CompletableFuture 有什么注意事项?

  • 使用传入线程池来执行任务,不然大家都用一个,堵死了都
  • 一定要处理异常,方便排查
  • 等结果的时候设置最大时间,防止一直阻塞
  • 对传入的线程池的队列做监测,任务大量堆积要报警,及时处理

什么是 Java 的 ForkJoinPool?

Java 7 引入了一种特殊的线程池,每个线程维护一个双向队列,他们会分解自己的任务,大任务变成小任务,小任务放到队列中,每个线程完成任务之后会别的线程那里帮忙,小任务的结果再组合成大任务

具体来说,ForkJoinPool 的工作过程分为三部分:

  • Fork:将大任务拆成两个小任务,一个交给本线程执行,另一个放到当前线程的工作队列,等自己做或者别人做
  • Compute:任务跑跑跑~
  • Join:任务结果合并

任务类型为:ForkJoinTask,这也意味着他只能适配一些分治类型的任务,比如计算密集型任务,涉及大量求和,矩阵,归并排序之类的,并行流的底层就是这个,CompletableFuture 底层使用 JVM 维护的一个公共的 ForkJoinPool 实例(虽然大家都不用就是了)

如果一个任务涉及大量的阻塞,分治就没了意义,大家都阻塞了,如果一个任务可以分的很细,也没有太大的意义,分解任务的开销太大,如果任务之间有依赖关系,也不是很适合做分解,不然会产生更多的阻塞

说说 ForkJoinTask 的子类?

有两个子类:RecursiveTask 和 RecursiveAction

RecursiveTask:有返回值,适合做计算,累加这种需要合并结果的任务

RecursiveAction:没有返回值,适合做扫描之类的操作

为什么 ForkJoinPool 默认线程数是 CPU 核心数而不是核心数的两倍?

因为 ForkJoinPool 的 ForkJoinTask 本身是为了处理 CPU 密集型的任务,这种任务基本不用阻塞,设置核心数的两倍没有意义,反而有上下文开销,核心数两倍的线程是为了防止 IO 密集型任务在等待,不让 CPU 休息才设置成两倍的

fork 和 join 的顺序有讲究吗?

当然是先 fork 然后在 join,不然就等着死锁把,毕竟你不能等一个没提交的任务自己完成,对吧

还有个注意的点,分解出去的任务两个不一定都要提交,要留一个自己做,不然两个任务都扔到队列中,自己傻等肯定不行,一般是自己做一个,扔一个

并行流用的是 commonPool,怎么让它用自定义线程池?

可以将并行流包裹在自定义的 ForkJoinPool 中执行,这样操作会直接提交到自定义的池子,或者可以直接更改 commonPool 的并行度,但是会影响别的使用 commonPool 的对象,还是使用第一种方案好

ForkJoinPool 的 ManagedBlocker 是干什么的?

优化操作,如果当前任务阻塞了,可以通过 ManagedBlocker 通知 ForkJoinPool 去创建一个新的线程去顶上,因为 ForkJoinPool 主要面向 CPU 密集型任务,IO 阻塞了影响性能,这个线程会在阻塞结束之后再回收,但是阻塞还是会影响性能

如何在 Java 中控制多个线程的执行顺序?

  • 使用 .join() 方法,让主线程去等待任务的结束,然后再执行下面的线程
  • 使用 CountDownLatch 等并发工具类可以适配各种场景
  • CompletableFuture 的链式调用也可以实现线程之间的顺序执行,代码简洁,更加灵活
  • 使用单线程的线程池,任务执行顺序完全看提交顺序

如果用 join() 控制 3 个线程顺序执行,其中一个线程抛异常了会怎样?

join 的底层就是去拿到对象锁,然后去等待,并不关心具体执行,如果发生异常爆炸了,就不等了,直接执行,所以异常的情况需要自己处理

CountDownLatch 和 CyclicBarrier 都能让线程等待,它俩核心区别是啥?

countDownLatch 适配的场景是两部分线程,一部分等待另一部分准备完成,再去工作,CyclicBarrier 适合一堆线程互相等待的场景

还有个区别就是 CountDownLatch 不能复用,只能使用一次,CyclicBarrier 可以重复使用,适用于多轮多线程场景

CompletableFuture 链式调用中,前面的任务抛异常了,后面的 thenRun 还会执行吗?

不会,异常会传播,CompletableFuture 提供了专门处理异常的方法, 使用这些方法可以处理异常,否则异常就会一直传递下去

为什么说不能在 Thread 对象上随便调 notify?

因为 Thread 会被其他的地方 wait,这个可能是隐形的,因为有些方法内部就是 wait 这个Thread 对象,比如 Join 方法就是依靠这个实现的,如果调用 notify 可能唤醒正在等待的线程,程序的顺序就乱掉了,所以最好别碰

你使用过 Java 中的哪些阻塞队列?

阻塞队列的本质就是存放都会被阻塞,存元素时会检测是否满了,如果满了就直接进行阻塞,直到空出位置,拿元素的时候如果没有元素,就进入阻塞,直到有元素加入队列,这种模式天然适合消费者 - 生产者模型

  • ArrayBlockingQueue:底层使用数组实现,创建时必须指定容量,后续不可更改,使用一个 ReentrantLock 控制并发,并发效率比较低
  • LinkedBlockingQueue:底层是链表,创建的时候如果不指定大小的话就是无界队列,不是很合适,使用两个锁分别维护头尾做并发保护,因为使用两个锁,所以生产和消费并不会相互阻塞,并发效率比较高,但是每个元素进入都得 new 一个 Node,GC 方面因为大量短命对象出现,表现并不好
  • PriorityBlockingQueue:无界队列,底层是堆,所以元素要实现 Comparable 或者传入 Comparator,用来支持优先出队
  • DelayQueue:延迟队列,元素必须实现 Delayed 接口,内部需要实现获取剩余时间的方法,如果到时间了就可以取出来,定时任务可以用
  • SynchronousQueue:没有容量的队列,也就是说 put 之后必须等待另外一个线程 take,有线程池也实现了这个,目的是任务直接提交线程,不进入队列

除了会进行阻塞的 put 和 take 方法,还提供了快速失败的 offer 和 poll 方法,如果队列满了/空了,直接返回 false

add(),remove 更狠,如果队列空了/满了直接去报错

为什么说无界队列可能导致 OOM,有界队列就不会吗?

无界队列之所以会 OOM,是因为生产的速度大于消费的速度,队列没有上限,生产者有了数据就直接 put 到队列中,队列无限膨胀,就会 OOM,有界队列会指定大小,生产者不会无限的投放数据,如果有界队列用于线程池,就要考虑任务提交失败的情况

LinkedBlockingQueue 两把锁具体是怎么实现的,不会出问题吗?

LinkedBlockingQueue 在头结点和尾节点各自设立了一把锁,takeLock 和 putLock,take 操作只会加锁 TakeLock,put 操作只会加锁 PutLock,但是这对于一个队列来说仍然存在并发问题,因为存取元素的时候不只需要更改队列,还需要更改链表长度之类的元数据,LinkedBlockingQueue 使用 AtomicInteger 记录长度,避免并发问题

DelayQueue 底层是怎么实现延迟的,到期了怎么知道?

DelayQueue 的元素必须实现 Delayed 接口,这个接口定义了一个方法,能够查看剩余的时间,DelayQueue 维护了一个线程,这个线程会拿到顶端的元素,如果这个时候时间没到,就 wait 指定的时间,醒过来再看看,所以任务并不是自己出来的,而是线程拉出来的

SynchronousQueue 没有容量,那多个生产者同时 put 会怎样?

大家都等着,如果没有消费者在等待元素,生产者就会被阻塞,进入等待队列中,如果有消费者来拿数据,就会从等待队列中随便唤醒一个,配对交换数据

你使用过 Java 中的哪些原子类?

原子类是 JUC 中无锁化实现安全并发操作的工具,使用 CAS 操作代替加锁,在竞争不激烈的环境下,表现比加锁好

  • 原子基本类型:AtomicInteger、AtomicLong、AtomicBoolean,很明显,对应 int long bool 三种基本类型
  • 数组类型:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray:对数组里面的类型做原子操作
  • 引用类型:AtomicReference 对对象引用做原子更新;AtomicStampedReference 带版本号,解决 ABA 问题;AtomicMarkableReference 带布尔标记。
  • 字段(属性)更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater,通过反射原子更新对象的某个 volatile 字段,不用把整个对象换成原子类。
  • 累加器:LongAdder、DoubleAdder,专门为高并发累加优化的,比 AtomicLong 性能好很多。

AtomicLong 和 synchronized 加锁相比,性能差距有多大?

低竞争时,AtomicLong 使用 CAS 来避免加解锁的开销,所以性能比 Synchronized 高,但是高竞争环境下,CAS 大概率失败,大量线程一直自旋,CPU 开销很大,性能反而不如 Synchronized

CAS 自旋会不会把 CPU 跑满?有什么办法缓解?

会的,大家一起重试,CPU 就满了,一般的解决办法是使用 LongAdder 的方案,进行分段式分散临界资源,再就是失败太多次之后直接阻塞,再就是失败之后就随机休息一段时间,防止大量线程同时自旋,打爆 CPU

为什么 LongAdder 的 sum() 方法不是原子的?

LongAdder 使用分段式来削减 CAS 失败带来的影响,具体来说是他维护了 BaseCount 来计算值,还维护了 Cell 数据用来在竞争激烈的时候做备选,CAS 失败的线程会去 Cell 选个地方修改,因此,计算和的操作本质是计算 BaseCount 和 Cell 数组的和,不是原子的,甚至不是精确的,如果需要精确的值,就加锁计算,或者使用 AtomicLong

AtomicStampedReference 的版本号用 int 存,会溢出吗?

会的,但是影响,因为版本号只是为了解决 ABA 问题,Int 溢出之后也只是变成负数,还是会保留变化,不会影响 CAS 的正确性

Synchronized 能不能禁止指令重排序?

不能,Synchronized 保证的是同时只能有一个线程进入临界区执行代码,临界区的代码怎么执行的,是否重排,并不关心,这也是为什么单例模式虽然使用 Synchronized 加锁 new 操作但是还是需要双捡操作的原因

Synchronized 只能在临界区前后加上屏障,保证读操作不跑到前面,写操作不跑到后面

什么是 Java 中的指令重排?

指令重排就是 CPU 和编译器为了优化执行的效率,将不依赖的指令调换顺序的操作,这个操作保证单线程运行下,结果不变,但是这个操作在多线程的情况下就不太可靠了

  • 编译器重排:编译器会在生成字节码的时候,将不相干的指令调整顺序,使寄存器分配更加高效
  • CPU 重排:现在的 CPU 都是乱序执行的,并行处理多条指令,没有依赖的两条指令会被并行执行,完成之后再按照原始顺序提交结果
  • 内存重排:CPU 有 store buffer 和 invalidate queue,写操作可能先进入 Store Buffer,然后再刷到缓存,读操作会从 invalidate queue 拿到没来得及刷缓存的数据,这个数据就是旧的,也就是内存的操作顺序和执行顺序不一样

JVM 在遵守 as-if-serial 前提下进行指令的重排,对于多线程场景下,使用 volatile、synchronized 和 happens-before 规则来限制重排序,让开发能够更加方便的控制指令执行的顺序

说说内存屏障?

指令重排遵守 as-if-serial 原则进行性能方面的优化,但是在多线程下面就不好用了,所以我们使用内存屏障来限制指令重排

内存屏障分为四种:

屏障类型作用插入时机
LoadLoad禁止读操作重排到屏障后面的读之前volatile 读之后
LoadStore禁止读操作重排到屏障后面的写之前volatile 读之后
StoreStore禁止写操作重排到屏障后面的写之前volatile 写之前
StoreLoad禁止写操作重排到屏障后面的读之前volatile 写之后

什么是 Java 的 happens-before 规则?

现代不同的 CPU 架构很多,不同的架构对于指令重排的激进程度不同,如果开发者需要根据不同的架构去设计不同的程序以适应不同的排序方式带来的多线程的影响,那 一次编写,到处运行 就成了空话,JVM 提供了 happens-before 规则来适配不同的架构,开发者只需要关注 happens-before,其他的交给 JVM

happens-before:JVM 定义的用于约束多线程环境下操作的可见性和顺序性,A happens-before B 表示 A 的操作一定对 B 可见,A 一定在 B 之前

核心规则:

  • 程序顺序规则:在一个线程之内,前面的操作 happens-before 后面的操作,很好理解,单线程下如果这都不能保证,程序就乱套了
  • 监视器锁规则:一个锁的释放动作 happens-before 后续同一个锁的加锁动作,这个也好理解,否则临界区就乱套了
  • volatile 规则:对 volatile 变量的写操作 happens-before 对这个变量的读操作,保证可见性的必要性
  • 线程启动规则:Thread.start() 调用 happens-before 被启动线程里的所有操作。这意味着一个线程被另一个线程启动之后,他能看到主线程做的任何操作
  • 线程终止规则:线程里面的所有操作 happens-before 其他线程调用该线程的 join() 返回,这意味着如果一个线程返回之后,可以被等待他的线程看到所有结果
  • 线程中断规则:interrupt() 调用 happens-before 被中断线程检测到中断事件,很简单,被中断触发的操作必须发生在中断之后
  • 对象终结规则:对象的构造方法 happens-before finalize() 方法的开始。构造器里设置的字段值,finalize() 里一定能看到。
  • 传递性规则:如果 A happens-before B,B happens-before C,那么 A happens-before C。这条规则让 happens-before 关系可以传递推导。

happens-before 规则里的"happens-before"是不是指时间上的先后?

不是,A早于B并不意味着 A happens-before B 的,相同的,A happens-before B,也并不代表着 A 一定比 B 先执行,最终效果是这样的,但是执行顺序不一定了就

volatile 变量的写读一定比 synchronized 快吗?

是的,因为 volume 只是去添加内存屏障,而不用去加锁,JVM 针对 Synchronized 做了很多优化,轻量锁在无竞争环境下也很快,如果是一个变量的标记,volume 是比较好的,如果是一组变量的话,Synchronized 比较好

两个线程分别读写不同的 volatile 变量,它们之间有 happens-before 关系吗?

没有的,volatile 只是针对一个变量可见性,如果两个变量先后读写一个变量,他们之间就有 happens-before 关系,如果不是一个的话,就没有关系了

如果我只在构造函数里给 final 字段赋值,不加任何同步,其他线程一定能看到正确的值吗?

在没有逸出的时候是这样的,但是逸出就说不准了,比如构造函数启动了另外一个线程,将 final 引用的字段给拉出去了,别的线程就可能看到这个 final 字段,final 保证可见性的底线是构造跑完了,如果在中途被跑出去,就可能被别的线程访问

为什么 x86 架构上有些并发 bug 测不出来?

因为 X86 号称强一致性,只允许 Store-Load 重排,其他的重排就被禁止了,这就意味着很多赖弱内存模型才暴露的 bug 不能被检测出来,直到在一些比较激进的架构上才会被查出来,所以最好直接遵守 happens-before 这样代码就可以到处跑了

volatile 的内存屏障开销大吗?实际项目中应该怎么用?

volatile 插入的是 StoreLoad 屏障,这个是最大的屏障,直接刷新 soreBuffer,在高频写入场景下,频繁添加这个屏障开销不小,如果需要频繁写入一个变量并且要保证线程安全的话,可以用 AtomicLong 或者 LongAdder 使用 CAS 更好

new 对象的时候,JVM 是怎么保证对象在堆上正确初始化的?

首先会在堆上面开辟一个空间,所有的字段都是默认值,然后再执行构造函数,这里会被重排,引用赋值可能发生在构造函数执行完毕,这个时候,别的线程可能拿到没有被初始化的对象,所以可以使用 volatile 禁止重排,synchronized 加锁,或者使用 final 字段来保证初始化的安全性

你说双重检查锁不加 volatile 会有问题,那如果构造方法里没有任何操作,还会有问题吗?

会的,初始化对象的逻辑不止构造函数,比如对象头,字段的默认值都需要指令去实现,而 JVM 不能保证其顺序性,这种指令仍然可能和引用赋值去调换顺序,所以不加就是不安全,和构造没关系

synchronized 块内部的指令重排会影响正确性吗?

一般情况下不会,因为 Synchronized 的作用就是让一个线程去访问临界资源,如果只有一个线程在操作的话,即使发生重排,也不会影响最后的结果,但是如果有别的线程不加锁也能访问临界资源,那就没辙了,会访问到中间态的数据,导致数据不正确,程序的正确性不保证了

JDK 5 之前的双重检查锁为什么不能用?

JDK 5 之前的 volume 只能保证可见性,而不能防止指令重排,双捡 + volume 的作用是防止 new 对象的过程中指令被重排导致别的线程访问到没有初始化完成的对象,如果不能防止指令重排的话,volume 在双捡中就没了意义

当 Java 的 synchronized 升级到重量级锁后,所有线程都释放锁了,此时它还是重量级锁吗?

看情况,在锁的同步块没有任何线程在的时候,mark word 仍然指向 ObjectMonitor,意味着这个锁依然是膨胀的状态

然后 JVM 有 monitor deflation(锁收缩)机制,他会在 ObjectMonitor 的空闲数量达到一定程度的时候进行回收,空闲的 ObjectMonitor 会被回收,对象头就会变成无锁的状态

这个回收发生之后如果有新的线程来加锁,会继续从轻量锁开始,遵循升级规则,一步步升级

偏向锁释放之后对象头会变成什么状态?

释放之后,对象头中还是会保留线程的 ID,线程进来之后直接比对就行,

既然重量级锁可以被 deflation 收缩回无锁,那"锁不能降级"这个说法到底指的是什么?

锁不能降级是指的竞争状态的锁不能降级,避免重复升级带来的开销,如果一个锁没人用了,还不降级,会带来资源的浪费,所以 deflation 的本质是对资源的回收,这个和竞争状态的锁不可回收不矛盾

deflation 在不同 JDK 版本中有什么区别?

JDK 15 之前使用 deflation 在 safepoint 中被执行,但这样会增加 safepoint 的停留时间,15 之后就增加了 Async Monitor Deflation,ServiceThread 负责执行 deflation,当空闲 ObjectMonitor 数量达到一定程度的时候就进行一次清理

如果一个对象频繁地从无锁升级到重量级锁再被 deflation,这样反复折腾性能会不会很差?

会的,锁的升级和清理的开销不小,创建 ObjectMonitor 涉及到大量的互斥量,回收 ObjectMonitor 也需要遍历 monitor 表,恢复对象头之类的操作,性能消耗还是比较可观的

如果出现这种情况,可能是竞争模式不稳定,可以细化锁,拆分热点数据,或者使用一些无锁化的并发数据结构来避免竞争

如何优化 Java 中的锁的使用?

如果一个锁长时间被抢占,那么就会有大量的线程堆积,效率变慢

主要的思路就是减小锁的范围,如果一个线程更够快速的通过一个锁,那么性能就会更高,可以只锁真正涉及线程安全的操作,或者可以使用无锁化编程,CAS 能解决的就不要使用锁,

你了解 Java 中的读写锁吗?

读写锁是一种特殊的锁,它允许不同的线程进行读操作的并行,因为读操作并不会涉及线程安全问题,相应的,写操作和写操作,写操作和读操作是互斥的,这种机制适合读多写少的场景

  • 读锁是共享锁,线程来了只需要标记一下自己加锁了,除非写锁正在被持有
  • 写锁是独占锁,线程等待读锁或者写锁小时,拿到写锁,其他的线程就获取不到写锁和读锁了

ReentrantReadWriteLock 基于 AQS 实现了 ReadWriteLock 接口,使用一个 32 位的整数来进行锁的计算,这个整数的前 16 位记录读锁的持有数目,后 16 位记录写锁的重入次数,这意味着,读锁和写锁最多被获取/重入 65535 次,超过就报错了

ReentrantReadWriteLock 支持锁降级,就是写锁过渡到读锁,这样可以减少一次尝试获取锁的机会,在写立刻读中有很大作用,但是没有增加读锁升级到写锁的机制,因为同时可能存在多个读锁,大家都想升级,而且不释放读锁,就死锁了

ReentrantReadWriteLock 可以指定公平性,也就是是否先 CAS 一把

StampedLock:作为 JDK 8 引入的乐观读写锁,不加锁读,读完检测写锁版本号,如果版本号更改就说明数据更改了,直接升级锁 但是由于追求性能,设计上的简单高效使其不支持公平模式,不支持升级,不支持重入,但是也够用

读写锁的写线程饥饿问题怎么解决?

使用公平锁模式,公平锁让所有线程都去排队,每个线程都有机会得到锁,并不会饿死,但是频繁挂起与唤醒线程会造成极大的上下文压力,所以并不如非公平锁效率高

StampedLock 的乐观读为什么能提升性能?

因为不用加锁,不用 CAS,读取数据前后直接比对一下写锁版本号就可以,如果版本号改变,就说明读到的数据没错,直接返回就行,没有竞争,在低竞争的环境下,确实快

什么场景下不应该用读写锁?

如果写操作和读操作一样频繁,甚至超过了读操作就没必要了,读写锁维护了两套锁,开销更大,使用 ReentrantLock 应对这种情况更好,另外,临界区的业务如果非常简单,使用锁的开销可能比业务的开销还大,这个时候使用 CAS 或者无锁的数据结构就可以

什么是 Java 内存模型(JMM)?

JMM 是 JVM 定义的一套规范,他规定了多线程中,变量应该如何在内存中使用和传递,规定了线程从内存中读取数据和向内存中写入数据的方式,用于屏蔽掉不同操作系统的差异,让 Java 能够在各大平台上运行

核心目的就是用于维护多线程下 Java 运行的可见性,有序性和原子性

JMM 模型:

  • JMM 维护两种区域:主内存,每个线程的内存。
  • 主内存放共享变量,每个线程的内存存放变量的副本
  • 线程操作变量必须通过自己的副本内存进行
  • 线程之间的信息交换必须通过主内存进行

JMM 的出现使我们只需要使用 Java 提供的并发类 和 happens-before 原则就能写出并发安全的代码,

volatile 能保证可见性,那为什么还需要 synchronized?

volatile 只能保证单个变量的可见性,而当我们需要保证原子性,有序性或者多个变量的可见性的时候,volume 就不顶用了,简单的操作使用 volume 够了,效率还高,但是复杂的操作需要使用 Synchronized 才可以

指令重排序在什么情况下会导致问题?

比如单例模式的懒汉创建对象操作,一个 new 操作分为三部分:分配内存,初始化对象,更新引用,如果 2,3 重排了,就会导致一个空对象传出去,其他线程拿到就会报错,这就是防止指令重排的意义

本地内存是真实存在的吗?

并不是,实际上这玩意对应的是 寄存器,三层缓存那些,JMM 通过定义这些架构,将开发者和 CPU 架构隔离开来,只需要遵守 happens-before 规则就能写出正确的并发代码。

什么是 Java 中的原子性、可见性和有序性?

  • 原子性:用来描述操作的,一个操作或者一组操作要么成功,要么失败,不能做一半就结束,
  • 可见性:CPU 架构都有缓存,有时候指令执行完毕之后,数据还在缓存中,其他的核读取不到最新的数据了就,可见性就是保证每次操作之后都将最新的数据刷新到大缓存中。
  • 有序性:主要指代码的执行顺序和书写顺序一致,防止多线程环境下由于编译器和CPU和内存的指令重拍带来一系列诡异的 BUG

volatile 能保证原子性吗?为什么 volatile int count 的 count++ 不是线程安全的?

不能,volatile 只能保证可见性和禁止重排序,不能保证原子性,++ 操作分为三部分,读取,加一,写回,不能保证原子性当然不能说是线程安全的

为什么双重检查锁定的单例需要加 volatile,不加会出什么问题?

new 对象的过程分为三部分,申请内存,初始化对象,更改引用,在允许重排的情况下,引用被先一步更改,如果这个时候别的线程通过这个引用访问了一个空的对象,就会报错。所以要加上防止指令重排带来的问题

Java 中的 final 关键字是否能保证变量的可见性?

不能,可见性通过强制刷新缓存来实现的,final 显然不具有这种功能,final 保证的是修饰的字段肯定会在构造方法完成之后才被读取(如果构造函数将 final 字段逸出,那就不能保证了)

final 通过内存屏障实现这种功能,首次写 final 的时候添加 StoreStore,读的时候添加 LoadLoad 屏障

刚才说了逸出问题,如果提前将 final 对象的引用给别人了。就直接给别的线程暴露出去了,如果还没完成创建,就可能拿到未初始化的值

volatile 的内存屏障和 final 的有什么不一样?

final 在读的时候会插入LoadLoad 屏障,在写的时候插入 StoreStore,而 volume 在写的时候会插入 StoreStore + StoreLoad 屏障,在读的时候插入 LoadLoad + LoadStore 屏障,开销大得多,所以能使用 final 就不使用 volume

String 是不可变的,内部 char 数组也是 final 的,那如果我通过反射改了这个数组,其他线程能看到吗?

不一定,JVM 只针对正常的 final 写操作进行插入内存屏障的操作,通过反射来进行的写操作并不能插入内存屏障,这个时候其他的线程看没看到就全看 CPU 缓存了,不建议给自己埋雷

构造函数里如果给 final 字段赋值两次,会发生什么?

不能通过编译,final 字段只允许写入一次,要么声明赋值,要么构造赋值

为什么在 Java 中需要使用 ThreadLocal?

ThreadLocal 让每个线程持有一份数据,互相不干扰,相对于 synchronized、Lock,其采用不同的方式实现资源的获取,从原来的竞争关系改为使用自己的数据,没有加解锁,没有等待,没有上下文切换,性能自然上去了

每一个 Thread 对象内部维护一个 Map,类型是 ThreadLocalMap,key 就是 ThreadLocal,值可以是任何东西,也就是说,在一个线程中,Map 中的资源可以在任何时候被访问,一些通用的信息可以存储在里面,方便直接调用,不用来回传来传去

另外,ThreadLocaMap 中,Entity 继承了 WeakReference,key 是弱引用,这意味着在外部对 ThreadLocal 的强引用消失的时候,ThreadLocal 会被回收掉,Value 就成了泄露的数据,虽然 ThreadLocaMap 会在 get、set 的时候顺手清理掉一些 key 为 null 的值,但是毕竟不是长久之计,还是用完之后就 remove 比较好

另外,ThreadLocal 有个问题,就是子线程拿不到父线程的值,因为每个 ThreadLocalMap 都是独立的,可以使用 InheritableThreadLocal 来进行传递,子线程在创建的时候会拷贝一份父线程的值,问题又来了,这玩意不能在线程池中使用,因为他只是创建的时候拷贝一份,线程池的线程都是复用的,TransmittableThreadLocal 解决了这个问题,在每次任务被提交的时候就拿到父线程的值

你刚才提到 ThreadLocal 会有内存泄漏,那为什么 Entry 的 key 要设计成弱引用,直接用强引用不行吗?

使用强引用,只要 ThreadLocalMap 还在,这个 Entry 就不会消失,如果设计成弱引用,key 会被 GC 回收,Value 也会被异步清理,虽然还是会发生泄露,但是好很多了,最好的方法还是及时 remove

线程池场景下用 ThreadLocal 要注意什么?

由于线程池的线程是复用的,ThreadLocalMap 中的值也会流到下一个任务中,下一个任务可能读取到的数据是上一个任务的,就可能错误,因此,在一个任务结束之后,一定要清理掉 ThreadLocalMap 中的所有值,另外 InheritableThreadLocal 在线程池里基本废了,因为值是创建线程时拷贝的,复用的线程不会重新拷贝。使用阿里的 TransmittableThreadLocal 可以避免这一个问题

ThreadLocal 的哈希冲突是怎么解决的?

ThreadLocal 使用的是 ThreadLocalMap,会发生哈希碰撞,因为一个 ThreadLocalMap 并不会存储太多值,其采用的处理策略是线性探测法,递增一个固定值:0x61c88647,这个数字是斐波那契的魔数,能够让哈希分布更加均匀,

Java 中使用 ThreadLocal 的最佳实践是什么?

如果线程是池化的,对 ThreadLocalMap 的清理工作就很重要了,尽量使用 try-catch 来进行清理,防止下一个任务拿到上一个任务的数据,比如 Tomcat 的线程就是复用的

使用 static final 做多次任务的 ThreadLocal 的声明,没必要每一次就 new 一个,这样虽然放弃了弱引用带来的 CG 回收,但是可以强制用 remove 来清理数据

尽量少用,能传递就传递值,因为这玩意本质是隐形的,代码检查 bug 不好找

Spring 的 RequestContextHolder 是怎么保证请求结束后清理的?

Spring 在 FrameworkServlet 的 processRequest 方法里做了统一处理,用 try-finally 包住整个流程,finally 里调用 resetContextHolders 清理 RequestContextHolder 和 LocaleContextHolder。也就是说,无论发生了什么,都保证内存不会泄露

如果 ThreadLocal 存的是大对象,有什么需要注意的?

ThreadLocal 存放大对象一般是要存放很多个这样的对象,针对这种大对象的频繁创建和销毁,开销是很恐怖的,可以使用对象池管理这些大对象,ThreadLocal 只存储对应对象的引用,还要做好监控,ThreadLocalMap 大了就报警

为什么推荐把 ThreadLocal 声明成 static 的?

如果不设置成静态的话,每一次线程来都得创建一个,开销很大,这玩意只做一个 Key 使用,没必要使用这么大的开销,另外,设置成静态的,防止 GC 销毁,就得手动写 remove 了,也算是防御性编程

为什么 Netty 不使用 ThreadLocal 而是自定义了一个 FastThreadLocal ?

Netty 中大量使用 ThreadLocal,因此,ThreadLocal 的性能瓶颈成为了制约 Netty 的瓶颈,主要有两点:ThreadLocalMap 的 reHash 开销和弱引用 key 带来的内存泄漏风险

针对内存冲突,Netty 在创建 FastThreadLocal 的时候给每个 FastThreadLocal 分配了不同的值,直接用这个 index 作为下标,不会发生冲突,要改 ThreadLocalMap 就得动 Thread,但 JDK 的 Thread 改不了,所以 Netty 配套搞了三个类:FastThreadLocal 对标 ThreadLocal,InternalThreadLocalMap 对标 ThreadLocalMap,FastThreadLocalThread 对标 Thread。三者配合使用才能发挥最大性能。

代价就是空间,如果 new 了好几个 FastThreadLocal,数据就得开这么大,但是有的线程又用不了这么多,就会浪费

FastThreadLocal 比 ThreadLocal 快多少?有没有具体的性能数据?

官网说快 3 - 5 倍左右,但是 ThreadLocal 本身就够快了,只是 Netty 用的很多,才有优化的必要

FastThreadLocal 的空间浪费问题在实际使用中严重吗?

Netty 中不严重,因为 Netty 只有EventLoop 线程数量固定且每个线程用到的 FastThreadLocal 基本一致,所以浪费不大。

实际项目中使用 FastThreadLocal 顶天十几个,不会造成很大的浪费的

如果不用 DefaultThreadFactory,怎么保证 FastThreadLocal 不泄漏?

每次用完之后记得 remove 就是好习惯,或者嫌麻烦直接 FastThreadLocal.removeAll() 直接清理这个线程所有的 FastThreadLocal。

Java 中的 wait、notify 和 notifyAll 方法有什么作用?

wait、notify 和 notifyAll 被定义在 Object 中,需要配合 synchronized 用于获取对象头锁使用,

  • wait:用于让当前线程进入等待状态,同时释放锁,直到被唤醒或者超时
  • notify:唤醒等待队列的一个线程,在 HotSpot 实现中是按 FIFO 顺序唤醒。
  • botifyAll:唤醒队列中的所有线程,让他们去竞争锁

调用 wait 之后线程还能响应中断吗?

能,调用后线程会报错并且清除掉标志位,所以一般来说调用之后要么处理报错要么重置标志位

如果不在 synchronized 块里调用 wait 会怎样?

直接报错,因为 wait 会释放锁,如果没锁就不能释放。JVM 会检查当前线程是否持有对象的 Monitor,没有的话就报错

在 Java 中主线程如何知晓创建的子线程是否执行成功?

是否执行成功主要靠拿到子线程的结果,看看是正常的结果还是报错,主要方法:

  • Thread.join(),主线程等待子线程执行完毕,如果发生异常可以通过子线程自己去通过异常处理来更改共享变量通知主线程
  • Callable + Future,使用 Callable 创建任务,Future 拿到任务的结果,如果结果正常就说明执行成功,如果报错就说明任务执行失败
  • 回调机制:主线程传递一个回调函数来让子线程执行,不需要阻塞,适合异步场景
  • CountDownLatch:这玩意天生适合让一个线程等待另一个线程,缺点是只能用一次而且不能知道成功还是失败

CompletableFuture 更是重量级,可以异步处理线程的结果,还能聚合处理多个任务的结果,适合现代化的代码

Future.get() 会一直阻塞吗?如果子线程死循环了怎么办?

会的,所以一般使用它的重载版本,这样超时了就不等了,直接报错,然后可以尝试中断任务,但是如果线程是死循环而且又没有中断检测,那就没辙了

join() 和 CountDownLatch 都能等线程结束,有什么区别?

join 只能等待一个线程,而 CountDownLatch 允许多个线程等待多个线程,而且,这个等待不一定是线程任务执行完毕,也能是线程任务的某个阶段就能触发,更加灵活,而且,join 必须持有对这个线程的引用,对于线程池来说,拿不到 Thread 就没办法等待,而 CountDownLatch 只需要持有对 CountDownLatch 的引用就可以了

CompletableFuture 的 thenApply 和 thenApplyAsync 有什么区别?

主要是针对后续任务的分配问题,thenApply 会将后续任务丢给当前线程执行,thenApplyAsync 会将这个任务重新丢给线程池,然后由线程池选择谁执行,也就是说,如果回调重的话,使用后者可以防止阻塞当前线程

Java 创建线程池有哪些方式?

  • ThreadPoolExecutor 手动创建:直接 new ThreadPoolExecutor,7 个参数全部自己指定。这是阿里巴巴开发规范推荐的方式,因为你清楚地知道线程池的每个配置,出了问题好排查。

  • Executors 工厂类:一行代码搞定,比如 newFixedThreadPool、newCachedThreadPool。看着方便,但生产环境不推荐用,因为默认配置容易踩坑,比如无界队列之类的问题

  • -ForkJoinPool:专门用来跑可以拆分的并行任务,内部用工作窃取算法,适合 CPU 密集型的递归计算场景。

线程池的核心线程能被回收吗?

一般不可以,核心线程作为线程池中的常驻线程,并不会被回收,但是可以通过 allowCoreThreadTimeOut(true),来让核心线程也会在最大空闲时间之后被回收,这样做其实并不好,线程的频繁创建和回收会造成大量的性能开销,还不如挂着呢

工作队列选 LinkedBlockingQueue 还是 ArrayBlockingQueue?

LinkedBlockingQueue 底层是链表,用两把锁维护两端,并发友好,ArrayBlackingQueue 底层是数组,出入用一把锁,但是内存连续,CPU 缓存友好,差距不大,重点是要指定容量,不使用无界队列,小心 OOM,

如果我想给线程池里的线程起个有意义的名字,怎么做?

传入自定义的 ThreadFactory。可以用 Guava 的 ThreadFactoryBuilder,一行代码搞定:new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build()。线程名带业务含义,出问题看线程堆栈一眼就能定位是哪个线程池。

submit 和 execute 有什么区别?

提交方式的区别,execute 只能提交 Runnable,也就是说,并不会有返回值出现,异常直接爆出来,submit 可以提交 Runnable 和 Callable,返回 Future 对象,可以拿到结果或者报错,缺点就是异常不会主动跑出来,而是会被封装到 Future 中,如果报错了,忘了处理,异常就会被吞掉,这在开发中是不会被允许的

Java 线程安全的集合有哪些?

主要分为两大阵营,早期的同步集合和 JUC 提供的并发集合

早期的集合主要是 Vector 和 Hashtable,这两个的同步方式就是给所有的方法加了个锁,锁的力度大,并发效率低,没人用了

JUC 在 Java 5 被引入,针对高并发场景做了优化

  • ConcurrentHashMap 是用得最多的,JDK 8 之后用 CAS + synchronized 实现,粒度细到每个桶,几十个线程同时读写也不怕。像本地缓存、计数器这种场景特别适合。
  • CopyOnWriteArrayList 和 CopyOnWriteArraySet 走的是"写时复制"路线,写的时候复制一份新数组,读的时候完全不加锁。Spring 的事件监听器列表就是这么实现的,因为监听器注册一次之后很少改动,但每次事件触发都要遍历。
  • BlockingQueue 系列专门给生产者-消费者模型设计的,队列空了消费者自动阻塞,满了生产者自动阻塞,线程池的任务队列就是这玩意。常用的有 LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue。
  • ConcurrentSkipListMap 和 ConcurrentSkipListSet 是有序的并发集合,底层用跳表实现,查找是 O(log n),不需要像 TreeMap 那样加锁整棵树。

Contact me: 1943284256@qq.com