面试必备之Synchronized关键字

90 阅读9分钟

 "Synchronized"面试官让我解释锁的原理,我反手就给他表演了个线程安全街舞!

大家好,我是你们的老朋友——经常在多线程中"踩坑"的程序员小Q。今天我们要聊的是Java中那个让人又爱又恨的synchronized关键字。它就像夜店门口的保安,决定哪个线程能进"VIP区"(临界区),哪个线程得在外面排队喝西北风。

 面试现场:当我在面试官面前表演线程安全

面试官:(推了推眼镜) "说说你对synchronized的理解?"

:(突然站起来开始跳舞) "您看,我现在就像个线程,这段solo舞蹈就是临界区代码。如果没锁..."

面试官:(惊恐地看着我)"等等,你在干什么?"

:(边跳边说)"这就是没有synchronized的情况啊!任何线程(人)都可以随时进来跳舞,场面就会混乱!"(突然停下)"现在加上synchronized..."

(我掏出一把玩具锁挂在门上,然后继续跳舞)

"现在其他线程(人)想进来,得等我跳完释放锁才行!这就是线程安全!"

面试官:(擦汗)"很有...创意的解释。那你知道它的实现原理吗?"

 深入原理:JVM的"秘密小本本"

synchronized的实现远比表面看起来复杂,它背后是JVM的**对象监视器(Monitor)**机制。每个Java对象都有三个"小本本":

  1. Entry Set:线程A在获取锁,但锁被线程B持有时,线程A在这里排队
  2. Owner:当前持有锁的线程
  3. Wait Set:调用了wait()的线程在这里休息
public class SynchronizedPrinciple {
    private static final Object lock = new Object();
    
    public void criticalSection() {
        synchronized(lock) {  // 1.尝试通过指针找到Monitor
            // 2.检查Owner是否为当前线程
            // 3.如果是重入,计数器+1
            // 4.如果不是,进入Entry Set排队
            System.out.println("执行关键代码");
            // 5.执行完毕,计数器-1
            // 6.如果计数器为0,唤醒Entry Set中的线程
        }
    }
}

底层原理:

原子性 通过lock unlock 保证同一时刻只有一个线程对变量进行操作,汇编底层 同步代码块是通过monitor enter 和monitor exit 来进行控制

同步方法是通过acc_synchronized 关键字检查识别 可见性 通过对一个变量unlock之前,必须把该变量刷新回主内存中来控制 有序性 一个变量,同一时刻,只允许一个线程进行操作来控制

面试官:"等等,你提到了重入和计数器?"

:"没错!synchronized是可重入锁。就像你去澡堂(临界区),出示VIP卡(锁)进去后,再进桑拿房(嵌套同步块)不用重复买票!"

public class ReentrantDemo {
    public synchronized void method1() {
        System.out.println("method1");
        method2(); // 可以重入
    }
    
    public synchronized void method2() {
        System.out.println("method2");
    }
}

⚖️ 锁升级:从"小打小闹"到"真枪实弹"

JVM并不是一开始就用重量级锁,它有个锁升级的优化过程:

  1. 无锁:新创建的对象
  2. 偏向锁:第一个线程访问时,在对象头记录线程ID(贴个标签:"此物归线程A所有")

在当前线程栈内创建锁记录,并且让锁记录的锁标识指向锁对象, 通过cas设置markword的锁指针指向当前线程地址偏向锁的获取条件:

markword的锁锁标识是01(无锁),则直接通过cas替换正常的业务代码,为了保证并发使用了synchronized关键字,但是大多数场景下都是单线程操作,引入偏向锁就是为了提升性能,降低资源消耗,像轻量级锁,加锁和解锁都需要cas,耗费cpu资源,而偏向锁,只有加锁需要cas,解锁,则不需要获取偏向锁的线程执行完同步代码后,会不会释放偏向锁?答案不会,jvm执行monitorexit指令,当前线程会过去虚拟机栈中获取与当前线程持有锁的锁对象的所有锁记录,并删除最后一条,然后锁对象检查markword是不是偏向锁,若是直接结束,为了下次线程再次获得偏向锁的时候,直接对比,不需要cas如果不存在竞争,偏向锁的性能很好,如存在竞争,则需要执行锁升级的逻辑,耗费性能

  1. 轻量级锁:有竞争时,升级为CAS自旋锁(线程们小声商量:"你先?不,你先")

线程交替执行,不存在并行执行,比如a执行完,b执行,此刻需要轻量级锁,不存在互斥性,通过cas即可完成,性能比重量级锁好;

无锁升级为轻量级锁

a、  在当前线程栈内创建锁记录,并且让锁记录的锁标识指向锁对象;

b、  生成一个无锁状态的markword,jvm叫做displacedmarkword,将其保存到锁记录的Displaced字段内;

c、 使用cas设置锁对象的markword值,指向当前的锁记录的markword 升级为轻量级锁,

如果锁对象本来就是无锁状态,修改成功,否则失败;

轻量级锁锁重入,执行上述的abc操作,因为markword已经不是无锁状态,所以第三步cas失败,此时回去检查失败的原因,是当前线程,则重入,每次重入一次,都会在当前的线程虚拟机栈内插入当前锁对象的锁记录; 轻量级锁释放,获取与当前锁对象相关的所有锁记录,把锁记录中的指向锁对象的引用清空,并把diaplsced字段通过cas替换回锁对象markword,成功则释放,失败,可能已经升级为重量级锁或者处于膨胀中状态;

