基础篇:深入JMM内存模型解析volatile、synchronized的内存语义

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性

1 java内存模型,JMM(JAVA Memory Model)

  • 1.1 线程A需要和线程B交互,则需要更新工作内存的共享变量副本到主存,然后线程B去主存读取更新后的变量
  • 1.2 java线程之间的通信是由JMM控制的,JMM决定线程对共享变量的写入何时对另一线程可见。共享变量存在主存,线程拥有自己的工作内存(一个抽象的概念,它覆盖了缓存,写缓冲区,寄存器等)

2 CPU高速缓存、MESI协议

  • 处理器的高速发展,CPU的性能和内存性能差距拉大,为了解决问题,CPU设置多级缓存,例如L1、L2、L3高速缓存(Cache)。

  • 和JMM的内存布局相似,前者是系统级别,解决缓存一致性问题;后者是应用级别的,解决的是内存一致性问题

  • 这些高速缓存一般都是独属于CPU内部的,对其他CPU不可见,此时又会出现缓存和主存的数据不一致现象,CPU的解决方案有两种

    • 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
    • 缓存一致性协议(MESI):缓存一致性协议也叫缓存锁定,缓存一致性协议会阻止两个以上CPU同时修改映射相同主存数据的缓存副本

  • MESI实现是依靠处理器使用嗅探技术保证它的内部缓存、系统主内存和其他处理器的缓存的数据在总线上保持一致


  • 例:处理器打算回写脏内存地址,而此内存处于共享状态(Share);那么其他处理器会嗅探到,并将使自身的对应的缓存行无效,在下次访问相应内存地址时,刷新该缓存行


  • 缓存数据状态有如下四种(MESI):

    缓存状态 描述
    M(Modifed) 在缓存行中被标记为Modified的值,与主存的值不同,这个值将会在它被其他CPU读取之前写入内存,并设置为Shared
    E(Exclusive) 该缓存行对应的主存内容只被该CPU缓存,值和主存一致,被其他CPU读取时置为Shared,被其他CPU写时置为Modified
    S(Share) 该值也可能存在其他CPU缓存中,但是它的值和主存一致
    I(Invalid) 该缓存行数据无效,需要时需重新从主存载入

3 指令重排序和内存屏障指令

  • 为提高程序性能,编译器和处理器经常会对指令做重排序,分别是编译器优化的重排序指令并行级别的重排序内存系统的重排序
  • 指令并行重排序和内存系统重排序归为处理器重排序
  • 编译器级优化重排序,可由JMM规则禁止特定类型的指令重排;对于处理器级别重排序则是插入特定类型的内存屏障指令,以此禁止特定类型的重排序
  • CPU的设计者提供内存屏障机制,是将对共享变量读写的高速缓存的强一致性控制权(MESI的机制)交给了程序员或编译器
  • 这里介绍两种处理器级别的内存屏障指令
    • 写内存屏障:该屏障之前的写操作先于之后的写操作;在指令后插入StoreBarrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
    • 读内存屏障:该屏障之前的读操作先于之后的读操作;在指令前插入LoadBarrier,让高速缓存中的数据失效,强制从主内存加载数据

  • 内存屏障有两个作用:阻止屏障两侧的指令重排序强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
  • JAVA的内存屏障指令,基本可以理解为在CPU内存屏障指令上二次封装
JAVA内存屏障指令 作用描述
Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存),先于Store2及所有后续存储指令的存储。
Load1;LoadStore;Store2 确保Load1数据装载先于Store2及所有后续存储指令的存储。
Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。
Load1;LoadLoad;Load2 确保Load1数据的装载,先于Load2及所有后续装载指令的装载。


  • 特殊的是StoreLoad,会使该屏障之前的所有内存访问指令(装载和存储指令)完成之后,才执行该屏障之后的内存访问指令;是一个”全能型”的屏障,它同时具有其他三个屏障的效果
  • 用一句话描述java内存屏障的目的:把当前工作内存的数据全部刷新到主内存,并且其他工作内存的共享变量全部失效,真正需要用时再读取主存最新的值

