并发三大特性——可见性

简介: 并发三大特性——可见性

引言


熟悉并发的童鞋们都知道,并发编程有三大特性,分别是可见性、有序性、原子性,今天我们从一个demo中分析可见性,以及我们如何保障可见性。


JMM模型


在我们分析可见性之前,我们需要了解一个概念,就是JMM模型,也就是我们常说的java memory model .


java虚拟机规范中定义了Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现程序在各个平台上达到一致的并发效果,JMM规范了java虚拟机与计算机内存是如何协同工作的。规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式。


14db8b82609ed9309b9527d5bb63f5df.png


上代码


package com.tuling.juc.service;
import com.tuling.concurrent.lock.UnsafeInstance;
import com.tuling.juc.Factory.UnsafeFactory;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.LockSupport;
/**
 * @Description
 * @Author zhenghao
 * @Date 2021/10/28 18:26
 **/
public class VisibilityTest {
    //volatile  是通过内存屏障来实现 可见性 ,汇编层面 会在执行的指令前面 增加  lock关键字
    //lock 关键字 可以起到和内存屏障一样的作用,但不是内存屏障
    //lock 前缀指令 会等待它之前所有的指令都完成,并且所有缓冲的写操作写会内存
    private volatile boolean flag = true;
    private int count = 0;
    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag");
    }
    public void load() {
        System.out.println(Thread.currentThread().getName() + "开始执行.....");
        int i = 0;
        while (flag) {
            i++;
            count++;
            //TODO  业务逻辑
            //等待足够长的时间的时候,本地内存中的变量值会被主动刷新到主内存中
            //没有这句代码的时候,程序不会中断,这是因为whil true 计算机任务flag这个
            //变量随时都会被用到,所以就不会刷新到主内存中
            //shortWait(1000000);
            //下面这种方式,是如何解决可见性呢?线程上下文切换 加载上下文
//            Thread.yield();//使当前线程由运行态 转换成 就绪态,让出CPU占用时间
            //通过内存屏障来实现 可见性
//            UnsafeFactory.getUnsafe().storeFence();
            // 底层使用到了 synchronized 该关键字 最后也是调用了 storeFence方法
            //通过 内存屏障 实现 可见性
//            System.out.println(count);
//            LockSupport.unpark( Thread.currentThread());
//
//            try {
//                Thread.sleep(1000);
//            } catch (InterruptedException e) {
//                e.printStackTrace();
//            }
            //总结 java保证 可见性 分为两种方式
            // 1、 内存屏障 jvm层面的 storeLoad内存屏障  === > X86 lock 替代了 mfence
            // 2、线程上下文切换Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
    }
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
    public static void main(String[] args) throws Exception {
        VisibilityTest visibilityTest = new VisibilityTest();
        Thread threadA = new Thread(() -> visibilityTest.load(), "ThreadA");
        threadA.start();
        Thread.sleep(1000);
        Thread threadB = new Thread(() -> visibilityTest.refresh(), "ThreadB");
        threadB.start();
    }
}

一、运行上述代码结果如何?


从代码的写法上应该得到的结果是,运行一段时间后,程序会结束while 循环,并且打印出:跳出循环。但是当我们实际运行上面 结果的时候,程序会直接进入死循环,并不会结束。这就是我们常说的共享变量的可见性导致的,线程B对flag的修改,线程A并不能感知到。这样的代码在如果出现在实际业务中就会导致严重的bug。


       又JMM模型我们知道,这是因为在线程运行的时候,将flag变量都加载到了各自线程的本地内存中,而两个线程之间的通讯是通过主内存,所以我们如果想让线程B修改的变量的值,让线程A及时感知到,这就需要线程B对变量的修改及时刷新到主内存,并且其他线程本地内存对该变量的缓存失效。


二、解决方案


2.1 等待


我们从常理来推测,如果我们程序在短时间内不使用这个flag变量,理论上计算机会定时将变量从缓存中移除,毕竟每个线程的本地内存空间并不是很大,并且从一些常用的内存清理中间件来分析大概都是这个原理,所以我们在whil循环中增加 等待1ms,运行结果:程序跳出while 循环,达到可见性目的,这也说明计算机会将最近不是使用的变量从本地内存清除。具体等待多久才会达到这个效果和具体的硬件有关系,没有具体详细精准的时间。


2.2 线程上下文切换


这钟方式典型的用法就是 Thread.yield(),让当前线程从运行态变成就绪态,交出cpu使用权。基本线程切换的时间大概为5ms-10ms之间,线程上下文切换会导致当前线程本地内存失效,当该线程再次获得CPU使用权的时候,从程序计数器中获得下一条执行的指令,并且从主内存中加载变量值,这种方式也解决了 可见性问题。


2.3 volatile关键字


这种方式是我们最常见的一种解决方式,那么为什么volatile关键字可以解决这个问题呢?因为这是jdk中自带的一个关键字,所以我们需要查看jdk源码才可以更好的理解


973aad79a283d909233503d0acb9b1d6.png


从源码中我们可以发现,该关键字调用了storeload()方法,从这个方法命我们就可以知道,这里开始调用内存屏障的实现了。

