Java并发编程系列 - Java内存模型

2023-11-09

Java并发编程 - 可见性、原子性、有序性 & Java内存模型如何解决可见性、有序性

并发问题产生的根源:可见性、原子性、有序性

可见性

Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。

  • 主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。
  • 工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

线程工作内存的可见性

下面我们再用一段代码来验证一下多线程工作内存的可见性。下面的代码,每执行一次 add() 方法,都会循环num次 count++ 操作。在主方法中我们创建了两个线程,每个线程调用一次 add(100000) 方法,我们来想一想执行完得到的结果应该是多少呢?

public class Test {

    private int count = 0;

    public void add(int num){
        for(int i=0; i<num; i++){
            count++;
            System.out.println(Thread.currentThread().getName() + "current count : "+count);
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread t1 = new Thread(() -> {
            test.add(100000);
        });
        Thread t2 = new Thread(() -> {
            test.add(100000);
        });
        t1.start();
        t2.start();
    }
}

可能会有很多人认为会输出200000,如果在单线程中调用2次add(100000),那么结果确实应该是200000;但是当开启多个线程执行时,如上面代码的线程t1和线程t2,那么结果会有所不同,上面最终输出的count值会在100000到200000之间的一个随机数,结果如下图:

为什么会出现这种结果呢?

我们假设线程 1 和线程 2 同时开始执行,那么第一次都会将 count=0 读到各自的线程工作内存里,执行完 count++ 之后,各自的线程工作内存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的线程工作内存里都有了 count 的值,两个线程都是基于线程工作内存里的 count 值来计算,所以导致最终 count 的值都是小于 200000 的。这就是线程工作的可见性问题。

物理硬件层面的可见性

在单核电脑中,所有的线程都在一个处理器上执行,处理器的高速缓存与内存的一致性很容易解决。但是在多核处理器时代,每个处理器都有自己的高速缓存,如下图中,当线程1执行的时候访问的是处理器1的高速缓存1,线程2执行访问的是处理器2的高速缓存2,这个时候如果线程1和线程2修改同一个变量val,那么线程1和线程2的操作彼此是不具备可见性的。

目前基于高速缓存的存储交互很好的解决了cpu和内存等其他硬件之间的速度矛盾,多核情况下各个处理器(核)都要遵循一定的诸如MSI、MESI等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。

原子性

当前的操作系统执行基本是基于“时间片”来执行,即当有多个进程需要获取CPU的执行操作时,CPU采用时间片轮转法并发的执行,即CPU会允许某个进程执行一小段时间,例如 100 毫秒,过了 100 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),如下图:

  • 原子的意思代表着——“不可分”;
  • 在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。例如 a=1是原子性操作,但是i++和i+=1就不是原子性操作。

i++和i+=1不能保证原子操作的原因如下原子性:

有序性

有序性指的是程序按照代码的先后顺序执行。

有序性从不同的角度来看是不同的。在本线程内观察,所有操作都是有序的(即指令重排不会导致单线程程序执行结果与排序前有任何差别);在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。前半句说的就是“线程内表现为串行的语义”,后半句指得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

我们来看个简单的例子,单例的dubbo check的实现,我们在用单例的dubbo check方式时会在实例变量Singleton s 前面用volatile修饰变量,volatile关键字就是为了解决重排序的优化带来的并发问题:


public class Singleton {

    private static volatile Singleton s;
    /**
     * 懒汉式
     * dubbo check:存在的问题(指令重排序)
     * 1. 申请一块内存空间s
     * 2. 在这块空间里实例化对象
     * 3. 将这块空间的地址赋值给变量 s
     *    JVM为了优化性能可能先执行1,3,后执行2,  [1,2,3]->[1,3,2]
     *    若1,3先执行,2还未执行时判断s==null,
     *    并发时就会出问题,为了防止这种情况,Singleton s 需用volatile修饰
     * @return
     */
    public static Singleton getInstance(){
        if(null == s){
            synchronized (Singleton.class){
                if(null == s){
                    s = new Singleton();
                }
            }
        }
        return s;
    }
}

