JAVA并发下对象逃逸导致锁失效的真实案例及优化路径
大家好,我是今天的讲师,很高兴能和大家一起探讨Java并发编程中一个非常常见但又容易被忽视的问题:对象逃逸导致的锁失效。很多时候,我们信心满满地使用了锁机制,却发现线程安全问题依然存在,这很可能就是对象逃逸在作祟。今天我将通过实际案例、代码演示和分析,帮助大家深入理解对象逃逸,并提供相应的优化路径。
什么是对象逃逸?
首先,我们需要明确什么是对象逃逸。简单来说,对象逃逸指的是一个对象在它的创建范围之外被访问。更具体地说,当一个对象被发布到堆中,并且可以被多个线程访问到,那么这个对象就发生了逃逸。
对象逃逸会打破我们对锁的预期行为。我们通常认为,在锁的保护下,对共享变量的访问是线程安全的。但如果对象逃逸了,即使我们对包含该对象的代码块加锁,锁也可能无法有效保护该对象的数据一致性。这是因为不同的线程可能通过不同的路径访问到同一个逃逸对象,从而绕过锁的保护。
对象逃逸主要分为以下几种情况:
- 方法逃逸: 对象作为方法的返回值,或者作为参数传递给其他方法,使得该对象可以在方法之外被访问。
- 线程逃逸: 对象被发布到多个线程,使得多个线程可以并发地访问该对象。
案例一:发布未完成的对象
我们来看一个非常经典的案例,也是最容易出现的对象逃逸场景:发布未完成的对象。
public class UnsafePublication {
private int a;
private int b;
public UnsafePublication() {
a = 1;
b = 2;
}
public void publish() {
// 将对象发布到堆中,允许其他线程访问
Holder.holder = this;
}
public static void main(String[] args) throws InterruptedException {
UnsafePublication unsafePublication = new UnsafePublication();
Thread t1 = new Thread(() -> {
unsafePublication.publish();
});
Thread t2 = new Thread(() -> {
UnsafePublication publishedObject = Holder.holder;
if (publishedObject != null) {
System.out.println("Thread 2: a = " + publishedObject.a + ", b = " + publishedObject.b);
}
});
t1.start();
Thread.sleep(10); // 模拟线程竞争
t2.start();
t1.join();
t2.join();
}
static class Holder {
public static UnsafePublication holder;
}
}
在这个例子中,UnsafePublication 对象在构造函数中初始化了 a 和 b 两个成员变量。publish() 方法将该对象赋值给 Holder.holder,从而将其发布到堆中。
问题在于,UnsafePublication 对象可能在构造函数尚未完成时就被发布出去。也就是说,线程 t2 可能在 a = 1 之后,b = 2 之前读取到 Holder.holder,从而访问到未完全初始化的对象。
运行结果(可能):
Thread 2: a = 1, b = 0
问题分析:
- 指令重排序: JVM 为了优化性能,可能会对指令进行重排序。例如,
a = 1和b = 2的顺序可能被颠倒。 - 可见性问题: 即使指令没有被重排序,线程 t1 对
a和b的修改也可能对线程 t2 不可见。
优化路径:
解决这个问题最常用的方法是使用 final 关键字。
public class SafePublication {
private final int a;
private final int b;
public SafePublication() {
a = 1;
b = 2;
}
public void publish() {
Holder.holder = this;
}
public static void main(String[] args) throws InterruptedException {
SafePublication safePublication = new SafePublication();
Thread t1 = new Thread(() -> {
safePublication.publish();
});
Thread t2 = new Thread(() -> {
SafePublication publishedObject = Holder.holder;
if (publishedObject != null) {
System.out.println("Thread 2: a = " + publishedObject.a + ", b = " + publishedObject.b);
}
});
t1.start();
Thread.sleep(10); // 模拟线程竞争
t2.start();
t1.join();
t2.join();
}
static class Holder {
public static SafePublication holder;
}
}
原因:
final关键字保证了a和b在构造函数完成之前不会被发布出去。这是因为final字段在构造函数中初始化后,其值对所有线程都是可见的。final字段的初始化过程受到特殊的内存屏障保护,防止指令重排序。
其他优化方法:
- 静态初始化器: 将对象的初始化放在静态初始化器中,可以保证对象在类加载时被初始化完成,从而避免发布未完成的对象。
- 延迟初始化: 使用
lazy initialization holder class模式,可以延迟对象的初始化,直到第一次被访问时才进行初始化。
案例二:共享可变状态
另一个常见的对象逃逸场景是共享可变状态。当多个线程共享一个可变对象时,如果不对该对象的访问进行同步,就可能导致数据竞争和不一致。
import java.util.concurrent.atomic.AtomicInteger;
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.getCount());
}
}
在这个例子中,UnsafeCounter 对象包含一个 count 成员变量,increment() 方法用于递增该变量。两个线程并发地调用 increment() 方法,期望最终 count 的值为 20000。
运行结果(可能):
Final count: 16789
问题分析:
count++操作不是原子性的,它实际上包含了三个步骤:- 读取
count的值。 - 将
count的值加 1。 - 将结果写回
count。
- 读取
- 在多线程环境下,这三个步骤可能被中断,导致数据竞争。例如,线程 t1 读取到
count的值为 10,然后线程 t2 也读取到count的值为 10。线程 t1 将count的值加 1,并将结果 11 写回count。线程 t2 也将count的值加 1,并将结果 11 写回count。最终count的值只增加了 1,而不是 2。
优化路径:
解决这个问题最常用的方法是使用 synchronized 关键字或者 AtomicInteger 类。
方法一:使用 synchronized 关键字
public class SafeCounterWithSynchronized {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SafeCounterWithSynchronized counter = new SafeCounterWithSynchronized();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.getCount());
}
}
原因:
synchronized关键字保证了increment()方法的原子性。在同一时刻,只有一个线程可以执行该方法。
方法二:使用 AtomicInteger 类
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounterWithAtomicInteger {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
public static void main(String[] args) throws InterruptedException {
SafeCounterWithAtomicInteger counter = new SafeCounterWithAtomicInteger();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.getCount());
}
}
原因:
AtomicInteger类提供了原子性的incrementAndGet()方法,可以保证count的递增操作是原子性的。
选择 synchronized 还是 AtomicInteger?
| 特性 | synchronized |
AtomicInteger |
|---|---|---|
| 锁机制 | 悲观锁 | 乐观锁 |
| 适用场景 | 竞争激烈 | 竞争不激烈 |
| 性能 | 竞争激烈时更好 | 竞争不激烈时更好 |
| 代码复杂度 | 较低 | 较低 |
一般来说,如果竞争非常激烈,使用 synchronized 关键字可能更高效,因为它可以避免大量的 CAS 重试。如果竞争不激烈,使用 AtomicInteger 类可能更高效,因为它可以避免线程阻塞。
案例三:闭包中的对象逃逸
闭包,特别是Lambda表达式,也可能导致对象逃逸。如果Lambda表达式捕获了可变对象,并且该Lambda表达式被传递到其他线程执行,那么就可能发生对象逃逸。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ClosureEscape {
public static void main(String[] args) throws InterruptedException {
List<String> messages = new ArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(2);
// Lambda表达式捕获了可变对象messages
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
messages.add("Message " + i + " from " + Thread.currentThread().getName());
}
};
executor.submit(task);
executor.submit(task);
Thread.sleep(100); // 等待任务完成
executor.shutdown();
System.out.println("Total messages: " + messages.size());
}
}
在这个例子中,Lambda表达式 () -> { ... } 捕获了可变对象 messages。该Lambda表达式被提交给线程池执行,导致多个线程并发地访问 messages,从而可能发生数据竞争。
运行结果(可能):
Total messages: 1997
问题分析:
messages.add()方法不是线程安全的。- 多个线程并发地调用
messages.add()方法,导致数据竞争和不一致。
优化路径:
解决这个问题的方法是使用线程安全的集合类,例如 ConcurrentLinkedQueue 或者 CopyOnWriteArrayList。
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SafeClosure {
public static void main(String[] args) throws InterruptedException {
List<String> messages = new CopyOnWriteArrayList<>(); // 使用线程安全的集合
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
messages.add("Message " + i + " from " + Thread.currentThread().getName());
}
};
executor.submit(task);
executor.submit(task);
Thread.sleep(100); // 等待任务完成
executor.shutdown();
System.out.println("Total messages: " + messages.size());
}
}
原因:
CopyOnWriteArrayList是线程安全的集合类,它通过写时复制的方式来保证线程安全。每次修改CopyOnWriteArrayList时,都会创建一个新的副本,从而避免了数据竞争。
总结表格,方便查阅:
| 案例 | 问题 | 优化方法 |
|---|---|---|
| 发布未完成的对象 | 对象在构造函数尚未完成时就被发布出去 | 使用 final 关键字、静态初始化器、延迟初始化 |
| 共享可变状态 | 多个线程共享一个可变对象,未进行同步 | 使用 synchronized 关键字或者 AtomicInteger 类 |
| 闭包中的对象逃逸 | Lambda表达式捕获了可变对象,并被传递到其他线程执行 | 使用线程安全的集合类,例如 ConcurrentLinkedQueue 或者 CopyOnWriteArrayList |
如何避免对象逃逸?
避免对象逃逸是编写线程安全代码的关键。以下是一些常用的技巧:
- 使用不可变对象: 不可变对象一旦创建,其状态就不能被修改。因此,不可变对象是线程安全的,不需要进行同步。
- 限制对象的可见性: 尽量将对象的可见性限制在最小范围内。例如,可以使用
private关键字来限制对象的访问权限。 - 不要发布未完成的对象: 确保对象在构造函数完成之前不要被发布出去。
- 使用线程安全的集合类: 如果需要在多个线程之间共享集合,请使用线程安全的集合类,例如
ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList等。 - 仔细审查Lambda表达式: 确保Lambda表达式没有捕获可变对象,或者Lambda表达式捕获的对象是线程安全的。
对象逃逸与性能
虽然避免对象逃逸对于编写线程安全的代码至关重要,但在某些情况下,过度的避免对象逃逸可能会影响性能。例如,如果为了避免对象逃逸,而频繁地创建新的对象,可能会增加垃圾回收的负担。
因此,在实际开发中,需要在线程安全性和性能之间进行权衡。在保证线程安全的前提下,尽量减少对象的创建和复制,以提高性能。
最后的几句话
对象逃逸是Java并发编程中一个非常重要的概念。理解对象逃逸,并掌握避免对象逃逸的技巧,是编写高质量的并发代码的基础。希望今天的讲解能够帮助大家更好地理解对象逃逸,并在实际开发中避免相关的陷阱。 线程安全是程序健壮性的基石,要时刻警惕对象逃逸。 了解对象逃逸的原理和危害,才能更好地编写并发代码。