深入理解 Java 中的 volatile 关键字

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

文章目录

  • 前言
  • 一、CPU 三级缓存
  • 二、JMM
  • 三、并发编程正确性的基础
  • 四、volatile 关键字
  • 五、volatile 可见性
  • 六、volatile 有序性
    • 6.1 指令重排序
    • 6.2 volatile 禁止指令重排
    • 6.3 volatile 有序性的应用
  • 七、FAQ
  • 推荐阅读

前言

在多线程编程中,确保共享变量的可见性是非常重要的。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 中的解构赋值

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/716003.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

六西格玛培训:一次学习,终身受益!

六西格玛培训&#xff0c;这个听起来就充满智慧的名字&#xff0c;其实是一种追求卓越的管理哲学。它的核心理念在于减少缺陷、降低变异&#xff0c;以提升企业的运营效率和质量水平。对于我们这些渴望在职场中更上一层楼的人来说&#xff0c;六西格玛培训无疑是一把打开成功之…

结合gin框架在沙箱环境下实现电脑网站支付和当面支付

文章目录 配置支付宝开放平台编写代码测试电脑网站支付当面扫码支付 配置支付宝开放平台 支付宝开放平台 点击链接&#xff0c;扫码进入后&#xff0c;点击沙箱&#xff1a; 点击沙箱应用&#xff0c;可以看到APPID&#xff0c;接口加签方式选择系统默认密钥就行&#xff0…

C的I/O操作

目录 引言 一、文件与目录操作 1. 打开与关闭文件 2. 文件读写操作 3. 文件定位与错误处理 二、字符流与字节流 1. 字符流处理 2. 字节流处理 三、序列化与反序列化 1. 序列化 2. 反序列化 四、新的I/O&#xff08;NIO&#xff09; 表格总结 文件与目录操作 字符…

nuxt3+vue3+vite+TS实现国际化

前言 博主最近打算用Nuxt3重构了自己SSR博客&#xff0c;打算添加国际化功能&#xff0c;众所周知&#xff0c;实现国际化已是一个很常见的功能。用户可以根据自己的喜好&#xff0c;设置页面的自己合适的语言&#xff0c;从而实现国际化浏览。这样用户体验度会大大提升。博客…

使用Midjourney为产品创建出色效果图-关键词

使用MJ为产品创建效果图并不难&#xff0c;可以使用这个固定提示词公式。 Mockup empty, blank [ product ], [ decorating items ] [ background or context ], [ 1- 3 descriptive style], [ color palette ] 创建产品形象 首先&#xff0c;你需要准备一个透明背景的产品。…

Python中文自然语言处理(NLP)中文分词工具库之pkuseg使用详解

概要 在中文自然语言处理(NLP)中,分词是一个基础且关键的任务。pkuseg 是由北京大学开发的一个中文分词工具,专为处理现代汉语而设计。它采用了先进的深度学习技术,能够准确地进行中文分词,同时支持自定义词典和多领域分词。本文将详细介绍 pkuseg 库,包括其安装方法、…

动态 SQL

动态 SQL 是 MyBatis 的强大特性之一&#xff0c;能够完成不同条件下不同的 sql 拼接。也就是说执行的 SQL 语句并不是固定的&#xff0c;而是不同人的不同操作执行的语句会有所差异。MyBatis 通过使用 标签 的方式来实现这种灵活性的。 <if>标签 例如在有一些网站进行…

一些个人电脑用的小工具软件

1 个人电脑信息管理 如下&#xff1b; 整理自己的电脑信息&#xff1b;录入&#xff0c;保存&#xff0c;查询&#xff1b;添加和更新界面如下&#xff0c; 每次添加、更新之后重新点一下菜单的浏览&#xff1b; 下载&#xff0c; https://download.csdn.net/download/bcb…

如何使用 pip 卸载所有已安装的 Python 包?

在开发过程中&#xff0c;我们可能会安装许多 Python 包&#xff0c;有时需要彻底清理环境&#xff0c;以便从头开始或者解决冲突问题。下面将介绍如何使用 pip 命令卸载所有已安装的 Python 包。 一、列出所有已安装的包 首先&#xff0c;需要列出当前环境中所有已安装的包。…

Vue49-props属性