正常情况下我们new一个对象应该有以下几步操作:

  1. 申请一块内存空间s
  2. 在这块空间里实例化对象
  3. 将这块空间的地址赋值给变量 s

但实际执行的顺序可能是这样的:

  1. 申请一块内存空间s
  2. 将这块空间的地址赋值给变量 s
  3. 在这块空间里实例化对象

在单线程模式下这种优化是不会有问题的,但是当多线程并发执行时有可能汇报NPE异常。我们假设线程 1 先执行 getInstance() 方法,当执行完步骤 2 时恰好发生了线程切换,切换到了线程 2 上;如果此时线程 2 也执行 getInstance() 方法,那么线程 2 在执行第一个判断时会发现 s != null ,所以直接返回 s,而此时的 s 是没有初始化过的,如果我们这个时候访问 s 的成员变量就可能触发空指针异常。

Java内存模型(如何解决可见性和有序性)

Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。 在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存。详细见图线程、工作内存、主内存之间的交互关系。

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有 序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程 序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓 存和编译优化的方法即可。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,从我们程序员的视角来看可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方 法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。

volatile关键字

当一个变量定义为 volatile 之后,将具备两种特性:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量,并更新本地内存的值。   ------可见性

  • 禁止指令重排序优化(底层通过内存屏障解决,这里不过多介绍)   ------有序性

volatile没有解决原子性的问题,原子性只能依靠我们在代码层面解决。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

public class Test{

    private int count=2;
    private boolean flag=false;

    public void write1()  {
        count=10;
        flag=true;  //没有volatile修饰,实际执行顺序,有可能是flag=true先执行
    }

    public void read1()  {
        if(flag){
            System.out.print(count); 
        }
    }
    
    public static void main(String[] args) {
        Singleton s = new Singleton();
        Thread t1 = new Thread(() -> s.write1());
        Thread t2 = new Thread(() -> s.read1());
        t1.start();
        t2.start();
    }

}

上面的代码,由于指令会重排序,当线程一里面执行write1方法的flag=true的时候,同时线程2执行了read1方法,那么count的值是不确定的,可能是10,也可能是2。

public class Test{

    private boolean flag=false;
    private volatile boolean sync=false;

     public void write2() {
     count=10;
     sync=true;// 由于出现了volatile,所以这里禁止重排序
    }

    public void read2()  {
        if(sync){
            System.out.print(count); // 在jdk5之后,由volatile保证,count的值总是等于10
        }

    }


    public static void main(String[] args) {
        Singleton s = new Singleton();
        Thread t1 = new Thread(() -> s.write2());
        Thread t2 = new Thread(() -> s.read2());
        t1.start();
        t2.start();
    }

}

注意这里的sync变量是加了volatile修饰,意味着禁止了重排序,第一个线程调用write2方法时候,同样第二个线程在调用read2方法时候,如果sync=true,那么count的值一定是10,有人会认为count变量没有用volatile修饰啊,如何保证100%可见性呢? 确实在jdk5之前volatile关键字确实存在这种问题,必须都得加volatile修饰,但是在jdk5及以后修复了这个问题,也就是在jsr133里面增强了volatile关键字的语义,volatile变量本身可以看成是一个栅栏,能够保证在其前后的变量也具有volatile语义,同时由于volatile的出现禁止了重排序,所以在多线程下仍然可以得到正确的结果。

Happens-Before 规则

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

  • 如果操作A happens-before 操作 B,那么操作A的执行结果将对操作可见 B,而且操作 A 的执行顺序排在操作 B 之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

