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
第三条就是意思就是可以先恢复线程运行,等线程暂停的时候,就相当于已经提前被恢复过了
定位死锁
第一种方法:控制台命令jps
+ jstack
先用jps
列出线程id,再用jstack + 线程id
查看线程情况,如果有死锁就可以看到了
第二种方法:jconsole工具可以直接看有没有死锁
活锁
和死锁不同,死锁是因为没有得到锁线程而进入阻塞状态,处于活锁的多个线程可能还仍然处于活跃状态,但由于相互之间的干扰,导致彼此之间一直达不到运行结束的条件。
饥饿
ReentrantLock⭐️
相对于 synchronized 它具备如下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
与 synchronized 一样,都支持可重入
基本语法
1 | // 获取锁 |
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
可中断
锁超时
trylock()
可以设置等待时间,如果一定时间内得不到锁就放弃等待
这个可以用来解决哲学家进餐问题
公平锁
公平锁就是进入等待队列的线程,会按照进入的顺序去得到锁,而非公平锁则是通过竞争去获得锁,和进入等待队列的时机没关系
不过一般也不用公平锁,会降低并发度
条件变量
类似于synchronized的wait/notify,但这里不同的是,锁粒度更细,可以根据不同的功能去创建更小的锁,而前者锁粒度太大
使用要点:
- await 前需要获得锁
- await 执行后,会释放锁,进入 conditionObject 等待
- await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
- 竞争 lock 锁成功后,从 await 后继续执行
交替打印⭐️
这里现用的同步锁synchronized实现
这里OOP的思想太强了,把所有的工作全部都封装到类内部,🐂🍺
代码如下:
1 | public class printABCbySynchronized { |
可重入锁的实现依赖于条件变量,可以为三个线程要
最后是park/unpark的实现,这种实现最简便
这里可以自己实现一下,增强理解。看完了我只能说🐂🍺
内存
Java内存模型
JMM(Java Memory Model),它定义了主存、工作内存抽象概念
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
✅ 一、JMM 中的内存结构概念
- 主存(Main Memory)
- 所有线程共享的一块内存区域;
- 存储所有共享变量(instance/static fields);
- 每个线程对变量的最终写入,都会刷新到主存;
- 每个线程的变量读取,也最终来自主存。
- 工作内存(Working Memory)
- 每个线程私有的区域;
- 保存主存中共享变量的副本(拷贝);
- 线程对变量的操作都在自己的工作内存中进行,不会直接操作主存。
🧩 二、对应到实际硬件(物理层面)
JMM 概念 | 对应的实际硬件/运行时 |
---|---|
主存 | 主内存(RAM) |
工作内存 | CPU 寄存器 + CPU 缓存(如 L1/L2) |
同步操作 | 缓存一致性协议 / 内存屏障等 |
所以,JMM 的主存 ≈ RAM,工作内存 ≈ 每个线程运行在 CPU 上时看到的本地副本(缓存 + 寄存器)。
✅ 三、Java 程序中“主存”对应什么?
在 Java 程序中,主存指的是:
所有线程共享的那部分堆内存中的共享变量(对象字段、类的静态字段)
也就是说:
- 被多个线程访问的 对象实例字段(非 final)
- 类的 静态变量
- 数组中的元素
这些都被存放在 堆内存 中,由所有线程共享,对应 JMM 中的“主存”。
✅ 四、Java 程序中“工作内存”对应什么?
Java 中的 工作内存 是指:
每个线程对主存变量的本地副本拷贝,存在于线程的栈、寄存器或 CPU 缓存中
虽然它在 JVM 里并没有直接暴露的代码或变量,但可以理解为:
- 线程在访问共享变量时,不是直接访问主存,而是先复制一份到本地;
- 对本地副本做计算或修改;
- 最终同步(flush)回主存,或者重新从主存读取(load)最新值。
✅ 总结一句话:
JMM 中的“主存”和“工作内存”是抽象模型,在物理上分别对应 主内存(RAM) 和 CPU 缓存/寄存器,JMM 通过这个模型来解释多线程之间的共享变量访问、可见性和一致性问题。 在 Java 程序中,JMM 的“主存”就是堆中共享的变量(对象字段、静态字段);“工作内存”是线程私有的变量副本(不可直接访问),由 JVM 优化在栈、寄存器、缓存中,操作变量时都要在这两个内存之间同步。
可见性
分析以下代码:
1 | static boolean falg = true; |
注意,这里要让主线程先睡眠一会
不加 sleep 时,子线程可能压根没进循环就读到了 false
,就自然退出了,属于时机“刚刚好”。
加了 sleep,让子线程先进入了循环并缓存了 true
,之后主线程的更改 对它不可见,才导致它“停不下来”。
以下就是我debug不加睡眠时,可以发现子线程第一次读到的就是false
volatile原理
🧠 一、volatile 是什么?
volatile
是 Java 提供的一种轻量级同步机制,具备以下两个核心语义:
- 可见性:一个线程对变量的修改,能立即被其他线程看到;
- 有序性(禁止指令重排序):防止某些代码重排序导致程序语义错误。
⚠️ 注意:
volatile
不保证原子性,例如i++
仍然是线程不安全的。
🧩 二、JMM 视角下的 volatile
JMM 中对 volatile
的规定是:
- 对一个
volatile
变量的写操作,会把 该变量的新值立即刷新到主内存; - 对一个
volatile
变量的读操作,会从 主内存中重新读取最新值; - 同时,
volatile
会 插入内存屏障 来禁止指令重排序。
三、实现原理——可见性——通过内存屏障
当线程对 volatile 变量进行写操作时,JVM 会在这个变量写入之后插入一个写屏障指令,这个指令会强制将本地内存中的变量值刷新到主内存中。保证写之前的指令执行完,强制刷新到主存
当线程对 volatile 变量进行读操作时,JVM 会插入一个读屏障指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。 保证后续读写不能重排,强制从主存读取最新值
四、实现原理——有序性——禁止指令重排序
写 volatile 变量时:
- 之前的写操作不能被重排到 volatile 写之后
读 volatile 变量时:
- 后面的读操作不能被重排到 volatile 读之前
举个典型例子:双重检查锁(DCL)中的单例模式必须用 volatile 修饰实例变量,否则对象初始化顺序可能被重排导致线程看到未初始化完成的对象。
✅ 五、总结一句话:
volatile
通过在字节码编译阶段插入 内存屏障(memory barrier),并依赖 CPU 的 缓存一致性协议(如 MESI),实现了对主内存的强制读写,从而保证了可见性;同时屏障也 限制了指令重排序,保证了一定的有序性。