【线程安全】内存可见性问题及解决方案

news2025/1/16 14:46:38

1. 关于内存可见性的一段代码

import java.util.Scanner;
public class ThreadDemo {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (count == 0) {

            }
            System.out.println("t1 线程结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            count = scanner.nextInt();
            System.out.println("t2 线程结束! count = " + count);
        });
        t1.start();
        t2.start();
    }
}

此时这段代码相信你们都不难看懂,t1 线程有一个空循环,只要 count 的值等于 0,这个空循环就会一直循环下去,t2 线程则是让我们从键盘输入一个数,用来改变 count 的值,并打印一下修改后 count 的值,如果 count 的值改变了,不等于 0 了,是不是也就意味着 t1 会跳出循环,接着打印 "t1线程结束"。

所以上述代码,我们预期由 t2 线程从键盘读一个数,改变 count 的值,结束 t1 线程的循环!

接下来我们就运行这个代码:

此时发现,count 的值确实被改了,为什么 t1 线程仍然没有结束呢???此时程序也就出现了 BUG!

那么上述这种情况,就是内存可见性问题!一个线程读,一个线程写(线程不安全)

为什么会发生上述的情况呢?这里就需要分析 t1 线程里 while 循环的条件部分了,count == 0,这个操作从汇编的角度上,需要分成两步:

1. load,把内存中的值读到寄存器中(也可能是读到cache)
2. cmp,把寄存器中的值和 0 进行比较,决定下一步往哪走

上述代码的循环执行速度是非常快的!在 t2 线程对 count 进行修改前,这个循环可能已经执行了上千万次了!

每次 load 的操作是比 cmp 慢很多的,因为 load 大概率要从内存中读数据(此处忽略 cache),而 cmp 不需要经过内存,直接用 CPU 进行比较。

由上述两个点:load 速度太慢,t2 改 count 值之前,循环每次 load 的 count 都是0,可能读了上千万次 count 的值都是0,此时 JVM/编译器 就做了一个大胆的决定,不在重复从内存读取 count 的值了!因为发现读了几百万次都没人修改,那我还从内存读这个值干嘛!

所以以至于后续 t2 线程修改了内存中 count 变量的值,但是 t1 线程仍然没有察觉到!

上述这种情况,也是编译器优化的一种!

内存可见性:
一个线程针对一个变量进行读操作,另一个线程针对这个变量进行修改,此时读的线程,不一定能感知到这个变量被改了

2. volatile 关键字

这个关键字从字面意思上理解是 "易变的,不稳定的",如果给变量加上这个关键字,仿佛在告诉 JVM/编译器,这个变量很不稳定,极有可能发生变化,从而不让编译器优化!那么事实是不是这样呢?

于是我们对 count 变量加上 volatile 关键字:

import java.util.Scanner;
public class ThreadDemo {
    public static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (count == 0) {

            }
            System.out.println("t1 线程结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            count = scanner.nextInt();
            System.out.println("t2 线程结束! count = " + count);
        });
        t1.start();
        t2.start();
    }
}

通过程序执行结果,确实发现当给 count 加上 volatile 修饰之后,t1 线程能感知到 count 被 t2 给修改了,那么 volatile 这个关键字的作用就是,告诉 JVM/编译器 这个变量是 "易变" 的,每次都要重新读取这个变量的内容,因为他指不定啥时候就发生变化了,你可不能莽撞的进行优化了啊!

volatile 这个关键字是保证内存可见的!而且还能禁止编译器指令重排序(单例模式讲解)


3. JVM 官方内存模型

大概是说,从 JVM 的角度表述内存可见性的问题:

Java 的主程序中,有一个主内存,每个线程有自己的工作内存(t1 和 t2 有不同的工作内存),当 t1 线程进行读的时候,只是读取了工作内存的值,t2 进行修改的时候,先是修改了工作内存的值,再把工作内存修改后的值,同步到主内存中,但是由于编译器的优化,导致 t1 没有重新从主内存中同步数据到 t1 的工作内存中,所以读到的就是 "修改前" 的结果。

什么是主内存,工作内存?

这里可以简单理解成,主内存 main memory 理解为平时所说的内存,工作内存 work memory 工作存储区 理解成 CPU 的寄存器 + cache 缓存。

由于 CPU 硬件结构复杂,Java 官方为了不区分硬件的细节和差异,就用工作内存代替了 CPU寄存器 + CPU 的 cache。

简单来说,主内存就是内存,工作内存就是 CPU寄存器 + cache 缓存

所以最终就是 t1 没有同步内存中的值到寄存器/cache,从而导致 while 条件判断 count 的值永远都是0。


