Synchronized
是 Java 中的重要关键字,用来解决多线程并发问题。以下是围绕几个方面对其进行通俗化的讲解。
1、synchronized
关键字的简介
为什么需要 synchronized
?
在多线程编程中,多个线程可能会同时访问或修改同一个共享资源(例如变量、集合、文件等)。如果不加控制,很容易导致 数据不一致 或 竞态条件(Race Condition),从而引发程序逻辑错误。
Synchronized
就是用来 保证线程安全 的一种机制,作用是:
- 互斥访问:同一时间只允许一个线程访问共享资源。
- 保证可见性:线程对共享数据的修改对其他线程可见。
synchronized
的地位
- 在 Java 的并发控制中,
synchronized
是最基础的同步工具,最早出现在 JDK 1.0 中。 - 它通过 锁 的机制,控制线程对资源的访问顺序,是线程安全的关键保障之一。
- 在 JDK 1.5 之后,
ReentrantLock
等高级工具逐步取代了部分场景下的synchronized
,但它仍然是非常重要的同步手段。
2、synchronized
的使用
Synchronized
可以锁住对象或类,主要分为以下两种情况:
1)对象锁
对象锁是针对某个具体的实例对象(对象级别的锁)。
使用方式:
- 同步方法:在方法声明中加
synchronized
,锁住的是调用该方法的实例对象。 - 同步代码块:使用
synchronized(obj)
,锁住指定的对象。
示例:
public class Counter {
private int count = 0;
// 同步方法,锁住当前实例对象
public synchronized void increment() {
count++;
}
// 同步代码块,锁住某个对象(比如 this 或其他共享对象)
public void add(int value) {
synchronized (this) {
count += value;
}
}
}
假如两个线程访问同一个 Counter
实例,increment()
和 add()
都受 synchronized
保护,避免并发冲突。
2)类锁
类锁是针对整个类的,所有对象实例共享同一把锁(类级别的锁)。
使用方式:
- 静态同步方法:在静态方法前加
synchronized
,锁住的是Class
对象(即类的模板)。 - 静态同步代码块:使用
synchronized(ClassName.class)
。
示例:
public class Counter {
private static int count = 0;
// 静态同步方法,锁住整个类
public static synchronized void increment() {
count++;
}
// 静态同步代码块
public static void add(int value) {
synchronized (Counter.class) {
count += value;
}
}
}
无论多少线程,或者多少个 Counter
实例,当它们访问 increment()
或 add()
时,都必须等待获取到类锁。
3、synchronized
的使用注意事项
在实际使用中,synchronized
有一些常见的注意事项和使用场景:
(1) 锁住的对象要明确
- 锁住的是方法中的
this
对象 or 某个共享对象,还是整个类。 - 如果锁的对象不同,线程之间不会互斥。
示例:
public void method1() {
synchronized (this) { // 锁住当前实例对象
// 线程安全
}
}
public void method2() {
synchronized (Counter.class) { // 锁住整个类
// 不同锁,线程互不影响
}
}
(2) 注意锁的粒度
- 锁的范围越大,并发性能越差(因为更多线程会被阻塞)。
- 推荐优先使用 同步代码块,缩小锁的粒度,只锁需要同步的代码。
(3) 静态方法和实例方法的锁互不干扰
- 静态同步方法使用类锁,实例同步方法使用对象锁,互不冲突。
(4) 不要锁住字符串常量
- 常量对象(如
"abc"
)在 JVM 中是共享的,可能被其他无关代码锁住,容易引发死锁等问题。 - 推荐:锁住具体的对象实例。
(5) 避免死锁
- 多线程中,如果两个线程互相等待对方释放锁,会导致死锁。
- 避免:确保锁的获取顺序一致。
(6) 多线程性能问题
Synchronized
是一种阻塞机制,如果锁被占用,线程会进入阻塞状态,可能降低程序性能。- 优化:尽量减少锁粒度或使用更高效的并发工具(如
ReentrantLock
)。
4、synchronized
的两个性质
1)可重入性
- 可重入性指的是:同一线程在持有锁的情况下,可以再次获取该锁。
- Java 的
synchronized
是 可重入锁,即同一线程可以多次进入同步方法或代码块,而不会被自己阻塞。
示例:
public synchronized void method1() {
method2(); // 当前线程可以再次获取锁
}
public synchronized void method2() {
System.out.println("Reentrant lock");
}
2)不可中断性
- 当一个线程进入
synchronized
方法或代码块后,其他线程只能等待,无法中断。 - 和
ReentrantLock
提供的 中断支持 不同,synchronized
是无法中断的。
5、synchronized
的底层原理
Synchronized
是 Java 中实现线程同步的重要关键字。虽然它用起来很简单,但其底层实现其实非常精妙。下面从简单到深入,以通俗的方式为你讲解 synchronized
的底层原理。
在 JVM 中,synchronized
的实现依赖 对象头的监视器(Monitor) 和 锁升级机制。
(1)synchronized
的底层依赖:对象头和 Monitor
A. 对象的结构:对象头
在 Java 中,每个对象在内存中都有两部分组成:
- 对象头(Header):存储对象的运行时信息,比如同步锁信息、HashCode 等。
- 实例数据(Fields):存储对象的成员变量。
重点:对象头中包含与锁相关的信息。
- 在 32 位 JVM 中,对象头占用 32 位,其中有一部分专门用来表示锁的状态。
- 在 64 位 JVM 中,对象头占用 64 位,锁状态信息依然存储在其中。
B. Monitor(监视器机制)
synchronized
的底层依赖一种叫 Monitor 的机制。这是一种操作系统级别的同步工具,用来管理线程的访问权限。
当线程执行 synchronized
方法或代码块时,会进入对象的 Monitor,只有持有 Monitor 的线程才能访问共享资源。Monitor 本质上是一种 互斥锁。
(2)synchronized
的锁状态和锁升级过程
synchronized
的锁是可以升级的,具体分为以下几种状态:偏向锁、轻量级锁和重量级锁。JVM 会动态调整锁的状态,以提高性能。
A. Lock 的四种状态
对象头的锁状态有以下几种:
- 无锁(Unlocked):没有线程竞争,资源可以自由访问。
- 偏向锁(Biased Locking):一个线程第一次拿到锁后,会偏向这个线程,后续不需要竞争锁,性能最高。
- 轻量级锁(Lightweight Locking):当多个线程访问同一个锁时,使用 CAS(Compare-And-Swap)机制来尝试竞争锁,避免线程挂起。
- 重量级锁(Heavyweight Locking):当线程竞争非常激烈时,JVM 会将锁升级为重量级锁,线程会进入阻塞状态。
B. 锁升级过程
- 通常情况下:锁从无锁开始,逐步升级为偏向锁、轻量级锁,最后升级为重量级锁。
- 锁降级:锁一旦升级,无法降级。
锁升级的具体过程如下:
-
偏向锁(默认开启):
- 如果一个线程首次访问锁,JVM 会将锁标记为“偏向锁”,并在对象头记录该线程的 ID。
- 后续该线程再次访问锁时,不需要任何同步操作,效率极高。
- 如果有其他线程试图访问锁,则偏向锁会撤销,进入轻量级锁状态。
-
轻量级锁:
- 当多个线程访问同一个锁时,JVM 会使用 CAS 操作让线程竞争锁。
- 如果 CAS 成功,则线程获得锁,进入临界区。
- 如果 CAS 失败(有其他线程持有锁),线程会自旋(不断尝试获取锁)一段时间。
-
重量级锁:
- 如果线程自旋多次仍未成功获取锁,JVM 会将锁升级为重量级锁。
- 此时,线程会被挂起,进入操作系统的 阻塞队列,直到其他线程释放锁。
- 重量级锁会导致线程的上下文切换,性能较差。
(3)synchronized
的实现方式
synchronized
的实现方式在 方法 和 代码块 中有所不同,但本质都是基于对象头中的锁信息和 Monitor 实现。
A. 同步方法的实现
当一个方法被声明为 synchronized
时,编译器会在方法的字节码中插入一条 ACC_SYNCHRONIZED 标志。
- 当线程调用同步方法时,JVM 会自动先检查 Monitor,只有持有锁的线程才能执行方法体。
- 如果没有获得锁,线程会被挂起。
字节码示例:
public synchronized void method() {
System.out.println("Hello");
}
反编译后,字节码中会多一条 ACC_SYNCHRONIZED
标志,JVM 通过这个标志来实现同步。
B. 同步代码块的实现
当使用 synchronized(obj)
锁定一个对象时,编译器会在字节码中生成 monitorenter
和 monitorexit
指令。
- monitorenter:指令表示当前线程尝试获取对象的 Monitor。
- monitorexit:指令表示当前线程释放对象的 Monitor。
字节码示例:
public void method() {
synchronized (this) {
System.out.println("Hello");
}
}
反编译后,字节码类似如下:
0: aload_0 // 加载 this
1: dup
2: monitorenter // 尝试获取锁
3: getstatic ... // 执行代码
4: invokevirtual ...
5: monitorexit // 释放锁
6: goto ...
(4)锁的性能优化(为什么说现代 JVM 优化了 synchronized)
A. 偏向锁的引入
偏向锁是一个性能优化,它的设计理念是:大多数情况下,锁只会被一个线程使用。因此,JVM 会为第一次获得锁的线程分配偏向锁,避免频繁的 CAS 操作。
B. 自旋锁
传统的重量级锁会让线程直接挂起,而 自旋锁 允许线程暂时不挂起,而是循环尝试获取锁。这种方式避免了线程上下文切换带来的额外开销。
C. 锁消除
如果 JVM 检测到某一段代码中的锁是完全不必要的(比如局部变量锁),会自动消除锁。
D. 锁粗化
如果一段代码中频繁加锁和解锁,JVM 会将多个锁合并为一个范围较大的锁,减少性能开销。
(5)通俗理解
- 对象头是关键:所有 Java 对象都带有一个“头”,它记录了锁的状态(无锁、偏向锁、轻量级锁、重量级锁)。
- 锁升级机制:锁会按竞争情况逐步升级,从无锁到偏向锁,再到轻量级锁和重量级锁。锁升级提高了性能。
- Monitor 是核心:
synchronized
底层依赖 Monitor,线程只有拿到 Monitor 才能访问共享资源。 - JVM 的优化:现代 JVM 针对锁的性能进行了大量优化(如偏向锁、自旋锁、锁消除等),使
synchronized
在大多数场景下性能优越。
(6)通俗比喻
可以把 synchronized
想象成电影院买票的窗口:
- 偏向锁:如果只有一个人买票,售票员会记住这个人,下次他来了,直接优先处理,不用排队。
- 轻量级锁:如果来了几个人,售票员会叫大家排好队,按顺序买票,避免冲突。
- 重量级锁:如果来了太多人,大家开始争吵,售票员会强制维持秩序,让每个人按大队列等候,别人买完票后再轮到你。
通过这种机制,synchronized
保证大家都能有序买票,而不会发生混乱。
6、synchronized
的常见缺陷
尽管 synchronized
简单易用,但也有一些缺点:
(1) 性能开销
- 如果线程竞争激烈(重量级锁),可能导致较多的上下文切换和阻塞,性能较低。
- 现代 JVM 已优化
synchronized
,降低了锁的开销。
(2) 不支持中断
- 线程在等待锁时,无法被中断,只能等待锁释放,可能导致程序响应性较差。
(3) 容易使用不当
- 由于锁的粒度和锁对象的选择不当,容易引发死锁或性能瓶颈。
(4) 缺乏高级功能
- 比如
synchronized
不支持尝试获取锁或超时机制(高级用户可以选择ReentrantLock
)。
结论
- 作用:
synchronized
保证多线程对共享资源的安全访问,是 Java 中的基础同步工具。 - 使用:可以在方法上加锁(实例锁或类锁),也可用同步代码块锁住某个对象。
- 注意事项:明确锁的粒度和对象,避免死锁和性能问题。
- 性质:支持可重入,不支持中断。
- 底层原理:通过对象头和锁升级机制实现。
- 缺陷:性能可能较低,功能较简单,但在绝大多数场景中仍然有效。
建议:在复杂并发场景下,结合 ReentrantLock
或其他并发工具,灵活选择同步机制。