并发基础

2023/12/14 Java多线程与并发

# 1、进程与线程

# 1.1、进程

进程(Process)是计算机中正在运行的程序。 程序是一种静态的概念,而进程是程序在执行过程中创建的动态实体。每个进程都有自己的内存空间、代码、数据和资源,它也是操作系统进行任务调度和资源分配的基本单位。

进程是正在运行一个软件或者脚本。这样理解更为简单一点。

多个进程可以同时运行在计算机上,彼此独立并且互不干扰。操作系统通过进程管理来控制和监视进程的创建、运行、暂停和终止等操作。进程还可以通过进程间通信机制来实现进程之间的数据交换和同步。

打开任务管理器,运行的程序就是一个个进程。

# 1.2、线程

线程(Thread)是程序执行的最小单位,它是进程的一部分。 一个进程可以包含一个或多个线程。同一个进程中的多个线程共享同一块内存空间和其他资源,它们可以同时执行不同的任务。每个线程都有自己的程序计数器、栈和一组寄存器,这使得线程能够独立地执行代码。

线程的特点

  • 轻量级:相对于进程来说,线程的创建和上下文切换开销较小。
  • 共享资源:同一进程中的线程可以共享内存和其他资源,因此可以更方便地进行数据共享和通信。
  • 并发执行:多个线程可以同时执行,提高了程序的并发性和效率。

线程的使用可以提升程序的性能和响应性,特别是在多核处理器上可以实现并行计算。但多线程编程也需要注意线程间的同步和共享数据的安全性问题,以避免出现竞态条件和数据不一致的情况。

线程的时间效率和空间效率都比进程要高。

# 2、并行与并发

  • 并行

并行(Parallel)是指同时进行多个任务,任务之间可以同时执行,彼此独立。 例如,在多核处理器上,可以同时执行多个线程或进程,这就是并行。

  • 并发

并发(Concurrent)则是指同时交替进行多个任务,任务之间可能有依赖关系或竞争条件。 例如,在单核处理器上,通过时间片轮转的方式,多个线程或进程通过快速切换执行,看起来是同时进行的,但实际上是交替执行的,并发中的任务可能需要依赖共享资源或竞争临界区资源。

并行是多个任务同时进行,而并发是多个任务交替进行,并且可能存在资源竞争和依赖。

并行和并发之间的区别在于任务是否可以同时执行以及是否需要竞争共享资源。并行通常需要硬件支持,如多核处理器,能同时执行多个任务。而并发则可以在单核处理器上通过时间片轮转等技术实现。

在实际应用中,可以用并行提高计算性能和执行速度,可适用于多线程编程或分布式计算;而并发则可以提高资源利用率和系统吞吐量,可适用于任务调度和资源管理。

# 3、多线程的必要性

我们先来看个JMM(Java内存模型)简单模型

Java内存模型

CPU、内存(主存)、I/O(读写),这三者的处理速度有着极大的差异。为了平衡这三者的速度差异,需要在计算机体系结构、操作系统、编译程序上进行优化。

# 3.1、CPU缓存优化

  • CPU(中央处理器)是计算机的核心部件,负责处理大部分的计算任务。然而,CPU处理数据的速度远高于内存读取或写入数据的速度,这种速度上的差异就会导致CPU在等待数据时可能无事可做,从而浪费其处理能力。
  • 为了解决这个问题,专门在CPU和内存之间增加了缓存(Cache)作为一个数据的临时存储区,用于存放CPU预期会用到的数据。因为缓存是位于CPU和内存之间的临时存储区,它的存取速度比内存要快得多,所以能够有效地解决CPU和内存速度上的差异。
  • 当CPU需要读取或写入数据时,它会首先查找缓存中是否有这些数据。如果有(这称为“缓存命中”),CPU就可以直接从缓存读取或写入数据,从而避免了等待内存的消耗时间。如果没有(这称为“缓存未命中”),CPU就需要从内存中读取数据,并同时将这些数据写入缓存,以供后续使用。
  • 通过这种方式,缓存能够有效地利用CPU的高速处理能力,提高计算机的整体性能。

CPU增加缓存,会导致可见性问题。

# 3.2、操作系统优化

