java内存模型(JMM)


文章目录

  • 内存模型的相关概念
  • 一、计算机内存模型
  • 二. Java内存模型
    • Java内存模型的实现
    • 2.1 内存间交互操作
    • 2.2 happens-before原则
    • 参考:

内存模型的相关概念

大家都知道,计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必涉及到数据的读取和写入。
由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题:由于 CPU 执行速度很快,而从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要差几个数量级,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。所以现代计算机不得在CPU和主存之间加入一层读写速度近可能接近CPU运算速度的高速缓存(寄存器)
也就是说,在程序运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么, CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据同步到主存当中。

举个简单的例子,比如下面的这段代码:

int i=0;
i=i+1;

比如,同时有两个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 的高速缓存当中,然后线程1 进行加 1 操作,然后把 i 的最新值 1 写入到内存。
此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。
最终结果 i 的值是 1,而不是 2 。这就是著名的 缓存一致性问题

一、计算机内存模型

如下图所示
每个CPU都有自己的高速缓存(寄存器),而它们又共享同一主存(Main Memory)。当多个CPU的运算任务都涉及到同一变量时,在每个CPU对应的高速缓存都会缓存一份该变量的值,那同步回主存时以谁的为准呢?

为了解决一致性的问题所以,就出现了 缓存一致性协议 ,其中最出名的就是 Intel 的 MESI 协议。MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是: 当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态。因此,当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

二. Java内存模型

在 Java虚拟机规范 中,试图定义一种 Java内存模型(Java Memory Model,JMM)
来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

Java内存模型 规定所有的变量都是存在主存当中,每个线程都有自己的工作内存,工作内存内保存了该线程使用到的主内存中变量的副本
线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接对主存进行操作, 线程间变量值的传递均需要通过主内存来完成。并且不同的线程之间也无法直接访问对方工作内存中的变量。

线程、主内存、工作内存三者之间的交互关系如下图:

主内存是所有的线程所共享的,工作内存是每个线程自己有一个 (所有线程都不能访问其他线程的工作内存),不是共享的。

Java内存模型的实现

上面提到的Java内存模型是虚拟机中定义的用来保证 Java 程序在各种平台下都能达到一致的内存访问效果机制及规范。

Q: 那这些规范具体是怎么实现的呢?

在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给开发者直接使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,不需要关心底层的编译器优化、缓存一致性等问题。

2.1 内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,从工作内存同步回主内存的实现细节
JMM定义了以下8种操作来完成,都具备原子性

  • lock(锁定): 作用于主内存变量,把一个变量标识为一条线程独占的状态

  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放,释放后的变量才可以被其它线程锁定;unlock之前必须将变量值同步回主内存

  • read(读取):作用于主内存变量,把一个变量的值从主内存传输到工作内存,以便随后的load动作使用

  • load(载入):作用于工作内存变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中

  • use(使用):作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到的变量的值的字节码指令时将会执行这个操作

  • assign(赋值):作用于工作内存变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

  • store(存储):作用于工作内存变量,把工作内存中一个变量的值传送到主内存,以便随后的write操作使用

  • write(写入):作用于主内存变量,把store操作从工作内存中得到的值放入主内存的变量中

这些行为是不可分解的原子操作,在使用上相互依赖,read-load从主内存复制变量到当前工作内存,use-assign执行代码改变共享变量值,store- write用工作内存数据刷新主存相关内容。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。 * 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作(同一条线程可以重复执行多次lock),lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
    如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
    对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

2.2 happens-before原则

  • 程序顺序规则:在一个线程内,按照代码的顺序,定义在前面的操作先行发生于定义在后面的操作。

  • 管程锁定规则:对同一个锁的unlock解锁操作,happens-before于随后对这个锁的lock加锁操作。

  • volatile变量规则:对一个volatile域的写操作,happens-before于任意后续对这个volatile域的读操作。

  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

先行发生(happens-before)原则可以用来确定一个内存访问在并发环境下是否安全。

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序 。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此, JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证 (如果A线程的写操作a与B线程的读操作b之间存在happens- before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下。

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

上面的1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果 A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!

上面的2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)

JMM把happens-before要求禁止的重排序分为了下面两类。

  • ·会改变程序执行结果的重排序。
  • ·不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略,如下。

  • ·对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • ·对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

图3-33是JMM的设计示意图。

图3-33 JMM的设计示意图

1. 原子性

在 Java 中,java内存模型只保证对基本数据类型的变量的 读取 和 赋值 操作是原子性操作(long和double例外),即这些操作是不可被中断的 : 要么全部执行,要么不执行。例如下面的代码

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

乍一看,可能会说上面的四个语句中的操作都是原子性操作。 其实 只有 语句1 是原子性操作,其他三个语句都不是原子性操作。

语句1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中;

语句2 实际上包含两个操作,它先要去读取 x 的值,再将 x 的值写入工作内存。虽然,读取 x 的值以及 将 x 的值写入工作内存这两个操作都是原子性操作,但是合起来就不是原子性操作了;

同样的,x++ 和 x = x+1 包括3个操作:读取 x 的值,进行加 1 操作,写入新的值。

所以,上面四个语句只有 语句1 的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