一、当同一个组件标签被使用多次 因为data属性写的是函数形式&#xff01; 二、需求&#xff1a;老王也想用<Student>组件&#xff0c;但是需要动态把老王想要的值传进来。 2-1、使用props属性接收参数 使用props属性&#xff0c;接收的这三个参数&#xff0c;是被保存在…

入门机器视觉的正确打开方式——徒手撸一个python+opencv实现的机器视觉简易调试工具(上)

目录 1.引言2.框架思路3.环境搭建4.图像处理流程化的实现5.流水线上的算法块5.1 算法块的可视化 6.总结7.调试工具成品链接PS.扩展阅读ps1.六自由度机器人相关文章资源ps2.四轴机器相关文章资源ps3.移动小车相关文章资源 1.引言 在当今AI时代&#xff0c;关于视觉识别似乎已被…

位运算算法:编程世界中的魔法符号

✨✨✨学习的道路很枯燥&#xff0c;希望我们能并肩走下来! 文章目录 目录 文章目录 前言 一. 常见位运算总结 二、常见位运算题目 2.1 位1的个数 2.2 比特数记位&#xff08;典型dp&#xff09; 2.3 汉明距离 2.4 只出现一次的数字&#xff08;1&#xff09; 2.5 只出…

QtScrcpy最出色的C++开源手机投屏控制软件

QtScrcpy是一款开源的跨平台屏幕录制和投屏工具 基本概述&#xff1a; 它基于Android的ADB&#xff08;Android Debug Bridge&#xff09;和Electron框架&#xff0c;为用户提供了简洁且功能强大的用户界面。 支持平台&#xff1a; QtScrcpy支持Windows、macOS和Linux三大…

算力与能源正在成为世界的硬通货,看超级计算机安腾如何突围

特斯拉创始人马斯克公开表态称未来两年人工智能行业将由“缺硅”变为“缺电”。据媒体报道&#xff0c;OpenAI的ChatGPT每天消耗超过50万千瓦时的电力&#xff0c;用于处理约2亿个用户请求&#xff0c;相当于美国家庭每天用电量的1.7万多倍。除了这类生成式AI耗能外&#xff0c…

【Linux】使用 iptables 验证访问HDFS 所使用到的端口

目录 ​编辑 一、实操背景 二、iptables 简介 三、模拟操作 一、实操背景 背景&#xff1a; 在客户有外网的服务器需要访问内网大数据集群HDFS&#xff0c;使用iptable模拟测试需要开放的端口。 二、iptables 简介 具体介绍看文章&#xff1a; 【Linux】Iptables 详解与实战…

Elasticsearch出现Connection reset by peer

Elasticsearch出现Connection reset by peer分析 1.异常&#xff1a; 2024-06-13 13:17:10.539 WARN [http-nio-30411-exec-9]com.longdaotech.config.ESConfig -onFailure node:[hosthttp://192.168.239.253:9200] 2024/6/13 13:17:10 2024-06-13 13:17:10.541 WARN [http-n…

aop注解快速实现数据脱敏返回

说明&#xff1a; 公司之前数据接口数据管理不严格&#xff0c;很多接口的敏感数据都没有脱敏处理&#xff0c;直接返回给前端了&#xff0c;然后被甲方的第三方安全漏洞扫出来&#xff0c;老板要求紧急处理&#xff0c;常用的话在单个字段上加上脱敏注解会更加的灵活&#xf…

Win11升级24H2出现绿屏怎么办?这些方法帮你解决!

在Win11电脑操作中&#xff0c;用户为了体验24H2版本推出的新功能&#xff0c;所以要把系统版本升级为24H2版本。但升级过程中电脑却出现了绿屏问题&#xff0c;不清楚要怎么操作才能解决绿屏的问题&#xff1f;接下来小编给大家分享几种简单有效的解决方法&#xff0c;让大家能…

轨迹优化 | 图解欧氏距离场与梯度场算法(附ROS C++/Python实现)

目录 0 专栏介绍1 什么是距离场&#xff1f;2 欧氏距离场计算原理3 双线性插值与欧式梯度场4 仿真实现4.1 ROS C实现4.2 Python实现 0 专栏介绍 &#x1f525;课程设计、毕业设计、创新竞赛、学术研究必备&#xff01;本专栏涉及更高阶的运动规划算法实战&#xff1a;曲线生成…