Happens-Before具体有以下六项规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Java并发编程系列 - Java内存模型 的相关文章

  • Java 多线程事务回滚 ——多线程插入数据库时事务控制

    背景 日常项目中 经常会出现一个场景 同时批量插入数据库数据 由于逻辑复杂或者其它原因 我们无法使用sql进行批量插入 串行效率低 耗时长 为了提高效率 这个时候我们首先想到多线程并发插入 但是如何控制事务呢 直接上干货 实现效果 开启多条
  • 一次线上的GC问题排查

    6 19号下午 线上系统出现了一次实时链路数据 不通畅的问题 业务方反应更新的增量数据没有流入到HA3搜索集群 登录机器后检查日志后发现 在周六晚上到周天下午 cr search merge 机器人schema统一 表增量数据猛增 初步估计
  • JUC常用到的类

    JUC java util concurrent 并发包中包含了许多并发编程中需要用到的类 锁 如ReentratLock ReadWriteLock ReentrantLock重入锁 可以替代synchronized使用 并且有更多强大的
  • 【2021最新版】Java多线程&并发面试题总结(108道题含答案解析)

    文章目录 JAVA并发知识库 1 Java中实现多线程有几种方法 2 继承Thread类 3 实现Runnable接口 4 ExecutorService Callable Future有返回值线程 5 基于线程池的方式 6 4 种线程池
  • Callable和Future原理解析

    首先进行分析前 我们需要了解到的概念 Callable是一个接口 是用于创建线程执行里面有一个call方法 用于线程执行的内容 由业务自己定义 Future也是一个接口 可以异步的通过get方法获取到call返回的内容 比较常见的使用场景
  • java晋级赛 深入并发编程

    根据黑马java并发编程学习做的笔记 传送门 https www bilibili com video BV16J411h7Rd p 15 java晋级赛 深入并发编程 一 多线程基础 进程与线程 创建线程的方式及运行原理 创建线程的方式
  • 159.并发编程(三):线程池,JUC

    目录 一 线程池 1 线程池的作用 2 手写线程池 3 ThreadPoolExecutor 1 线程池状态
  • Java并发编程的相关知识(7)-阻塞队列

    阻塞队列 ArrayBlockingQueue LinkedBlockingQueue ProiorityBlockingQueue DelayQueue SynchronousQueue LinkedTransferQueue Linke
  • Java并发之锁

    Java并发之锁 一 临界区 二 线程安全 三 解决临界区线程安全问题 四 Java对象头 五 重量级锁 Monitor 5 1 synchronized 5 1 1 synchronized加锁流程 六 轻量级锁 6 1 轻量级锁加锁流程
  • Java并发编程-第二章

    以下内容来自 Java并发编程 书籍第二章 补充 1 volatile的有序性 volatile通过内存屏障实现禁止指令重排序保证有序性 硬件层面的内存屏障分为Load Barrier 和 Store Barrier即读屏障和写屏障 2 同
  • 在Windows下使用MingGW[GCC+OpenMP]和CodeBlocks开发多核应用基本环境配置

    转自 http blog csdn net danny xcz article details 3332251 从06年开始 多核开发已经越来越多的成为所有应用设计必须考虑的问题 我使用MingGW CodeBlocks来测试OpenMP多
  • shell编程笔记3--shell并发

    shell编程笔记3 shell并发 shell编程笔记3 shell并发 介绍 并发方法 1 简单后台方式 2 普通控制并发量方式 3 通过管道控制并发量 参考文献 shell编程笔记3 shell并发 介绍 在shell中适当使用并发功
  • Java并发编程实战——并发容器之ConcurrentHashMap(JDK 1.8版本)

    文章目录 ConcurrentHashmap简介 从关键属性及类上来看ConcurrentHashMap的结构 put 方法管中窥豹 CAS关键操作 ConcurrentHashmap简介 在使用HashMap时在多线程情况下扩容会出现CP
  • 深入浅出 Java Concurrency (J.U.C)

    深入浅出 Java Concurrency J U C 转载 1 http www blogjava net xylz archive 2010 06 30 324915 html http www blogjava net xylz ar
  • 并发编程系列之原子操作实现原理

    前言 上节我们讲了并发编程中最基本的两个元素的底层实现 同样并发编程中还有一个很重要的元素 就是原子操作 原子本意是不可以再被分割的最小粒子 原子操作就是指不可中断的一个或者一系列操作 那么今天我们就来看看在多处理器环境下Java是如何保证
  • 从0实现基于Linux socket聊天室-实现聊天室的公聊、私聊功能-4

    前面文章链接如下 从0实现基于Linux socket聊天室 多线程服务器模型 1 从0实现基于Linux socket聊天室 多线程服务器一个很隐晦的错误 2 从0实现基于Linux socket聊天室 实现聊天室的登录 注册功能 3 上
  • 锁介绍名词解释&&Lock && synchronized

    各种锁名词解释及应用 一 名词解释 1 乐观锁 VS 悲观锁 2 自旋锁 VS 适应性自旋锁 3 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁 4 公平锁 VS 非公平锁 5 可重入锁 VS 非可重入锁 6 独享锁 VS 共享锁 二
  • 接口并发性能测试开发之:从测试方案设计、测试策略、指标分析到代码编写,这一篇全搞定。

    并发接口性能设计思路与代码编写 1 引言 2 并发测试定义 3 并发测试分类 4 设计思路整理 5 测试方案设计 6 指标分析 7 代码实战 8 总结 1 引言 这篇是我3月份在公司内部做的技术分享内容 由于我在公司内部分享的内容较多 以及
  • Java并发编程之设计模式

    同步模式之保护性暂停 1 定义 即 Guarded Suspension 用在一个线程等待另一个线程的执行结果 要点 有一个结果需要从一个线程传递到另一个线程 让他们关联同一个 GuardedObject 如果有结果不断从一个线程到另一个线
  • 并发编程 (6)一不小心就死锁了,怎么办?

    在上一篇文章中 我们用 Account class 作为互斥锁 来解决银行业务里面的转账问题 虽然这个方案不存在并发问题 但是所有账户的转账操作都是串行的 例如账户 A 转账户 B 账户 C 转账户 D 这两个转账操作现实世界里是可以并行的