偏向锁升级到轻量级锁

在当前线程的虚拟机栈中创建锁记录,并让锁记录的锁标识指向锁对象,然后检查锁对象的锁状态,发现是偏向锁,并且不是当前线程,则会向vm提交一个任务,vm会在安全点下执行锁升级的任务 安全点其实就是为了执行full gc 或者撤销偏向锁

  1. 重量级锁:自旋超过一定次数,升级为OS层面的互斥锁(叫来操作系统保安维持秩序)
// 我们可以用JOL工具查看对象头信息
import org.openjdk.jol.info.ClassLayout;

public class LockUpgrade {
    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        
        synchronized(obj) {
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

面试官:"那什么时候用synchronized,什么时候用ReentrantLock呢?"

:"这就好比选择交通工具:

  • synchronized是地铁:简单方便但不够灵活
  • ReentrantLock是专车:功能多(可中断、公平锁、条件变量)但要自己驾驶
特性synchronizedReentrantLock
使用简便性简单复杂
可中断✔️
公平锁✔️
条件变量单一多个
性能优化后接近略高

 实战应用:从单例模式到分布式锁

1. 双重检查锁定单例模式(经典但容易翻车)

public class Singleton {
    private volatile static Singleton instance; // 必须volatile
    
    public static Singleton getInstance() {
        if (instance == null) {                     // 第一次检查
            synchronized (Singleton.class) {       // 加锁
                if (instance == null) {             // 第二次检查
                    instance = new Singleton();     // 问题根源!
                }
            }
        }
        return instance;
    }
}

面试官:"为什么需要volatile?"

:"因为new Singleton()不是原子操作!它分为:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存空间

没有volatile,可能发生指令重排序,导致其他线程拿到未初始化的对象!就像外卖小哥把还没做好的饭送给你了!"

2. 售票系统案例(避免超卖)

public class TicketSales {
    private int tickets = 100;
    private final Object lock = new Object();
    
    public void sellTicket() {
        synchronized(lock) {
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() 
                    + "卖出第" + tickets-- + "张票");
            }
        }
    }
}

面试官:"如果我想让多个方法共用同一把锁呢?"

:"可以用类锁或者同一个对象锁!"

public class MultiMethodLock {
    // 方法1:使用对象锁
    private final Object lock = new Object();
    
    public void methodA() {
        synchronized(lock) { /*...*/ }
    }
    
    public void methodB() {
        synchronized(lock) { /*...*/ }
    }
    
    // 方法2:使用类锁
    public static void staticMethodC() {
        synchronized(MultiMethodLock.class) { /*...*/ }
    }
}

 进阶思考:synchronized的局限性

面试官:"synchronized在分布式系统中有用吗?"

:"(擦汗)就像用自行车锁锁银行金库——完全不够用啊!这时候需要:

  1. Redis分布式锁:SETNX命令
  2. Zookeeper:临时有序节点
  3. 数据库乐观锁:version字段

不过它们的本质思想是一样的:在共享资源前加个'门卫' !"

// 伪代码:Redis分布式锁实现
public boolean tryLock(String key, String value, long expireTime) {
    return redisTemplate.opsForValue()
        .setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
}

public void unlock(String key, String value) {
    // 只有加锁的客户端才能解锁
    if (value.equals(redisTemplate.opsForValue().get(key))) {
        redisTemplate.delete(key);
    }
}

 性能优化小贴士

  1. 减小同步块范围:像在超市排队,只锁收银台,不锁整个超市
// 不好 ❌
synchronized(this) {
    // 大量非同步代码
    // 只有这一行需要同步
    counter++;
}

// 好 ✔️
// 非同步代码
synchronized(this) {
    counter++;
}

2. 避免锁字符串:因为字符串有常量池,不同地方相同的字符串可能是同一个对象!

/ 危险操作!
synchronized(userId.toString()) {
    // 可能意外锁住其他相同userId的操作
}

2. 读写分离:读多写少时用ReadWriteLock

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

public void read() {
    rwLock.readLock().lock();
    try { /* 读取操作 */ } 
    finally { rwLock.readLock().unlock(); }
}

public void write() {
    rwLock.writeLock().lock();
    try { /* 写入操作 */ } 
    finally { rwLock.writeLock().unlock(); }
}

 终极思考题

面试官:"如果让你设计一个新的同步机制,你会考虑哪些方面?"

:"(突然正经)我会考虑:

  1. 粒度:锁的粗细程度
  2. 公平性:先到先得还是允许插队
  3. 重入性:同一个线程能否重复获取
  4. 性能:在低竞争和高竞争下的表现
  5. 死锁检测:能否自动发现和解决死锁

其实Java的StampedLock就是个很好的创新,它用了乐观读的模式!"

 总结

synchronized就像Java并发世界的"瑞士军刀":

  • 简单但强大
  • 自动管理锁的获取和释放
  • 随着JVM版本不断优化

记住:没有最好的锁,只有最合适的锁!选择同步机制时要像选择结婚对象一样慎重——毕竟它们都要陪你度过很多"并发"时刻呢!

(面试官站起来鼓掌)"你被录用了!不过下次面试...别再跳舞了..."


看完这篇文章,你是不是对synchronized有了全新的认识?快去评论区分享你遇到过的"锁事"吧!下期我们讲volatile——保证让你看得见又摸得着的关键字!