Java并发学习(二)Java内存模型与happens-before原则

      在并发编程中分析线程安全的问题时往往需要切入点,本文主要围绕两大核心:JMM抽象内存模型以及happens-before规则,三条性质:原子性,有序性和可见性

目录

Java内存模型概述

Java内存模型

Java内存模型与硬件内存架构

1、硬件内存架构

2、JVM对线程的实现

3、Java内存模型与硬件内存架构的映射

JMM执行内存交互情况

原子性、可见性和有序性

happens-before 原则

volatile的内存语义

volatile的可见性

volatile的禁止指令重排序

volatile和synchronized的区别


Java内存模型概述

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

并发编程的两个关键问题:

  1.  线程之间如何通信
  2.  线程之间如何同步

线程之间的通信:

                 线程之间的通信是指线程之间以何种机制来交换信息。线程之间的通信机制有两种共享内存消息传递

                 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

                  在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

线程同步:

                 线程同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

                 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

                 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型JMM,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

Java内存模型

      Java线程之间的通信采用的是过共享内存模型,这里提到的共享内存模型指的就是Java内存模型(Java Memory Model)JMM决定一个线程对共享变量的写入何时对另一个线程可见。

      从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory,也叫工作内存),本地内存中存储了该线程以读/写共享变量的副本。此处的主内存与介绍物理硬件的主内存名字一样,两者也可以相互类比;但这里提到的无论是主内存还是本地内存都是JMM的一个抽象概念,在虚拟机中并不真实区分存在,他们都是虚拟机内存的一部分。

                                          Java并发学习(二)Java内存模型与happens-before原则_第1张图片

以上为Java内存模型的抽象示意图,从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 

                                        Java并发学习(二)Java内存模型与happens-before原则_第2张图片

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

这里所讲的JMM的主内存、工作内存与Java内存区域中的堆、栈、方法区等并不是一个层次的内存划分,两者基本是没有关系的。更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的。

如果两者一定要勉强对应的话,都存在共享数据区域和私有数据区域,在JMM中主内存从某个程度上讲应该包括了堆和方法区,而工作内存从某个程度上讲则应该对应程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。

从更低层次来说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让个工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

Java内存模型与硬件内存架构

1、硬件内存架构

       所谓模型,自然到实际物理架构的某种映射关系,最终的执行还是运行在计算机硬件上的,所以我们有必要了解计算机硬件内存架构。

Java并发学习(二)Java内存模型与happens-before原则_第3张图片

如图为简化的CPU与内存操作示意图,就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行

在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。

需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

2、JVM对线程的实现

对于Sun JDK来说,在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,即一条Java线程映射到一条轻量级进程之中;即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务

内核线程(KLT,Kernel-Level Thread),直接由操作系统内核(Kernel,即内核)支持的线程。由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核叫做多线程内核(Multi-Threads Kernel)。
程序一般不会去直接使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),即通常意义上的线程*。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程与内核线程之间1:1关系称为一对一的线程模型。

Java并发学习(二)Java内存模型与happens-before原则_第4张图片

以上是Java虚拟机对于线程的实现过程,更多关于Java线程实现的知识请移步 Java线程的实现原理

3、Java内存模型与硬件内存架构的映射

    通过对前面的硬件内存架构、Java内存模型以及Java线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行。

    但Java内存模型和硬件内存架构并不完全一致,对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(线程共享数据区域)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中或者存储到CPU缓存或者寄存器中。因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(对于Java内存区域划分也是同样的道理)

Java并发学习(二)Java内存模型与happens-before原则_第5张图片

JMM执行内存交互情况

关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了以下8种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的(除了double和long类型的变量,之后会提及)。

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

内存交互操作图:

Java并发学习(二)Java内存模型与happens-before原则_第6张图片

在将变量从主内存复制读取到工作内存中,必须顺序执行read和load操作;要将变量从工作内存同步回主内存中,必须顺序执行store和write操作。但是JMM只要求上述操作是顺序执行的,并不一定是连续执行的,即read和load之间、store和write之间可以插入其他指令;如对主内存中的变量a、b进行访问时,一种可能的顺序是read a、read b、load a、load b,只要保证read和load的顺序执行即可,store和write亦是如此;除此之外,JMM还规定了执行上述8种操作时必须满足以下原则:

