在Java并发编程中,线程安全是永恒的核心话题。当多个线程同时访问共享资源时,很容易出现数据不一致、脏数据等问题。而synchronized关键字作为Java内置的同步机制,是解决线程安全问题的基础手段。本文将从线程安全本质出发,逐步拆解synchronized的使用场景、底层实现原理、锁升级机制,并用生产者-消费者模型实战验证,帮助大家彻底掌握这一核心技术。
一、线程安全的核心:为什么需要Synchronized?
在深入synchronized之前,我们首先要明白:为什么多线程环境下会出现安全问题?
1.1 线程安全的定义
线程安全是指:多个线程并发访问某个Java对象时,无论操作系统如何调度线程、如何交替执行,该对象都能表现出一致的、正确的行为,最终结果与单线程执行完全相同。
1.2 线程不安全的根源:三个核心概念
要理解线程不安全,必须先掌握以下三个关键概念:
- 临界区资源:可以被多个线程共享访问的资源(如共享变量、数据库连接、文件等)。
- 临界区代码段:每个线程中访问临界资源的那段代码(比如自增运算、修改共享变量的逻辑)。
- 竞态条件:多个线程在临界区代码段并发执行时,由于代码执行顺序不同,导致最终结果不确定的情况。
1.3 经典案例:自增运算的线程不安全
最典型的线程不安全场景就是自增运算(i++),它看似是一个原子操作,实则包含三个独立的步骤:
- 从主内存读取变量
i的值到线程工作内存(内存取值); - 在线程工作内存中对
i进行加1操作(寄存器计算); - 将计算后的结果写回主内存(存值到内存)。
当多个线程同时执行i++时,就可能出现"指令交错":
- 线程A读取
i=0,还未完成加1; - 线程B也读取
i=0,执行加1后写回主内存,i=1; - 线程A继续执行加1(此时工作内存中还是0),写回主内存,
i=1。
最终两次自增只得到了1,这就是竞态条件导致的线程不安全。而synchronized的核心作用,就是保证临界区代码段的原子性执行——同一时间只有一个线程能进入临界区,避免指令交错。
二、Synchronized的使用方式:同步块与同步方法
synchronized的使用非常灵活,主要分为同步块和同步方法两大类,核心区别在于"锁对象"的不同。
2.1 同步块(Synchronized Block)
同步块是指用synchronized关键字包裹的代码块,语法格式:
synchronized(锁对象){// 临界区代码段}- 锁对象要求:必须是一个普通的Object对象(如
new Object()、自定义对象实例),不能是基本数据类型(如int、long)。 - 核心原理:线程进入同步块前,必须先获取"锁对象"的监视锁(内置锁);执行完代码块后,自动释放锁。
- 优点:粒度更细,只锁定临界区代码,不影响其他代码的执行效率。
示例:用同步块解决自增线程安全问题
publicclassSafeCounter{privateintcount=0;// 锁对象(建议使用专门的锁对象,避免与其他用途冲突)privatefinalObjectlock=newObject();publicvoidincrement(){synchronized(lock){// 锁定临界区count++;}}publicintgetCount(){returncount;}}2.2 同步方法(Synchronized Method)
同步方法是指在方法声明时添加synchronized关键字,语法格式:
// 普通同步方法publicsynchronizedvoidmethod(){// 临界区代码}// 静态同步方法publicstaticsynchronizedvoidstaticMethod(){// 临界区代码}同步方法的锁对象是隐式指定的,分为两种情况:
- 普通同步方法:锁对象是当前对象(
this),即调用该方法的对象实例。 - 静态同步方法:锁对象是当前类的
Class对象(如SafeCounter.class),因为静态方法属于类,不属于某个实例。
注意事项:
- 普通同步方法和静态同步方法的锁对象不同,因此它们之间不会相互阻塞(比如一个线程执行普通同步方法,另一个线程执行静态同步方法,不会竞争锁)。
- 同步方法的粒度较粗,会锁定整个方法体,如果方法中包含非临界区代码,会降低执行效率。
示例:同步方法实现自增安全
publicclassSafeCounter{privateintcount=0;// 普通同步方法,锁对象是thispublicsynchronizedvoidincrement(){count++;}// 静态同步方法,锁对象是SafeCounter.classpublicstaticsynchronizedvoidstaticIncrement(SafeCountercounter){counter.count++;}}三、实战:用Synchronized实现生产者-消费者模型
生产者-消费者模型是并发编程中的经典场景,恰好能体现synchronized的同步能力,同时需要结合wait()和notify()实现线程间通信。
3.1 模型核心需求
- 并发安全:生产者与生产者、消费者与消费者、生产者与消费者之间并发操作缓冲区,不能出现数据不一致(如重复生产、重复消费、数据丢失)。
- 边界控制:缓冲区满时生产者不能继续生产,缓冲区空时消费者不能继续消费。
- 资源优化:空闲线程(如缓冲区满的生产者、缓冲区空的消费者)应阻塞而非空循环,避免浪费CPU。
3.2 实现思路
- 缓冲区:用队列
Queue存储数据,作为临界区资源。 - 同步机制:用
synchronized锁定缓冲区,保证生产/消费操作的原子性。 - 线程通信:用
wait()(让线程阻塞并释放锁)和notifyAll()(唤醒所有阻塞线程)实现边界控制。
3.3 完整代码实现
importjava.util.LinkedList;importjava.util.Queue;// 数据缓冲区(临界区资源)classDataBuffer{privatefinalQueue<Integer>buffer=newLinkedList<>();privatefinalintMAX_CAPACITY=10;// 缓冲区最大容量// 生产者生产数据publicsynchronizedvoidproduce(intdata)throwsInterruptedException{// 缓冲区满时,生产者阻塞while(buffer.size()==MAX_CAPACITY){System.out.println("缓冲区满,生产者"+Thread.currentThread().getName()+"阻塞");wait();// 释放锁,进入阻塞状态}// 生产数据buffer.offer(data);System.out.println("生产者"+Thread.currentThread().getName()+"生产:"+data+",当前缓冲区大小:"+buffer.size());notifyAll();// 唤醒所有阻塞的线程(可能是消费者或其他生产者)}// 消费者消费数据publicsynchronizedintconsume()throwsInterruptedException{// 缓冲区空时,消费者阻塞while(buffer.isEmpty()){System.out.println("缓冲区空,消费者"+Thread.currentThread().getName()+"阻塞");wait();// 释放锁,进入阻塞状态}// 消费数据intdata=buffer.poll();System.out.println("消费者"+Thread.currentThread().getName()+"消费:"+data+",当前缓冲区大小:"+buffer.size());notifyAll();// 唤醒所有阻塞的线程returndata;}}// 生产者线程classProducerimplementsRunnable{privatefinalDataBufferbuffer;publicProducer(DataBufferbuffer){this.buffer=buffer;}@Overridepublicvoidrun(){for(inti=0;i<20;i++){// 生产20个数据try{buffer.produce(i);Thread.sleep(100);// 模拟生产耗时}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}}// 消费者线程classConsumerimplementsRunnable{privatefinalDataBufferbuffer;publicConsumer(DataBufferbuffer){this.buffer=buffer;}@Overridepublicvoidrun(){for(inti=0;i<20;i++){// 消费20个数据try{buffer.consume();Thread.sleep(200);// 模拟消费耗时}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}}3.4 关键说明
- 为什么用
while而非if判断边界?:wait()可能被"虚假唤醒"(即没有被notify()唤醒,而是系统自发唤醒),while会重新检查条件,确保线程只有在满足条件时才继续执行。 notifyAll()vsnotify():notify()只唤醒一个随机阻塞的线程,可能导致某些线程长期阻塞(如生产者一直唤醒生产者);notifyAll()唤醒所有阻塞线程,确保公平性,因此在生产者-消费者模型中更常用。synchronized与wait()/notify()的关系:wait()和notify()必须在synchronized代码块/方法中调用,否则会抛出IllegalMonitorStateException。因为这两个方法需要操作对象的监视器(锁),必须先获取锁才能操作。
四、底层原理:Java对象结构与内置锁
要真正理解synchronized,必须深入其底层实现——它依赖于Java对象的内置锁(监视器锁,Monitor),而内置锁的实现又与Java对象结构密切相关。
4.1 Java对象的内存结构
4.1.1 核心部分:对象头(Object Header)
对象头是实现synchronized的关键,其中最重要的是Mark Word和Class Pointer:
- Mark Word(标记字):占8字节(64位),用于存储对象的线程锁状态、哈希码、GC分代年龄等信息。其结构会随着锁状态的变化而变化(后面锁升级会详细讲)。
- Class Pointer(类型指针):占8字节,指向方法区中该对象对应的
Class元数据(如类名、方法、字段等),JVM通过它确认对象的类型。 - Array Length(数组长度):仅数组对象有,占4字节,存储数组的长度。
4.1.2 对象体与对齐字节
- 对象体:存储对象的成员变量值(如
count、name等),变量的类型和顺序会影响内存占用。 - 对齐字节:JVM要求对象的总大小必须是8字节的整数倍(内存对齐),这样CPU访问内存时效率更高。如果对象头+对象体的大小不是8的倍数,就用对齐字节补齐。
4.2 内置锁(Monitor)的本质
synchronized的"锁"本质是Java对象的监视器(Monitor),每个Java对象在创建时都会伴随一个Monitor的创建(与对象生命周期一致)。
Monitor的结构可以简单理解为:
Monitor { Owner: null/线程ID // 持有锁的线程,初始为null EntryList: 线程队列 // 等待获取锁的线程(阻塞状态) WaitSet: 线程队列 // 调用wait()后阻塞的线程 RecursionCount: 0 // 重入计数器(记录线程重入锁的次数) }- 锁的获取:线程进入同步代码前,会尝试获取Monitor的Owner权限。如果Owner为null,当前线程成为Owner,RecursionCount+1;如果Owner是当前线程,RecursionCount+1(重入锁特性);如果Owner是其他线程,当前线程进入EntryList,变为BLOCKED状态。
- 锁的释放:线程执行完同步代码或调用
wait()后,RecursionCount-1。当RecursionCount为0时,Owner变为null,同时唤醒EntryList中的一个线程,让它尝试获取锁。
五、锁升级:从偏向锁到重量级锁的进化
在JDK1.6之前,synchronized的实现非常简单粗暴——无论是否有线程竞争,都直接使用重量级锁(依赖操作系统的互斥量Mutex)。但重量级锁有个致命问题:线程切换时需要从用户态切换到内核态,这个过程开销很大,导致并发效率极低。
为了解决这个问题,JDK1.6引入了锁升级机制:锁会根据线程竞争的激烈程度,从无锁→偏向锁→轻量级锁→重量级锁逐步升级,避免一开始就使用重量级锁带来的性能损耗。
5.1 锁的四种状态
锁的状态存储在对象的Mark Word中,通过lock(锁标志位)和biased_lock(偏向锁标志位)区分,四种状态的对比如下:
| 锁状态 | biased_lock(偏向标志) | lock(锁标志位) | Mark Word主要存储内容 | 适用场景 |
|---|---|---|---|---|
| 无锁 | 0 | 01 | 哈希码、GC分代年龄 | 无线程竞争 |
| 偏向锁 | 1 | 01 | 偏向线程ID、GC分代年龄 | 单线程重复获取锁 |
| 轻量级锁 | 0 | 00 | 锁记录指针(指向线程栈帧) | 多线程轻度竞争(交替获取锁) |
| 重量级锁 | 0 | 10 | Monitor指针(指向监视器) | 多线程重度竞争(同时争抢锁) |
5.2 锁升级的详细流程
5.2.1 1. 无锁状态
- 场景:Java对象刚创建时,还没有任何线程尝试获取它的锁。
- Mark Word结构:存储对象的哈希码(调用
hashCode()后会计算并存储)、GC分代年龄,锁标志位01,偏向标志0。
5.2.2 2. 偏向锁(Biased Lock)
核心目标:单线程重复获取锁时,避免频繁的CAS操作,提高效率。
升级流程:
- 当第一个线程尝试获取锁时,JVM检查Mark Word的偏向标志为0、锁标志位为01(无锁状态)。
- 线程通过CAS操作,将自己的线程ID写入Mark Word,同时将偏向标志设为1(变为偏向锁状态)。
- 后续该线程再次进入同步代码时,只需检查Mark Word中的线程ID是否为自己,无需再次CAS,直接获取锁(快速重入)。
偏向锁的撤销:
当有其他线程尝试获取该锁时,偏向锁会被撤销,升级为轻量级锁或重量级锁。撤销的触发条件:- 多个线程竞争偏向锁(第二个线程CAS修改Mark Word失败);
- 调用
hashCode()方法(Mark Word需要存储哈希码,与偏向线程ID冲突)。
撤销流程(会触发STW:Stop The World,所有用户线程暂停):
- JVM等待全局安全点(所有用户线程停止执行);
- 遍历所有线程的栈帧,检查是否有线程持有该锁的锁记录;
- 清空锁记录,将Mark Word恢复为无锁状态,清除偏向线程ID;
- 将锁升级为轻量级锁(少数场景直接升级为重量级锁);
- 唤醒被阻塞的线程,让它们竞争轻量级锁。
注意:由于偏向锁的撤销会触发STW,对于高并发场景(多线程频繁竞争锁),偏向锁反而会降低性能。因此可以通过JVM参数
-XX:-UseBiasedLocking关闭偏向锁(默认开启)。
5.2.3 3. 轻量级锁(Lightweight Lock)
- 核心目标:多线程轻度竞争时,通过CAS自旋避免升级为重量级锁(自旋是用户态操作,开销远小于内核态切换)。
- 升级流程:
- 偏向锁撤销后,JVM会在抢锁线程的栈帧中创建一个锁记录(Lock Record),存储当前对象Mark Word的拷贝(称为Displaced Mark Word)。
- 抢锁线程通过CAS操作,尝试将对象Mark Word中的内容替换为"锁记录指针"(指向自己栈帧中的Lock Record)。
- 如果CAS成功,当前线程获取轻量级锁,执行同步代码;如果CAS失败,说明有其他线程也在争抢锁(竞争加剧),轻量级锁会膨胀为重量级锁。
5.2.4 4. 重量级锁(Heavyweight Lock)
- 核心目标:多线程重度竞争时,通过操作系统的互斥量保证线程安全(牺牲效率换稳定性)。
- 升级流程:
- 轻量级锁CAS自旋失败后,JVM会将锁升级为重量级锁,此时对象Mark Word中的内容替换为"Monitor指针"(指向该对象对应的Monitor)。
- 后续争抢锁的线程会直接进入Monitor的EntryList,变为BLOCKED状态(释放CPU资源)。
- 持有锁的线程释放锁后,会唤醒EntryList中的一个线程,让它尝试获取Monitor的Owner权限。
5.3 锁升级的核心总结
- 锁升级是不可逆的:一旦升级为重量级锁,就不会再降级为轻量级锁或偏向锁。
- 核心优化思路:按需分配锁的开销——无竞争时用无锁,单线程竞争用偏向锁(低开销),轻度竞争用轻量级锁(自旋CAS),重度竞争用重量级锁(阻塞等待)。
- 实际开发启示:高并发场景下,应尽量避免锁竞争(如使用局部变量、无锁编程),如果必须使用锁,也要控制锁的粒度(如用同步块而非同步方法),避免锁升级为重量级锁。
六、Synchronized的完整执行流程
结合前面的锁升级和Monitor原理,我们可以梳理出synchronized的完整执行流程:
线程进入同步代码前,首先检查锁对象的
Mark Word:- 如果
biased_lock=1且lock=01(可偏向状态),检查Mark Word中的线程ID是否为当前线程:- 是:直接进入同步代码(偏向锁重入);
- 否:通过CAS尝试将线程ID写入
Mark Word,CAS成功则进入同步代码,CAS失败则撤销偏向锁,升级为轻量级锁。
- 如果
biased_lock=0且lock=00(轻量级锁状态):- 线程在栈帧中创建Lock Record,存储
Mark Word拷贝; - 通过CAS尝试将
Mark Word替换为Lock Record指针,CAS成功则获取锁,失败则自旋重试; - 自旋超过阈值(默认10次)或有更多线程竞争,轻量级锁膨胀为重量级锁。
- 线程在栈帧中创建Lock Record,存储
- 如果
lock=10(重量级锁状态):- 线程进入Monitor的EntryList,变为BLOCKED状态,等待被唤醒。
- 如果
线程执行同步代码:
- 如果线程再次进入同步代码(重入),轻量级锁会增加Lock Record计数,重量级锁会增加RecursionCount。
线程释放锁:
- 轻量级锁:删除Lock Record,通过CAS将
Mark Word恢复为原始值; - 重量级锁:RecursionCount减1,当计数为0时,释放Monitor的Owner权限,唤醒EntryList中的线程;
- 释放锁后,线程退出同步代码。
- 轻量级锁:删除Lock Record,通过CAS将
七、Synchronized的特性与线程间通信
7.1 Synchronized的三大核心特性
- 原子性:临界区代码段的执行是不可中断的,同一时间只有一个线程能执行(由Monitor保证)。
- 可见性:线程释放锁时,会将工作内存中的修改同步到主内存;线程获取锁时,会从主内存重新读取最新值(避免脏读)。
- 有序性:禁止指令重排序(JVM会在同步代码前后添加内存屏障),保证代码执行顺序与编写顺序一致。
7.2 线程间通信的三种方式
多线程操作共享资源时,需要通过通信协调执行顺序,常见方式有:
- 等待-通知机制(wait/notify):基于
synchronized实现,线程通过wait()释放锁并阻塞,通过notify()/notifyAll()唤醒阻塞线程(如生产者-消费者模型)。 - 共享内存:线程通过读写共享变量进行通信(如用
volatile修饰共享变量,保证可见性)。 - 管道流:通过
PipedInputStream和PipedOutputStream实现线程间的字节流通信(适用于线程间数据传输)。
其中,等待-通知机制是synchronized的配套通信方式,也是最常用的并发协调手段。
八、总结与面试重点
synchronized作为Java并发编程的基础,是面试中的高频考点。核心要点总结如下:
核心知识点
- 线程安全根源:竞态条件导致指令交错,
synchronized通过锁定临界区保证原子性。 - 使用方式:同步块(锁对象自定义,粒度细)、同步方法(普通锁this,静态锁Class)。
- 底层原理:依赖Java对象的Monitor(内置锁),锁状态存储在Mark Word中。
- 锁升级:无锁→偏向锁→轻量级锁→重量级锁,按需优化性能。
- 核心特性:原子性、可见性、有序性,支持重入锁。
面试常问问题
synchronized和volatile的区别?
- volatile仅保证可见性和有序性,不保证原子性;synchronized保证原子性、可见性、有序性。
- volatile用于变量级别的同步,synchronized用于代码块/方法级别的同步。
- volatile不会阻塞线程,synchronized会导致线程阻塞。
synchronized是公平锁还是非公平锁?
- 非公平锁。唤醒线程时,会随机选择EntryList中的一个线程,不遵循"先到先得"(公平锁会增加开销)。
synchronized的锁重入原理是什么?
- 轻量级锁通过栈帧中的Lock Record计数实现,重量级锁通过Monitor的RecursionCount计数实现,线程再次获取锁时只需增加计数,无需重新竞争。
为什么偏向锁在高并发场景下建议关闭?
- 高并发时,偏向锁会频繁被撤销,触发STW(Stop The World),反而降低性能。通过
-XX:-UseBiasedLocking关闭后,直接从无锁升级为轻量级锁。
- 高并发时,偏向锁会频繁被撤销,触发STW(Stop The World),反而降低性能。通过
生产者-消费者模型用synchronized怎么实现?
- 缓冲区作为临界区,用synchronized锁定;
- 生产者用
while(buffer满) wait(),消费者用while(buffer空) wait(); - 生产/消费后调用
notifyAll()唤醒线程。
通过本文的学习,相信大家已经对synchronized的使用、原理、优化有了全面的理解。在实际开发中,要根据并发场景选择合适的同步方式,同时结合锁升级机制优化性能;在面试中,要能清晰阐述底层原理和实战经验,展现自己的技术深度。