并发编程 | 线程安全-编写零错误代码

一、引言

在编程的世界里,线程安全问题是一个永恒的主题。当我们的代码在多线程环境下运行时,如何保证数据的一致性和正确性,避免各种奇怪的并发问题,是每一个开发者都需要面对的挑战。然而,对于这个问题,并没有一个固定的模板答案,因为正确的解决方案取决于具体的应用场景和需求。在这个并发的世界里,我们如何才能编写出零错误的代码呢?
在这篇博客中,我们将一起探索如何在并发环境下编写线程安全的代码。我们将通过深入理解并发中的基本概念,以及学习各种实用的并发工具和方法,帮助我们更好地理解和解决线程安全问题,从而提高我们代码的质量和健壮性,编写出真正的零错误代码。让我们开始这段并发编程的旅程吧。

二、基础 | 理解线程不安全问题所在:可见性,原子性,有序性

这三个问题(可见性,原子性,有序性)是由计算机系统的硬件架构、编译优化策略和操作系统的调度机制共同导致的,并不是Java语言本身的问题。不过,由于Java语言需要在这样的环境下运行,所以必须提供相应的机制来处理这些问题。我们来逐一讨论这三个问题的来源:

  1. 可见性:现代计算机系统中,为了提高系统的性能,往往会将主内存中的数据复制到CPU的缓存中。多个CPU核心各自有自己的缓存,可能会同时复制主内存的同一个数据。当某个CPU对它缓存中的数据进行了修改,其他CPU由于无法立即看到这个修改,就产生了数据的可见性问题。(多级缓存设计带来的问题)
  2. 原子性:对于复合操作(由多个步骤组成的操作),如果在执行完一部分步骤后,线程被操作系统调度出去,此时另一个线程执行了相同的操作,可能会导致数据的不一致。这就是原子性问题。(高级语言:CPU指令 = 1:N, 这时线程切换就会带来问题)
  3. 有序性:为了提高程序的运行效率,编译器和处理器可能会对指令进行重新排序。虽然这种重排序不会改变单线程程序的执行结果,但在多线程环境下,由于各个线程可能看到不同的指令执行顺序,可能会导致数据的不一致。这就是有序性问题。(编译优化带来的指令重排序)

三、基础 | Java在这方面的努力

内存模型(Java Memory Model,简称JMM): 规定了Java程序在多线程环境中如何协调访问共享变量。
在Java中,为了解决并发编程中的可见性,原子性,和有序性问题,Java内存模型(Java Memory Model,JMM)和相关的并发库提供了很多机制和工具。

  1. 可见性:在Java中,关键字volatile能保证变量的修改对其他线程立即可见,这是通过禁止指令重排序和强制从主内存(而不是CPU缓存)读取变量来实现的。
  2. 原子性:Java提供了Atomic类(如AtomicInteger,AtomicLong等)来保证对变量操作的原子性。另外,synchronized关键字和Lock接口的实现类(如ReentrantLock)也能保证在同一时刻只有一个线程访问临界区,从而实现复合操作的原子性。
  3. 有序性:Java的synchronized和volatile关键字能防止指令重排序。特别地,volatile还有一个双重作用:保证可见性和防止指令重排序。

四、进阶 | 解读JMM内存模型

JMM内存模型究竟是什么东西? 它为什么可以解决上面的问题?

Java内存模型(JMM)是一个抽象的概念,它描述了Java程序中各种共享变量(堆内存中的对象实例、静态字段和数组)在多线程环境下如何交互,以及在并发操作时如何处理内存一致性问题。

JMM的主要目的是定义程序中各个变量的访问规则,包括读取赋值、加载存储、锁定解锁等,以保证在多线程环境下,线程对共享变量操作的可见性、原子性和有序性。通过这些规定,JMM帮助开发者编写出正确、高效的并发程序。

  • 如果想要继续深入了解JMM内存模型,强烈建议阅读: Java内存模型

  • 关于happen-before原则的官方定义文档如下:
    并发编程 | 线程安全-编写零错误代码_第1张图片
    JavaSE官方定义文档