- 1,不允许read和load、store和write操作之一单独出现。即不允许一个变量从主内存被读取了,但是工作内存不接受,或者从工作内存回写了但是主内存不接受。 
- 2,不允许一个线程丢弃它最近的一个assign操作,即变量在工作内存被更改后必须同步改更改回主内存。 
- 3,工作内存中的变量在没有执行过assign操作时,不允许无意义的同步回主内存。 
- 4,在执行use前必须已执行load,在执行store前必须已执行assign。 
- 5,一个变量在同一时刻只允许一个线程对其执行lock操作,一个线程可以对同一个变量执行多次lock,但必须执行相同次数的unlock操作才可解锁。 
- 6,一个线程在lock一个变量的时候,将会清空工作内存中的此变量的值,执行引擎在use前必须重新read和load。 
- 7,线程不允许unlock其他线程的lock操作。并且unlock操作必须是在本线程的lock操作之后。 
- 8,在执行unlock之前,必须首先执行了store和write操作。

      这8种内存交互访问的操作以及上述的规则限定,再加上稍后介绍的对volatile内存语义的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。

原子性、可见性和有序性

原子性:

      原子性是指一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。假定有两个操作A和B,如果从A执行的线程来看,当另一个线程执行B操作时,要么将B全部执行完,要么完全不执行B,那么A和B对于彼此来说是原子的。

      JMM来直接保证的原子操作有read、load、assign、use、store和write。我们大致可以认为,对于基本数据类型读写是原子操作。需要注意的是,对于32位系统的来说,long和double类型数据,它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道即可。

    如果需要更大范围的原子性保证,Java提供2种方式实现原子操作:内置锁和CAS

内置锁:

         内置锁也叫监视器锁,JMM提供了lock和unlock操作,尽管虚拟机未把这两个操作直接开放给用户使用,但却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作;这两个字节码指令反映到Java代码中就是同步块-----synchronized关键字,因此在synchronized块之间的操作也具备原子性。

循环CAS:

       Java中的CAS(Compare and Swap)操作利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路是循环CAS操作直到成功为止。

更多关于内置锁和CAS的知识,请见并发机制底层实现原理

可见性:

可见性是指当一个线程修改了共享对象的状态后,其他线程能立刻得知这个改变。

    我们知道,JMM是通过在变量修改后将新值同步回主内存中,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的(共享内存模型)。

所以volatile关键字修饰的变量通过volatile的特殊规则保证了新值能立刻刷新到主内存(稍后会详细介绍此关键字),除了volatile外Java还有两个关键字能保住可见性,synchronizedfinal

有序性:

Java中天然的有序性可以总结为:如果在本线程内观察,所有操作都视为有序行为;如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的。前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

在学习有序性的原理之前,我们需要对指令重排序有一定的了解,在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。重排序分为3种类型:

  1. 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

单线程程序语义:as-if-serial语义,不管怎么重排序,单线程(串行)下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

数据依赖性:即如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。 编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

    我们知道,JMM是通过在变量修改后将新值同步回主内存中,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的(共享内存模型)。但是由于指令重排序的存在,工作内存与主内存同步延迟现象就造成了可见性问题。对此,JMM实现了在不同的编译器和不同的处理器平台之上,通过插入特定类型的内存屏障指令Memory Barrier)来禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

Java并发学习(二)Java内存模型与happens-before原则_第7张图片

其中StoreLoad Barriers 屏障兼具前三者的功能,是开销最大的一种屏障,当前处理器要写入缓冲区的数据全部刷新到内存中。

注意,上述内存屏障都是指的是JMM的抽象内存屏障,它并不代表实际的cpu操作指令,而是代表一种效果。

Java语言提供了volatilesynchronized两个关键字来保证线程之间操作的有序性:

synchronized语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。因此,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized具有有序性。

volatile关键字具有禁止指令重排序的语义,所以具有有序性,稍后会对volatile关键字详细的解析。

happens-before 原则

happens-before是JMM最核心的概念,理解happens-before是理解Java内存模型的关键。

为什么这样说呢,从JMM设计者的角度来分析,设计JMM需要考虑两个关键因素:

  • 程序员希望内存模型易于理解和编程,最好是不要重排序复合人的思维,是个强内存模型。
  • 编译器和处理器希望内存模型对它们的束缚越小越好,这样可以做更多的优化提高性能。

但是这两个因素是相互矛盾的,所以JMM设计者需要尽可能的找到一个平衡点:既为程序员提供足够强的内存可见性保证,又要使得编译器和处理器的限制尽可能小。换句话说就是,会改变查询执行结果的重排序是必须禁止的,而不会改变执行结果的重排序JMM对于编译器和处理器不做限制,允许它们为了提高性能而重排序。