操作系统增加了进程和线程的概念来实现分时复用 CPU 资源,进而均衡 CPU 和 I/O 设备的速度差异。

  • 进程是指计算机中正在运行的程序的实例。操作系统通过为每个进程分配一段独立的内存空间和一组资源(如 CPU 时间片、文件描述符等)来管理并控制进程的运行。通过轮流分配 CPU 时间片,操作系统可以让多个进程交替运行,从而实现了 CPU 的分时复用。
  • 线程是指进程中的一个独立执行单元。一个进程可以有多个线程,它们共享该进程的资源和状态。每个线程有自己的栈空间和程序计数器,但它们共享同一进程的内存空间、文件描述符等。操作系统可以通过调度算法在不同的线程之间切换,从而实现多个线程在单个进程中的并发执行。
  • 通过引入进程和线程的概念,操作系统可以将 CPU 时间片分配给不同的进程,实现进程之间的轮流执行并提高 CPU 的利用率。同时,操作系统可以通过线程的并发执行来隐藏 I/O 设备操作的等待时间,提高系统的响应速度。
  • 总而言之,操作系统通过增加进程和线程的概念,实现了分时复用 CPU 资源,使得多个进程和线程可以并发执行,从而在 CPU 和 I/O 设备的速度差异中实现均衡。

操作系统增加了进程、线程,会导致原子性问题。

# 3.3、编译程序优化

编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

编译程序优化指令执行次序是指在编译过程中,编译器根据各种优化策略和规则,自动调整和重排程序中指令的顺序,以提高程序运行的效率和性能。

编译器进行指令优化的主要目的是为了解决以下问题:

  • 硬件资源利用:通过优化调整指令次序,可以更好地利用CPU的并行性,提高CPU利用率,减少资源浪费。例如,通过指令调度可以让CPU同时执行多条无关的指令,从而提高指令的并行等级。
  • 减少等待时间:某些指令执行需要等待前面指令的结果,这会导致CPU空闲等待。通过优化指令顺序,可以尽可能地避免这种依赖,减少CPU空闲时间,提高运行效率。
  • 克服性能瓶颈:例如,优化内存访问指令的顺序,可以减少缓冲区溢出或者下溢的可能性,避免因为内存访问导致的性能瓶颈。
  • 管线浪费:现代CPU通常将指令执行过程分解成多个阶段,并行执行以提高性能,这就是所谓的管线技术。如果指令的执行顺序不能很好地匹配CPU管线,就会导致管线阶段的闲置,造成性能下降。

通过这些优化,编译器可以帮助程序员在不需要手动干预的情况下自动提高程序的运行效率和性能,使得程序在各种硬件平台上都能获得更好的运行效果。

编译程序优化指令执行次序,会导致有序性问题。

# 4、线程安全问题

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }
}

public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}

// 输出结果:992
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

为什么多线程操作会导致结果小于1000呢?是什么导致了线程安全问题?

导致线程安全问题的主要原因有三个方面:CPU缓存导致的可见性问题、进程或线程导致的原子性问题、编译器优化导致的有序性问题。

# 5、线程安全导致原因

多线程操作共享变量时,才会引起线程安全问题。所以下面操作的变量默认都是共享变量。

# 5.1、可见性

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

CPU 缓存会导致可见性规则打破,案例:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;
1
2
3
4
5
6

  • 假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
  • 此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。

线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。所以 CPU 缓存会导致可见性问题

# 5.2、原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

分时复用会引起原子性打破,案例:

int i = 1;

// 线程1执行
i += 1;

// 线程2执行
i += 1;
1
2
3
4
5
6
7

这里需要注意的是:i += 1需要三条 CPU 指令

  • 将变量 i 从内存读取到 CPU寄存器;
  • 在CPU寄存器中执行 i + 1 操作;
  • 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。所以 CPU分时复用会导致原子性问题

# 5.3、有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

重排序优化会打破程序有序性,案例:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2
1
2
3
4

虽然上述代码语句2在语句1之后,但是多线程下执行顺序不一定按语句1、语句2这个顺序执行。重排序是指计算机系统(如处理器、编译器等)在执行程序时,按照某种规则重新调整指令或操作的顺序,以提高程序性能或满足其他需求。

重排序可以分为三种类型:编译器重排序、处理器重排序和内存系统重排序。

  • 编译器重排序:在编译阶段,编译器可能会对源代码中的指令重新排序,以优化代码的执行效率。编译器重排序不会改变程序的语义,即保证最终的执行结果与源代码的顺序一致。编译器重排序可以通过指令级并行、循环展开、常量传播等技术来实现。
  • 处理器重排序:在处理器执行指令时,由于处理器采用了流水线技术,它可以对指令进行重排序,以尽可能地利用处理器资源。处理器重排序可能包括指令级重排序(乱序执行)和内存访问重排序。指令级重排序是指处理器可以改变指令的执行顺序,以提高指令的并行度和执行效率。内存访问重排序是指处理器可以改变对内存的读写操作的顺序,以充分利用内存系统的各级缓存。
  • 内存系统重排序:由于现代计算机系统中存在多级缓存、总线和内存等层次结构,因此对于内存的读写操作也可能存在重排序。内存系统重排序可以通过缓存一致性协议和写缓冲区等技术来实现。

重排序在一定程度上可以提高程序的执行速度和效率,但必须在确保程序正确性和语义一致性的前提下进行。在并发编程中,重排序可能会引发数据竞争、原子性问题等多线程并发问题,因此需要采取同步和内存屏障等手段进行控制和保护。

编译程序优化的重排序下,程序的有序性会打破。所以编译器优化和处理器重排序可能会导致有序性问题

Java重排序流程:

  • 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
  • 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
  • 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

# 6、Java解决并发问题

Java需要解决多线程并发安全问题,就需要解决 可见性、有序性、原子性 这三个问题。那么,Java是如何解决的呢?

JMM(Java内存模型)规范了 JVM 如何按需禁用缓存和编译优化的方法。

# 6.1、volatile、synchronized 和 final 关键字

这几个关键字,后面会进行详解,这里先看下概念:

  • volatile

volatile是Java提供的一种轻量级的同步机制:

  • 保证共享变量的可见性:当一个线程修改了volatile变量的值,新值对于其他线程来说是可以立即得知的。也就是说,volatile变量在各个线程中是一致的,这就是所谓的可见性。
  • 禁止指令重排序:有些场合下,为了提高性能,编译器和处理器可能会对输入代码进行优化,它们会把存在数据依赖关系的操作,重新进行排序。声明为volatile的变量,编译器和处理器就不会对其进行重排序。
  • 不提供原子性:虽然volatile变量能保证可见性和有序性,但没办法保证复合操作的原子性。例如,num++这样的操作,其实包含了多个子操作,包括:读取原有的值、进行加1操作、将新值写回到内存。这三个子操作并不是原子性的,也并不会因为volatile声明而变成原子操作。因此,若需要保证原子性,通常需要结合synchronized或者Atomic变量来使用。
  • synchronized

synchronized关键字在Java中被用来作为一种同步锁:

  • 保证线程安全:synchronized可以修饰方法或者以同步块的形式来修饰代码段,能够保证在同一时刻最多只有一个线程执行该段代码,从而保证了类实例的成员变量的线程安全。
  • 保证可见性和有序性:synchronized可以保证被其修饰的变量的修改能够及时地被其他线程看到,从而避免出现数据不一致的情况。此外,其还能够保证线程的执行是有序的,防止出现指令重排的情况。
  • 锁的释放与获取:包括以下三种情况会释放锁。一是当前线程执行完同步代码就会释放掉锁。二是如果线程执行同步代码块的过程中,出现了异常且异常被捕获,也会导致锁的释放。三是当前线程在执行同步代码块的过程中执行了锁所属的对象的wait()方法,这也会导致线程释放掉锁。
  • final

final保证不可修改,不变的内容不会引起多线程安全问题:

  • final修饰变量:final修饰的变量表示常量,它的值不能被修改。一旦赋值后,就不能再改变。常量一般使用大写字母表示,并使用下划线分隔单词。
  • final修饰方法:final修饰的方法不能被子类重写。这种方法在继承关系中起到了保护作用,可以确保父类的方法行为不被子类修改。
  • final修饰类:final修饰的类不能被继承,即不能有子类继承该类。这样的类通常是不希望被修改和扩展的最终版本。
  • final修饰参数:final修饰方法的参数,表示该参数在方法内部不可修改。这可以用来保护方法内部的参数不被意外改变。

# 6.2、可见性、有序性、原子性的理解

  • 可见性

Java提供了volatile关键字来保证可见性。

volatile关键字可以保证共享变量的可见性。当一个共享变量被volatile修饰时,它的值的修改会立即被更新到主存中,当其他线程需要读取该共享变量时,它会去主存中获取最新的值。相比之下,普通的共享变量不能保证可见性,因为其修改的值可能会延迟写入主存,当其他线程需要读取时,可能得到的仍然是旧值,从而无法保证可见性。

通过synchronized和Lock保证可见性。

通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 有序性

在Java中,使用volatile关键字可以确保一定的有序性。此外,也可以使用synchronized和Lock来确保有序性。显然,synchronized和Lock保证同步代码每次只有一个线程执行,这相当于让线程按顺序执行同步代码,自然而然地保证了有序性。当然,Java内存模型(JMM)通过Happens-Before规则来保证有序性。

  • 原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。 请分析以下哪些操作是原子性操作:

x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3
1
2
3
4

上面4个语句只有语句1的操作具备原子性。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

# 6.3、Happens-Before 规则

Java内存模型(JMM)通过Happens-Before规则用于确保多线程环境下的可见性和有序性,指定了对于一个操作的结果,在另一个操作中的可见性和有序性的保证。

Java中的Happens-Before规则:

  • 程序顺序规则(Program Order Rule):在同一个线程中,按照程序的顺序,前一个操作的结果对于后续操作是可见的。换句话说,线程中的操作按照代码顺序执行。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作对于后续的lock操作是可见的。之前已经释放的锁,之后的加锁操作可以感知到。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作,对于后续对该变量的读操作,是可见的。volatile关键字会禁止指令重排,保证了写操作的可见性。
  • 线程启动规则(Thread Start Rule):一个线程的start操作对于其它线程中的后续操作是可见的。换句话说,其他线程可以看到线程启动之后的操作。
  • 中断规则(Thread Interrupt Rule):一个线程中断的发生(调用interrupt方法),对于该线程的后续操作是可见的。
  • 线程终结规则(Thread Termination Rule):主线程的所有操作对于所有已经加入到该线程的子线程的join操作是可见的。换句话说,主线程的操作对于子线程的join操作是可见的。
  • 线程中断规则(Thread Interruption Rule):对于在线程A中调用线程B的interrupt方法,如果线程B捕获到该中断,则线程A的所有操作对于线程B捕获中断之后的操作是可见的。
  • 对象终结规则(Finalizer Rule):一个对象的构造函数完成对该对象的初始化后,对于finalize方法的调用是可见的。

# 7、线程安全程度

线程安全可以从强到弱分为以下几个级别:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立

  • 不可变

不可变对象是线程安全的,因为它们的状态在创建后不可更改。多个线程可以同时访问和使用不可变对象,而无需任何同步控制。

  • final 关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
  • 绝对线程安全

绝对线程安全意味着对象的所有方法都是线程安全的,可以多线程并发地访问和修改对象,而不需要额外的同步控制。这通常是通过使用同步机制(如synchronized关键字或使用Lock接口)或线程安全的数据结构来实现的。

  • 相对线程安全

  • 相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
  • 在 Java 中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
  • 线程兼容

  • 线程兼容意味着对象在单线程环境中是安全的,但在多线程环境中可能会有问题。在多线程环境中访问和修改该对象时,可能需要使用同步控制来确保多个线程之间的访问和修改顺序。这种级别通常需要开发人员来注意使用同步机制。
  • Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
  • 线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

# 8、线程安全实现

# 8.1、互斥同步

互斥同步是一种保证多个线程在访问共享资源时的互斥性的机制,以防止竞态条件和数据不一致问题。

核心

加锁:synchronized 和 ReentrantLock。

