深入理解 Java 中的 volatile 关键字

news2024/10/6 18:29:56

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

文章目录

  • 前言
  • 一、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.coloradmin.cn/o/1830868.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

虚拟机使用桥接模式网络配置

1、获取本机的网络详细信息 windowr 输入cmd 使用ipconfig -all 一样即可 在自己的虚拟机中设置网络 虚拟机中的ip ---------192.168.36.*&#xff0c;不要跟自己的本机ip冲突 网关-----------192.168.36.254 一样即可 dns -----------一样即可&#xff0c;我多写了几个&am…

数字孪生智慧机场:引领航空未来

图扑数字孪生技术赋能智慧机场&#xff0c;实现运营管理和乘客服务的全面优化。实时数据监控与智能决策助力高效安全的航空体验&#xff0c;推动行业创新与发展。

网络安全:SQL注入防范

文章目录 网络安全&#xff1a;SQL注入防范引言防范措施概览使用参数化查询示例代码 输入验证和过滤示例代码 使用ORM框架示例代码 其他防范措施结语 网络安全&#xff1a;SQL注入防范 引言 在上一篇文章中&#xff0c;我们介绍了SQL注入攻击的基础知识。本文将重点讨论如何防…

【UML用户指南】-16-对高级结构建模-构件

目录 1、概念 2、构件与接口 3、可替换性 4、组织构件 5、端口 6、内部结构 6.1、部件 6.2、连接件 7、常用建模技术 7.1、对结构类建模 7.2、对API建模 构件是系统中逻辑的并且可替换的部分&#xff0c;它遵循并提供对一组接口的实现。好的构件用定义良好的接口来定…

来自工业界的知识库 RAG 服务(四),FinGLM 竞赛冠军项目详解

背景介绍 在 前一篇文章 中介绍过智谱组织的一个金融大模型 RAG 比赛 FinGLM 以及 ChatGLM反卷总局 团队的项目&#xff0c;这篇文章继续介绍下获得冠军的馒头科技的技术方案。 建议不了解比赛背景信息的可以先查看 来自工业界的知识库 RAG 服务(三)&#xff0c;FinGLM 竞赛获…

[YOLOv10涨点改进:注意力魔改 | 轻量级的 Mixed Local Channel Attention (MLCA),加强通道信息和空间信息提取能力]