4. volatile 不保证原子性

这里用一段很简单的代码就能验证出来了:

public class ThreadDemo {
    public static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}
// 第一次执行结果:count = 77038
// 第二次执行结果:count = 74220
// 第三次执行结果:count = 77001

如果你是使用 IDEA 编译器,当你把鼠标放在 count++ 这条代码上,就能发现有一个警告:

Non-atomic operation on volatile field 'count'

易失性字段“count”上的非原子操作

通过这个和上述代码的执行结果,很容易得出 volatile 关键字不保证原子性。

小结:
synchronized 和 volatile 都是保证线程安全的,但是他俩的功能是不一样的,synchronized 是能够保证原子性,而 volatile 是保证内存可见性,至于网上也有的人说 synchronized 也能保证原子性,也有的人说不能保证原子性,这个有待考证,这里我们就不杠,如果后面写代码的时候,既要考虑原子性,又要考虑内存可见性,直接把 synchronized 和 volatile 都加上即可。

下期预告:【多线程】wait 和 notify

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

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

相关文章

双向链表及双向链表的常见操作和用js封装一个双向链表

书接上回&#xff0c;上一篇文章讲了单向链表以及用 js 封装一个单向链表&#xff0c;所以这节将介绍双向链表以及用 js 封装一个双向链表。待会我也会继续在文章后面附上视频学习链接地址&#xff0c;大家想学习的可以去看看 一、认识双向链表 首先来认识一下什么是双向链表&…

广和通发布5G智能模组SC151系列,助力AIoT应用更智能高效

2023年5月&#xff0c;广和通发布5G R16智能模组SC151系列。SC151系列基于4nm制程工艺的高通QCM4490解决方案设计&#xff0c;采用8核高性能处理器&#xff0c;为工业与商业物联网终端提供高性能处理能力。面对与日俱增的终端智能化需求&#xff0c;SC151系列将助力打造高生产力…

【致敬未来的攻城狮计划】第2期定向赠书《RT-Thread设备驱动开发指南》+ 《GD32 MCU原理及固件库开发指南》

开启攻城狮的成长之旅&#xff01;这是我参与的由 CSDN博客专家 架构师李肯&#xff08;超链接&#xff1a;http://yyds.recan-li.cn&#xff09;和 瑞萨MCU &#xff08;超链接&#xff1a;瑞萨电子 (Renesas Electronics Corporation)&#xff09; 联合发起的「 致敬未来的攻…

【LeetCode】每日一题:移除链表元素 [C语言实现]

&#x1f47b;内容专栏&#xff1a;《LeetCode刷题专栏》 &#x1f428;本文概括&#xff1a;203.移除链表元素 &#x1f43c;本文作者&#xff1a;花 碟 &#x1f438;发布时间&#xff1a;2023.5.5 203. 移除链表元素 - 力扣&#xff08;LeetCode&#xff09; 题目&#xff…

微信小程序的生命周期

微信小程序的生命周期 1.什么是生命周期&#xff1f;2.生命周期的分类3.什么是生命周期函数&#xff1f;4.生命周期函数的分类5.应用生命周期函数6.页面周期函数 1.什么是生命周期&#xff1f; 生命周期&#xff08;Life Cycle&#xff09;是指一个对象从创建 -> 运行 ->…

Ant Design Pro---【02 UMI路由讲解】

在 Umi 中&#xff0c;应用都是单页应用&#xff0c;页面地址的跳转都是在浏览器端完成的&#xff0c;不会重新请求服务端获取 html&#xff0c;html 只在应用初始化时加载一次。所有页面由不同的组件构成&#xff0c;页面的切换其实就是不同组件的切换&#xff0c;你只需要在配…

设计模式——观察者模式(应用广,较难)

是什么&#xff1f; 又被称为发布-订阅模式&#xff0c;它定义了一种一对多的依赖关系&#xff0c;让多个观察者同时监听某一个主题对象&#xff0c;这个主题对象在状态变化时&#xff0c;会通知所有的观察者对象&#xff0c;使它们能够更新自己&#xff1b; 结构 抽象主题&…

复兴号列车司机室

复兴号上线有一段时间了&#xff0c;很多旅客都已经体验过&#xff0c;但是&#xff0c;司机室可能绝大多数人都没有机会一睹容颜&#xff0c;今天带你去司机室操纵台去探索一番。 按照上图的圈圈划分为操作台右侧&#xff08;蓝色圈圈&#xff09;、中部&#xff08;红色圈圈…

从C到C++

目录 1、输入输出库与名字空间 2、输入输出流对象cin和cout 3、string字符串类 4、bool类型 5、自增自减--运算符&#xff08;必考&#xff09; 6、其他 1、输入输出库与名字空间 #include <iostream> using namespace std; 每次写代码先把这两个东西加上 <io…

