当前位置: 首页 > news >正文

多线程——内存可见性问题和指令重排序问题(volatile详解)

目录

1.内存可见性问题

1.1 一段非线程安全的代码

1.2 分析原因

2.volatile

2.1 处理机制

2.2 不保证原子性

2.3 (Java内存模型)JMM

3.指令重排序问题

4.小结


在前面学习线程安全问题中,我们说内存可见性问题和指令重排序问题会造成线程安全问题,这两个问题是怎么造成线程安全问题的呢?这是我们这期要探寻的答案。


1.内存可见性问题

1.1 一段非线程安全的代码

public class Main {private static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("线程1开始");while (flag == 0) {//.........}System.out.println("线程1结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值:");flag = scanner.nextInt();});t1.start();t2.start();}
}

代码解读:在线程t1中,有一个永远为真的 while 循环,现在希望通过在线程t2中修改 flag 达到值来结束线程t1。对于多线程来讲,这两个线程同时启动,那么我们通过线程t2对 flag 进行修改,应该是可以结束 t1 这个线程的。但是,当我们运行后发现,线程t1并不会结束,而是继续死循环:

很明显,这个代码存在了bug,存在线程安全问题。像这种一个线程读取,另一个线程修改,当修改线程对数据进行修改时,读取线程并没有读取到的问题,就叫“内存可见问题”

1.2 分析原因

在前面的学习,我们说遇到线程安全问题,可以给期望运行的逻辑代码加锁,但是在这里,如果我们尝试加锁,那是不行的。可以简单地分析:我们加锁的目的就是让先抢到锁的线程先执行,其它线程阻塞等待,就这个代码而言,如果是线程t2先抢到锁,自然线程t1是可以正常结束,而如果是线程t1先抢到锁,一直进入 while 循环中,而循环条件永远满足,自然就不会释放锁,会一直永远循环,即便是想让t2线程先执行,对t1线程用sleep,这种情况就属于t2先抢到锁,但这种做法又会影响程序的效率,我们使用多线程的目的就是希望得到更高的效率,这种做法就不再符合我们的初衷。这种概率的可能不是我们想要的,打败妖怪,只需要从很多种可能中找到一种可能就可以击败,但对多线程来说,任何一种可能都不能放过。

在计算机中,程序运行是快速运转的,可以达到每秒几十亿次,这取决于CPU,不过就现代计算机而言,也不会相差多少。我们人类的几秒钟,在计算机看来就是“沧海桑田”。因此,在Java中,JVM优化了我们书写的代码。是的,没有听错,是优化。优化了怎么还出问题呢?是如何进行优化的呢?

在上面的代码中,JVM会对我们的代码,在保证逻辑不变的前提下进行修改,使得程序效率更高,这就是所谓的优化。但是,虽然是优化,确保保证了代码的逻辑不变,而在多线程中,这种编译器的判断就引来了比较严重的失误。

比如:有两个快递员负责一个驿站工作,有一天王先生买了一个包裹,王先生就问快递员1“我的包裹到了吗?”,快递员1说“没有。”,并且在自己的备忘录里记录王先生的包裹flag == 0。几天后呢,是快递员2把王先生的包裹送上货架,快递员1没来得及更新自己的备忘录。王先生又打电话问快递员1“我的包裹到了吗?”,快递员1马上查看自己备忘录,还是 flag == 0,就是“您的快递还没到”。本来快递实际已经送到,但是王先生迟迟没有收到快递,陷入了等待。

所以,对于计算机来说,每秒可以达到几十亿次,而执行这么多次(执行的次数取决于我们在多久之后输入,如果十几秒才输入,那就更不得了了),flag 一直等于0,于是JVM就认为,既然都是一样的结果,干脆把它看成一个固定值,所以线程t2去修改的时候,线程t1得到的值依旧不会发生改变。

在这里,JMM(Java内存模型)是这么描述的每个线程有自己的工作内存,同时这些线程共享一个主内存,当一个线程循环进行读取变量操作的时候,就把主内存的数据拷贝到该线程的工作内存中,后续另一个线程进行修改操作时,也是修改自己工作内存的数据,再拷贝到主内存中,由于第一个线程仍然在等待自己的工作内存,因此感受不到主内存的变化。

所以,为了解决这个问题,引入了关键字 volatile

2.volatile

2.1 处理机制

volatile 是 Java 提供的一种轻量级同步机制,用于确保多线程环境下变量的可见性和有序性,用于修饰变量,

在计算机中,每个线程有自己的一个工作内存(理解为每个人上班都有一个工位),这些线程不论是读操作还是写操作,都要从主内存中获取数据。所以,上述的问题就是:线程t1一直决定flag不会变,就干脆把这个flag“据为己有”,每次需要读的时候就读自己的工作内存数据,而不会从主内存中获取数据。使用关键字 volatile ,就可以确保这些线程在读写写时,都会从主内存获取一个最新的数据,然后再读或者写,如果是写,还会把写之后的数据更新到主内存。

所以,对上述代码的 flag 使用volatile 修饰,来看是否符合预期结果:

public class Main {private volatile static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("线程1开始");while (flag == 0) {//.........}System.out.println("线程1结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值:");flag = scanner.nextInt();});t1.start();t2.start();}
}

输出结果:

当输入一个非零的 flag 值时,程序运行结束,说明符合预期结果。

flag 被 volatile 修饰后,使得 flag 是可见的,当线程想要对 flag 进行读 / 写时,都会从主内存中获取最新数据。

volatile机制:

  1. 对于普通变量,线程针对写操作时,修改可能会先写入自己的工作内存中,而不会立即同步到主内存中,针对读操作时可能从线程工作内存中读取旧值
  2. 对于volatile变量,线程针对写操作时会立即同步到主内存中,读的操作会从主内存中读取最新值

实现原理:JMM

  • 写一个volatile变量时,JMM把该线程对应的工作内存中的共享变量刷新到主内存中
  • 读一个volatile变量时,JMM把该线程对应的工作内存中的数据设为无效,从主内存读取最新值

2.2 不保证原子性

在前期解决线程安全问题时,我们用到关键字 synchronized ,这是针对原子性问题。那么,volatile 关键字是否也可以保证原子行呢?

先来回顾代码:

public class Main {private volatile static int count = 0;public static void main(String[] args) throws InterruptedException {//线程1Thread thread1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//线程2Thread thread2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//开启线程thread1.start();thread2.start();//等待线程thread1.join();thread2.join();//两个线程各自增 5000 次,期望输出 count = 10000System.out.println("count = " + count);}
}

这段代码两个线程,各自增5000次,期望结果 count = 10000。在定义时,count 是被关键字 volatile 修饰的。来看结果:

显然这个结果,是不符合预期结果的,因此 volatile 不保证原子性

2.3 (Java内存模型)JMM

JMM 是 Java 虚拟机规范中定义的一种模型,目的是屏蔽掉各种硬件和操作系统的内存访问异常,以实现让Java程序可以在任何平台下都能达到一致的并发效果。

这个工作内存其实并不难理解,就是CPU的寄存器。

核心概念:

  • 线程之间共享的变量存放在主内存中
  • 每一个线程都有自己的工作内存
  • 当线程要修改一个共享变量的时候,会先从修改工作内存的副本(拷贝),再同步到主内存
  • 当线程要读取一个共享变量的时候,会从主内存中把这个共享变量拷贝到工作内存中,再从工作内存读取数据

因为每个线程都有自己的工作内存,这些工作内存中的内容相当于一个共享变量的副本,因此在修改一个线程的工作内存中的值,另一个线程可能来不及发生变化。

 导致线程不安全的过程

(1)初始情况,两个线程的工作内存中的数据一样(线程1和线程2都从主内存拷贝数据 a = 5)

(2)如果线程1发生修改,但是可能没有及时同步到主内存中,对应的线程2得到的又是主内存中的数据

    经过这两拨操作,就导致了线程出现安全问题。文章开头的代码就是因为这种导致,所以使用关键字 volatile 修饰变量后,线程1修改操作时及时地让更新的内容同步到主内存中,线程2读取时也会从主内存从读取最新值。

    3.指令重排序问题

    关于指令重排序问题,先来理解这个术语。拆开来看:指令就相当于一个要执行的任务,比如生活中我们要去拿快递就是一个指令,重排序就是调整指令的执行顺序。

    假设你要去超时买牙膏、酱油、零食、洗衣粉,这几样东西在超市布局如下:

    现在让你最短时间内就进这个超市买完你的需求品,任何出超市,你肯定不会按清单牙膏、酱油、零食、洗衣粉购买,而是先买零食、洗衣粉、牙膏,最好买酱油就出超市,这就是指令重排序。

    但在计算机中,线程指令不能重排序,如果指令重排序就会造成线程安全问题

    分析:

    指令重排序是指编译器在逻辑不变的情况下,调整代码的执行顺序,以达到提升性能的效果但在多线程环境下,以读操作和写操作为例,指令重排序问题是在读操作时可能会把读之前的操作放在读之后,写操作时可能把写之后的操作放在写之前。

