在编程的世界里,线程安全问题是一个永恒的主题。当我们的代码在多线程环境下运行时,如何保证数据的一致性和正确性,避免各种奇怪的并发问题,是每一个开发者都需要面对的挑战。然而,对于这个问题,并没有一个固定的模板答案,因为正确的解决方案取决于具体的应用场景和需求。在这个并发的世界里,我们如何才能编写出零错误的代码呢?
在这篇博客中,我们将一起探索如何在并发环境下编写线程安全的代码。我们将通过深入理解并发中的基本概念,以及学习各种实用的并发工具和方法,帮助我们更好地理解和解决线程安全问题,从而提高我们代码的质量和健壮性,编写出真正的零错误代码。让我们开始这段并发编程的旅程吧。
这三个问题(可见性,原子性,有序性)是由计算机系统的硬件架构、编译优化策略和操作系统的调度机制共同导致的,并不是Java语言本身的问题。不过,由于Java语言需要在这样的环境下运行,所以必须提供相应的机制来处理这些问题。我们来逐一讨论这三个问题的来源:
内存模型(Java Memory Model,简称JMM): 规定了Java程序在多线程环境中如何协调访问共享变量。
在Java中,为了解决并发编程中的可见性,原子性,和有序性问题,Java内存模型
(Java Memory Model,JMM)和相关的并发库
提供了很多机制和工具。
JMM内存模型究竟是什么东西? 它为什么可以解决上面的问题?
Java内存模型(JMM)是一个抽象的概念,它描述了Java程序中各种共享变量(堆内存中的对象实例、静态字段和数组)在多线程环境下如何交互,以及在并发操作时如何处理内存一致性问题。
JMM的主要目的是定义程序中各个变量的访问规则,包括读取赋值、加载存储、锁定解锁等,以保证在多线程环境下,线程对共享变量操作的可见性、原子性和有序性。通过这些规定,JMM帮助开发者编写出正确、高效的并发程序。
// 线程内按照程序顺序,先执行A后执行B
public class ProgramOrderExample {
public void execute() {
int A = 5; // 动作A
int B = A * 6; // 动作B,happens after A
}
}
// 监视器锁规则,解锁happens-before后续的加锁
public class MonitorLockExample {
private final Object lock = new Object();
public void method1() {
synchronized(lock) { // 加锁
// do something...
} // 解锁,happens-before下一次加锁
}
public void method2() {
synchronized(lock) { // 加锁,happens after上一次解锁
// do something...
}
}
}
// volatile变量规则,写操作happens-before后续的读操作
public class VolatileExample {
private volatile boolean ready = false;
public void writer() {
ready = true; // 写操作
}
public void reader() {
if (ready) { // 读操作,happens after写操作
// do something...
}
}
}
// 传递性规则
public class TransitivityExample {
private int A = 0;
private volatile boolean flag = false;
public void method1() {
A = 1; // 动作A
flag = true; // 动作B,happens after A
}
public void method2() {
if (flag) { // 动作C,happens after B
int temp = A * 5; // 动作D,happens after C,从而也happens after A
}
}
}
现在设想一下,假如你是一位出租车司机,正在城市中快速穿梭。突然,一名情绪不稳定的乘客试图抢夺你的方向盘。时间暂停!你会如何应对这种情况呢?
接下来,我将向你介绍三种应对策略:
基于以上三种策略,我们来看看Java是怎么做的。
没有共享就没有伤害, 尽量避免共享变量的使用。
栈封闭
"栈封闭"是一种避免线程安全问题的编程技巧,它意味着只有一个线程可以访问特定的数据。当我们将对象的全部生命周期都限制在单个线程内,即该对象自始至终都不会逃逸出当前线程,那么我们就说这个对象被"栈封闭"了。这样一来,这个对象就完全由这个线程独占,不可能被其他线程访问,从而不存在线程安全问题。
使用ThreadLocal
如果对ThreadLocal
相关内容感兴趣,建议您阅读这篇文章:并发编程 | ThreadLocal - 线程的私有存储
当然,在多数情况下上策是很难达成的,我们来看下中策
这种方式主要依赖硬件级别的原子操作实现,比如Java的Atomic类系列和java.util.concurrent包中的LockFree数据结构等。相比阻塞同步,非阻塞同步提供了更好的性能,因为它减少了线程间的等待。
为了加深你对它的印象,我在文中展示了代码,你可以看一下:
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 创建1000个线程,每个线程对count进行1000次自增操作
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
});
threads[i].start();
}
// 等待所有线程执行完成
for (int i = 0; i < 1000; i++) {
threads[i].join();
}
// 输出最终的count值
System.out.println(counter.getCount()); // 输出1000000
}
}
需要注意的是CAS可能会造成ABA问题, 虽然多数情况下在我们工作中并不影响, 但是假如线上出现问题, 可以考虑这方面的排查思路
这种方式通常使用锁或同步块来实现,它能保证同一时间只有一个线程能访问临界区(共享资源)。当一个线程在执行临界区的代码时,其他线程必须等待。常用的有以下两种:
为了加深你对它的印象,我在文中同样为其展示了代码,你可以看一下:
synchronized
示例:
// 第一种:锁的是当前的是实例对象
// ...略
private int count = 0;
// 同步实例方法
public synchronized void increment() {
count++;
}
// 第二种:锁的是当前类Class对象
// ...略
private static int count = 0;
// 同步静态方法
public static synchronized void increment() {
count++;
}
// 第三种:锁的是lock对象,作用于synchronized修饰的代码块
private int count = 0;
private Object lock = new Object();
public void increment() {
// 同步代码块
synchronized (lock) {
count++;
}
}
ReentrantLock 是JDK1.5之后的又一重大更新。与synchronized关键字相比,ReentrantLock提供了更高级的锁定机制,包括更灵活的锁获取和释放、可中断的锁获取、公平和非公平锁策略以及条件变量等。我将在并发工具类
之后为你讲解。
使用锁可以有效地保证数据的安全性和一致性,防止出现数据竞争的情况。然而,使用锁也有一些潜在的性能问题。锁定会导致线程阻塞,即当一个线程获取不到锁时,它会被挂起,直到锁可用为止。这会导致线程调度开销和上下文切换开销,从而影响系统的整体性能。此外,如果不正确地使用锁,还可能导致死锁、活锁和资源饥饿等问题。我将在并发编程 | 锁 - 并发世界的兜底方案 这篇文章重点展开为你讲解。
感谢你看到这里。现在,让我们回顾一下重要知识点。首先,我为你揭示了线程安全问题的根源:可见性、原子性以及有序性这三大原则。然后,我从语言层面向你展示了Java如何应对这些问题,提出了有效的解决策略。其中,Java内存模型(JMM)无疑是其中最重要的一环,它为我们处理多线程带来的问题提供了基础的理论架构。针对JMM内存模型,我对其进行了详尽的解读,并通过实际的代码示例,帮助你更深入地理解其运行原理和使用方法。在理解和掌握了这些知识后,我们就能够编写出线程安全的代码,有效地避免并发中的常见问题。当然,我们在解决并发问题时,有多种不同的方案可以选择。我为你列举了其中的三种主要策略:互斥同步方案、非阻塞同步方案以及无同步方案。每种方案都有其独特的优点和适用场景,选择哪一种,需要根据我们的具体需求和实际情况来决定。希望你能从中找到最适合你的方案,更好地掌握并发编程。