JVM系列:Java内存模型


Java的并发采用的是共享内存模型(而非消息传递模型),线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。多个线程之间是不能直接传递数据交互的,它们之间的交互只能通过共享变量来实现

1. Java内存模型(JMM)

Java虚拟机规范想定义一种Java内存模型(jmm),来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。此处的变量不包括局部变量。因为局部变量是线程私有的,不会被共享。

Java内存模型规定了所有的变量都存储在主内存,每个线程都有自己的工作内存(可与高速缓存类比),线程工作内存中保存了该线程使用到的变量的内存副本。线程对变量的操作都在工作内存中完成。不同的线程之间无法直接访问其他工作内存中进行。线程之间的变量传递通过主内存俩完成。
java内存模型

线程间通信的步骤:

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

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

根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。

2. 内存间交互

内存间的交互即一个变量如何从内存拷贝到工作空间,如何从工作空间同步回主内存等操作。Java内存模型中定义了8种操作来完成。虚拟机实现时必须保证每一种操作都是原子的。

  • lock:用于主内存的变量,它把一个变量表示为一条线程独占的状态。
  • unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load:作用于工作内存的变量,它把read操作从主内存中的到的变量值放入工作内存的变量副本中。
  • use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需求使用到变量的值得字节码指令时将会执行这个操作。
  • assign:作用于工作内存的变量,它把一个从执行引擎收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store:作用于工作内存的变量,它把工作内存中的一变量的值传送到主内存中,以便随后write操作使用。
  • write:作用于主内存的变量,它把store操作从工作内存中的得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
    这8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。
3.原子性、可见性和有序性

Java内存模型处理并发是通过原子性、可见性和有序性来进行的。

原子性
为了保证原子性,Java内存模型提供了lock和unlock操作,尽管这些操作未开放给用户直接使用,但是却提供了更高层次的字节码指令mointorerter和monitorxit来隐式的使用这两个操作,这两个字节码指令反应到Java代码中就是同步快-sychronized关键字。因此synchronized块内的操作具备原子性

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

volatile变量保证了新值能够立即同步到主内存,以及每次使用前立即从主内存刷新。而普通变量不能保证这一点。除了volatile,synchronized和final也可以实现可见性。同步块的可见性是因为:对一个变量执行unlock之前,必须把此变量同步回主内存(执行store、write操作)。final关键字是因为:被final修饰的字段在构造器中一旦完成初始化,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final字段的值。

有序性
无序是指指令重排序和工作内存与主内存同步延迟的现象。
volatile关键字本身就包含了禁止指令重排序的语意。Synchronized是能够保证有序性的。根据as-if-serial语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。而synchronized保证了单线程独占CPU,也就保证了有序性。

4.重排序

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序都可能会导致多线程程序出现内存可见性问题。从硬件架构上来说,指令重排序是指CPU采用了允许将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,而不是指令任意重排。重排序分成三种类型:

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

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

  • 写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
  • 写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
  • 读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

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

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

先行发生原则(Hanppen before)

定义:先行发生时Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,那么在发生操作B之前,操作A产生的影响能被B观察到。影响包括修改内存中共享变量的值、发送消息、调用方法等。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间,happens-before规则如下:

  • 程序次序规则(Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操纵。准确的说是程序的控制流顺序,考虑分支和循环等。
  • 管理锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时间上的顺序)对同一个锁的lock操作。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted()可以检测是否有中断发生。
  • 对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的开始。
  • 传递性(Transitivity):如果操作A 先行发生于操作B,操作B 先行发生于操作C,那么可以得出A 先行发生于操作C。

上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:

  • 将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
  • 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
  • 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
  • 释放Semaphore许可的操作Happens-Before获得许可操作
  • Future表示的任务的所有操作Happens-Before Future#get()操作
  • 向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作

如果两个操作不存在上述任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。

下面就用一个简单的例子来描述下happens-before原则:

private int i = 0;
 
public void write(int j ){
    i = j;
}
 
public int read(){
    return i;
}

我们约定线程A执行write(),线程B执行read(),且线程A优先于线程B执行,那么线程B获得结果是什么?

  • 由于两个方法是由不同的线程调用,所以肯定不满足程序次序规则;
  • 两个方法都没有使用锁,所以不满足锁定规则;
  • 变量i不是用volatile修饰的,所以volatile变量规则不满足;
  • 传递规则肯定不满足

所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但是就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。

happen-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。

5. 剖析volatile关键字

volatile关键字用来修饰变量,是轻量级的同步机制。
当一个变量定义为volatile后,它将具备两种特性

  1. 保证此变量对所有线程的可见性。即当一条线程修改了在这个变量的的值。新值对其他线程来说是立即可见的。但是普通变量不能做到这一点。voatile只保证可见性,在以下两种情况下,仍需要加锁来保证原子性。

    • 运算结果依赖变量的当前值,或者是多个线程同事修改值
    • 变量需要于其他状态变量共同参与不变约束。
  2. 禁止指令重排序优化

    • 普通变量仅仅会保证结果的正确性,不能保证程序代码顺序于执行顺序的一致性,
6.内存屏障

内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令: 1. 确保一些特定操作执行的顺序;2. 影响一些数据的可见性。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

  1. LoadLoad屏障

    序列:Load1, LoadLoad, Load2
    作用:确保Load1所要读入的数据能够在Load2和后续指令访问前读入

  2. StoreStore屏障

    序列:Store1, StoreStore, Store2
    作用:确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见

  3. LoadStore屏障

    序列:Load1, LoadStore, Store2
    作用:确保Load1的数据在Store2和后续Store指令被刷新之前读取

  4. StoreLoad屏障

    序列:Store1, StoreLoad, Load2
    作用:确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。

为什么volatile不能保证原子性
public class VolatileDemo  {
    volatile static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        for(int i=0; i<100;i++) {
            new Thread(()->
            {
                for (int j = 0; j < 1000; j++) {
                    num++;
                }
            }
            ).start();
        }
       /*while(Thread.activeCount()>1)
           Thread.yield();*/
        Thread.sleep(2000);
        System.out.println(num);

    }
}

上面代码的结果输出可能是100*1000,可能比它小。

volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。假如某个时刻变量num的值为10,线程A对变量进行自增操作,于此同时线程B对变量进行自增操作,线程B也读取了变量num的原始值,由于线程A只是对变量num进行读取操作,而没有对变量进行修改操作,所以不会导致线程B的工作内存中缓存变量num的缓存行无效,所以线程B会直接去本地缓存或者主存读取num的值,发现num的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程A接着进行加1操作,由于已经读取了num的值,注意此时在线程A的工作内存中num的值仍然为10,所以线程A对num进行加1操作后num的值为11,然后将11写入工作内存,最后写入主存。结果两个线程分别进行了一次自增操作后,inc只增加了1。

这里有一个挑战:为什么线程B中修改num的值,线程A的本地缓存num为什么不失效?
答案是线程A还没有执行到sfence指令,但是线程B已经执行完了,并且刷到了主存。
参考 :
深入剖析volatile

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值