除了上面提到的由java内存模型来直接保证的8种原子性操作(red 、load、use、assign、store、write、lock、unlock),如果要实现更大范围操作的原子性,可以通过 synchronized 来实现。由于 synchronized 能够保证任一时刻只有一个线程执行该代码块( synchronized隐式的使用了lock和unclock操作 ),那么自然就不存在原子性问题了,从而保证了原子性。

2. 可见性

通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。

概念

当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改后的值。 JMM通过在变量修改后将新值同步回主内存,在变量读取前从主内存中读取新值刷新到工作内存。这种依赖主内存作为传递媒介的方法来实现可见性

volatile之可见性
当一个共享变量被 volatile 修饰时,它会保证当前线程修改的值会立即被更新到主存;
其他线程工作内存中的变量会强制立即失效,当其他线程需要读取时,会去主内存中读取最新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

synchronized之可见性:
JMM关于lock和unlock的规定

  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
  • 线程加锁时(lock操作),将清空工作内存中该变量的值,从而在使用共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

synchronized保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

在 Java内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序

3.1volatile之有序性

原理:volatile的可见性和有序性都是通过加入内存屏障来实现

对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存
对volatile变量读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量

3.2 synchronized之有序性

  • JMM中有一条关于synchronized关键字的规则如下:”一个变量在同一时刻只允许一条线程对其进行lock操作"
    这条规则决定了持有同一个对象锁的两个线程只能串行的进入某个同步块( 同步块中的操作相当于单线程执行

  • ( as-if-serial语义 )
    意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器和处理器都必须遵守as-if-serial语义

而且上面提到的重排序过程不会影响到单线程程序的执行,所以对外就表现出了有序性

例子:指令重排导致单例模式失效

我们都知道一个经典的懒加载方式的单例模式:

public class Singleton {
     // 指向自己实例的私有静态引用
    private static Singleton instance = null;
    // 私有的构造方法
    private Singleton() { }
    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton getInstance() {
            // 被动创建,在真正需要使用时才去创建
            if(instance == null) {
                //同一时刻只有一个线程进入同步块执行创建对象
                synchronzied(Singleton.class) {
                    if(instance == null) {
                        instance = new Singleton();
                    }
                }
            }
        return instance;
    }
}

看似简单的一段赋值语句:instance = new Singleton();,其实JVM内部已经转换为多条指令:

memory = allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance = memory; //3:设置instance指向刚分配的内存地址

但是有可能经过重排序后如下:

memory = allocate(); //1:分配对象的内存空间

instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化

ctorInstance(memory); //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得一个不完整(未初始化)的 Singleton 对象进行使用就可能发生错误。

在1.5或之后的版本中,我们可以将instance设置为volatile就可以了,这样就会确保将实例域的数据写回到主内存的动作在将实例赋值给instance引用动作之前发生(即volatile的 happens-before 规则),所以这样就确保了在使用前对象已完全初始化完成。

线程安全的实现方式

1.互斥同步(阻塞同步)

互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。 在java中最基本的互斥同步手段就是synchronized关键字

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因为java线程的操作是通过操作系统来完成的,操作系统从用户态转换到核心态需要耗费衡多的处理器时间(比如synchroinzed修饰的getter()方法和setter()方法,状态转换消耗的时间可能比代码执行的时间还要长)。

除了synchroinzed外,我们还可以使用java.util.concurrent(J.U.C)并发包中的ReentrantLock(重入锁)来实现同步

ReentrantLock和synchroinzed都具备一样的线程重入特性,但在实现锁的功能上实现方式不同。synchroinzed是原生语法层面的隐式锁,ReentrantLock是显示锁,需要显示进行 lock 以及 unlock 操作(lock()和unlock()方法配合try/finally语句块完成)。

不同点

  1. synchronized 在发生异常时会自动释放线程占用的锁资源,ReentrantLock在异常时需要主动释放(而且一定要记得在finally中释放锁而不是其他地方,这样才能保证即使出了异常也能释放锁。)
  2. synchronized 在锁等待状态下无法响应中断而 Lock 可以响应中断。Synchronized在进入同步块时,采取的是无限等待的策略,一旦开始等待,就既不能中断也不能取消,容易产生饥饿与死锁的问题。ReentrantLock中的lockInterruptibly() 方法可以使得线程在等待锁时支持响应中断。

非阻塞同步

CAS(compare and swap)比较并替换,实现并发算法时常用到的一种技术,在java同步器中大量使用了CAS技术,实现了多线程执行的安全性

思想很简单:三个参数

一个当前内存值V(主存中共享变量的值)
旧的预期值A(当前线程的工作内存中的值)
即将更新的值B
当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。

也就是说,当一个线程对自己工作内存中的值进行修改之前会判断该值是否和主存中的值一致;不一致,说明该值已经被其它线程提前修改过了,那只能重新从主存中获取

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:ABA问题、循环时间长开销大和只能保证一个共享变量的原子操作。

ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

不适用于竞争激烈的情形中:并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。

只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁<fon,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,因此可以把多个变量放在一个对象里来进行CAS操作。


原文链接:https://blog.csdn.net/wandoubi/article/details/80147858