随机推荐

  • 为什么bytes32等于uint256

    先说1byte等于8个字节 bytes32则等于8 32 256个字节 接着uint8同样等于8个字节 uint256即8个字节的32倍 256 8 32 因此看到byteX和uintY时 如果X 8 Y 意味着byteX uintY
  • JVM Troubleshooting命令-jinfo

    概述 用来查看正在运行的Java应用程序的扩展参数 支持在运行时 修改部分参数 命令格式 jinfo option pid jinfo option executable core jinfo option servier id remot
  • 代码坏味道与重构之重复代码

    文章目录 1 重复代码的特征 2 重复代码的影响 3 重复代码的重构技巧 1 重复代码的特征 重复代码是代码坏味道的典型代表 重复代码是指相同或相似的代码在一个以上的地方出现 通常有以下几种情形 同一个类 多个方法间重复 子类之间代码重复
  • ChatGPT API 中文版(google翻譯)

    https platform openai com docs api reference introduction 介紹 您可以通過任何語言的 HTTP 請求 我們的官方 Python 綁定 我們的官方 Node js 庫或社區維護的庫與
  • 1566 重复至少 K 次且长度为 M 的模式(模拟)

    1 问题描述 给你一个正整数数组 arr 请你找出一个长度为 m 且在数组中至少重复 k 次的模式 模式 是由一个或多个值组成的子数组 连续的子序列 连续 重复多次但 不重叠 模式由其长度和重复次数定义 如果数组中存在至少重复 k 次且长度
  • 微信小程序的事件绑定、接收参数、示例

    1 微信小程序的事件类别 tap 点击事件 input 输入事件 longtap 长按事件 touchstart 触摸开始 touchend 触摸结束 touchcansce 取消触摸 注1 小程序中请求处理方法是不能传递参数 正确方式 通
  • node js 文件,文件夹,文件流操作

    引入模块 const fs require fs const path require path 读取文件 同步读取 var data fs readFileSync read txt utf 8 console log 同步读取 data
  • LogisticRegression - 参数说明

    LogisticRegression 逻辑回归参数详细说明 参数说明如下 penalty 惩罚项 str类型 可选参数为l1和l2 默认为l2 用于指定惩罚项中使用的规范 newton cg sag和lbfgs求解算法只支持L2规范 L1G
  • SCAU 18724 二叉树的遍历运算

    18724 二叉树的遍历运算 Description 二叉树的三种遍历都可以通过递归实现 如果我们知道一棵二叉树的先序和中序序列 可以用递归的方法求后序遍历序列 输入格式 两行 第一行一个字符串 表示树的先序遍历 第二行一个字符串 表示树的
  • PC机(笔记本)安装Linux系统

    五年前买的联想ThinkPad E531 配置非常低非常低 实在是卡的不行 做个word都想砸了它的冲动 最近想开始学习Linux 发现好多建议初学者在Windows下装虚拟机 这样虽好 但感觉好麻烦 简单粗暴点 直接装个Linux系统 1
  • 管理端-角色设置

    本文是关于无纸化会议系统管理端角色设置的操作 本界面使用html css js进行开发 使用Vue框架和ElementUI进行辅助开发 管理员上传完文件之后 进入到角色设置界面 角色设置界面主要是给参加会议的每个角色设置文件权限以及分配会议
  • 人工智能环境搭建

    前言 2016年 人工智能自AlphaGo战胜世界著名围棋九段选手李世石后大火 人类首次感受到人工智能的强大和前所未有的危机 各大IT企业纷纷布局人工智能 准备开启新一轮的产业革命 本文就来讲一讲人工智能开发环境的搭建 即Ubuntu的Li
  • Object.hasOwn 低版本浏览器兼容性问题解决

    使用 hasOwn 去测试属性是否存在 报错如下 原因 hasOwn是es2022新语法 旧浏览器不支持 解决方案 使用Object hasOwnProperty 代替 Object prototype hasOwnProperty cal
  • 随机森林算法

    转载自 http www zilhua com 629 html 1 随机森林使用背景 1 1 随机森林定义 随机森林是一种比较新的机器学习模型 经典的机器学习模型是神经网络 有半个多世纪的历史了 神经网络预测精确 但是计算量很大 上世纪八
  • QGIS 加载WMS数据,重新投影

    1 加载WMS数据 点击需要的栅格数据 注意这里的投影是EPSG 3857 实际需要转换成WGS84 EPSG 4326 一个简单的方法 先在QGIS加载一个shp 投影方式为WGS84 再加载栅格数据 点击set project CRS
  • 告别尬聊,觅伊迎来Z世代青年的社交新世界

    什么是Z世代 从定义上看 Z世代泛指出生在1995年到2005年前后的人群 根据相关数据统计 目前我国 Z世代 年轻人多达2 64亿人 约占我国人口总数的19 因为基数较大 所以在许多从业者的眼中 Z世代作为未来互联网的主力人群 是所有产品
  • docker-swarm ui shipyard部署

    shipyard 搭建 参考网址 https shipyard project com docs deploy https juejin im entry 588940fc2f301e0069b2397d 下载shipyard相关的镜像 为
  • OpenGL学习笔记三(编译着色器)

    运行着色器程序 ps 大部分出自 LearnOpenGL 上一篇 OpenGL学习笔记二 着色器 说到什么是着色器 如何写着色器 这次我们要开始运行我们写好的着色器代码了 着色器程序的创建与C C 程序的创建相似 首先你需要编写着色器程序文
  • C++的宏观与微观

    宏观世界是由微观世界所组成 这是亘古不变的道理 C 同样是这样 如果说生物是由原子组成的 那么C 就是由对象模型组成的 对象模型就相当于原子模型 对象模型的变化和互相联系形成了目前C 一切的特性 再说C 宏观 C 的宏观表现为面向对象 由此
  • Java并发编程系列 - Java内存模型

    Java并发编程 可见性 原子性 有序性 Java内存模型如何解决可见性 有序性 并发问题产生的根源 可见性 原子性 有序性 可见性 Java内存模型的主要目标是定义程序中变量的访问规则 即在虚拟机中将变量存储到主内存或者将变量从主内存取出