深入理解 Java 中的锁与锁优化机制

在 Java 开发中,并发编程是绕不开的重要主题,而其中的核心问题之一便是——如何控制多个线程对共享资源的访问。这就引出了本文的主角:synchronized 关键字以及 Java 中的锁机制


一、从计算机体系结构看并发问题

要真正理解 synchronized,我们不能仅仅停留在 Java 层面,而应从计算机体系结构出发。

1.1 缓存一致性与可见性问题

CPU 的执行速度远快于内存访问速度。为缩小二者差距,现代处理器通常采用多级缓存机制(L1、L2 为核心私有,L3 为共享)。在多线程环境中,这会导致一个典型问题:

  • 某线程在某 CPU 核心上修改了缓存数据;
  • 数据尚未同步回主存,另一个线程从主存中读取了旧数据;
  • 结果:一个线程对共享变量的修改,其他线程无法立刻看到。

这就是可见性问题(Visibility)

1.2 指令重排序与有序性问题

出于性能考虑,编译器或 CPU 可能会对指令进行优化,即重排序,造成执行顺序与源码顺序不一致,导致并发程序出现异常行为。这便是有序性问题(Ordering)

1.3 原子性问题

多个线程同时对共享变量进行读写操作时,如果操作不是原子性的,便可能产生竞态条件,引发程序逻辑错误。


二、synchronized 是什么?解决了什么问题?

synchronized 是 Java 提供的关键字,用于对代码加锁,以实现线程间的互斥与同步。

2.1 本质:monitorenter 与 monitorexit

synchronized 在字节码层面会被编译为:

  • monitorenter:加锁,尝试获取对象的监视器锁;
  • monitorexit:释放锁。

加锁时,会强制从主存中读取变量(读屏障),以保证读到的都是最新值,解决可见性问题;解锁时,会强制将工作内存的变量刷新到主存(写屏障),确保修改对其他线程立即可见

此外,synchronized 还通过内存屏障机制,禁止指令重排序,从而解决有序性问题

而其本质的互斥机制,自然也解决了原子性问题

2.2 synchronized 的用法

  • 修饰实例方法:锁的是当前对象(this)。
  • 修饰静态方法:锁的是类的 Class 对象(所有实例共享)。
  • 修饰代码块:锁的是指定的对象(括号中的对象)。

三、锁的升级机制

在 JDK1.6 之前,synchronized 一直被批评为“性能杀手”,原因在于它底层依赖操作系统的 mutex(互斥量),一旦涉及线程阻塞/唤醒,便要进行用户态与内核态切换,开销巨大。

为了解决这个问题,JDK1.6 引入了锁优化机制(也称锁升级机制),锁的状态可以在运行时根据竞争情况进行升级,从而避免不必要的线程阻塞,提高性能。

3.1 锁升级的动因

大多数情况下,系统的并发量并不高。例如电商网站凌晨时段的访问量很低,线程间抢锁的情况非常少。这意味着绝大多数时间下锁是“轻松拿到”的。若仍旧让线程经历阻塞/唤醒的操作,会极大浪费资源。

因此,Java 引入了从“无锁”到“偏向锁”再到“轻量级锁”最后到“重量级锁” 的升级流程,尽可能延迟引入高开销的阻塞机制。


四、锁升级的全过程

4.1 无锁(No Lock)

程序刚启动时,所有对象默认处于无锁状态。

4.2 偏向锁(Biased Lock)

当一个线程第一次访问同步块时,JVM 会将对象头的 Mark Word 设为偏向锁模式,并记录该线程的 ID。后续只要是该线程访问,无需任何加锁操作,直接进入同步代码块,几乎无开销。

场景适用:

  • 同步块始终只被一个线程访问。

4.3 轻量级锁(Lightweight Lock)

当第二个线程尝试获取偏向锁时,偏向锁被撤销,升级为轻量级锁。此时采用 CAS + 自旋 的方式尝试获取锁。

自旋的优势:

为什么要有轻量级锁呢?轻量级所考虑的是竞争所的线程不多,而且县城持有所的时间也不长的一个情景。如果刚阻塞(用户态内核态切换),锁就被释放了,就得不偿失了

  • 避免线程阻塞带来的上下文切换:
  • 保持线程在用户态执行,提高效率。

注意:

  • 自旋适合持锁时间短的场景;
  • 自旋时间过长将浪费 CPU。

JVM 采用自适应自旋策略,即:上一次自旋成功的时间越长,下一次允许的自旋时间越长。

4.4 重量级锁(Heavyweight Lock)

当:

  • 自旋超过设定次数;
  • 或有更多线程参与锁竞争;

JVM 将轻量级锁升级为重量级锁,此时线程将被阻塞,需要操作系统层面进行唤醒,代价显著增加。

当升级到重量级锁的时候,对象头的 Mark word 的指针就会指向锁监视器 monitor。


五、锁监视器(Monitor)详解

synchronized 的底层机制依赖锁监视器对象(Monitor),每个对象天生带有一个 Monitor,用于管理锁的状态和线程的阻塞唤醒。

5.1 Monitor 的核心结构

  • owner:记录当前持有锁的线程。
  • recursion:重入计数器(支持可重入锁)。
  • EntryList(锁池):抢锁失败后被阻塞的线程队列。
  • WaitSet(等待池):主动调用 wait() 方法,放弃锁并等待被唤醒的线程。

5.2 可重入机制

synchronized 是可重入锁,即同一个线程可以多次获得相同的锁。实现依赖 Monitor 中的重入计数器,每次加锁+1,释放锁时-1,直到为0才真正释放。

5.3 流程

  • 当有线程拿到锁,此时监视器的 owner 字段就记录拿到锁的线程

  • 没有拿到锁的线程就被阻塞住,进入 blocking 状态,然后放到锁池中。

  • 当拿到锁的线程调用了 wait 方法,那该线程就释放锁,然后进入 waiting 状态,然后被放到等待池当中。

  • 当某个线程调用了 notify(),唤醒了这个 waiting 的线程,那这个线程就从 waiting 的状态变成 blocking 状态,然后再被放入到锁池中,等待锁释放,重新去抢锁 —— 这就是锁池和等待池的作用

5.3 锁池 vs 等待池:不同目的,不同机制

  • 锁池(EntryList)
    • 存放抢锁失败的线程;
    • 线程状态为 BLOCKED
    • 目标是尽快重新获得锁。
  • 等待池(WaitSet)
    • 存放主动调用 wait() 的线程;
    • 线程状态为 WAITINGTIMED_WAITING
    • 目标是等待资源准备好后被 notify() 唤醒,再去抢锁。

这两个队列对应了两种不同的线程状态和编程语义:互斥与通信


六、总结:synchronized 的前世今生

问题 synchronized 如何解决
可见性 解锁时写屏障 + 加锁时读屏障,保证主存同步
有序性 通过内存屏障阻止指令重排序
原子性 加锁机制保证同一时间只有一个线程执行

参考视频

面试官内心os:坏了今天碰到了个锁王!!!

posted @ 2025-05-19 00:43  Vcats  阅读(49)  评论(0)    收藏  举报