synchronized高频考点模拟面试过程

**面试官:**你好,请坐。今天我们重点聊一聊 Java 并发编程中的 synchronized 关键字,这是面试中的高频考点,我们从基础到底层逐步深入,你放松回答就好。

**候选人:**好的,谢谢面试官。

**面试官:**首先,你能说说 synchronized 的核心作用是什么吗?

候选人: synchronized 的核心作用是保证多线程环境下共享资源访问的原子性、可见性和有序性,从而避免线程安全问题。

  • 原子性是指被修饰的代码块或方法能完整执行,不会被其他线程中断;

  • 可见性是线程修改共享资源后会立即刷新到主内存,其他线程获取时会从主内存重新加载最新值;

  • 有序性是通过内存屏障禁止指令重排序,保证代码执行顺序和编写顺序一致。

**面试官:**回答得很全面。那它有几种用法?分别对应的锁对象是什么?

**候选人:**有三种核心用法。

第一种是修饰实例方法,锁对象是当前实例 this,只对当前实例的线程竞争有效;

第二种是修饰静态方法,锁对象是当前类的 Class 对象,对所有实例的线程竞争都有效;

第三种是修饰代码块,锁对象是括号里指定的自定义对象,比如 new Object()、this 或者类的 Class 对象,这种方式灵活性最高,可以精准控制锁粒度。

**面试官:**很好,那你再对比一下,修饰实例方法和静态方法的核心区别是什么?

候选人: 核心区别在于锁对象和作用范围

  • 修饰实例方法的锁是当前实例,只有多线程调用同一个实例的这个方法时才会竞争锁;

  • 修饰静态方法的锁是类的 Class 对象,不管是哪个实例,只要调用这个静态方法,都会竞争同一把锁。比如两个线程分别调用两个不同实例的同步实例方法,不会竞争,但调用同步静态方法就会竞争。

**面试官:**没错,这两者的锁作用范围是关键,很多人容易混淆。接下来我们深入一点,你了解 synchronized 的底层实现原理吗?

**候选人:**synchronized 的底层依赖 JVM 的对象头 Mark Word 和 monitor 监视器锁机制。首先,Java 对象头里的 Mark Word 会存储对象的锁状态,比如无锁、偏向锁、轻量级锁、重量级锁,锁状态切换的本质就是修改 Mark Word 的值。然后是 monitor 机制,monitor 包含 Owner、EntryList 和 WaitSet 三个部分,Owner 是持有锁的线程,没获取到锁的线程会进入 EntryList 阻塞,持有锁的线程调用 wait () 会释放锁进入 WaitSet 等待,被 notify () 唤醒后再重新竞争锁。

**面试官:**很清晰。那你再展开说说,什么是对象头?Mark Word 在锁状态切换中起到了什么作用?

**候选人:**Java 对象在内存中由对象头、实例数据和对齐填充组成,对象头又包含 Mark Word 和 Klass Pointer。Klass Pointer 是指向类的 Class 对象的指针,Mark Word 则是核心,它会根据锁状态存储不同的信息 ------ 无锁状态存哈希码和 GC 分代年龄;偏向锁状态存偏向线程 ID 和时间戳;轻量级锁状态存指向线程栈帧中锁记录的指针;重量级锁状态存指向 monitor 对象的指针。所以 Mark Word 是锁状态的载体,锁升级或降级(实际降级基本不发生)的过程就是修改它的字段值的过程。

**面试官:**对的。那 monitor 的核心结构和工作流程你能再梳理一遍吗?

**候选人:**没问题。monitor 的核心结构有三个:Owner 是独占字段,同一时间只有一个线程能成为 Owner;EntryList 是未获取锁的线程的阻塞队列,线程状态是 BLOCKED;WaitSet 是调用 wait () 方法的线程的等待队列,线程状态是 WAITING。工作流程大概是:线程 1 申请锁时,若 monitor 的 Owner 为 null,就会成为 Owner 执行临界区代码;这时候线程 2 竞争锁失败,就会进入 EntryList 阻塞;如果线程 1 调用了 wait () 方法,会释放 Owner 身份,进入 WaitSet 等待;当其他线程调用 notify (),线程 1 会被唤醒,从 WaitSet 转移到 EntryList 重新竞争锁;最后线程 1 执行完临界区代码,释放 Owner 身份,EntryList 里的线程就可以继续竞争锁了。

