1系统学习一下JUC部分
线程
栈与栈帧
Java Virtual Machine Stacks (Java 虚拟机栈)
我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
- 线程的 cpu 时间片用完
- 垃圾回收 (STW需要暂停当前线程执行,执行回收线程)
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Context Switch 频繁发生会影响性能
sleep 与 yield
sleep
睡眠结束后的线程未必会立刻得到执行
yield (就是让出当前CPU的执行权)
调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
join
1 | static int a = 0; |
以上代码并不会输出10,而是输出0;为什么?
因为主线程和线程 t1 是并行执行的,t1 线程需要一点时间之后才能算出 a=10 而主线程一开始就要打印 r 的结果,所以只能打印出 a=0
解决方法就是在这个join()
它可以确保调用这个方法的线程执行完了,再接着继续执行
1 | static int a = 0; |
interrupt
可以打断被阻塞的线程,比如sleep,wait,join。但是注意这些线程苏醒后会清除打断标记(打断标记就是,如果线程被打断过,这个标记就是true,否则默认是false)
也可以“打断”正在运行的线程
但是并不是真的打断这个线程,而是让这个标记为true,我们可以利用这个标记来人为的终止线程的运行
守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守 护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
垃圾回收器线程就是一种守护线程
同步
synchronized
注意了:
类对象和实例对象是不同的两个东西,所以以下代码两个线程并不会互斥
1 | class Number{ |
当 JVM 的类加载器首次加载一个类(或接口)时,就会在方法区(JDK8 以后是元空间 Metaspace)为它创建一个唯一的 java.lang.Class
对象。
这个 Class
对象保存了该类型的所有元数据(类名、字段、方法等),并且在同一个类加载器下全局唯一。
Monitor
同步锁锁的对象,底层依赖于Monitor(操作系统层面的实现)
对于同步锁锁住的对象,底层会让该对象的对象头中的mark word 指向一个Monitor
Monitor包括owner(锁持有者) entrylist(等待锁释放的线程)、waitlist()
锁升级——轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以用轻量级锁来优化
主要依赖于对象头的 Mark Word、栈上 Lock Record,以及 CAS 自旋。
Lock Record(锁记录)
- 线程栈上分配的小结构,拷贝了对象头的原始 Mark Word,后面用于解锁时恢复。
- 低几位也用来存标志位,和对象头的一致。
加锁流程:
- 检查偏向锁
- 如果开启了偏向锁并且对象处于“偏向”状态,且偏向线程就是 T1,那直接执行“偏向”路径,跳过轻量级锁。
- 如果偏向锁不可用(关闭或竞争),则进入轻量级锁逻辑。
- 准备 Lock Record
- 在 T1 的栈帧里给 O 分配一个 Lock Record LR,并把 O 原来头部(无锁状态下的 Mark Word)复制到 LR。
- CAS 更新对象头
- T1 尝试用 CAS 原子操作,将 O.MarkWord 从原始值替换为“指向 LR 的指针 + “轻量级锁”标志位”。
- 成功:
- 表示 T1 获得了轻量级锁。O.MarkWord 指向它栈上的 LR,表明锁被持有。
- 线程进入临界区。
- 解锁(monitorexit)
- T1 退出同步块时,从 LR 中取回原始 Mark Word,用 CAS 或直接写回到 O.MarkWord,恢复成无锁状态。
- 同时释放 LR 空间(随着栈帧退栈自动回收)。
锁膨胀:
如果此时另一个线程 T2 也要加同一把锁:
- 检测 CAS 失败
- T2 读到 O.MarkWord 发现已经被指向 T1 的 LR(轻量级锁标志),就知道有冲突。
- 自旋尝试
- T2 不会马上阻塞,而是会在用户态 CPU 上自旋若干次,反复重读 O.MarkWord,判断 T1 是否已经解锁。
- 如果自旋期间 T1 退出并恢复 O.MarkWord 到原始值,T2 就能在自旋中继续尝试 CAS,拿到锁。
- 锁膨胀(Inflate)
- 自旋若干次后,如果仍然竞争不过,就将轻量级锁“膨胀”为重量级锁(也叫“Monitor”或“Fat Lock”)。
- JVM 会在堆中为 O 分配一个 Monitor 结构(操作系统的互斥量 + 等待队列等),并把 O.MarkWord 更新为指向这个 Monitor。
- 此时T2会加入到entrylist中等待T1结束唤醒它。T1结束后LR发现此时O.MarkWord 指向这个 Monitor,于是设置Monitor的owner为null,然后唤醒T2。
总结:
轻量级锁:通过 CAS 更新对象头、在栈上保存原始 Mark Word、结合自旋,避免系统调用,适用于锁竞争少、持锁时间短的场景。
自旋:减少线程切换开销,但自旋次数过多会浪费 CPU,需要有膨胀机制。
膨胀为重量级锁:在强竞争下转为内核互斥锁,保证多线程阻塞唤醒的正确性。
锁升级——偏向锁
偏向锁是为 “无竞争” 的场景优化的锁——在大多数情况下,锁只会被同一个线程多次获取。偏向锁通过消除无竞争时的 CAS 和内存屏障开销,进一步提升 synchronized
在这种场景下的性能。
原理与标记
- 对象头 Mark Word
- 在 64 位 JVM(开启 CompressedOops)下,Mark Word 低 3 位用来记录锁状态:
001
:偏向锁000
:无锁010
:轻量级锁100
:重量级锁
- 偏向锁状态下,Mark Word 中还会存储 持有偏向锁的线程 ID,并记录一个 Epoch(锁批次)。
- 在 64 位 JVM(开启 CompressedOops)下,Mark Word 低 3 位用来记录锁状态:
- 加锁流程
- 当线程 T1 第一次在
monitorenter
时:- 如果对象处于无锁状态,T1 会 CAS 将对象头改为偏向锁状态,标记为 “偏向 T1”。
- 后续 T1 再次进入该
synchronized
块,不会做任何同步原语操作,直接进入,省去 CAS 自旋。
- 当线程 T1 第一次在
- 撤销偏向
- 如果 另一线程 T2 尝试获取同一把偏向锁:
- JVM 会发现当前偏向的是 T1,尝试撤销偏向:
- 如果还没发生轻量级锁膨胀,首先会把对象头重置成无锁或升级为轻量级锁,可能伴随一次 CAS。
- 删除偏向信息后,再走轻量级锁或重量级锁逻辑,处理真正的竞争。
- JVM 会发现当前偏向的是 T1,尝试撤销偏向:
- 如果 另一线程 T2 尝试获取同一把偏向锁:
锁升级全流程
无锁状态(Unlocked)
- 对象头(Mark Word)里保存的是哈希码或空闲标志,进入第一次
monitorenter
时走偏向锁逻辑。
偏向锁(Biased Lock)
- 如果开启偏向并且无竞争,第一次加锁时 CAS 将线程 ID 写入 Mark Word 并打上 “偏向” 标记;
- 同一线程重入时不再竞争,直接进入;若有其他线程来竞争,就会撤销偏向(Revoke):
- JVM 在撤销时先尝试批量撤销所有该类对象的偏向,若竞争仍在,则将对象头恢复到无锁或升级为轻量级锁。
轻量级锁(Lightweight Lock)
- 偏向撤销后,或在初次锁定时没走偏向路径,线程在自己栈上创建 Lock Record 并通过 CAS 将 Mark Word 指向它;
- 若没有竞争,解锁时直接通过回写原始 Mark Word 恢复无锁;若其他线程同时尝试加锁,CAS 失败后进入自旋阶段。
锁膨胀(Inflate) → 重量级锁(Heavyweight Lock)
- 自旋若干次仍未拿到锁时,JVM 会在堆上为该对象分配一个 Monitor(也就是“重量级锁”),并将 Mark Word 指向 Monitor;
- 所有持锁线程改为在操作系统层面阻塞/唤醒,解锁时通过 Monitor#wakeup 来唤醒等待队列中的线程。
解锁与降级
- 重量级锁在竞争结束后可被解锁并恢复到无锁(但不会自动降级到轻量或偏向,需要后续 GC 或批量撤偏机制触发)。
wait/notifyall的正确使用姿势
1 | ynchronized(lock) { |
为什么不用notify?因为notify是随机唤醒,不能指定唤醒哪一个线程。这就是虚假唤醒问题,可以用notifyall解决,因为notifyall是全部唤醒
但使用 if + wait 判断仅有一次机会,一旦条件不成立,就没有重新 判断的机会了 解决方法,用 while + wait,当条件不成立,再次 wait
就会退出当前代码块
保护性暂停
park/unpark
1 | / 暂停当前线程 |
与Object 的 wait & notify 相比
- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
- park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify
第三条就是意思就是可以先恢复线程运行,等线程暂停的时候,就相当于已经提前被恢复过了