五、进阶 | JMM内存模型代码示例

  1. 程序顺序规则:
// 线程内按照程序顺序,先执行A后执行B
public class ProgramOrderExample {
    public void execute() {
        int A = 5; // 动作A
        int B = A * 6; // 动作B,happens after A
    }
}
  1. 监视器锁规则:
// 监视器锁规则,解锁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...
        }
    }
}
  1. volatile变量规则:
// 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...
        }
    }
}
  1. 传递性规则:
// 传递性规则
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
        }
    }
}

六、基础 | 掌握线程安全的方案

现在设想一下,假如你是一位出租车司机,正在城市中快速穿梭。突然,一名情绪不稳定的乘客试图抢夺你的方向盘。时间暂停!你会如何应对这种情况呢?

接下来,我将向你介绍三种应对策略:

  1. 上策:尽力用言语安抚乘客,让他们放弃试图抢夺方向盘(共享变量)的想法。或者,在拒载乘客的情况下,预防这种事情的发生。
  2. 中策:你可以始终保持警惕。在正常驾驶时,你的注意力分散在各处,但是当你发现乘客试图伸手去夺取方向盘的时候,你会立即用一只手(你是个经验丰富的司机,一只手就足以控制住车辆)抵挡住他们。
  3. 下策:安装一个可开关的安全屏障,平时为了和乘客交流,可以将其打开。但在紧急情况下,你可以迅速启动这个安全屏障,使乘客无法触及到你的方向盘。

基于以上三种策略,我们来看看Java是怎么做的。

无同步方案

没有共享就没有伤害, 尽量避免共享变量的使用。

栈封闭
"栈封闭"是一种避免线程安全问题的编程技巧,它意味着只有一个线程可以访问特定的数据。当我们将对象的全部生命周期都限制在单个线程内,即该对象自始至终都不会逃逸出当前线程,那么我们就说这个对象被"栈封闭"了。这样一来,这个对象就完全由这个线程独占,不可能被其他线程访问,从而不存在线程安全问题。

使用ThreadLocal
如果对ThreadLocal相关内容感兴趣,建议您阅读这篇文章:并发编程 | ThreadLocal - 线程的私有存储

当然,在多数情况下上策是很难达成的,我们来看下中策

非阻塞同步方案

这种方式主要依赖硬件级别的原子操作实现,比如Java的Atomic类系列和java.util.concurrent包中的LockFree数据结构等。相比阻塞同步,非阻塞同步提供了更好的性能,因为它减少了线程间的等待。

  1. 使用CAS理念实现的代码逻辑
  2. 原子类例如:AtomicInteger (本质也是CAS, 但是这个是java提供的类)

为了加深你对它的印象,我在文中展示了代码,你可以看一下:

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问题, 虽然多数情况下在我们工作中并不影响, 但是假如线上出现问题, 可以考虑这方面的排查思路

互斥(阻塞)同步方案

这种方式通常使用锁或同步块来实现,它能保证同一时间只有一个线程能访问临界区(共享资源)。当一个线程在执行临界区的代码时,其他线程必须等待。常用的有以下两种:

  1. syncronized关键字
  2. ReentrantLock

为了加深你对它的印象,我在文中同样为其展示了代码,你可以看一下:
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内存模型,我对其进行了详尽的解读,并通过实际的代码示例,帮助你更深入地理解其运行原理和使用方法。在理解和掌握了这些知识后,我们就能够编写出线程安全的代码,有效地避免并发中的常见问题。当然,我们在解决并发问题时,有多种不同的方案可以选择。我为你列举了其中的三种主要策略:互斥同步方案、非阻塞同步方案以及无同步方案。每种方案都有其独特的优点和适用场景,选择哪一种,需要根据我们的具体需求和实际情况来决定。希望你能从中找到最适合你的方案,更好地掌握并发编程。

你可能感兴趣的:(并发编程,安全,bug,java,spring,后端,开发语言)