深入理解 Java 中的 synchronized
关键字
在 Java 中,线程同步是多线程编程中不可避免的一部分。线程间共享资源时,如果没有适当的同步机制,可能会导致线程安全问题,比如竞态条件、内存一致性错误等。synchronized
关键字是 Java 提供的最基础的线程同步工具之一,用于保证在多线程环境下对共享资源的安全访问。
什么是 synchronized
关键字?
synchronized
是 Java 中的一个关键字,它用于确保一个方法或代码块在同一时刻只能由一个线程执行。具体来说,synchronized
通过实现 监视器锁(Monitor Lock)机制,保证了对共享资源的互斥访问。
1. synchronized
的特性
synchronized
具有几个显著的特性:
1.1 互斥性
synchronized
确保同一时刻只有一个线程能访问同步代码块,这就是 互斥 的体现。当一个线程进入同步代码块时,其他线程必须等待当前线程释放锁后才能进入。
加锁和解锁
在程序执行时,进入 synchronized
修饰的代码块,线程需要先 加锁。当线程退出该代码块时,自动 解锁。
在上述图示中,Thread 1
加锁并执行同步代码块,直到它释放锁。Thread 2
必须等待 Thread 1
解锁后才能加锁并执行代码块。
1.2 刷新内存
synchronized
不仅保证了互斥性,还能保证 内存可见性。线程在进入同步代码块时,会将主内存中的共享变量的最新值复制到自己的工作内存中;在退出同步代码块时,修改后的值会被刷新回主内存。
这种机制可以确保不同线程在操作共享变量时,看到的是最新的值,从而避免了多线程中的内存一致性问题。
内存模型
1.3 可重入性
synchronized
是 可重入锁,意味着同一个线程可以多次进入同一个锁定的代码块,而不会导致死锁。每当线程进入 synchronized
代码块时,会为该线程的锁计数器增加 1;当它退出同步块时,计数器递减。当计数器为 0 时,锁才会被释放。
1.4 关于死锁
-
一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了. (synchronized 不会出现,而C++中的std::mutex就会出现这种情况)。
-
两个线程,两把锁,(无论是否是可重入锁)可能会出现死锁。
public class Demo14 { private static Object lock1 = new Object(); private static Object lock2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(()->{ synchronized (lock1) { //此处的sleep很重要,要确保t1和t2都拿到一把锁之后在进行后续动作 try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (lock2) { System.out.println("t1 加锁成功"); } } }); Thread t2 = new Thread(()->{ synchronized (lock2) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (lock1) { System.out.println("t2 加锁成功"); } } }); t1.start(); t2.start(); } }
上述代码中结果是阻塞,即两个线程都没有获取成功第二把锁,出现死锁(注意此代码中的锁是嵌套关系说明在占有一把锁的情况下获取下一把锁则可能会出现死锁,如果是并列关系则不会)。
-
N个线程,M把锁,则更容易出现死锁。
-
死锁的四个必要条件是:
互斥条件(Mutual Exclusion)
- 定义:资源只能由一个进程占用,而其他进程在该资源被占用时不能访问。
- 实例:假设有一个打印机资源,多个进程都需要打印文件,但在同一时刻只能由一个进程使用。如果进程A正在使用打印机资源,而进程B也需要使用打印机,进程B必须等待,直到进程A释放打印机资源。这时,资源的独占访问导致了互斥条件。
占有并等待条件(Hold and Wait)
- 定义:进程已占有至少一个资源,并且在等待其他资源时,持有已占用的资源。
- 实例:进程A占用了资源R1,并且需要资源R2才能继续执行。与此同时,进程B占用了资源R2,并且等待资源R1。两者都在等待对方释放资源,这样就形成了占有并等待的情况。
非抢占条件(No Preemption)
- 定义:资源一旦被进程占有,系统不能强行抢占资源,必须等到进程自行释放资源。
- 实例:进程A占用了资源R1,并且在运行过程中等待资源R2,而进程B已经占用了资源R2。由于非抢占条件,操作系统不能强制抢占进程A或进程B的资源,只能等到它们自行释放资源,从而导致死锁。
循环等待条件(Circular Wait)
-
定义:存在一组进程P1, P2, …, Pn,进程P1等待进程P2占用的资源,进程P2等待进程P3占用的资源,…,直到进程Pn等待进程P1占用的资源,形成闭环。
-
实例:进程A等待资源R2,进程B等待资源R3,进程C等待资源R1,形成一个环路:A → B → C → A。此时,每个进程都在等待另一个进程释放资源,形成了循环等待的死锁条件。
这四个条件同时满足时,就会导致死锁的发生。
-
由于第一个和第二条件是synchronized自带特性无法干预,所以程序员遇到死锁时需要破坏第三或者第四条件。
2. synchronized
的使用
synchronized
可以修饰方法,也可以修饰代码块。它的具体行为根据修饰的对象不同而有所不同。
2.1 修饰实例方法
当 synchronized
修饰实例方法时,锁定的是当前对象实例。
class Counter {
private int count = 0;
// 使用 synchronized 锁定当前对象
synchronized void increase() {
count++;
}
}
2.2 修饰静态方法
当 synchronized
修饰静态方法时,锁定的是 类对象,而不是实例对象。这意味着对所有实例来说,只有一个锁。
class Counter {
private static int count = 0;
// 使用 synchronized 锁定类对象
synchronized static void increase() {
count++;
}
}
2.3 修饰代码块
synchronized
还可以用于修饰代码块,指定锁定的对象。这时,锁定的是特定对象,而不是整个方法。
class Counter {
private int count = 0;
void increase() {
synchronized (this) { // 锁当前对象
count++;
}
}
}
3. 线程安全类与 synchronized
Java 标准库中的一些类本身是线程安全的,通常它们会使用 synchronized
或其他锁机制来实现线程安全。而一些类则是线程不安全的,使用时需要自行加锁。
3.1 线程不安全的类
这些类没有内部同步机制,多个线程同时操作它们可能导致数据不一致。
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
3.2 线程安全的类
这些类通过加锁或其他机制保证了线程安全,通常它们已经实现了 synchronized
。
Vector
Hashtable
ConcurrentHashMap
StringBuffer
4. synchronized
的底层实现
4.1 监视器锁(Monitor Lock)
synchronized
的底层实现是基于 监视器锁(Monitor Lock)机制的。在 Java 中,每个对象都可以被看作是一个 监视器(Monitor),它通过内部的锁机制来保证对同步代码块的访问是互斥的。当一个线程试图访问一个被 synchronized
修饰的方法或代码块时,它需要获得该对象的监视器锁。
在 Java 的内存模型中,每个对象都有一个隐含的锁,这个锁就是所谓的监视器。synchronized
关键字会自动为代码块加锁,锁的粒度通常是对象级别(实例方法锁)或类级别(静态方法锁)。当线程获得锁时,它就可以安全地访问同步代码块,其他线程则会被阻塞,直到锁被释放。
4.2 锁的获取与释放
当线程调用 synchronized
修饰的代码时,它会进入 加锁 状态,阻止其他线程进入同一个同步代码块。具体的锁获取和释放过程如下:
- 加锁:线程尝试进入同步代码块时,检查是否已被其他线程锁定。如果没有被锁定,它会获得锁并继续执行代码。如果锁已被占用,线程会进入等待队列,直到锁被释放。
- 执行:一旦线程获得锁,它会执行同步代码块中的代码。此时,其他线程无法进入被锁定的代码块。
- 释放锁:线程执行完同步代码块后,自动释放锁。其他等待的线程可以进入同步代码块,并再次执行。
4.3 锁的升级和优化
Java 提供了几种锁优化策略来提高性能,这些策略是基于 锁的升级 和 锁的降级 实现的。最常见的优化包括:
- 偏向锁:默认情况下,Java 对于单线程持有锁的情况进行优化,称为偏向锁。当一个线程获得锁后,锁会“偏向”该线程,后续的加锁操作会跳过锁竞争,直接让该线程继续执行。
- 轻量级锁:当多个线程竞争锁时,Java 会通过 自旋锁 的方式进行尝试,即线程不断自旋,检查锁是否释放,这样可以避免进入阻塞状态,提高性能。
- 重量级锁:当竞争非常激烈时,Java 会升级锁为重量级锁,进入操作系统的原子锁机制。此时,线程进入阻塞并由操作系统调度。
这种锁的升级和降级机制帮助 Java 程序在并发环境中平衡了性能和安全性。
5. synchronized
的性能问题
尽管 synchronized
是 Java 中非常重要的同步工具,但它也带来了一些性能上的问题。在多线程环境下,频繁使用 synchronized
可能导致以下问题:
5.1 锁竞争
当多个线程同时竞争同一个锁时,线程将会进入阻塞状态,直到其他线程释放锁。高频率的锁竞争会导致性能下降,特别是在锁粒度较大或者同步方法/代码块较多的情况下。
5.2 上下文切换
当线程因为竞争锁而被阻塞时,操作系统会将该线程从 CPU 中移除,并将其他线程放入执行队列中。这一过程称为 上下文切换。频繁的上下文切换会消耗大量的 CPU 资源,影响程序的性能。
6. 常见陷阱与优化建议
6.1 锁的粒度
锁的粒度决定了线程能够访问同步代码的范围。粒度过大(例如将整个方法加锁)可能会导致性能瓶颈,而粒度过小(例如对每个共享变量加锁)则可能导致同步问题。
优化建议:
- 尽量缩小同步块的范围,减少不必要的同步代码。
- 使用局部变量代替全局变量,减少共享资源的访问次数。
public void method() {
// 仅对需要同步的部分加锁
synchronized (this) {
// 临界区代码
}
}
6.2 避免死锁
死锁通常发生在多个线程依赖不同资源时。在加锁时,确保按照统一的顺序请求资源,可以有效避免死锁。
优化建议:
- 尽量避免嵌套加锁,即避免在一个锁定代码块内再次加锁。
- 如果必须嵌套加锁,使用定时锁(如
ReentrantLock
)可以有效避免死锁。
public void method1() {
synchronized (lock1) {
synchronized (lock2) {
// 临界区代码
}
}
}
6.3 使用替代同步机制
在一些情况下,synchronized
可能不适合使用,或者可能导致性能问题。在这种情况下,可以考虑使用 显式锁,如 ReentrantLock
,它提供了更多的灵活性,比如可以尝试获取锁、可中断的锁获取等。
示例:
ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock(); // 尝试获取锁
try {
// 临界区代码
} finally {
lock.unlock(); // 保证解锁
}
}
ReentrantLock
提供了比 synchronized
更灵活的锁机制,适用于复杂的多线程环境。
7. 总结与实践
synchronized
是 Java 中最基本、最重要的线程同步机制之一。它保证了对共享资源的互斥访问,避免了竞态条件和内存一致性错误。然而,频繁的锁竞争、死锁以及性能瓶颈是使用 synchronized
时需要注意的问题。通过合理地设计锁粒度、避免死锁、优化性能等策略,可以有效地提高程序的并发性能。
在实际开发中,我们需要根据具体的应用场景选择合适的同步机制。在一些复杂的多线程程序中,可能需要结合使用 synchronized
、ReentrantLock
、volatile
等多种工具,以实现更高效、可维护的并发控制。
希望这篇博客对你有帮助,了解 synchronized
背后的机制和优化方法,对于编写高效的多线程程序非常关键。如果你有其他问题,或者想了解更多细节,随时欢迎提问!