**面试官:**底层这块掌握得不错,接下来我们聊聊锁优化。

**面试官:**JDK 1.6 对 synchronized 做了优化,你知道为什么要优化吗?核心优化思路是什么?

**候选人:**JDK 1.6 之前的 synchronized 是重量级锁,完全依赖 monitor 机制,有两个明显的性能瓶颈:一是线程阻塞和唤醒需要在用户态和内核态之间切换,开销很大;二是即使是单线程或低并发场景,也要走 monitor 的竞争流程,存在无意义的资源浪费。

核心优化思路是按并发强度动态适配锁状态,引入了偏向锁和轻量级锁,避免低并发场景直接使用重量级锁,从而减少锁的性能开销。

**面试官:**那偏向锁、轻量级锁、重量级锁的核心区别是什么?分别适用于什么场景?

候选人: 三者的区别主要在实现机制、性能开销和适用场景上

  • 偏向锁是 Mark Word 记录偏向线程 ID,首次获取锁用 CAS 设置线程 ID,后续该线程获取锁直接判断 ID 即可,开销极低,适合单线程重复获取同一把锁的无竞争场景;

  • 轻量级锁是线程在栈帧中创建锁记录,用 CAS 替换 Mark Word 为锁记录指针,竞争时线程自旋等待,开销较低,适合少量线程竞争、锁持有时间短的场景;

  • 重量级锁依赖 monitor 机制,未获取锁的线程会阻塞,开销很高,适合多线程激烈竞争、锁持有时间长的场景。

**面试官:**锁升级的完整流程是什么?为什么锁升级是不可逆的?

候选人: 锁升级的流程是无锁 → 偏向锁 → 轻量级锁 → 重量级锁,这个过程是不可逆的。具体来说,无锁状态下,单线程首次获取锁会升级为偏向锁;当有其他线程竞争偏向锁时,会触发偏向锁撤销,若持有锁的线程还在运行,就升级为轻量级锁;轻量级锁竞争时,线程会自旋等待,一旦自旋次数耗尽或者新增线程竞争,就会升级为重量级锁。不可逆的原因主要有两点:一是优化设计的初衷,锁升级是为了应对越来越高的并发强度,若允许降级,JVM 需要额外判断竞争状态,会增加实现复杂度;二是性能考量,降级的开销大于收益,低并发场景下直接创建新对象就能得到无锁或偏向锁状态,没必要做降级操作。

**面试官:**那自适应自旋解决了什么问题?你能简单说下吗?

**候选人:**自适应自旋是 JDK 1.6 对轻量级锁的优化。它的自旋次数不是固定的,而是根据线程之前的自旋历史成功率动态调整 ------ 如果当前线程之前自旋成功过,就增加自旋次数;如果失败过,就减少甚至停止自旋。它解决了固定自旋次数的痛点:比如锁持有时间长时,固定自旋会浪费 CPU 资源;锁持有时间短时,固定自旋次数不足又会导致频繁升级为重量级锁。自适应自旋能智能适配锁持有时间,平衡 CPU 开销和锁升级成本。

**面试官:**锁优化这块理解得很透彻,接下来我们聊聊实战应用。

**面试官:**你能用 synchronized 实现线程安全的懒加载单例吗?为什么需要双重检查?volatile 为什么不能少?

**候选人:**可以,用双重检查锁定(DCL)实现。

代码大概是这样的:

java 复制代码
public class SafeSingleton{
  private static volatile SafeSingleton instance;
  private SafeSingleton(){}
  public static SafeSingleton getInstance(){
    if(instance ==null){
      synchronized(SafeSingleton.class){
        if(instance ==null){
          instance =new SafeSingleton();
        }
       }
    }
    return instance;
  }
}

需要双重检查的原因:

第一次检查是在加锁前,避免每次调用方法都加锁,减少锁竞争开销;

第二次检查是在加锁后,防止多个线程同时通过第一次检查,进入临界区后重复创建实例。

volatile 不能少的原因是 instance = new SafeSingleton() 不是原子操作,会拆分为分配内存、赋值引用、初始化对象三步。如果不加 volatile,JVM 可能会指令重排序,比如先赋值引用再初始化对象。这时候线程 A 赋值后,线程 B 第一次检查会发现 instance 不为 null,直接返回一个未初始化的实例,就会引发空指针异常。volatile 可以禁止指令重排序,避免这种问题。

