抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

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
2
3
4
5
6
7
8
9
10
11
12
13
static int a = 0;
public static void main(String[] args) {
testTs();
}

private static void testTs() {
new Thread(() -> {
System.out.println("开始");
a = 10;
System.out.println("结束");
}, "t1").start();
System.out.println(a);
}

以上代码并不会输出10,而是输出0;为什么?

因为主线程和线程 t1 是并行执行的,t1 线程需要一点时间之后才能算出 a=10 而主线程一开始就要打印 r 的结果,所以只能打印出 a=0

解决方法就是在这个join()

它可以确保调用这个方法的线程执行完了,再接着继续执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int a = 0;
public static void main(String[] args) throws InterruptedException {
testTs();
}

private static void testTs() throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println("开始");
a = 10;
System.out.println("结束");
});
t1.start();
t1.join(); //这样就没问题了
System.out.println(a);
}

interrupt

可以打断被阻塞的线程,比如sleep,wait,join。但是注意这些线程苏醒后会清除打断标记(打断标记就是,如果线程被打断过,这个标记就是true,否则默认是false)

也可以“打断”正在运行的线程

但是并不是真的打断这个线程,而是让这个标记为true,我们可以利用这个标记来人为的终止线程的运行

守护线程

默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守 护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。

垃圾回收器线程就是一种守护线程

同步

synchronized

注意了:

类对象和实例对象是不同的两个东西,所以以下代码两个线程并不会互斥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}

当 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,后面用于解锁时恢复。
  • 低几位也用来存标志位,和对象头的一致。

加锁流程:

  1. 检查偏向锁
    • 如果开启了偏向锁并且对象处于“偏向”状态,且偏向线程就是 T1,那直接执行“偏向”路径,跳过轻量级锁。
    • 如果偏向锁不可用(关闭或竞争),则进入轻量级锁逻辑。
  2. 准备 Lock Record
    • 在 T1 的栈帧里给 O 分配一个 Lock Record LR,并把 O 原来头部(无锁状态下的 Mark Word)复制到 LR。
  3. CAS 更新对象头
    • T1 尝试用 CAS 原子操作,将 O.MarkWord 从原始值替换为“指向 LR 的指针 + “轻量级锁”标志位”。
    • 成功:
      • 表示 T1 获得了轻量级锁。O.MarkWord 指向它栈上的 LR,表明锁被持有。
      • 线程进入临界区。
  4. 解锁(monitorexit)
    • T1 退出同步块时,从 LR 中取回原始 Mark Word,用 CAS 或直接写回到 O.MarkWord,恢复成无锁状态。
    • 同时释放 LR 空间(随着栈帧退栈自动回收)。

锁膨胀:

如果此时另一个线程 T2 也要加同一把锁:

  1. 检测 CAS 失败
    • T2 读到 O.MarkWord 发现已经被指向 T1 的 LR(轻量级锁标志),就知道有冲突。
  2. 自旋尝试
    • T2 不会马上阻塞,而是会在用户态 CPU 上自旋若干次,反复重读 O.MarkWord,判断 T1 是否已经解锁。
    • 如果自旋期间 T1 退出并恢复 O.MarkWord 到原始值,T2 就能在自旋中继续尝试 CAS,拿到锁。
  3. 锁膨胀(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 在这种场景下的性能。

原理与标记

  1. 对象头 Mark Word
    • 在 64 位 JVM(开启 CompressedOops)下,Mark Word 低 3 位用来记录锁状态:
      • 001:偏向锁
      • 000:无锁
      • 010:轻量级锁
      • 100:重量级锁
    • 偏向锁状态下,Mark Word 中还会存储 持有偏向锁的线程 ID,并记录一个 Epoch(锁批次)。
  2. 加锁流程
    • 当线程 T1 第一次在 monitorenter 时:
      1. 如果对象处于无锁状态,T1 会 CAS 将对象头改为偏向锁状态,标记为 “偏向 T1”。
      2. 后续 T1 再次进入该 synchronized 块,不会做任何同步原语操作,直接进入,省去 CAS 自旋。
  3. 撤销偏向
    • 如果 另一线程 T2 尝试获取同一把偏向锁:
      1. JVM 会发现当前偏向的是 T1,尝试撤销偏向:
        • 如果还没发生轻量级锁膨胀,首先会把对象头重置成无锁或升级为轻量级锁,可能伴随一次 CAS。
      2. 删除偏向信息后,再走轻量级锁或重量级锁逻辑,处理真正的竞争。

锁升级全流程

无锁状态(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
2
3
4
5
6
7
8
9
10
11
ynchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
修改条件
lock.notifyAll();
}

为什么不用notify?因为notify是随机唤醒,不能指定唤醒哪一个线程。这就是虚假唤醒问题,可以用notifyall解决,因为notifyall是全部唤醒

但使用 if + wait 判断仅有一次机会,一旦条件不成立,就没有重新 判断的机会了 解决方法,用 while + wait,当条件不成立,再次 wait

就会退出当前代码块

保护性暂停

park/unpark

1
2
3
4
/ 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

与Object 的 wait & notify 相比

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

第三条就是意思就是可以先恢复线程运行,等线程暂停的时候,就相当于已经提前被恢复过了

评论