本文属于原创独家改进:一种轻量级的Mixed Local Channel Attention (MLCA)模块,该模块考虑通道信息和空间信息,并结合局部信息和全局信息以提高网络的表达效果 1.YOLOv10介绍 论文:[https://arxiv.org/pdf/2405.14458] 代码: https://gitcode.com/THU-MIG/yolov10?utm_s…

基于R-Tree的地理空间数据分析加速

几年前&#xff0c;我正在做一个业余项目。我想创建一个 Web 应用程序&#xff0c;推荐当地的特色景点&#xff0c;例如咖啡馆、书店或隐藏的酒吧。我的想法是在地图上显示用户触手可及的所有兴趣点。我的数据集中有数十万个点&#xff0c;我必须巧妙地过滤用户给定范围内的数据…

DVWA - Brute Force

DVWA - Brute Force 等级&#xff1a;low ​ 直接上bp弱口令爆破&#xff0c;设置变量&#xff0c;攻击类型最后一个&#xff0c;payload为用户名、密码简单列表 ​ 直接run&#xff0c;长度排序下&#xff0c;不一样的就是正确的用户名和密码 ​ 另解&#xff1a; 看一下…

3DMAX网格插入插件使用方法讲解

3DMAX网格插入插件使用方法 3DMAX网格插入插件&#xff0c;在选择的面上安门窗、打螺丝、挖洞、插入眼耳口鼻及其它网格模型等可以分分钟搞定&#xff01;它通过将面选择替换为库中的资源来加快建模过程。非常适合硬网格和有机建模&#xff01; 【适用版本】 3dMax2013及更高版…

快速欧氏聚类与普通欧氏聚类比较

1、前言 文献《FEC: Fast Euclidean Clustering for Point Cloud Segmentation》介绍了一种快速欧氏聚类方法,大概原理可以参考如下图,具体原理可以参考参考文献。 2、时间效率比较:快速欧氏聚类VS普通欧氏聚类 网上搜集的快速欧式聚类,与自己手写的普通欧式聚类进行对比,…

网络知识:这些特殊的IP地址,具体的用途你都知道吗

目录 一、0.0.0.0 二、255.255.255.255 限制广播地址 三、127.0.0.1 本机地址 四、224.0.0.1 组播地址 五、169.254.x.x 六、10.x.x.x、172.16。x。x&#xff5e;172.31。x。x、192.168。x。x 私有地址 对于计算机网络来说&#xff0c;IP地址是非常重要的概念&#xff0c…

Objective-C 学习笔记 | 协议(property)

Objective-C 学习笔记 | 协议&#xff08;property&#xff09; Objective-C 学习笔记 | 协议&#xff08;property&#xff09; Objective-C 学习笔记 | 协议&#xff08;property&#xff09; iOS 应用经常会用 UITableView 实例来显示数据&#xff0c;但是它本身不包含数据…

采集罗克韦尔AB、西门子等PLC数据发布成HTTP接口

智能网关IGT-DSER集成了多种PLC的原厂协议&#xff0c;方便实现各种PLC的原厂协议转换为HTTP协议的服务端&#xff0c;通过网关的参数配置软件绑定JSON文件的字段与PLC寄存器地址&#xff0c;即可接收来自客户端的GET、PUT和POST命令&#xff0c;解析和打包JSON文件(JSON文件格…

去哪儿网PMO张璐受邀为第十三届中国PMO大会演讲嘉宾

全国PMO专业人士年度盛会 去哪儿网PMO张璐女士受邀为PMO评论主办的2024第十三届中国PMO大会演讲嘉宾&#xff0c;演讲议题为“数字化助力组织目标落地”。大会将于6月29-30日在北京举办&#xff0c;敬请关注&#xff01; 议题简要 本次议题将分享去哪儿流程标准化&工具化…

我用chatgpt写了一款程序

众所周知&#xff0c;Chatgpt能够帮助人们写代码&#xff0c;前几天苏音试着完全用Chatgpt写一款Python程序 有一句话我很赞同&#xff0c;未来能代替人的不是AI&#xff0c;是会使用AI的人。 最终&#xff0c;写下来效果还不错&#xff0c;完全提升了我的办公效率。 开发前…

告密者斯诺登:永远不要信任 OpenAI 或其 ChatGPT 等产品|TodayAI

为什么 OpenAI 变得越来越难以信任 OpenAI&#xff0c;一家以开发先进人工智能技术而闻名的公司&#xff0c;正面临越来越多的信任危机。近期&#xff0c;一些令人不安的迹象使人们对这家公司的透明度和安全性产生了质疑。 首先&#xff0c;在 OpenAI 的旧金山办公室外&#…

顺安蜘蛛池四川官网下载

baidu搜索&#xff1a;如何联系八爪鱼SEO? baidu搜索&#xff1a;如何联系八爪鱼SEO? baidu搜索&#xff1a;如何联系八爪鱼SEO? 虽然影视泛目录很火,但超度站群版本自出现以来-直流量稳定,可惜这两年起站全靠域名。但话说回来,咱不能否认,只要用的域名好,做啥泛目录都有好…

湖北省小学毕业学籍照片采集流程及教师手机拍摄方法说明

随着教育信息化的不断推进&#xff0c;学籍管理也越来越规范和便捷。湖北省小学毕业学籍照片采集作为学籍管理的重要组成部分&#xff0c;对于确保学生信息的准确性和完整性具有重要意义。本文将详细介绍湖北省小学毕业学籍照片采集的流程&#xff0c;并提供教师使用手机拍摄照…

如何用优盘加密自己的电脑:人离后自动锁定

看电影的时候&#xff0c;看到有人展示&#xff0c;用优盘加密自己的电脑&#xff0c;人走开的时候拔下优盘&#xff0c;自动上锁。似乎很科幻&#xff0c;其实这样的软件非常多&#xff0c;不论是成品商业用的还是免费的&#xff0c;都非常多&#xff0c;很多版权管理比较强的…

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

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