synchronized的一些思考
1. synchronized的可见性
加锁时:线程会清空工作内存中共享变量的值,从主内存重新加载最新值到工作内存。
解锁时:线程会将工作内存中修改后的共享变量值强制刷新到主内存。
Java 的happens-before原则明确规定:一个线程解锁监视器的操作,happens-before 于后续线程对同一个监视器的加锁操作。
晚上突然对happens-before原则有了新的认识,这是不是就是代码重排序时的if else,满足了这些规则,则不允许重排序
2. 加锁和解锁是怎么做的
JVM为每个锁对象关联一个monitor,monitorenter和monitorexit字节码指令
Monitor { int count; // 锁的重入计数器 Thread owner; // 当前持有锁的线程 Queue EntrySet; // 等待获取锁的线程队列 Queue WaitSet; // 调用wait()后等待被唤醒的线程队列 }
synchronized的加锁 / 解锁本质是对Monitor 的获取与释放,通过控制线程对 Monitor 的竞争,结合工作内存与主内存的同步机制,实现了线程安全的三大特性:原子性(互斥执行)、可见性(内存同步)、可重入性(计数器机制)。
3. 可重入性如何处理父子类
无论子类是否重写父类的synchronized方法,只要通过子类实例调用父类的synchronized方法,锁对象都是子类实例本身。
非静态synchronized方法的锁对象是this,而this永远指向 “当前实例”。
静态方法的锁对象是类本身。
4. EntrySet 和 WaitSet 的唤醒机制
EntrySet(获取锁的等待队列)
- 当持有锁的线程释放锁(计数器归 0)时,JVM 会唤醒EntrySet中阻塞的线程。
- 被唤醒的线程会立即进入锁竞争状态,谁能抢到锁取决于底层操作系统的线程调度机制(通常是抢占式,优先级高的线程可能更先获得调度)。
- 注意:唤醒操作不保证 “公平性”,即先进入队列的线程不一定先获得锁(非公平锁特性)。
WaitSet(调用 wait () 后的等待队列) - 线程调用wait()后会释放锁并进入WaitSet,需等待其他线程调用notify()/notifyAll()唤醒。
- notify()会随机唤醒WaitSet中的一个线程,notifyAll()会唤醒所有线程。
- 被唤醒的线程不会直接获得锁,而是先进入 EntrySet 队列,与其他线程一起重新竞争锁(同样是抢占式)。
5. 唤醒后的锁竞争
被唤醒的线程不会直接获得锁,而是进入就绪状态,参与锁的重新竞争:竞争的核心是修改 Monitor 的owner和count:谁能成功将owner设为自己并将count置 1,谁就获得锁。竞争结果由操作系统的线程调度器决定(抢占式),优先级高的线程可能更先获得调度,但无绝对保证。未抢到锁的线程会重新进入EntrySet,继续阻塞等待下一次唤醒。
synchronized的EntrySet唤醒逻辑是非公平的,体现在:
- 新线程可能插队:即使EntrySet中有等待的线程,新到达的线程仍可能直接抢到锁(无需进入队列)。
- 唤醒顺序不保证:EntrySet中的线程唤醒顺序与它们进入队列的顺序无关,先阻塞的线程可能后被唤醒。
6. 唤醒顺序不保证
EntrySet本质是一个无序的等待集合(而非严格的 FIFO 队列)
- 当线程阻塞时,会被添加到集合的任意位置(而非必须尾部)。
- 当唤醒时,JVM 会从集合中随机或按某种非顺序规则选择线程(如选择第一个找到的线程,或基于线程优先级筛选),而非按入队顺序选择。
为了最大化性能,避免为保证公平性而引入的额外开销 - 减少数据结构维护成本
- 避免 “唤醒 - 阻塞” 的恶性循环
- 与synchronized的非公平性设计一致
7. 非就绪态线程为什么不会被操作系统调度
操作系统通过线程状态标记区分是否可调度,阻塞态线程不在就绪队列中,自然不会被调度。
等待特定事件唤醒:阻塞态的线程会关联一个 “等待事件”(如锁释放),当事件发生时(如 JVM 触发唤醒),操作系统会将其从阻塞态转为就绪态,重新加入就绪队列,此时才可能被调度。
8. 非抢占式的缺点
非抢占式的 “致命缺陷”:无法解决 “线程饥饿” 与 “响应性” 问题
抢占式的 “补救措施”:通过 “锁优化” 减少无效切换
- JVM 层面:减少 “不必要的唤醒” 和 “阻塞”;轻量级锁
- 操作系统层面:通过 “调度策略” 优先调度 “更可能拿到锁的线程”;A持有锁,A优先级++,A释放锁,优先唤醒等待锁的B
抢占式调度模型—— 它不是 “完美无开销” 的,但却是 “在资源开销、响应性、公平性之间平衡的最优解”。
9.EntrySet和操作系统锁的等待队列有什么关系
每个synchronized升级为重量级锁时,维护一个操作系统的内核锁对象。pack()将当前线程,在操作系统层面,设置为阻塞并放到内核锁关联的内核等待队列,此时线程完全脱离 CPU 调度,不再消耗 CPU 资源。
唤醒指定线程:
- JVM 从EntryList中选中要唤醒的 Java 线程;
- 通过线程 ID 映射找到对应的操作系统线程;
- 调用内核系统调用(如futex_wake),基于内核锁对象和线程 ID 精准唤醒目标线程。
Java 线程的 “入队阻塞” 和 “唤醒就绪”,本质是JVM 负责逻辑管理,操作系统负责物理调度的协作过程:
- JVM:通过 ObjectMonitor 的 EntryList 维护等待线程的逻辑队列,决定唤醒哪个线程(策略层面)。
- 操作系统:通过内核等待队列和就绪队列管理线程的实际状态(阻塞 / 就绪),负责 CPU 调度(执行层面)。
- 交互桥梁:通过park/unpark(或类似系统调用)实现线程状态的转换,这些操作由 JVM 的底层代码(C++ 实现)封装,对 Java 开发者透明。
阻塞态是 “自旋失败” 后的最优解,本质是对 “锁持有时间” 的判断:
- 自旋(轻量级锁 / 偏向锁阶段):当锁持有时间很短时,自旋几次(如默认 10 次)就能等到锁,此时不用切换状态,开销极小(自旋是空转,但比上下文切换快);
- 阻塞(重量级锁阶段):自旋失败,说明锁持有时间较长(比如毫秒级)—— 如果继续自旋(或主动放弃时间片 + 就绪态等待),会持续浪费 CPU;而切换到阻塞态,相当于让线程 “暂时休眠”,把 CPU 资源让给其他线程,直到锁释放时再被唤醒,此时整体开销最低。
10.AQS又是怎么做的
两者作为同步机制,核心目标一致(保证互斥、可见性等),因此存在以下相似设计:
1.等待队列机制:
- synchronized依赖ObjectMonitor的EntryList存储等待线程;
- AQS 依赖 CLH 队列存储等待线程。
- 两者均通过队列管理竞争失败的线程,避免 “忙等” 浪费 CPU。
2.线程阻塞 / 唤醒: - 两者最终都需要通过操作系统的线程阻塞 / 唤醒机制(如 Linux 的futex)实现线程状态转换;
- synchronized通过ObjectMonitor的park/unpark(底层 JVM 调用);
- AQS 通过LockSupport.park/unpark(Java 代码直接调用,底层同样依赖系统调用)。
3.可重入性支持: - synchronized通过ObjectMonitor的计数器实现可重入;
- AQS 通过state变量的递增 / 递减(如ReentrantLock)实现可重入。
4.互斥与同步语义: - 两者都能保证临界区代码的原子性执行,通过可见性机制(synchronized的内存语义、AQS 的volatile state)保证共享变量的可见性。
synchronized 升级为重量级锁时,会为锁对象关联一个 内核互斥量(mutex_t)(这是一个真正的 “内核锁对象”),所有等待该锁的线程都会挂到这个 mutex_t 对应的内核等待队列。
而 AQS 的 park/unpark 更轻量:它不直接绑定一个持久化的 mutex_t,而是通过 futex 的 “地址关联” 临时复用队列,只有当有线程等待时,内核才会为该地址创建临时队列,无等待时队列可被回收,资源开销更低。
11.synchronized为什么要依赖内核互斥量
- 在早期操作系统中,最成熟、最可靠的内核同步原语是内核互斥量(mutex_t)、信号量(semaphore) 等 “显式对象级同步工具”
- synchronized的核心语义是 “对象级锁” —— 锁的归属是 “Java 对象”(如new Object()),而非 “内存地址”;内核互斥量与 Java 对象的 “一对一绑定”;“地址关联” 的风险:与对象语义的脱节
- 内核互斥量自带 “成熟同步能力”,减少 JVM 层面的开发成本
而 AQS 选择futex的 “地址关联”,是因为 AQS 的定位是 “并发工具的基础框架”(如支持 ReentrantLock、CountDownLatch 等),需要灵活定制等待队列(如公平 / 非公平锁的队列排序),因此愿意承担 “用户态维护队列” 的复杂度,以换取更轻量的资源开销(避免长期持有mutex_t这样的内核对象)。
12.synchronized存在锁降级吗?内核互斥量怎么办
不支持,仅偏向锁阶段存在优化;内核互斥量是懒加载,跟随锁对象的生命周期
13.内核条件变量
内核条件变量(以下简称 “条件变量”)本质是 操作系统内核维护的一个 “等待队列”,但它不是独立工作的 —— 必须绑定一个内核互斥量(mutex),二者协同实现 “安全的条件等待”。
条件变量的 wait() 方法会原子性地执行 “释放互斥量 + 进入等待队列”
- 线程在调用 cond.wait() 前,已经持有互斥量(保证条件检查的安全性)。
- cond.wait() 执行时,会先释放互斥量,再让线程阻塞 —— 这两个操作是 “原子的”,中间不会被其他线程打断。
- 其他线程只有获取到互斥量后,才能修改条件并调用 notify(),保证唤醒信号不会丢失。
条件变量可能发生 “虚假唤醒”(Spurious Wakeup)—— 即线程在没有被其他线程调用 notify() 的情况下,也可能被操作系统唤醒(比如内核调度的偶然触发);wait() 必须在 while 循环中调用,而不是 if 语句
补充:
锁住对象的时候,要加final,保证对象的不可变
14.volatile怎么保证可见性的
- JMM 层面:定义 volatile 变量的读写必须通过主内存,禁止线程工作内存的 “私有缓存” 导致的旧值使用;
- JVM 层面:通过插入内存屏障(StoreStore/StoreLoad/LoadLoad/LoadStore),禁止指令重排序,强制变量读写与主内存交互;
- 硬件层面:依赖 CPU 的 lock 指令和缓存一致性协议(如 MESI),确保修改后的值立即同步到主内存,并使其他 CPU 的旧缓存失效。
Java 内存模型(JMM)定义的内存屏障(StoreStore、StoreLoad、LoadLoad、LoadStore)是逻辑层面的抽象,其具体实现依赖底层 CPU 架构的指令集。在 Linux 系统中(主流是 x86 架构 CPU),这些逻辑内存屏障会被映射为特定的 CPU 指令(或通过 CPU 自身的内存模型特性隐式保证)。
StoreLoad 屏障
逻辑作用:禁止 “后面的读 / 写操作” 越过 “前面的 volatile 写”(最关键的屏障,确保 volatile 写的结果立即同步到主内存,且后续操作能看到最新值)。
x86 对应指令:mfence 或带 lock 前缀的指令。
这是 x86 上唯一需要显式指令的 Java 内存屏障,原因是:x86 允许 “写操作之后的读操作” 提前(即 Store 之后的 Load 可能被重排序到 Store 之前),这会破坏 volatile 写的可见性。
- mfence 指令:强制所有之前的内存写操作(Store)完成并同步到主内存,之后才能执行后续的内存读 / 写操作(Load/Store),直接满足 StoreLoad 屏障的需求。
- 带 lock 前缀的指令:如 lock addl $0, (%rsp)(对栈指针加 0,无实际计算意义)。lock 前缀会触发 CPU 缓存刷新,强制将当前核心的缓存写回主内存,并通过 MESI 协议使其他核心的缓存失效,间接实现 “写操作完成后再执行后续操作” 的效果。
实际中,JVM 更倾向于用带 lock 前缀的指令(而非 mfence),因为 lock 指令在多数 CPU 上性能更优。

浙公网安备 33010602011771号