JMM高并发详解(java内存模型、JMM三大特征、volatile关键字 )

简介: JMM高并发详解(java内存模型、JMM三大特征、volatile关键字 )

目录

一、什么是JMM

二、JMM定义了什么

原子性

可见性

有序性

三、八种内存交互操作

四、volatile关键字

可见性

volatile一定能保证线程安全吗?

禁止指令重排序

volatile禁止指令重排序的原理

四、总结


一、什么是JMM

JMM就是Java内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。所以java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。

Java内存模型规定所有的变量都存储在主内存中,包括实例变量,静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行线程不能直接读写主内存中的变量

不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。

image.gif

每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。

温馨提醒一下,这里有些人会把Java内存模型误解为Java内存结构,然后答到堆,栈,GC垃圾回收,最后和面试官想问的问题相差甚远。实际上一般问到Java内存模型都是想问多线程,Java并发相关的问题


二、JMM定义了什么

这个简单,整个Java内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可见性,有序性。这三个特征可谓是整个Java并发的基础。

原子性

原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。

面试官拿笔写了段代码,下面这几句代码能保证原子性吗

int i = 2;
int j = i;
i++;
i = i + 1;

image.gif

第一句是基本类型赋值操作,必定是原子性操作。

第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。

第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。

JMM只能保证基本的原子性,如果要保证一个代码块的原子性,提供了monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。


可见性

可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。 当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。

除了volatile关键字之外,final和synchronized也能实现可见性。

synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。

final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。


有序性

在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。实现原理有些区别:

volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。

synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。


三、八种内存交互操作

内存交互操作有8种:

    • lock(锁定),作用于主内存中的变量,把变量标识为线程独占的状态。
    • read(读取),作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
    • load(加载),作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。
    • use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
    • assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
    • store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
    • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
    • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

    我再补充一下JMM对8种内存交互操作制定的规则吧:

      • 不允许read、load、store、write操作之一单独出现,也就是read操作后必须load,store操作后必须write。
      • 不允许线程丢弃他最近的assign操作,即工作内存中的变量数据改变了之后,必须告知主存。
      • 不允许线程将没有assign的数据从工作内存同步到主内存。
      • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过load和assign操作。
      • 一个变量同一时间只能有一个线程对其进行lock操作。多次lock之后,必须执行相同次数unlock才可以解锁。
      • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
      • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
      • 一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。

      四、volatile关键字

      很多并发编程都使用了volatile关键字,主要的作用包括两点:

        1. 保证线程间变量的可见性。
        2. 禁止CPU进行指令重排序。

        可见性

        volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。

        volatile保证可见性的流程大概就是这个一个过程:

        图片.png


        volatile一定能保证线程安全吗?

        先说结论吧,volatile不能一定能保证线程安全。

        怎么证明呢,我们看下面一段代码的运行结果就知道了:

        public class VolatileTest extends Thread {
        private static volatile int count = 0;
        public static void main(String[] args) throws Exception {
        Vector<Thread> threads = new Vector<>();
        for (int i = 0; i < 100; i++) {
        VolatileTest thread = new VolatileTest();
        threads.add(thread);
        thread.start();
        }
        //等待子线程全部完成
        for (Thread thread : threads) {
        thread.join();
        }
        //输出结果,正确结果应该是1000,实际却是984
        System.out.println(count);//984
        }
        @Override
        public void run() {
        for (int i = 0; i < 10; i++) {
        try {
        //休眠500毫秒
        Thread.sleep(500);
        } catch (Exception e) {
        e.printStackTrace();
        }
        count++;
        }
        }
        }

        image.gif

        为什么volatile不能保证线程安全?

        很简单呀,可见性不能保证操作的原子性,前面说过了count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。需要保证线程安全的话,需要使用synchronized关键字或者lock锁,给count++这段代码上锁:

        private static synchronized void add() {
        count++;
        }

        image.gif


        禁止指令重排序

        首先要讲一下as-if-serial语义,不管怎么重排序,(单线程)程序的执行结果不能被改变。

        为了使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率,只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序

        重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:

        图片.png

        指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。

        所以在多线程环境下,就需要禁止指令重排序

        volatile关键字禁止指令重排序有两层意思:

          • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
          • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

          下面举个例子:

          private static int a;//非volatile修饰变量
          private static int b;//非volatile修饰变量
          private static volatile int k;//volatile修饰变量
          private void hello() {
          a = 1; //语句1
          b = 2; //语句2
          k = 3; //语句3
          a = 4; //语句4
          b = 5; //语句5
          //...
          }

          image.gif

          变量a,b是非volatile修饰的变量,k则使用volatile修饰。所以语句3不能放在语句1、2前,也不能放在语句4、5后。但是语句1、2的顺序是不能保证的,同理,语句4、5也不能保证顺序。

          并且,执行到语句3的时候,语句1,2是肯定执行完毕的,而且语句1,2的执行结果对于语句3,4,5是可见的。


          volatile禁止指令重排序的原理

          首先要讲一下内存屏障,内存屏障可以分为以下几类:

            • LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
            • StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
            • LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
            • StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

            在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障。

            图片.png


            图片.png

            在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个SotreLoad屏障。

            图片.png


            大概的原理就是这样。


            四、总结

            要学习并发编程,java内存模型是第一站了。原子性,有序性,可见性这三大特征几乎贯穿了并发编程,可谓是基础知识。对于后面要深入学习起到铺垫作用。

            在这篇文章中,如果面试的话,重点是Java内存模型(JMM)的工作方式,三大特征,还有volatile关键字。为什么喜欢问volatile关键字呢,因为volatile关键字可以扯出很多东西,比如可见性,有序性,还有内存屏障等等,可以一针见血地看出面试者的技术水平。

            相关文章
            |
            2月前
            |
            安全 Java 程序员
            深入理解Java内存模型与并发编程####
            本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
            50 0
            |
            2月前
            |
            存储 监控 算法
            深入探索Java虚拟机(JVM)的内存管理机制
            本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
            |
            2月前
            |
            存储 算法 Java
            Java内存管理深度解析####
            本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
            |
            2月前
            |
            存储 监控 算法
            Java内存管理的艺术:深入理解垃圾回收机制####
            本文将引领读者探索Java虚拟机(JVM)中垃圾回收的奥秘,解析其背后的算法原理,通过实例揭示调优策略,旨在提升Java开发者对内存管理能力的认知,优化应用程序性能。 ####
            62 0
            |
            3月前
            |
            缓存 Prometheus 监控
            Elasticsearch集群JVM调优设置合适的堆内存大小
            Elasticsearch集群JVM调优设置合适的堆内存大小
            635 1
            |
            3月前
            |
            Java
            JVM内存参数
            -Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
            |
            3月前
            |
            Java
            JVM运行时数据区(内存结构)
            1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
            38 3
            |
            3月前
            |
            存储 缓存 监控
            Elasticsearch集群JVM调优堆外内存
            Elasticsearch集群JVM调优堆外内存
            73 1
            |
            3月前
            |
            Arthas 监控 Java
            JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
            本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
            |
            4月前
            |
            缓存 算法 Java
            JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
            这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
            176 4
            JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS