深入理解 Java 中的 volatile 关键字

暮色四合,晚风轻拂,湖面上泛起点点波光,宛如撒下了一片星河。

前言

在多线程编程中,确保共享变量的可见性是非常重要的。volatile 关键字就是为了解决这个问题而设计的。本文将深入介绍 volatile 关键字的作用、原理以及在实际开发中的应用场景。

一、CPU 三级缓存

计算机中的三级缓存通常是指处理器芯片(CPU)上的 L1、L2 和 L3 缓存层次结构。这三级缓存按照其靠近处理器核心和主存的距离分布,具有不同的特点和作用:

  1. L1 Cache(一级缓存)
    • 位置:位于处理器核心内部或非常接近处理器核心。
    • 作用:L1 缓存是最接近处理器核心的缓存层次,主要用于存储处理器当前正在执行的指令和数据。由于其靠近处理器核心,访问速度非常快,但容量通常较小。
  2. L2 Cache(二级缓存)
    • 位置:位于处理器核心和主存之间,通常在处理器芯片上但比 L1 缓存更大更远。
    • 作用:L2 缓存用于存储 L1 缓存未命中的数据。它比 L1 缓存容量大,访问速度较慢但仍比主存快。
  3. L3 Cache(三级缓存)
    • 位置:通常位于处理器芯片上,被多个处理器核心共享。
    • 作用:L3 缓存用于存储 L2 缓存未命中的数据或者多个核心之间共享的数据。它的容量比 L2 更大,速度比主存快,但比 L2 和 L1 慢。

这三级缓存层次结构设计的目的是在处理器核心和主存之间提供多层次的快速访问存储,以提高数据访问速度和整体系统性能。 L1 缓存作为最快速但容量最小的缓存,L2 缓存作为 L1 缓存未命中时的备用存储,而 L3 缓存则更大、更慢但能提供更高的整体性能,因为在一个计算机系统中的多个核心之间共享数据。

workspace.png

缓存虽然可以提升系统性能,却也带来了两个非常严重的问题:

  1. 缓存如何才能保证一致性
  2. 多线程环境中如何保证正确性

二、JMM

想要 CPU 缓存与主内存保证一致性,这想想就很复杂,尤其是在多线程环境下。为了简化 JAVA 开发人员的工作,JAVA 定义了一个概念 —— JMM。

JMM(Java Memory Model,Java 内存模型)是 Java 平台定义的一种规范,用于规定 Java 程序中多线程之间的内存访问和操作行为。它定义了多线程程序中的共享内存模型,以及在共享内存模型下,对变量读写、锁的获取和释放等操作的具体规则。

workspace (1).png

JMM 主要解决了以下几个问题:

  1. 内存可见性:保证一个线程对共享变量的修改对其他线程是可见的。
  2. 指令重排序:禁止编译器和处理器对指令进行重排序优化。
  3. 原子性:保证一个操作(如读写变量)是原子的,即在执行过程中不会被中断。
  4. happens-before 关系:规定了程序中操作的执行顺序,确保线程之间的一致性。

JMM 通过对线程之间的内存交互行为进行规范,使得程序员能够编写出正确的多线程程序。

JMM 定义了 8 种原子性操作,以确保在多线程环境中对共享内存的访问和操作保持正确性和一致性。以下是这 8 种操作的具体用途:

  1. lock(锁定):这个操作作用于主内存的变量,它把一个变量标记为一条线程独占状态。这意味着,被锁定后,这个变量就只能被持有锁的线程访问。
  2. unlock(解锁):这个操作也作用于主内存的变量,它把一个处于锁定状态的变量释放出来。解锁后的变量才可以被其他线程锁定。
  3. read(读取):这个操作作用于主内存的变量,它把一个变量的值从主内存传送到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):这个操作作用于工作内存的变量,它把read操作的值放入工作内存中的变量副本中。
  5. use(使用):这个操作作用于工作内存的变量,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作。
  6. assign(赋值):这个操作作用于工作内存的变量,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作。
  7. store(存储):这个操作作用于工作内存的变量,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用。
  8. write(写入):这个操作作用于主内存的变量,它把 store 传送值放到主内存中的变量中。

workspace (1).png

这些操作都是原子的,不能被中断。它们共同支持了线程间的同步和并发控制,使得 Java 程序在各种平台下都能达到一致的并发效果。

三、并发编程正确性的基础

在并发编程中,有几个关键的概念是确保多线程程序正确性的基础:可见性、有序性和一致性。

  1. 可见性(Visibility):可见性指的是一个线程对共享变量的修改,是否能及时被其他线程看到。在多线程环境中,每个线程都有自己的工作内存(缓存),一个线程对变量的修改可能不会立即被写回主内存,其他线程也可能从自己的工作内存中读取变量的旧值,从而导致数据不一致。例如:

    public class VisibilityExample {
        // 一个共享变量,控制线程是否停止
        private static boolean stop = false;
    
        public static void main(String[] args) throws InterruptedException {
            // 启动一个新线程,运行一个无限循环
            Thread thread = new Thread(() -> {
                while (!stop) { // 循环检查 stop 变量
                    // busy-wait 忙等待
                }
            });
            thread.start();
    
            Thread.sleep(1000); // 确保新线程启动并运行一段时间
            stop = true; // 更新 stop 变量,尝试让线程停止
        }
    }
    
    

    在上述代码中,主线程更新 stop 变量,但如果没有适当的同步机制,工作线程可能永远看不到这个更新。

  2. 有序性(Ordering):有序性指的是程序执行过程中指令的顺序。在单线程环境中,程序的执行顺序通常按照代码的编写顺序进行。然而,在多线程环境中,由于编译器优化、处理器重排序等原因,指令的实际执行顺序可能与代码的编写顺序不同,这可能导致线程间不一致的行为。例如:

    public class OrderingExample {
        private int a = 0;
        private boolean flag = false;
    
        public void writer() {
            a = 1;          // 1. 赋值操作1
            flag = true;    // 2. 赋值操作2
        }
    
        public void reader() {
            if (flag) {     // 3. 检查 flag
                int i = a;  // 4. 使用变量 a
            }
        }
    }
    

    在这个示例中,编译器和处理器可能会将指令重排序,使得 a = 1flag = true 的执行顺序不同于代码书写顺序,这会影响多线程环境下的正确性。

  3. 原子性(Atomicity):原子性指的是一个操作是不可分割的,即使在多线程环境下也是如此。原子操作执行时,其他线程不能中断或观察到它的部分完成状态。例如:

    public class AtomicityExample {
        private int count = 0;
    
        public void increment() {
            count++; // 递增操作(非原子性)
        }
    }
    

    在上述代码中,count++ 不是原子操作,它实际上由三个步骤组成:读取 count 的值增加值写回 count。在多线程环境中,可能会出现竞态条件,导致最终结果不正确。

四、volatile 关键字

在 Java 中,volatile 是一个关键字,用于声明变量。当一个变量被声明为 volatile 时,它告诉编译器和运行时系统,这个变量是可见的(即对其他线程可见)并且不会被缓存。换句话说,使用 volatile 修饰的变量能够确保对它的读取写入操作都是原子的,并且能够立即反映在其他线程中。

例如:当线程 1 执行写入操作之后,会立即执行写回主内存的操作,并通知其他线程缓存失效。当线程 2 执行读取操作时,会从主内存读取最新值到工作内存。

在这里插入图片描述

五、volatile 可见性

情景分析:假设有一个 volatile 变量 counter,初始值为 0。现在有两个线程 T1T2 同时读取这个变量,然后各自对其进行递增操作(即 counter++),最后将结果写回共享内存。

  1. 初始化
    • c o u n t e r = 0 counter = 0 counter=0
  2. 读取
    • 线程 T 1 T1 T1 T 2 T2 T2 同时读取 c o u n t e r counter counter 的值,都读到 0 0 0
  3. 修改
    • 线程 T 1 T1 T1 c o u n t e r counter counter的值加 1 1 1,得到 1 1 1
    • 线程 T 2 T2 T2 也将 c o u n t e r counter counter 的值加 1 1 1,得到 1 1 1
  4. 写回
    • 线程 T 1 T1 T1 1 1 1 写回 c o u n t e r counter counter
    • 线程 T 2 T2 T2 也将 1 1 1 写回 c o u n t e r counter counter
public class Counter {
    // 计数器变量声明为 volatile,以确保在多线程环境中的可见性
    private static volatile int counter;

    public static void main(String[] args) throws InterruptedException {
        // 循环运行测试方法 100 次
        for (int i = 0; i < 100; i++) {
            test(); // 调用测试方法
            counter = 0; // 重置计数器为0,以便下一次测试
        }
    }

    // 测试方法,用于演示多线程环境下的计数器操作
    static void test() throws InterruptedException {
        // 创建一个新线程 t1,该线程会在延迟后对计数器进行递增操作
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter++; // 对计数器进行递增操作
        });

        // 创建另一个新线程 t2,该线程也会在延迟后对计数器进行递增操作
        Thread t2 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter++; // 对计数器进行递增操作
        });

        // 启动两个线程
        t1.start();
        t2.start();

        // 等待两个线程执行完成
        t1.join();
        t2.join();

        // 打印计数器的值
        System.out.println(counter);
    }
}

最终,测试结果如下图所示:

image.png

经多次测试,我们发现出现了并发问题。

为什么会出现问题?

这个问题的答案,常规回答是:

c o u n t e r + + counter++ counter++ 操作实际上分解为以下三个步骤:

  1. 读取 c o u n t e r counter counter 的当前值。
  2. 将读取到的值加 1 1 1
  3. 将计算后的新值写回 c o u n t e r counter counter

在多线程环境下,这些步骤不是原子的,多个线程可能会交替执行这些步骤,导致竞态条件。例如上面的例子中,两个线程都同时读取了 c o u n t e r counter counter 0 0 0,然后分别加 1 1 1 并写回,导致最终值错误。

这个回答没什么不对。但仔细思考一下,我们提出几个问题:

  1. 我们不是加了 volatile 关键字修饰吗?
  2. 难道 volatile 关键字不能解决上面的问题?
  3. 既然不能解决那为什么使用 volatile 关键字又有什么用?

回答问题之前,我们先回顾一下可见性的定义:
可见性指的是一个线程对共享变量的修改,是否能及时被其他线程看到。

volatile 关键字修饰的变量是满足可见性的,即一个线程对变量进行了修改,其他线程会及时看到。

即:无论是 T 1 T1 T1 线程还是 T 2 T2 T2 线程谁先修改了变量,相互之间应该及时收到对方修改之后变量的值。

例如:线程 T 1 T1 T1 c o u n t e r counter counter 的值先加 1 1 1,得到 1 1 1 时,线程 T 2 T2 T2 应该及时获取到最新值 1 1 1,然后在新值上执行 + + ++ ++ 操作。反之,亦成立。

可是,事实并非如此。

我们将这段逻辑的处理流程放大,看看究竟发生了什么?

在这里插入图片描述

很明显,问题出现在递增阶段。递增阶段,当 T1 线程写回前,T2 线程已经读取了变量。这不是和可见性相违背了吗?

该如何理解可见性?

可见性指的是一个线程对共享变量的修改,是否能及时被其他线程看到。

我们注意到,可见性是在修改变量之后立刻写回主存,并及时让其他线程看到,并非立刻让其他线程看到。

volatile 拥有可见性,但是不能保证原子性。所以,出现了上述的并发问题。

那么,想要解决这一问题,就需要使用同步机制保证原子性。

public class Counter {
    // 将计数器变量声明为 volatile,以确保在线程间的可见性
    private static volatile int counter;

    public static void main(String[] args) throws InterruptedException {
        // 运行测试方法 100 次
        for (int i = 0; i < 100; i++) {
            test(); // 调用测试方法
            counter = 0; // 为下一次测试重置计数器
        }
    }

    // 用于测试线程同步的方法
    static void test() throws InterruptedException {
        // 创建一个新线程 t1,在延迟后增加计数器值
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (Counter.class) { // 在 Counter 类对象上同步
                counter++; // 在同步块中增加计数器值
            }
        });

        // 创建另一个线程 t2,也在延迟后增加计数器值
        Thread t2 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (Counter.class) { // 在 Counter 类对象上同步
                counter++; // 在同步块中增加计数器值
            }
        });

        // 启动两个线程
        t1.start();
        t2.start();

        // 等待两个线程执行完成
        t1.join();
        t2.join();

        // 在两个线程都完成后打印计数器的值
        System.out.println(counter);
    }
}

新的问题诞生了,可见性似乎很鸡肋。因为似乎可以不添加 volatile 关键字修饰,直接使用 synchronized 加锁同步。

public class Counter {
    // 普通变量,未使用 volatile 修饰
    private static int counter;

    public static void main(String[] args) throws InterruptedException {
        // 运行测试方法 100 次
        for (int i = 0; i < 100; i++) {
            test(); // 调用测试方法
            counter = 0; // 为下一次测试重置计数器
        }
    }

    // 用于测试线程同步的方法
    static void test() throws InterruptedException {
        // 创建一个新线程 t1,在延迟后增加计数器值
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (Counter.class) { // 在 Counter 类对象上同步
                counter++; // 在同步块中增加计数器值
            }
        });

        // 创建另一个线程 t2,也在延迟后增加计数器值
        Thread t2 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (Counter.class) { // 在 Counter 类对象上同步
                counter++; // 在同步块中增加计数器值
            }
        });

        // 启动两个线程
        t1.start();
        t2.start();

        // 等待两个线程执行完成
        t1.join();
        t2.join();

        // 在两个线程都完成后打印计数器的值
        System.out.println(counter);
    }
}

上面的代码测试运行,我们会发现是正确的。

image.png

这是因为 sychronized 也是可以保证可见性的。这进一步证明了 volatile 似乎没有用。

然而,事实并非如此。我们需要有一个基本认知是:在并发编程中(即:多线程环境),有一些场景只需要保证可见性,而不需要保证原子性或有序性。

例如,以下场景只需保证可见性:

  1. 标志位:使用 volatile 变量作为标志位来控制线程的行为。

    public class FlagExample {
        private volatile boolean stop = false;
    
        public void runExample() {
            Thread task = new Thread(() -> {
                while (!stop) {
                    // do work
                }
            });
            task.start();
    
            // 在其他线程中停止任务
            stop = true;
        }
    }
    
  2. 单次赋值的变量:一个变量只被赋值一次,然后被多个线程读取,但不会被其他线程修改。

    public class Configuration {
        // 使用 volatile 关键字修饰的变量,保证了其在多线程环境下的可见性
        private volatile Map<String, String> configMap;
    
        public Configuration() {
            // 在构造函数中,我们只对 configMap 变量赋值一次
            // 假设 loadConfig() 方法从某个配置文件中读取配置,并返回一个 Map
            this.configMap = loadConfig();
        }
    
        // 这个方法用于获取配置信息
        // 由于 configMap 是 volatile 的,所以每个线程都能看到它的最新值
        public String getConfig(String key) {
            return configMap.get(key);
        }
    
        // 这个方法用于加载配置信息
        // 在这个示例中,我们假设它返回一个空的 HashMap
        // 在实际应用中,你可能需要从文件、数据库或其他地方加载配置
        private Map<String, String> loadConfig() {
            // 加载配置的具体实现
            return new HashMap<>();
        }
    }
    

六、volatile 有序性

6.1 指令重排序

指令重排序是编译器和处理器为了优化程序性能而进行的一种优化技术,它可能会改变指令的执行顺序,但并不影响程序最终的执行结果。然而,在多线程环境下,指令重排序可能会导致线程间的竞态条件和不确定的结果。

public class ReorderExample {
    private static int x = 0, y = 0; // 共享变量x和y
    private static int a = 0, b = 0; // 共享变量a和b

    public static void main(String[] args) throws InterruptedException {
        // 线程one执行a=1,然后x=b
        Thread one = new Thread(() -> {
            try {
                Thread.sleep(100); // 为了增加指令重排序的可能性,让线程睡眠100毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            a = 1;
            x = b;
        });

        // 线程other执行b=1,然后y=a
        Thread other = new Thread(() -> {
            try {
                Thread.sleep(100); // 为了增加指令重排序的可能性,让线程睡眠100毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            b = 1;
            y = a;
        });

        one.start(); // 启动线程one
        other.start(); // 启动线程other
        one.join(); // 等待线程one执行完成
        other.join(); // 等待线程other执行完成

        // 输出(x, y)的值
        System.out.println("(x, y) = (" + x + ", " + y + ")");
    }
}

在上面的代码中,多执行几次可能会出现多种不同的结果,例如: ( x , y ) = ( 1 , 1 ) (x, y) = (1, 1) (x,y)=(1,1) ( x , y ) = ( 1 , 0 ) (x, y) = (1, 0) (x,y)=(1,0)

6.2 volatile 禁止指令重排

指令重排序是一种优化技术,但是在多线程环境中是会有问题的。所以,我们需要禁止指令重排。想要禁止指令重排,我们可以通过使用 volatile 关键字达到目的。

当一个变量被声明为 volatile 后,对这个变量的写操作就会有一个内存屏障(这是一种特殊的指令),这个屏障可以防止指令重排。具体来说,编译器和处理器在执行程序时,必须在读取 volatile 变量之前的操作都执行完毕,且在读取操作后,所有写入 volatile 变量的操作都未执行。

以下面代码为例:

volatile boolean ready = false;
int data = 0;

void write() {
    data = 1;          // 操作1
    ready = true;      // 操作2
}

void read() {
    if (ready) {       // 操作3
        int result = data; // 操作4
    }
}

在这个例子中,ready 是一个 volatile 变量。由于 volatile 变量的写操作(操作2)有一个内存屏障,所以操作1(data = 1;)必须在操作2(ready = true;)之前执行。这就保证了 write() 方法中的操作1 和操作2 的有序性。

同样,由于 volatile 变量的读操作(操作3)有一个内存屏障,所以操作4(int result = data;)必须在操作3(if (ready) { … })之后执行。这就保证了 read() 方法中的操作3和操作4的有序性。

6.3 volatile 有序性的应用

volatile 有序性最经典的一个运用便是在单例模式中。

public class Singleton {
    // 使用 volatile 关键字修饰,保证其在多线程环境下的可见性
    private static volatile Singleton instance;

    private Singleton() {
        // 私有构造函数,防止外部直接创建实例
    }

    public static Singleton getInstance() {
        // 第一次检查:如果实例不存在,则进入同步代码块
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次检查:如果实例仍然不存在,则创建新的实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上述代码是单例模式的一种写法, getInstance() 方法首先检查 instance 是否已经被初始化。如果 instance 已经被初始化,那么就直接返回 instance,这样就避免了每次调用 getInstance() 时都需要进入同步代码块,从而减少了同步的开销。

如果 instance 还没有被初始化,那么就进入同步代码块。在同步代码块中,我们再次检查 instance 是否已经被初始化。如果 instance 仍然没有被初始化,那么就创建一个新的 Singleton 实例。

这种方式称为双重检查锁定(Double-Checked Locking,简称 DCL),因为我们进行了两次 instance == null 的检查:一次是在同步代码块外,一次是在同步代码块内。

为什么要在同步代码块内再检查一次呢?这是因为可能会有多个线程同时进入同步代码块外的 if (instance == null)。假设线程 A 和线程 B 同时进入了这个 if,线程 A 首先进入同步代码块,创建了一个新的 Singleton 实例,然后线程 B 进入同步代码块。如果没有第二次检查,线程 B 会创建另一个 Singleton 实例,这就违反了单例模式。

这里,volatile 关键字的作用就是保证 instance 字段的读写操作不会被 CPU 指令重排,从而保证了程序的有序性。具体来说,当一个线程创建新的 Singleton 实例时(即 instance = new Singleton()),这个操作实际上包含了以下三个步骤:

  1. 为 Singleton 对象分配内存空间。
  2. 初始化 Singleton 对象。
  3. 将 instance 变量指向分配的内存地址。

在 Java 中,这三个步骤可能会因为编译器优化而被重排序。例如,步骤2可能会在步骤1之后执行,也可能在步骤1之前执行。如果步骤2在步骤3之后执行,那么在多线程环境下,可能会出现一个线程获取到一个未完全初始化的 Singleton 对象。

使用 volatile 关键字可以禁止这种重排序。当 instance 变量被声明为 volatile 后,对它的写操作就会有一个内存屏障(这是一种特殊的指令),这个屏障可以防止重排序。这就是为什么我们需要在双重检查锁定模式中使用 volatile 关键字。

七、FAQ

情景分析:假设有一个 volatile 变量 counter,初始值为 0。现在有两个线程 T1T2 同时读取这个变量,然后各自对其进行递增操作,不过现在我们要求 T1 线程 +1T2 线程 +2,最后将结果写回共享内存。

public class Counter {
    private static volatile int counter; // 使用volatile修饰共享变量

    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < 1000; i++) {
            test(); // 调用test方法
            counter = 0; // 重置counter的值
        }

    }

    // 测试方法
    static void test() throws InterruptedException {
        // 创建线程t1,对counter加1
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter++; // 对共享变量counter加1
        });

        // 创建线程t2,对counter加2
        Thread t2 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter += 2; // 对共享变量counter加2
        });

        t1.start(); // 启动线程t1
        t2.start(); // 启动线程t2

        t1.join(); // 等待线程t1执行完毕
        t2.join(); // 等待线程t2执行完毕

        System.out.println(counter); // 输出counter的值
    }
}

正常情况下,如果不发生并发冲突,可以获取到正确值 3 3 3

在这里插入图片描述

我们知道上面的代码并不能保证线程安全,所以是有问题的。之前已经讨论过了,但是现在有一个问题:

这个错误的值是怎么得到的?

我们调整一下测试代码:

public class Counter {
    private static volatile int counter; // 使用volatile修饰共享变量

    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < 1000; i++) {
            test(); // 调用test方法
            counter = 0; // 重置counter的值
        }

    }

    // 测试方法
    static void test() throws InterruptedException {
        // 创建线程t1,对counter加1
        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter++; // 对共享变量counter加1

            synchronized (Counter.class) {
                System.out.println("T1: " + counter); // 输出t1线程操作后的counter值
            }
        });

        // 创建线程t2,对counter加2
        Thread t2 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter += 2; // 对共享变量counter加2

            synchronized (Counter.class) {
                System.out.println("T2: " + counter); // 输出t2线程操作后的counter值
            }
        });

        t1.start(); // 启动线程t1
        t2.start(); // 启动线程t2

        t1.join(); // 等待线程t1执行完毕
        t2.join(); // 等待线程t2执行完毕

        System.out.println(counter); // 输出counter的值
        System.out.println(); // 输出空行,用于分隔不同次测试结果
    }
}

测试效果如下:

image.png

我们发现,测试结果少了一种情况:

  1. 初始化
    • c o u n t e r = 0 counter = 0 counter=0
  2. 读取
    • 线程 T 1 T1 T1 T 2 T2 T2 同时读取 c o u n t e r counter counter 的值,都读到 0 0 0
  3. 修改
    • 线程 T 1 T1 T1 c o u n t e r counter counter的值加 1 1 1,得到 1 1 1
    • 线程 T 2 T2 T2 也将 c o u n t e r counter counter 的值加 2 2 2,得到 2 2 2
  4. 写回
    • 线程 T 1 T1 T1 1 1 1 写回 c o u n t e r counter counter
    • 线程 T 2 T2 T2 也将 2 2 2 写回 c o u n t e r counter counter

即,结果是:

  1. T 1 : 1 T1: 1 T1:1
  2. T 2 : 2 T2: 2 T2:2
  3. 最终结果是: 2 2 2

  1. T 2 : 2 T2: 2 T2:2
  2. T 1 : 1 T1: 1 T1:1
  3. 最终结果是: 1 1 1

出现这个问题的原因是:

  1. 无论是线程 T 1 T1 T1 还是线程 T 2 T2 T2 写回之后,主内存就立刻通知其他线程缓存失效了
  2. 当其他线程发现缓存失效,便会重新从主内存中读取变量最新的值
  3. 紧接着执行未完成的步骤,从而导致了问题

在这里插入图片描述

推荐阅读

  1. Spring 三级缓存
  2. 深入了解 MyBatis 插件:定制化你的持久层框架
  3. 深入探究 Spring Boot Starter:从概念到实践
  4. Zookeeper 注册中心:单机部署
  5. 【JavaScript】探索 JavaScript 中的解构赋值
  • 26
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值