互斥同步的详解

  • 互斥(Mutual exclusion):互斥是指同一时刻只允许一个线程访问共享资源,其他线程必须等待。互斥机制可以保证在任何时刻只能有一个线程对共享资源进行操作。
  • 临界区(Critical section):临界区是指一段代码,其中访问共享资源的部分。在进入临界区前,线程需要获得互斥锁,执行完临界区代码后释放互斥锁,也就是说只有获得互斥锁的线程才能进入临界区。
  • 互斥锁(Mutex):互斥锁是一种同步机制,用于保护临界区的访问。在进入临界区之前,线程必须获取互斥锁,如果互斥锁已经被其他线程持有,则请求线程会被阻塞,直到互斥锁被释放为止。一旦线程获得了互斥锁,其他线程将无法获得该锁,直到它被释放。
  • 条件变量(Condition variable):条件变量是一种同步机制,用于在共享资源的状态发生变化时进行线程的等待和唤醒。条件变量通常与互斥锁一起使用。当某个线程发现共享资源的状态不满足其要求时,它会进入等待状态,同时释放互斥锁,允许其他线程继续执行。当其他线程更改了共享资源的状态并满足该线程的要求时,它会被唤醒,并重新获取互斥锁。

互斥同步的主要问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步

# 8.2、非阻塞同步

阻塞同步采用的是悲观策略,认为一个线程在修改时,一定会有其他线程进行访问修改,导致数据不一致。

非阻塞同步采用的是乐观策略,认为一个线程在修改时,不会有其他线程进行访问修改,那就修改成功了,如果有其他线程修改,那就采取补偿措施(不断地重试,直到成功为止)。一个线程在进行某段特定代码(临界区)操作时,其他线程可以进行其他代码的操作,不需要进行等待。

# 8.2.1、CAS

CAS(Compare And Swap)是用于解决多线程环境下的并发问题的一个方案(非阻塞同步方案),保证原子性。

通过比较一个内存位置的值与预期值,如果相等,则将新值写入该内存位置,否则不做任何操作。

  • CAS 操作包括三个操作数:内存位置(内存地址)V、预期值A和新值B。具体操作步骤如下:

  • 将内存位置的当前值(即预期值)V与预期值A进行比较。
  • 如果相等,则将新值B写入内存位置,操作成功。
  • 如果不相等,则表示内存位置的值已经被其他线程修改,操作失败。根据需要可以重试或者执行其他处理逻辑。
  • CAS 的特点和优势包括:

  • 原子性:CAS 操作是原子操作,保证了操作的完整性。在操作中,其他线程不能修改内存位置的值,因此可以确保数据的一致性。
  • 无锁:相比于使用锁进行同步,CAS 是一种无锁的方式。它避免了线程阻塞和上下文切换带来的开销,在高并发的情况下性能较好。
  • 忙等待:由于 CAS 是基于自旋的方式进行操作,当操作失败时,线程会忙等待直到操作成功。这可能会造成一定的 CPU 开销。
  • 无阻塞:由于 CAS 不涉及线程阻塞,因此不存在死锁的问题。

CAS 主要应用于一些需要高并发和原子性操作的场景,比如非阻塞算法、无锁队列和乐观锁等。在 Java 中,java.util.concurrent.atomic 包提供了一些原子类,如 AtomicInteger 和 AtomicLong,它们底层使用了 CAS 来实现线程安全的操作。

# 8.2.2、ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

在使用 CAS 时可能存在ABA问题,也就是说即使内存位置的值已经变化,但其实际含义对当前线程来说是没有变化的。为了解决ABA问题,可以使用版本号或引用的方式进行解决,比如 AtomicStampedReference 和 AtomicMarkableReference 类。

# 8.3、无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

# 8.3.1、栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}

public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}

/**
 * 输出结果:
 * 100
 * 100
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 8.3.2、线程本地存储

如果能保证共享变量每次使用时,都在同一个线程中执行,那么就没有线程安全问题可言。

# 8.3.3、可重入代码

可重入代码(reentrant code)是指可以由多个任务并发使用,且不会引发数据错误的代码。换言之,一个可重入的程序、函数或例程在执行过程中被中断,然后在中断返回前再次调用,它都将产生可预期的结果。

可重入性是一个重要的概念,尤其是在多线程或多任务的并发编程环境中,它确保了代码的执行不会被其他线程或任务的干扰。

要编写可重入代码,就必须避免使用全局变量、静态变量或其他非局部的状态,也需要避免调用非重入的函数,并确保对互斥对象的访问(如锁)是正确的。