inline void OrderAccess::storeload()  { fence(); }
inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }


以上代码中在x86处理器上的实现,在该处理其中利用 lock 实现类似内存屏障的效果,这种lock的实现方式效率更高。


lock前缀指令的作用


1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。


2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。


3. LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。


汇编层面的lock实现


我们可以通过增加jvm参数来观察


-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp


ba72d1cedaf1483083e2324cc23de0c9.png


从验证了可见性是用来了lock指令


其余的几种方式  System.out.println(count); 底层使用了 synchronized关键字,源码最后也是调用了storeFence方法,也是利用内存屏障来实现了可见性。包括Thead.sleep()等


2.4 将count 类型从int 更换为integer


这种方式其实是利用了final关键字,如果我们看过intege 源码定义的话,我们最后其实获得的是value值,而在integer中value 是通过final关键字定义的,所以说final关键字也可以解决可见性问题。


总结


所以我们将上面的各种解决方案进行总结,


如何保证可见性


通过 volatile 关键字保证可见性。

通过 内存屏障保证可见性。

通过 synchronized 关键字保证可见性。

通过 Lock保证可见性。

通过 final 关键字保证可见性

其实上面的这几种方式,从底层可以分文两个大的方案;


       1)线程上下文切换


       2)内存屏障   jvm层面的 storeLoad内存屏障 === > X86 lock 替代了 mfence

目录
相关文章
|
10月前
|
机器学习/深度学习 人工智能 自然语言处理
从五子棋到DeepSeek:揭开模式匹配的奥秘
本文通过五子棋AI与大语言模型DeepSeek的对比,探讨了模式匹配技术在不同领域的应用与相似性。从五子棋的棋局分析到自然语言处理,模式匹配构成了人工智能决策的核心机制。文章揭示了AI如何通过识别数据中的规律进行预测与生成,并展望了该技术在未来医疗、金融、自动驾驶等领域的广泛应用前景,展现了从简单游戏到智能世界的演进路径。
456 2
|
自然语言处理 安全 数据挖掘
通过 MCP 构建企业级数据分析 Agent
本文介绍了使用阿里云实时数仓 Hologres、函数计算 FC 和通义大模型 Qwen3 构建企业级数据分析 Agent 的方法。通过 MCP(模型上下文协议)标准化接口,解决大模型与外部工具和数据源集成的难题。Hologres 提供高性能数据分析能力,支持实时数据接入和湖仓一体分析;函数计算 FC 提供弹性、安全的 Serverless 运行环境;Qwen3 具备强大的多语言处理和推理能力。方案结合 ModelScope 的 MCP Playground,实现高效的服务化部署,帮助企业快速构建跨数据源、多步骤分解的数据分析 Agent,优化数据分析流程并降低成本。
1191 30
|
JavaScript 数据可视化 Docker
简易制作MCP服务器并测试
本文介绍了如何简易制作并测试MCP服务器,包括环境搭建、代码实现及Docker部署。首先通过uv包创建项目,在main.py中定义MCP服务器及其工具和资源函数。接着详细说明了在Windows上安装uv、配置Docker镜像加速、生成requirements.txt文件以及编写Dockerfile的过程。最后,通过构建和运行Docker容器部署MCP服务器,并使用Node.js工具测试其功能,确保服务器正常工作。此教程适合初学者快速上手MCP服务器的开发与部署。
4545 63
|
JSON 开发工具 git
git rebase 合并当前分支的多个commit记录
git rebase 合并当前分支的多个commit记录
1298 1
|
人工智能 搜索推荐 测试技术
AI 辅助编程的效果衡量
本文主要介绍了如何度量研发效能,以及 AI 辅助编程是如何影响效能的,进而阐述如何衡量 AI 辅助编程带来的收益。
|
SQL druid Java
高并发场景下必备利器:掌握连接池的使用和调优技巧
高并发场景下必备利器:掌握连接池的使用和调优技巧
|
消息中间件 缓存 JavaScript
Nacos+@RefreshScope 为什么配置能动态刷新?
Nacos+@RefreshScope 为什么配置能动态刷新?
|
SQL
Mybatis-plus 自定义SQL注入器查询@TableLogic 逻辑删除后的数据
Mybatis-plus使用@TableLogic注解进行逻辑删除数据后,在某些场景下,又需要查询该数据时,又不想写SQL。 自定义Mybatis-plus的SQL注入器一劳永逸的解决该问题
1641 0
|
监控 NoSQL MongoDB
mongodb查询100万数据如何查询快速
综上,提高MongoDB百万级数据的查询性能需要综合多项技术,并在实际应用中不断调优和实践。理解数据的特征,合理设计索引,优化查询语句,在数据访问、管理上遵循最佳的实践,这样才能有效地管理和查询大规模的数据集合。
914 1
|
开发工具 git
GIT:如何合并已commit的信息并进行push操作
通过上述步骤,您可以有效地合并已提交的信息,并保持项目的提交历史整洁。记得在执行这些操作之前备份当前工作状态,以防万一。这样的做法不仅有助于项目维护,也能提升团队协作的效率。
1200 5