AQS
AQS 是什么?
AQS 的全称为 AbstractQueuedSynchronizer
,翻译过来的意思就是抽象队列同步器。
AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock
,Semaphore
,其他的诸如 ReentrantReadWriteLock
,SynchronousQueue
等等皆是基于 AQS 的。
AQS 的原理是什么?
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
背:
AQS 使用 int 成员变量 state
表示同步状态或者共享资源的数量,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并对这个state进行操作,如果资源被占用,一套线程阻塞等待以及被唤醒时锁分配的机制来完成获取资源线程的排队工作。
AQS 资源共享方式
AQS 定义两种资源共享方式:Exclusive
(独占,只有一个线程能执行,如ReentrantLock
)和Share
(共享,多个线程可同时执行,如Semaphore
/CountDownLatch
)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock
。
常见同步工具类
下面介绍几个基于 AQS 的常见同步工具类。
Semaphore(信号量)
Semaphore 有什么用?
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
叫做信号量,可以用来控制同时访问特定资源的线程数量,可以允许多个线程同时访问资源
Semaphore 的原理是什么?
Semaphore
是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
,可以将 permits
的值理解为许可证的数量,只有拿到许可证的线程才能执行。
以无参 acquire
方法为例,调用semaphore.acquire()
,线程尝试获取许可证,如果 state > 0
的话,则表示可以获取成功,如果 state <= 0
的话,则表示许可证数量不足,获取失败。
如果可以获取成功的话(state > 0
),会尝试使用 CAS 操作去修改 state
的值 state=state-1
。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。
CountDownLatch 有什么用?
CountDownLatch
允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行到这个地方,才会继续执行。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用
CountDownLatch 的原理是什么?
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
。
当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。
当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。
直到count
个线程调用了countDown()
使 state 值被减为 0,或者调用await()
的线程被中断,该线程才会从阻塞中被唤醒,await()
方法之后的语句得到执行。
CyclicBarrier 有什么用?
CyclicBarrier
和 CountDownLatch
非常类似,它也可以实现线程间的等待技术,但是它的功能比 CountDownLatch
更加复杂和强大。主要应用场景和 CountDownLatch
类似。
CountDownLatch
的实现是基于 AQS 的,而CycliBarrier
是基于ReentrantLock
(ReentrantLock
也属于 AQS 同步器)和Condition
的。
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
JMM(Java 内存模型)
要想理解透彻 JMM(Java 内存模型),我们先要从 CPU 缓存模型和指令重排序 说起!
CPU缓存模型:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议open in new window)或者其他手段来解决。
指令重排序:为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序,并不一定是按照写的代码的顺序依次执行
常见的指令重排序有下面 2 种情况:
- 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
编译器和处理器的指令重排序的处理方式不一样。
对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性
什么是 JMM?为什么需要 JMM?
JMM是java语言提供的内存模型,Java内存模型是为了解决在并发环境下由于 CPU缓存、编译器和处理器的指令重排序 导致的可见性、有序性问题。JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile
、synchronized
、各种 Lock
)即可开发出并发安全的程序。
JMM 是如何抽象线程和主内存之间的关系?
JAVA内存模型抽象为本地内存和主内存
- 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
Java 内存模型的抽象示意图如下:
Java 内存结构和 JMM 有何区别?
Java 内存结构和内存模型是完全不一样的两个东西:
- JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
- Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
happens-before 原则是什么?
happens-before原则是JMM(Java内存模型)为了解决指令重排导致的有序性问题,来描述前一个操作对后续操作的可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。
原则的定义:
-
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
上面1是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
上面2是JMM对编译器和处理器冲排序的约束。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
happens-before 原则的设计思想其实非常简单:
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
happens-before 常见规则有哪些?谈谈你的理解?
happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。
- 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
- 解锁规则:解锁 happens-before 于加锁;
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
- 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 线程启动规则:Thread 对象的
start()
方法 happens-before 于此线程的每一个动作。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
happens-before 和 JMM 什么关系?
happens-before原则是JMM(Java内存模型)为了解决指令重排导致的有序性问题,来描述前一个操作对后续操作的可见性。
编程并发的三个重要特性
原子性
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
可见性
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
有序性
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
Atomic原子类
原子类说简单点就是具有原子/原子操作特征的类。
分为四类:基本类型、数组类型、引用类型、对象的属性修改类型。
AtomicInteger 线程安全原理简单分析
AtomicInteger
类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset()
方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
ThreadLocal 详解
ThreadLocal数据结构
每个Thread都会有自己的ThreadLocalMap,每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap
里存以ThreadLocal的弱引用为key,object对象为value的键值对。
ThreadLocal
的 key 是弱引用,那么在 ThreadLocal.get()
的时候,发生GC之后,key 是否为null?
ThreadLocalMap的Hash 算法?
int i = key.threadLocalHashCode & (len-1);
ThreadLocalMap通过使用threadLocalHashCode和哈希表对应的数组的长度减一相与来计算key对应的数组下标的位置。每当创建一个ThreadLocal对象,threadLocalHashCode这个值就会增长一个斐波那契数,也叫作黄金分割数,这样做的好处就是使位置分布的很均匀。
ThreadLocalMap中Hash 冲突如何解决?
ThreadLocalMap
中并没有链表结构,所以这里不能使用 HashMap
解决冲突的方式了。
ThreadLocalMap是通过线性探测法解决Hash冲突,一直向后找到 Entry
为 null
的槽位才会停止查找。在向后找的过程中,如果遇到了key 值相等的数据,直接更新即可。如果遇到了 Entry
中的 key
值为 null
的情况,也就是key过期的情况,此时就会执行replaceStaleEntry()
方法,替换过期数据的逻辑,进行探测式数据清理工作。
ThreadLocalMap的扩容机制?
ThreadLocalMap扩容的两个步骤:
1、在 ThreadLocalMap.set() 方法的最后,如果执行完 启发式清理 工作后,未清理到任何数据并且当前散列数组中 Entry 的数量已经达到了列表的扩容阈值threshold 即(len*2/3)
,就开始执行rehash()
逻辑:
2、rehash() 中,先进行一轮 探测式清理 流程,然后判断size >= threshold - threshold / 4
也就是size >= threshold * 3/4
来决定是否扩容。每次扩容为原来数组大小的2倍(ThreadLocalMap初始容量为16,必须是2的次幂)然后遍历老的数组,重新计算hash
位置,然后放到新的数组中。
ThreadLocalMap中过期 key 的清理机制?探测式清理和启发式清理流程?
ThreadLocalMap的两种过期key数据清理方式:探测式清理和启发式清理
探测式清理,也就是expungeStaleEntry
方法,遍历散列数组,从开始位置向后探测清理过期数据,如果遇到k==null
的过期数据,则清空该槽位数据将过期数据的Entry
设置为null
,如果碰到未过期的数据则将此数据rehash
后重新在table
数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的Entry=null
的桶中,这样使rehash
后的Entry
数据距离正确的桶的位置更近一些。
如上图,set(27)
经过 hash 计算后应该落到index=4
的桶中,由于index=4
桶已经有了数据,所以往后迭代最终数据放入到index=7
的桶中,放入后一段时间后index=5
中的Entry
数据key
变为了null
ThreadLocalMap.set()方法实现原理?
set()方法,首先通过ThreadLocalMap的hash
算法计算出对应的槽位:
-
情况一:槽位对应的
Entry
数据为空,直接添加Entry对象后进行一轮启发式清理, -
情况二:槽位对应的
Entry
数据不为空,key
值一致,执行更新操作 -
情况三:槽位对应的
Entry
数据不为空,往后遍历散列表,如果遇到Entry
为null
的执行添加,如果key
值相等的执行更新 -
情况四:槽位对应的Entry数据不为空,往后遍历过程中在遇到Entry为null之前碰到key
为null情况,标记此 key=nulll的节点为探测式清理过期数据的开始位置,初始化两个变量
A、B【源码中,A变量=slotToExpunge、B变量=staleSlot】
- A:往前遍历数组,遇到
key=null
的节点就更新A的下标(索引),直到遇到Entry=null
的槽位才停止迭代 - B:往后遍历数组,
k = key
说明是替换操作,可以使用- 碰到一个过期的桶,执行替换逻辑,占用过期桶。
- 碰到桶中
Entry=null
的情况,直接使用
- A:往前遍历数组,遇到
ThreadLocalMap.get()方法实现原理?
第一种情况: 通过查找key值计算出散列表中位置,然后该位置中的Entry.key和查找的key一致,则直接返回
第二种情况: 位置中的Entry.key和要查找的key不一致:从当前节点往后继续迭代查找,遇见key相同的值就返回Entry对象,遇到key过期的值就进行一次探测是清理流程,直到遇见key相同的返回。
第一种情况: 通过查找key
值计算出散列表中slot
位置,然后该slot
位置中的Entry.key
和查找的key
一致,则直接返回:
第二种情况: slot
位置中的Entry.key
和要查找的key
不一致:
我们以get(ThreadLocal1)
为例,通过hash
计算后,正确的slot
位置应该是 4,而index=4
的槽位已经有了数据,且key
值不等于ThreadLocal1
,所以需要继续往后迭代查找。
迭代到index=5
的数据时,此时Entry.key=null
,触发一次探测式数据回收操作,执行expungeStaleEntry()
方法,执行完后,index 5,8
的数据都会被回收,而index 6,7
的数据都会前移。index 6,7
前移之后,继续从 index=5
往后迭代,于是就在 index=6
找到了key
值相等的Entry
数据,如下图所示:
项目中ThreadLocal使用情况?遇到的坑?
虚拟线程
什么是虚拟线程?
虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
虚拟线程和平台线程有什么关系?
在引入虚拟线程之前,java.lang.Thread
包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:How to Use Java 19 Virtual Threadsopen in new window):
虚拟线程有什么优点和缺点?
优点
- 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
- 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
- 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。
缺点
- 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
- 依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程
四种创建虚拟线程的方法
1、使用 Thread.startVirtualThread()
创建
2、使用 Thread.ofVirtual()
创建
3、使用 ThreadFactory
创建
4、使用 Executors.newVirtualThreadPerTaskExecutor()创建