**面试官:**回答得很到位。那 synchronized 可能导致死锁,你知道如何预防吗?死锁又该怎么排查?

候选人: 死锁产生需要满足四个条件:互斥、持有并等待、不可剥夺、循环等待,预防死锁就是破坏其中一个或多个条件。

常用的方法有:

第一,按固定顺序申请锁,比如多个线程都先申请锁 A 再申请锁 B,破坏循环等待条件;

第二,避免持有锁时等待资源,比如持有锁期间需要 IO 操作的话,先释放锁,资源就绪后再重新申请,破坏持有并等待条件;

第三,用 ReentrantLock 替换 synchronized,通过 tryLock(timeout) 设置超时,超时未获取锁就放弃,破坏不可剥夺条件。

死锁排查的步骤一般是:首先用 jps -l 命令查看 Java 进程 ID;然后用 jstack 进程 ID 打印线程堆栈;最后查看 jstack 输出,它会自动检测死锁,并输出线程持有的锁和等待的锁的信息。

**面试官:**很好。那你在开发中使用 synchronized 时,遇到过哪些坑?或者说常见的坑有哪些?怎么规避?

**候选人:**常见的坑有几个。

第一,锁对象为 null,会抛出空指针异常,规避方法是用 private final 修饰锁对象,确保初始化赋值;

第二,误用字符串常量或包装类作为锁,因为常量池复用,会导致跨模块的锁竞争,建议用 new Object() 创建自定义锁对象;

第三,锁对象被重新赋值,原锁会失效,用 final 修饰锁对象就能禁止重新赋值;

第四,锁范围过大,导致线程持有锁时间长,性能下降,解决方法是缩小临界区,只对共享资源的修改操作加锁;

第五,混淆实例锁和类锁,导致锁失效,需要明确需求 ------ 控制单个实例用实例锁,控制所有实例用类锁;

第六,过度同步,对无共享资源的代码加锁,这时候只需要删除多余的 synchronized 即可。

**面试官:**实战经验很丰富。最后我们做个选型对比。

**面试官:**你能对比一下 synchronized 和 volatile 的区别吗?实际开发中该怎么选型?

候选人: 两者的区别主要在核心特性、作用范围和性能开销

synchronized能保证原子性、可见性、有序性,可修饰方法和代码块,性能开销会根据锁状态动态变化;

volatile只能保证可见性和有序性,不能保证原子性,只能修饰变量,性能开销极低,只有内存屏障的开销。

**选型建议:**如果是多线程对共享资源的复合操作,比如 count++,就用 synchronized;如果只是单纯的变量赋值或读取,比如用 flag 控制线程启停,就用 volatile。

**面试官:**那 synchronized 和 ReentrantLock 又该怎么选?它们的区别是什么?

候选人: 两者的区别体现在锁管理、可中断性、超时机制、公平锁、条件变量和性能上

synchronized是隐式锁,JVM 自动获取和释放,不支持中断和超时,没有公平锁,只有一个条件变量,低并发场景性能更优;

ReentrantLock是显式锁,需要手动调用 lock() 和 unlock(),且必须在 finally 中释放,支持中断和超时,能实现公平锁,有多个 Condition 可以精准唤醒线程,高并发场景性能更优。

**选型建议:**简单的并发场景用 synchronized,因为代码简洁易维护;复杂场景比如需要超时机制、公平锁、精准唤醒线程时,就用 ReentrantLock。

**面试官:**非常好!今天关于 synchronized 的面试就到这里,你的回答很全面,底层原理和实战应用都掌握得很扎实。我们后续会通知你面试结果。

**候选人:**谢谢面试官!辛苦您了。

相关推荐
JAVA+C语言2 小时前
Java ThreadLocal 的原理
java·开发语言·python
lkbhua莱克瓦242 小时前
进阶-SQL优化
java·数据库·sql·mysql·oracle
行稳方能走远2 小时前
Android java 学习笔记 1
android·java
kaico20182 小时前
多线程与微服务下的事务
java·微服务·架构
zhglhy2 小时前
QLExpress Java动态脚本引擎使用指南
java
小瓦码J码2 小时前
使用AWS SDK实现S3桶策略配置
java
廋到被风吹走2 小时前
【Spring】Spring Cloud 配置中心动态刷新与 @RefreshScope 深度原理
java·spring·spring cloud
牧小七2 小时前
springboot 配置访问上传图片
java·spring boot·后端