Java synchronized关键字详解:从入门到原理(两课时)

文章目录

适用对象
本课程适合具备Java基础语法知识,初步了解多线程概念但尚未系统学习并发编程的开发者。无论你是准备面试的求职者,还是希望提升并发编程能力的工程师,这门课程都将为你打下坚实基础。
学习目标
通过两个课时的系统学习,你将能够:
- 掌握 synchronized的三种使用方式及其区别
- 理解 synchronized解决线程安全问题的核心原理
- 剖析 synchronized底层的Monitor机制和锁升级过程
- 区分 synchronized与volatile、ReentrantLock的适用场景
- 应对 面试中关于synchronized的高频问题
课程安排
- 第一课时(约40分钟):基础概念、使用方式、特性详解
- 第二课时(约50分钟):底层原理、锁升级机制、对比与实战
第一课时:synchronized基础与使用
1.1 从一个线程安全问题开始
让我们先看一个经典问题:两个线程同时对共享变量执行自增操作。
publicclassCounter{privateint count =0;publicvoidincrement(){ count++;// 不是原子操作!}publicintgetCount(){return count;}}// 测试代码publicclassTest{publicstaticvoidmain(String[] args)throwsInterruptedException{Counter counter =newCounter();Thread t1 =newThread(()->{for(int i =0; i <10000; i++) counter.increment();});Thread t2 =newThread(()->{for(int i =0; i <10000; i++) counter.increment();}); t1.start(); t2.start(); t1.join(); t2.join();System.out.println("最终结果: "+ counter.getCount());// 期望20000,实际小于20000}}运行结果:多次执行,结果总是小于20000,有时甚至相差甚远。
问题根源:count++看似一行代码,但在JVM中分解为三条指令:
- 从主内存读取count到工作内存(读)
- 对count进行加1操作(改)
- 将结果写回主内存(写)
当两个线程同时执行这三步时,可能发生交替执行,导致最后写入的值覆盖了之前的计算结果——这就是典型的竞态条件。
1.2 synchronized是什么?
synchronized是Java提供的一个关键字,译为“同步”。它可以保证在同一时刻,最多只有一个线程执行被它修饰的代码段,从而解决并发环境下的线程安全问题。
从专业角度说,synchronized实现了互斥访问,它保证了三个关键特性:
| 特性 | 说明 |
|---|---|
| 原子性 | 被保护的代码块要么全部执行,要么完全不执行,不会被打断 |
| 可见性 | 一个线程修改共享变量后,其他线程能立即看到最新值 |
| 有序性 | 禁止编译器和处理器对同步块内的代码进行指令重排序 |
1.3 初识synchronized的三种用法
synchronized的使用非常灵活,可以根据需要选择不同的加锁对象。下面我们通过三个典型场景来理解它们的区别。
1.3.1 修饰实例方法
publicclassSyncMethodDemo{privateint count =0;// synchronized修饰实例方法,锁是当前对象thispublicsynchronizedvoidincrement(){ count++;}}特点:
- 锁对象是调用该方法的实例对象(即this)
- 同一个对象的多个synchronized实例方法,共用同一把锁
- 不同对象的方法互不干扰
示意图:
线程A ──> obj1.increment() ──> 获取obj1锁 ──> 执行 线程B ──> obj1.increment() ──> 等待obj1锁 线程C ──> obj2.increment() ──> 获取obj2锁 ──> 执行(与A不冲突) 1.3.2 修饰静态方法
publicclassSyncStaticDemo{privatestaticint count =0;// synchronized修饰静态方法,锁是当前类的Class对象publicstaticsynchronizedvoidincrement(){ count++;}}特点:
- 锁对象是当前类的Class对象(如SyncStaticDemo.class)
- 所有实例对象共享同一把类锁
- 静态方法锁与实例方法锁互不干扰
思考题:如果一个类既有synchronized静态方法,又有synchronized实例方法,两个线程分别调用它们,会互斥吗?
- 答案:不会。因为一个是类锁,一个是对象锁,两把不同的锁。
1.3.3 修饰代码块
publicclassSyncBlockDemo{privateObject lock =newObject();privateint count =0;publicvoidincrement(){// 同步代码块,锁是指定的lock对象synchronized(lock){ count++;}}publicvoiddecrement(){// 也可以锁thissynchronized(this){ count--;}}}特点:
- 锁对象可以任意指定,灵活性最高
- 可以精确控制同步范围,提高并发性能
- 常用锁对象:this、自定义锁对象、类.class
1.4 深入理解锁的范围
1.4.1 三种锁的对比表格
| 使用形式 | 锁对象 | 作用范围 | 典型应用场景 |
|---|---|---|---|
| 修饰实例方法 | 当前实例对象 | 单个实例内 | 保护实例变量 |
| 修饰静态方法 | 当前类的Class对象 | 所有实例间 | 保护静态变量 |
| 修饰代码块 | 指定的任意对象 | 代码块内 | 细粒度控制 |
1.4.2 常见面试题解析
问题1:synchronized修饰代码块可以给类加锁吗?
当然可以!只要在括号内传入类名.class即可。例如:
synchronized(SyncBlockDemo.class){// 这是类锁}问题2:构造方法可以用synchronized修饰吗?
不可以!Java语法规定构造方法不能是同步的。实际上,构造方法本身就是线程安全的,因为JVM会保证在对象初始化完成前,其他线程无法访问该对象。
问题3:静态同步方法和非静态同步方法同时调用会互斥吗?
不会互斥。假设线程A调用实例对象的非静态同步方法,线程B同时调用该对象所属类的静态同步方法,它们持有的是两把不同的锁(对象锁 vs 类锁),因此可以并行执行。
1.5 synchronized的核心特性
1.5.1 可重入性
概念:同一个线程在持有锁的情况下,可以再次获取同一把锁,而不会被阻塞。
publicclassReentrantDemo{publicsynchronizedvoidmethodA(){System.out.println("进入methodA");methodB();// 直接调用,不会死锁}publicsynchronizedvoidmethodB(){System.out.println("进入methodB");}}为什么需要可重入?
如果没有可重入性,线程在调用methodA获取锁后,调用methodB时发现自己还要申请同一把锁,就会形成死锁。这显然不合理。
实现原理:每个锁关联一个持有线程和一个计数器。线程第一次获取锁时,计数器置为1;同一线程再次获取时,计数器递增;释放一次,计数器递减。直到计数器归零,锁才真正释放。
1.5.2 可见性保证
synchronized不仅能保证原子性,还能保证内存可见性。JMM(Java内存模型)规定:
- 线程释放锁时,会将工作内存中的共享变量刷新到主内存
- 线程获取锁时,会清空工作内存,从主内存重新读取共享变量
这就确保了:一个线程修改的共享变量,在释放锁后,其他线程能立即看到最新值。
1.6 第一课时小结
- synchronized是Java内置的同步关键字,解决原子性、可见性、有序性问题
- 三种使用方式:实例方法(锁this)、静态方法(锁Class对象)、代码块(锁任意对象)
- 核心特性:可重入性、可见性、互斥性
- 不同锁对象决定了不同的同步范围
第二课时:synchronized原理与优化
2.1 从字节码看synchronized的本质
2.1.1 同步代码块的字节码
先看一段简单的同步代码块:
publicclassSyncCodeBlock{publicvoiddoSomething(){synchronized(this){System.out.println("hello");}}}使用javap -c -v反编译后,关键字节码如下:
3: monitorenter // 进入同步块,获取监视器锁 4: getstatic // 调用System.out 7: ldc // 加载字符串 9: invokevirtual // 调用println 12: aload_1 13: monitorexit // 退出同步块,释放监视器锁 14: goto 22 // 正常结束跳转 17: astore_2 // 异常处理开始 18: aload_1 19: monitorexit // 异常时也释放锁 20: aload_2 21: athrow // 抛出异常 22: return 关键发现:
- 同步代码块使用
monitorenter和monitorexit指令 - 有两个
monitorexit:一个正常退出,一个异常退出,确保锁一定被释放 - 这就是为什么synchronized即使抛出异常也不会死锁
2.1.2 同步方法的字节码
publicsynchronizedvoidsyncMethod(){System.out.println("hello");}反编译结果:
public synchronized void syncMethod(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 注意这个标志 Code: 0: getstatic #2 3: ldc #3 5: invokevirtual #4 8: return 同步方法没有monitorenter/monitorexit指令,而是通过方法表flags中的ACC_SYNCHRONIZED标识。JVM根据这个标识判断是否需要获取锁。
结论:两种实现方式本质上都是获取对象的监视器锁(Monitor Lock),只是表现形式不同。
2.2 Monitor机制深度剖析
2.2.1 什么是Monitor?
Monitor(监视器)是操作系统中用于实现线程同步的机制。Java中的每个对象都与一个Monitor关联,这个关联关系存储在对象头的Mark Word中。
可以把Monitor理解为一个“接待室”,里面有三个关键区域:
┌─────────────────────┐ │ Owner │ ← 当前持有锁的线程 ├─────────────────────┤ │ EntryList │ ← 等待获取锁的线程队列 ├─────────────────────┤ │ WaitSet │ ← 调用了wait()的线程队列 └─────────────────────┘ 2.2.2 Monitor的核心数据结构
在HotSpot虚拟机中,Monitor由C++的ObjectMonitor类实现,关键字段如下:
| 字段 | 作用 |
|---|---|
_owner | 指向当前持有锁的线程 |
_EntryList | 等待获取锁的线程队列 |
_WaitSet | 调用了wait()的线程队列 |
_recursions | 记录锁的重入次数 |
_count | 记录线程获取锁的次数 |
2.2.3 锁获取和释放的完整流程
以两个线程T0和T1竞争锁为例:
步骤1:线程T0尝试获取锁
- 根据对象头找到对应的ObjectMonitor
- 检查
_owner是否为null - 通过CAS操作将
_owner设为T0,_count设为1 - T0获取锁成功,进入同步块执行
步骤2:线程T1尝试获取锁
- 此时
_owner为T0,T1 CAS失败 - T1会先自旋几次尝试(适应性自旋)
- 如果T0很快释放锁,T1就成功获取
- 如果T0未释放,T1进入
_EntryList阻塞等待
步骤3:T0释放锁
- 执行
monitorexit,_count减1 - 若
_count变为0,_owner设为null - 唤醒
_EntryList中的线程(通常是队首线程) - T1被唤醒,重新竞争锁
2.3 锁升级:从偏向锁到重量级锁
早期Java的synchronized性能较差,被称为“重量级锁”。JDK 1.6之后引入了一系列优化,让synchronized的性能大幅提升。这就是著名的锁升级机制。
2.3.1 为什么需要锁升级?
想象一下这些场景:
- 场景A:只有一个线程访问同步代码,根本不需要锁竞争
- 场景B:两个线程交替访问,几乎没有同时竞争
- 场景C:多个线程激烈竞争,需要操作系统级别的锁
如果用重量级锁统一处理所有场景,场景A和B会造成不必要的性能开销。锁升级就是让synchronized能够根据竞争激烈程度,动态调整锁的“重量”。
2.3.2 锁的四种状态
从低到高,锁有四种状态:
- 无锁状态
- 偏向锁
- 轻量级锁
- 重量级锁
锁可以升级,但不能降级(除了一次GC清理)。
2.3.3 偏向锁(Biased Locking)
适用场景:只有一个线程反复获取同一把锁。
原理:
- 第一次获取锁时,通过CAS将线程ID记录到对象头
- 之后该线程再来时,只需检查对象头中是否是自己ID
- 如果是,直接进入,无需任何同步操作
示例代码:
publicclassBiasedLockDemo{privatestaticList<Integer> list =newVector<>();// Vector的方法都是同步的publicstaticvoidmain(String[] args){for(int i =0; i <100; i++){ list.add(i);// 同一线程反复获取锁}}}优点:几乎没有加锁开销,性能极高。
缺点:一旦有其他线程尝试竞争,偏向锁立即撤销并升级。
注意:从JDK 15开始,偏向锁被标记为废弃,未来可能移除。因为在实际应用中,它的性能提升有限,且维护成本高。
2.3.4 轻量级锁(Lightweight Locking)
适用场景:两个线程交替执行同步块,没有真正的竞争。
原理:
- 线程在自己的栈帧中创建Lock Record,存储对象头的拷贝
- 通过CAS尝试将对象头指向Lock Record
- 如果成功,获取轻量级锁
- 如果失败,说明有竞争,升级为重量级锁
轻量级锁的解锁:
- 通过CAS将对象头恢复为原Mark Word
- 如果恢复成功,解锁完成
- 如果失败,说明已经升级为重量级锁,走重量级锁解锁流程
2.3.5 重量级锁(Heavyweight Locking)
适用场景:多个线程激烈竞争。
原理:
- 线程进入
_EntryList队列阻塞 - 依赖操作系统Mutex Lock实现
- 涉及用户态和内核态切换,开销较大
2.3.6 锁升级流程总结
偏向锁 │ │ 有其他线程尝试获取 ↓ 轻量级锁 │ │ 有真实竞争 ↓ 重量级锁 2.4 synchronized与volatile的对比
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | ✅ 保证 | ❌ 不保证 |
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性 | ✅ 保证 | ✅ 保证(禁止重排序) |
| 用法 | 修饰方法或代码块 | 修饰变量 |
| 性能 | 较重(但有优化) | 极轻 |
| 适用场景 | 复合操作 | 单一变量状态标志 |
volatile适用示例:
publicclassVolatileDemo{privatevolatileboolean flag =true;// 只作为状态标志publicvoidstop(){ flag =false;// 简单写操作}publicvoidrun(){while(flag){// 执行任务}}}2.5 synchronized与ReentrantLock的对比
2.5.1 对比表格
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM关键字 | Java API(基于AQS) |
| 锁释放 | 自动释放 | 必须手动unlock |
| 公平性 | 非公平 | 可设置公平/非公平 |
| 可中断 | 不支持 | 支持lockInterruptibly() |
| 超时获取 | 不支持 | 支持tryLock(timeout) |
| 尝试获取 | 不支持 | 支持tryLock() |
| 条件变量 | 一个等待集 | 多个Condition |
| 锁状态查询 | 不支持 | 支持查询持有线程等 |
2.5.2 如何选择?
优先使用synchronized的情况:
- 同步逻辑简单,不需要高级功能
- 希望代码简洁不易出错
- JVM持续优化,性能已很好
选择ReentrantLock的情况:
- 需要公平锁
- 需要尝试获取锁或超时获取
- 需要可中断的锁获取
- 需要多个条件变量(如生产者-消费者模式)
- 高竞争场景需要更精细控制
2.5.3 ReentrantLock使用示例
ReentrantLock lock =newReentrantLock(true);// 公平锁Condition notFull = lock.newCondition();Condition notEmpty = lock.newCondition();// 标准使用模式 lock.lock();try{// 临界区代码while(条件不满足){ notEmpty.await();// 等待}// 执行操作 notFull.signal();}finally{ lock.unlock();// 必须释放}2.6 实战:线程安全的单例模式
综合运用synchronized,实现几种经典的单例模式。
2.6.1 饿汉式(线程安全)
publicclassEagerSingleton{privatestaticfinalEagerSingleton INSTANCE =newEagerSingleton();privateEagerSingleton(){}publicstaticEagerSingletongetInstance(){return INSTANCE;}}2.6.2 懒汉式(同步方法版)
publicclassLazySingleton{privatestaticLazySingleton instance;privateLazySingleton(){}publicstaticsynchronizedLazySingletongetInstance(){if(instance ==null){ instance =newLazySingleton();}return instance;}}2.6.3 双重检查锁(DCL)
publicclassDCLSingleton{// volatile保证可见性和禁止重排序privatestaticvolatileDCLSingleton instance;privateDCLSingleton(){}publicstaticDCLSingletongetInstance(){if(instance ==null){synchronized(DCLSingleton.class){if(instance ==null){ instance =newDCLSingleton();}}}return instance;}}为什么需要volatile?instance = new DCLSingleton()不是原子操作,可能发生指令重排序。如果不加volatile,其他线程可能拿到一个未初始化完成的对象。这是DCL的关键细节。
2.7 第二课时小结
- synchronized底层基于Monitor实现,字节码层面使用monitorenter/monitorexit或ACC_SYNCHRONIZED
- 锁升级机制(偏向锁→轻量级锁→重量级锁)大幅提升了性能
- synchronized保证原子性、可见性、有序性,是全面的同步工具
- 与volatile相比,synchronized更重量但功能更全
- 与ReentrantLock相比,synchronized简单易用,ReentrantLock功能更丰富
- 实际开发中,根据场景选择合适的同步机制
课程总结
通过两个课时的学习,我们全面掌握了synchronized关键字:
- 第一课时:从线程安全问题出发,学习了synchronized的三种使用方式、锁的范围、可重入性等基础概念
- 第二课时:深入底层,剖析了Monitor机制、锁升级流程,并对比了volatile和ReentrantLock
synchronized作为Java最基础的同步工具,虽然简单,但背后的原理并不简单。理解它的实现机制,不仅能帮助我们写出更正确的并发程序,还能在面试中脱颖而出。
课后思考题
- 如果一个线程在同步块中抛出异常,锁会自动释放吗?为什么?
- 偏向锁在JDK 15中被废弃的原因是什么?谈谈你的理解。
- 如何用synchronized实现一个阻塞队列?
- 为什么说synchronized是“悲观锁”,而CAS是“乐观锁”?