因此,JMM为程序中的所有操作定义了一个偏序关系,即happens-before先行发生原则。

偏序关系:偏序关系是离散数学范畴中集合上的一种关系,具有自反性、反对称性和传递性。但这种“序”不需要集合的全部元素都满足,就是说不必要保证此集合内的所有对象的相互可比较性。

偏序是在集合 P 上的二元关系(≤),它是自反的、反对称的、和传递的,就是说,对于所有 P 中的 a, b 和 c,有着:

  • a ≤ a (自反性);
  • 如果 a ≤ b 且 b ≤ a 则 a = b (反对称性);
  • 如果 a ≤ b 且 b ≤ c 则 a ≤ c (传递性)。

例如,集合的包含关系就是一种偏序关系

  • 自反性:A集合包含A集合自己,A ≤ A;
  • 反对称性:A集合包含B集合,B集合包含A集合,所以A = B;
  • 传递性:B集合包含A集合A ≤ B,C集合包含B集合B ≤ C,所以C集合包含A集合,A ≤ C
  • 且不是任何两个集合都具有包含关系的,可能存在2个完全无关,无法比较的集合

happens-before先行发生原则定义:      

  1. 在JMM中偏序关系体现为,如果A操作happens-before于B操作,那么A操作的执行结果将对B操作可见(无论A,B操作是否在同一线程),而且A操作的执行顺序排在B操作之前。这是JMM对于程序员的承诺。
  2. 两个操作存在happens-before关系,并不代表Java平台的具体实现必须要按照happens-before关系指定的顺序执行;只要保证了重排序后的执行结果与按happens-before关系执行的结果是一致的,无论编译器和处理器怎么重排序都是合法的。这是JMM对于编译器和处理器重排序的约束原则,满足此原则JVM就可以对操作任意重排序。

其实这也比较符合现实的情况,因为程序员其实并不关心操作是否被重排序了,只关心程序执行的语义不能改变,即执行结果不能变,在此基础上还更希望编译器和处理器执行重排序以达到性能优化的目的。

happens-before规则包括:

    1、程序次序规则:在同一个线程中,按照程序代码的执行流顺序,先执行的操作happen—before该线程后执行的操作。

    2、监视器锁规则:一个锁的解锁操作happens-before于后面对同一个锁的加锁操作。

    3、volatile变量规则:对一个volatile变量的写操作happen-before后面任意对该变量的读操作。

    4、线程启动规则:Thread对象的start()方法happen-before此线程的每一个动作。

    5、线程终止规则:线程的所有操作都happen-before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

    6、线程中断规则:对线程interrupt()方法的调用happen-before发生于被中断线程的代码检测到中断时事件的发生。

    7、对象终结规则:一个对象的初始化完成(构造函数执行结束)happen-before它的finalize()方法(终结器)的开始。

    8、传递性:如果A操作happen-before于B操作,B操作 happen-before操作C,则A操作happen-before于C操作。

volatile的内存语义

volatile关键字是Java语言提供的一种轻量型同步机制,主要有以下两个作用:

  1. 保证被volatile修饰的共享变量的“可见性”,即能够保证每个线程能够获取该变量的最新值
  2. 禁止指令重排序优化。

volatile的可见性

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

由此volatile实现了对于共享变量的可见性。

volatile的禁止指令重排序

      前面我们已经知道在happens-before规则中有一条是:volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

     而这条规则的实现在上文对有序性学习中也有提到,就是通过内存屏障实现有序性的,那么具体是如何实现的呢?

     为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

Java并发学习(二)Java内存模型与happens-before原则_第8张图片

    为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障;
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障;
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

volatile写插入内存屏障示意图:

Java并发学习(二)Java内存模型与happens-before原则_第9张图片

volatile读插入内存屏障示意图:

Java并发学习(二)Java内存模型与happens-before原则_第10张图片

什么时候才应该使用volatile变量

  1. 当变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量不会与其他状态变量一起纳入不变性条件中。
  3. 在访问变量时不需要加锁。

volatile和synchronized的区别

  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

 

参考文章:

《Java并发编程的艺术》

《Java Concurrency in Practice》

《深入理解Java虚拟机》

  https://blog.csdn.net/suifeng3051/article/details/52611310

  https://blog.csdn.net/javazejian/article/details/72772461

 

你可能感兴趣的:(Java学习,java后端,多线程,并发编程)