4 happen-before原则

  • 内存屏障是相对于jvm,cpu级别的内存一致性(内存可见性)的解决方案;为了让java程序员更容易理解,jsr-133使用happens-before的概念来说明不同操作之间的内存可见性
    • 程序次序规则:同一个线程,任意一操作happens-before同线程之后的全部操作
    • 监视器锁(synchronized)规则:对一个监视器锁的解锁,happens-before随后对这个锁的加锁
    • volatile变量规则:对volatile变量的写操作,happens-before该volatile变量之后的任意读操作
    • 传递性:如果A先于B;B先于C;则A先于C

  • happens-before部分规则是基于内存屏障实现的

5 synchronized内存语义

class Count{
    int a = 0;
    public synchronized void writer(){// 1 
        a++; //2
    } //3
    public synchronized void reader(){// 4 
        int i = a; //5 
    } //6
}
  • 根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens- before 6。 根据监视器锁规则,3 happens-before 4。根据happens-before的传递性得 2 happens-before 5。执行结果如下图

  • 线程释放锁时内存语义:JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
  • 线程获取锁时内存语义:JMM会把该线程对应的工作内存置为无效

6 volatile的内存语义

  • volatile变量具有可见性,Java线程内存模型确保所有线程看到这个变量的值是最新的,并且单个volatile变量的读/写具有原子性;java编译器对volatile变量处理如下
    • 在每个volatile写操作的前面插入一个StoreStore屏障
    • 在每个volatile写操作的后面插入一个StoreLoad屏障
    • 在每个volatile读操作的后面插入一个LoadLoad屏障
    • 在每个volatile读操作的后面插入一个LoadStore屏障

  • 注意i++是复合操作,即使 i 是volatile变量,也不保证i++是原子操作
volatile Object instance;
instance = new Object();
//相应汇编代码
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
  • 当volatile变量修饰的共享变量进行写操作的反汇编代码会出现0x01a3de24: lock addl $0×0,(%esp),其实就是插入了内存屏障导致的结果,lock表示volatile变量写时被缓存锁定了(MESI协议),作用如下
    • 禁止指令重排序
    • 将当前处理器缓存行的数据写回到系统内存
    • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

int a = 0volatile boolean v = false;

线程A
a = 1;    //1 
v = true//2

线程B
v = true//3
System.out.println(a);//4  
  • 根据程序次序规则,1 happens-before 2;3 happens-before 4。根据volatile变量规则,2 happens-before 3。 根据happens-before的传递性规则,1 happens-before 4。程序的执行结果表现如下图

  • volatile写的内存语义:写volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile读的内存语义:读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
  • 非基本字段不应该用volatile修饰。其原因是volatile修饰对象或数组时,只能保证他们的引用地址的可见性

7 final内存语义

  • final写内存语义:
    • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。保障对象被引用之前,fianl域里的变量都是被初始化的
    • 实现原理:编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
    public class Example 
        int i; //普通类型
        final int j; // 引用类型 
        public Example () // 构造函数 
            i = 0;  j = 1;
        }
        public static void writer () // 写线程A执行 
            obj = new Example (); 
        }
        public static void reader () // 读线程B执行 
            Example object = obj; // 读对象引用 
            int a = object.i; // 读普通域 
            int b = object.j; // 读final域 
        }
    }
    • final只会禁止对其修饰变量的写操作,被重排序到构造函数之外;普通变量 i 的赋值可能会被重排到序构造函数之外
    • A线程创建obj,可能让线程B拿到初始化一半的obj;final变量 j 被初始化,而普通变量 i 还没初始化
    • 疑问:内存屏障不是会禁止指令重排吗?个人猜想应该是编译器先重排序,此时普通变量已经在构造器外了,再根据final类型插入内存屏障。上面的代码执行可能有如下情况:

  • final读内存语义
    • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
    • 实现原理:要求编译器在读final域的操作前面插入一个LoadLoad屏障

  • 当使用final修饰引用对象或者数组时,final只保证在构造器返回之前对引用对象的操作先于构造器返回之后的操作
    public class Example 
        final int[] intArray; // intArray 是引用类型 
        public Example () // 构造函数 
            intArray = new int[1]; 
            intArray[0] = 1//此操作对获取该对象引用的线程是可见的
        }
    }

8 synchronized,volatile内存语义的原理梳理

9 应用题:延迟加载双重锁定是否真的安全

public class Instance {                         // 1
    private static Instance instance;           // 2
    public static Instance getInstance() {      // 3
        if (instance == null) {                 // 4:第一次检查
            synchronized (Instance.class) {     // 5:加锁
                if (instance == null)           // 6:第二次检查
                    instance = new Instance();  // 7:问题的根源出在这里
            }                                   // 8
        }                                       // 9
        return instance;                        // 10
    }                                           // 11
}

代码第7行instance=new Singleton();创建了一个对象。这一行代码可以分解为如下的3行伪代码

memory = allocate(); // A1:分配对象的内存空间 
ctorInstance(memory); // A2:初始化对象 
instance = memory; // A3:设置instance指向刚分配的内存地址

假如2和3之间重排序之后的顺序如下

memory = allocate(); // A1:分配对象的内存空间 
instance = memory;  //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象
  • 假如发生A3、A2重排序,线程是不保障赋值初始化对象两步骤操作结果会一起同步到主存
  • 因此第二个线程执行到if (instance == null);// 4:第一次检查时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用)
  • 相应的两个解决方法
    • 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:private volatile static Instance instance;
    • 使用类加载器的全局锁,在执行类的初始化期间,JVM会去获取一个锁;这个锁可以同步多个线程对同一个类的初始化,每个线程都会试图获取该类的全局锁去初始化类
    public class InstanceFactory 
        private static class InstanceHolder 
            public static Instance instance = new Instance();
        }
        public static Instance getInstance() {
            // 这里将导致InstanceHolder类被初始化 
            return InstanceHolder.instance ; 
        } 
    }

10 题外话:伪共享(false sharing)

  • 伪共享
    • 前面介绍到每个CPU都有属于自己的高速缓存,但是缓存数据大小是怎样的呢?
    • 这个大小并不是我们需求存多大就存多大的,而是一个固定的大小-64字节,缓存的加载更新都是以连续的64字节内存为单位,称之为缓存行
    • 一缓存行是可以存在多个变量的,比如long类型(64位==8字节),可以存入8个


  • 假如变量A和变量B是在同一连续的内存,CPU缓存加载A时,B也会被读取;反之亦然,A的脏回写导致在其他CPU相应内存失效的同时,同一缓存行的B内存也被标识为Modified(同舟共渡,一起翻船)
  • 设想变量A和B没有关联,却刚好在同一缓存行;然后A被CPU-X处理,B被CPU-Y处理;因为CPU-X对A的缓存更新而导致B的缓存失效;CPU-Y要处理B,则要读取更新后的缓存行(B实际是没被更新),造成没必要的内存读取开销。这就是伪共享

  • 伪共享的解决方法:
    1- 填充字节,将对应的变量填充到缓存行的大小。如下面定义的类,声明额外的属性
    public final static class FilledLong {
        /**value 加 p1 - p6;加对象头8个字节正好等于一缓存行的大小 */
        //markWord + klass (32位机,64位是16字节) 8字节 
        public volatile long value = 0L// 8字节
        public long p1, p2, p3, p4, p5, p6; //48字节
    }
    2- 使用jdk的注解@Contended修饰变量,jvm会自动将变量填充到缓存行的大小。注意的是需要加入启动参数 -XX:-RestrictContended

关注公众号,一起交流


目录
相关文章
|
26天前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
33 6
|
1月前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
19天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
1月前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
42 2
|
2月前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
71 2
|
2月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
76 0
|
2月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
62 0
|
2月前
|
存储 Java C++
Collection-PriorityQueue源码解析
Collection-PriorityQueue源码解析
66 0
|
2月前
|
安全 Java 程序员
Collection-Stack&Queue源码解析
Collection-Stack&Queue源码解析
86 0

推荐镜像

更多