synchronized 关键字深度解析
1. 概述
在 Java 多线程编程中,synchronized
关键字是一个至关重要的工具,它用于解决并发访问共享资源时可能出现的数据一致性问题,即防止“脏数据”的产生。当多个线程试图同时访问和修改同一个共享资源时,如果没有适当的同步机制,就可能导致数据混乱、逻辑错误甚至程序崩溃。synchronized
关键字提供了一种内置的同步机制,确保在同一时刻,只有一个线程能够执行被 synchronized
修饰的代码块或方法,从而保证了共享数据的原子性、可见性和有序性。
synchronized
是 Java 语言层面的关键字,由 JVM 实现,因此使用起来相对简单。尽管在 JDK 5 之后引入了 java.util.concurrent.locks.Lock
接口提供了更灵活的锁机制,但 synchronized
关键字因其易用性和可靠性,在实际开发中仍然被广泛使用。
核心作用:
- 原子性: 确保被
synchronized
保护的代码块或方法在执行过程中不会被中断,要么全部执行成功,要么全部不执行,中间不会出现其他线程的干扰。 - 可见性: 确保当一个线程修改了共享变量的值时,新值对于其他线程是立即可见的。当一个线程释放锁时,它会把对共享变量的修改刷新到主内存中;当另一个线程获取锁时,它会从主内存中读取共享变量的最新值。
- 有序性: 确保指令重排序不会对
synchronized
保护的代码块造成影响。在synchronized
块内部,指令会按照程序的顺序执行,避免了重排序可能带来的并发问题。
简而言之,synchronized
关键字通过提供一种互斥访问机制,使得在多线程环境下对共享资源的访问变得安全可靠。任何线程要执行 synchronized
里的代码,都必须先获取到对应的锁,没有获取到锁的线程只能等待。
2. 使用
synchronized
关键字可以修饰方法或代码块,其作用范围和锁定的对象有所不同。理解其不同的使用方式对于正确地实现线程安全至关重要。
2.1. 修饰实例方法
当 synchronized
关键字修饰一个非静态方法时,它锁定的是当前实例对象(this
)。这意味着,如果一个类的多个实例同时运行,每个实例都有自己的锁,它们之间互不影响。但对于同一个实例,在任何时刻,只有一个线程能够执行该实例的 synchronized
方法。
示例:
public class Counter {private int count = 0;public synchronized void increment() {count++;System.out.println(Thread.currentThread().getName() + ": " + count);}public int getCount() {return count;}
}// 使用示例
public class SynchronizedMethodDemo {public static void main(String[] args) {Counter counter = new Counter();Runnable task = () -> {for (int i = 0; i < 5; i++) {counter.increment();}};Thread t1 = new Thread(task, "Thread-1");Thread t2 = new Thread(task, "Thread-2");t1.start();t2.start();}
}
在上述 Counter
类中,increment()
方法被 synchronized
修饰。当 Thread-1
调用 counter.increment()
时,它会获取 counter
对象的锁。在 Thread-1
释放锁之前,即使 Thread-2
也尝试调用 counter.increment()
,它也必须等待,直到 Thread-1
完成并释放锁。这保证了 count
变量的原子性操作。
2.2. 修饰静态方法
当 synchronized
关键字修饰一个静态方法时,它锁定的是当前类的 Class
对象。由于类的 Class
对象是唯一的,因此无论创建多少个该类的实例,静态 synchronized
方法的锁都是同一个。这意味着,在任何时刻,只有一个线程能够执行该类的任何一个静态 synchronized
方法。
示例:
public class StaticCounter {private static int count = 0;public static synchronized void increment() {count++;System.out.println(Thread.currentThread().getName() + ": " + count);}public static int getCount() {return count;}
}// 使用示例
public class SynchronizedStaticMethodDemo {public static void main(String[] args) {Runnable task = () -> {for (int i = 0; i < 5; i++) {StaticCounter.increment();}};Thread t1 = new Thread(task, "Thread-A");Thread t2 = new Thread(task, "Thread-B");t1.start();t2.start();}
}
在这个例子中,StaticCounter.increment()
方法被 synchronized
修饰。Thread-A
和 Thread-B
都会尝试调用这个静态方法。由于锁是 StaticCounter.class
对象,因此在任何时候,只有一个线程能够成功进入 increment()
方法,从而保证了静态变量 count
的线程安全。
2.3. 修饰代码块
synchronized
关键字也可以修饰一个代码块,这提供了更细粒度的控制。被修饰的代码块称为同步语句块,其作用范围是大括号 {}
括起来的代码。括号中需要指定一个对象作为锁,这个对象被称为“监视器锁”或“互斥锁”。
语法:
synchronized (object) {// 需要同步的代码
}
object
可以是任何非 null
的 Java 对象。当线程进入 synchronized
代码块时,它会尝试获取 object
对象的锁。只有成功获取锁的线程才能执行代码块中的内容。这种方式的优点是,可以将锁的范围限制在真正需要同步的代码上,而不是整个方法,从而提高程序的并发性。
示例:
public class BlockCounter {private int count = 0;private final Object lock = new Object(); // 定义一个专门的锁对象public void increment() {synchronized (lock) { // 使用 lock 对象作为锁count++;System.out.println(Thread.currentThread().getName() + ": " + count);}}public int getCount() {return count;}
}// 使用示例
public class SynchronizedBlockDemo {public static void main(String[] args) {BlockCounter counter = new BlockCounter();Runnable task = () -> {for (int i = 0; i < 5; i++) {counter.increment();}};Thread t1 = new Thread(task, "Thread-X");Thread t2 = new Thread(task, "Thread-Y");t1.start();t2.start();}
}
在这个例子中,increment()
方法内部的 synchronized
代码块使用了 lock
对象作为锁。Thread-X
和 Thread-Y
都会尝试获取 lock
对象的锁。这确保了 count++
操作的原子性。与修饰方法不同,BlockCounter
类的其他非同步方法可以被其他线程同时访问,从而提高了并发度。
选择锁对象的注意事项:
- 实例方法内部: 通常使用
this
作为锁对象。 - 静态方法内部: 通常使用
ClassName.class
作为锁对象。 - 代码块: 可以使用任何非
null
的对象作为锁。通常建议创建一个私有的final
对象作为锁,以避免外部代码意外地获取到这个锁,从而导致死锁或性能问题。避免使用字符串字面量作为锁对象,因为字符串常量池可能导致不同地方的字符串字面量是同一个对象,从而造成不必要的竞争。
总结不同使用方式的锁对象:
使用方式 | 锁对象 | 适用场景 |
---|---|---|
修饰实例方法 | 当前实例对象 (this ) | 保护实例的共享数据,不同实例间互不影响 |
修饰静态方法 | 当前类的 Class 对象 (ClassName.class ) | 保护类的静态共享数据,所有实例共享同一把锁 |
修饰代码块 | 指定的任意对象 | 细粒度控制,保护特定代码块,提高并发性 |
正确选择 synchronized
的使用方式和锁对象是编写高效、线程安全并发程序的关键。
3. 实现原理
synchronized
关键字在 Java 虚拟机(JVM)层面是通过监视器(Monitor)来实现的。每个 Java 对象都可以关联一个监视器。当一个线程试图获取对象的锁时,它实际上是试图获取该对象关联的监视器。JVM 内部通过 monitorenter
和 monitorexit
字节码指令来实现 synchronized
的同步功能。
3.1. 同步代码块的实现原理
对于 synchronized
修饰的代码块,JVM 会在同步代码块的入口处插入 monitorenter
指令,在同步代码块的出口处(包括正常退出和异常退出)插入 monitorexit
指令。这两个指令都需要一个引用类型的参数,即要加锁的对象。
monitorenter
指令: 当执行monitorenter
指令时,线程会尝试获取对象的监视器。如果对象的监视器计数器为 0,则该线程可以成功获取监视器,并将计数器设置为 1。如果当前线程已经持有该对象的监视器,则可以重入,监视器计数器会递增。如果其他线程已经持有该对象的监视器,则当前线程会被阻塞,直到持有监视器的线程释放。monitorexit
指令: 当执行monitorexit
指令时,线程会释放对象的监视器。监视器计数器会递减。当计数器减为 0 时,表示该线程完全释放了监视器,其他被阻塞的线程可以尝试获取该监视器。
字节码示例(伪代码):
// Java 代码
public void method() {synchronized (this) {// 同步代码}
}// 对应的字节码(简化)
public void method();Code:0: aload_01: dup2: astore_13: monitorenter4: // 同步代码开始5: // ...6: // 同步代码结束7: aload_18: monitorexit9: goto 1712: astore_213: aload_114: monitorexit15: aload_216: athrow17: return
从字节码可以看出,monitorexit
指令有两个,一个在正常执行路径的末尾,另一个在异常处理路径的末尾。这确保了即使在同步代码块中发生异常,锁也能够被正确释放,避免死锁。
3.2. 同步方法的实现原理
对于 synchronized
修饰的方法(包括实例方法和静态方法),JVM 并没有使用 monitorenter
和 monitorexit
指令。而是通过方法修饰符 ACC_SYNCHRONIZED
来实现的。当一个方法被 ACC_SYNCHRONIZED
修饰时,JVM 会隐式地为该方法添加同步功能。
当线程调用一个 ACC_SYNCHRONIZED
修饰的方法时,它会尝试获取该方法对应对象的监视器(对于实例方法是实例对象,对于静态方法是 Class
对象)。如果获取成功,则执行方法体;执行完毕后,无论正常退出还是异常退出,都会释放监视器。如果获取失败,则线程会被阻塞。
3.3. synchronized
的优化
在 JDK 1.6 之后,JVM 对 synchronized
进行了大量的优化,引入了偏向锁(Biased Locking)、轻量级锁(Lightweight Locking)和自适应自旋锁(Adaptive Spinning)等技术,以减少锁竞争带来的性能开销。
- 偏向锁: 适用于只有一个线程访问同步块的场景。当一个线程第一次访问同步块时,会把锁偏向于该线程,后续该线程再次进入同步块时,无需进行同步操作,从而提高性能。如果出现其他线程竞争,偏向锁会升级为轻量级锁。
- 轻量级锁: 适用于多个线程交替执行同步块的场景,且线程之间没有激烈的竞争。当偏向锁升级或多个线程竞争时,JVM 会尝试使用轻量级锁。它通过 CAS(Compare And Swap)操作在栈帧中创建锁记录(Lock Record)来实现。如果竞争激烈,轻量级锁会升级为重量级锁。
- 自旋锁: 当一个线程尝试获取锁但失败时,它不会立即阻塞,而是会进行忙循环(自旋)一段时间,看看持有锁的线程是否会很快释放锁。如果很快释放,则避免了线程上下文切换的开销。自适应自旋锁会根据前一次自旋的结果和锁的拥有者的状态来动态调整自旋的次数。
- 重量级锁: 当锁竞争非常激烈时,轻量级锁会升级为重量级锁。重量级锁是基于操作系统互斥量(Mutex)实现的,线程的阻塞和唤醒都需要操作系统的介入,会涉及到用户态和内核态的切换,开销较大。
这些优化使得 synchronized
在大多数情况下都能提供不错的性能,因此在 Java 并发编程中仍然是一个非常重要的同步工具。
3.4. synchronized
与 volatile
的区别
虽然 synchronized
能够保证原子性、可见性和有序性,但它是一个重量级操作。而 volatile
关键字则是一个轻量级的同步机制,它只能保证共享变量的可见性和有序性,不能保证原子性。
特性 | synchronized | volatile |
---|---|---|
原子性 | 保证 | 不保证 |
可见性 | 保证 | 保证 |
有序性 | 保证(通过禁止指令重排) | 保证(通过内存屏障) |
适用范围 | 方法和代码块 | 变量 |
性能 | 相对较重(JDK 1.6 后有优化) | 相对较轻 |
通常情况下,如果需要保证复合操作的原子性,或者需要更复杂的同步逻辑,应该使用 synchronized
。如果只需要保证变量的可见性和有序性,并且操作本身是原子性的(例如对 int
或 boolean
变量的读写),那么 volatile
是一个更轻量级的选择。
4. 总结
synchronized
关键字作为 Java 并发编程的基石,为多线程环境下的共享资源访问提供了强大的同步保障。它通过对象监视器机制,确保了代码的原子性、可见性和有序性,有效避免了并发问题。
从使用层面来看,synchronized
可以灵活地修饰实例方法、静态方法和代码块,以适应不同粒度的同步需求。修饰实例方法时锁定当前实例对象,修饰静态方法时锁定当前类的 Class
对象,而修饰代码块则可以指定任意对象作为锁,提供了更细致的控制。在选择锁对象时,应遵循最佳实践,避免潜在的死锁或性能问题。
从实现原理来看,synchronized
底层依赖于 JVM 的 monitorenter
和 monitorexit
字节码指令(对于代码块)或 ACC_SYNCHRONIZED
标志(对于方法),结合对象监视器来实现线程的互斥访问。JDK 1.6 之后引入的偏向锁、轻量级锁和自适应自旋锁等优化,显著提升了 synchronized
的性能,使其在大多数场景下都能高效运行。
尽管 java.util.concurrent.locks.Lock
接口提供了更高级和灵活的锁机制,但 synchronized
关键字因其简洁、易用和 JVM 层面优化的特性,在 Java 并发编程中依然占据着不可替代的地位。作为 Java 后端开发者,深入理解 synchronized
的工作原理和正确使用方式,是编写健壮、高效并发应用程序的关键。