共享内存实现进程间大数据的交换

引言   进程间的数据交换和共享是一种非常重要和实用的技术。大、中型软件的开发设计多是由众多程序设计人员的合作完成&#xff0c;通常一个程序设计人员只负责其中一个或几个模块的开发&#xff0c;这些模块可以是动态链接库也可以是应用程序或是其他形式的程序组件。这些独…

Java 面试必问的线程池原理总结

本文首发自「慕课网」&#xff0c;想了解更多IT干货内容&#xff0c;程序员圈内热闻&#xff0c;欢迎关注"慕课网"&#xff01; 作者&#xff1a;大能老师 | 慕课网讲师 Java 线程池原理总结 &#xff08;一&#xff09;什么是线程池 线程池做的工作主要是控制运行…

Arcgis进阶篇(4)——arcgis js api使用geoserver服务

1、geoserver安装方法 geoserver-2.19.x&#xff08;此版本支持jdk8.x&#xff09;的下载地址&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/1PkmmjHWWzbUA9fYfa110Ew?pwd8tvf 提取码&#xff1a;8tvf 经测试&#xff0c;最新版本的geoserver需要jdk11&#xff…

elasticsearch 使用的基础工具包及配置安装信息

前言:elasticsearch 工具在查询大量数据上面有明显的优势&#xff0c;但是具体的使用门槛相对较高&#xff0c;具体使用中不同版本使用上还有些差别&#xff0c;需要匹配上 spring boot elasticsearch 版本才能少出错 使用到的工具包&#xff0c;注意具体的版本 elasticsearc…

坚持伙伴优先,共创数据存储新生态

4 月 26 日&#xff0c;2023 阿里云合作伙伴大会上&#xff0c;阿里巴巴集团董事会主席兼 CEO、阿里云智能集团 CEO 张勇表示&#xff0c;阿里云的核心定位是一家云计算产品公司&#xff0c;生态是阿里云的根基。让被集成说到做到的核心&#xff0c;是要坚定走向“产品被集成”…

linux0.12-8-4-sys_call.s

[301页] 8-4 sys_call.s 程序 sys_call.s 程序简单总结&#xff1a; int 0x80 – _system_call int16 – 处理器错误中断 int7 – 设备不存在或协处理器不存在。 int32 – (int 0x20)时钟中断处理程序。 两个系统功能的底层接口&#xff0c;分别是 sys_execve 和 sys_fork 。…

​Prometheus集群编队开发套件升级上市

Prometheus集群编队开发套件是一个面向集群、多智能体相关研究方向的无人机二次开发平台&#xff0c;采用分布式集群算法。与传统无人机集群相比&#xff0c;分布式无人机集群更加灵活、可靠和高效&#xff0c;可应用于更加复杂及多样化的任务场景。 分布式集群科研平台&#x…

Linux 内存管理 pt.2

哈喽大家好我是咸鱼&#xff0c;在《Linux 内存管理 pt.1》中我们学习了什么是物理内存、虚拟内存&#xff0c;了解了内存映射、缺页异常等内容 那么今天我们来接着学习 Linux 内存管理中的多级页表和大页 多级页表&大页 在《Linux 内存管理 pt.1》中我们知道了内核为每…

【Vue】学习笔记-Vue CLI ref props mixin plugin scoped

ref 属性 ref被用来给元素或子组件注册引用信息(id的代替者) 应用在html标签上获取的真实的DOM元素&#xff0c;应用在组件标签上获取的组件实列对象vc使用方式 a. 打标识:或 b.获取&#xff1a;this.$refs.xxx <template><div><h1 v-text"msg" re…

MySQL 优化一MySQL优化步骤

目录 定位执行效率较低的 SQL 1&#xff09;慢查询 2&#xff09;processlist 定位执行效率较低的 SQL 定位执行效率比较慢的 SQL 语句&#xff0c;一般有三种方式 1、可以通过慢查询日志来定位哪些执行效率较低的 SQL 语句。 2、使用 show processlist 命令查看当前 MyS…

紧急下架,AI以假乱真学明星唱歌;哈佛法学院专家谈AI和版权法

几周前&#xff0c;一首据称由 Drake 和 The Weeknd 创作的新歌登陆 TikTok 和 Spotify&#xff0c;并迅速在互联网上像野火一样传播开来。“我袖子上的心”在嘻哈乐迷中获得了好评如潮和高度兴奋&#xff0c;这不仅是因为该曲目具有感染力的歌词和旋律&#xff0c;而且还因为对…