    来看代码:

    public class Main {private static int x = 0;private static int y = 0;private static int a = 0;private static int b = 0;public static void main(String[] args) throws InterruptedException {for (int i = 0; ; i++) {x = y = a = b = 0;//每次循环后都初始化为0Thread t1 = new Thread(() -> {a = 1;  // 操作1x = b;   // 操作2});Thread t2 = new Thread(() -> {b = 1;  // 操作3y = a;   // 操作4});t1.start();t2.start();t1.join();t2.join();if (x == 0 && y == 0) {System.out.printf("第%d次循环后出现重排序 (x=%d, y=%d)\n", i, x, y);break;}}}
    }

    先看结束条件:x == 0 && y == 0,再看前面的代码,在给 x 和 y 赋值时,都是先执行了 a = 1, b = 1,这样看来,结束的条件应该不会成立。程序是一直死循环,还是会结束?来看运行结果:

    结果现实第 4898 次循环后程序就退出了(当然这个循环每次结果会不一样,因为多线程的调度是随机的、不可抢占的)。为什么会出现 x = 0, y = 0?因为在某时候的执行顺序可能是:

    线程1:x = b (读到b=0)
    线程2:y = a (读到a=0)
    线程1:a = 1
    线程2:b = 1
    

    这种情况下,a 和 b 还没来得及修改,所以这是程序退出的原因。

    指令重排序问题怎么解决呢?

    在文章前面就已经给出答案,也就是使用关键字 volatile,对变量用 volatile 修饰即可。

    关键字 volatile 的两个功能就是解决这两个问题:内存可见性问题和指令重排序问题。

    两个问题的处理是差不多的,它们的解决方案都是依赖JMM指定的内存屏障规则,内存可见性问题是确保主内存访问,指令重新排序问题确保指令有序执行。

    4.小结

    本期主要介绍线程安全——内存可见性和指令重排序问题的解决方法,关键字 volatile 主要用于解决内存可见性和指令重排序问题,但不保证原子性。处理机制是

    1. 对于普通变量,线程针对写操作时,修改可能会先写入自己的工作内存中,而不会立即同步到主内存中,针对读操作时可能从线程工作内存中读取旧值
    2. 对于volatile变量,线程针对写操作时会立即同步到主内存中,读的操作会从主内存中读取最新值

    在前面学习线程状态的转换时,我们看到等待状态有个方法:wait()。wait()、join()、sleep() 它们有什么区别呢?欲知后事如何,且听下回分解!

    http://www.dtcms.com/a/391689.html

    相关文章:

  • Linux第十八讲:应用层协议Http
  • 【C++】速识map与set
  • 多层感知机(MLP)
  • Linux系统诊断——拷贝日志系统
  • python中 ​实例方法​(普通方法)和 ​类方法​ 的核心差异
  • Sping AI接入deepseek-本地部署大模型-第二期
  • 数据分析-数据指标体系搭建及应用
  • 计算机专业课《大数据技术》课程导览:开启数据智能时代
  • dumpsys battery 简介
  • 从 CNN 基础到 AlexNet:计算机视觉的破局之路
  • 苏州自动化工厂1台服务器如何5人并发SolidWorks设计
  • 固态硬盘数据恢复一般多少钱?费用分析+恢复教程
  • WebRTC 探秘:构建你自己的实时视频应用
  • 在Ubuntu中离线安装miniconda3
  • Mem0 + 百度智能云向量数据库:为AI打造持久化记忆
  • MySQL 数据归档的技术困境与 Databend 解决之道
  • 2025icpc网络赛第一场The 2025 ICPC Asia East Continent Online Contest (I)
  • docker中ngnix的路径配置
  • 什么是黑板架构风格?
  • Redis 三大核心模式(主从复制 / 哨兵 / 集群):完整部署与问题解析
  • Docker生产环境容器OOM问题定位:镜像内存泄漏还是主机资源不足?
  • AcWing385. GF和猫咪的玩具——Floyd算法
  • 75、封装paddle ocr v5服务支持昇腾800 900 、800I A2、300I DUO卡推理识别
  • 【一文了解】线程的使用
  • 电力系统暂态稳定计算与单机无穷大系统建模
  • OmniGen2 - 智源研究院推出的开源多模态生成模型
  • 【故障排查:JDK8中Files.lines方法错误使用导致的Linux服务器文件描述符泄漏问题】
  • 【multisim仿真电子秒表74LS90】2022-12-15
  • v-show 和 v-if 的区别及使用场景
  • 动态二维码杜绝代签,手机端配置同步,巡检数据更可靠