JAVA内存模型相关原则

1.前言

前一章讲解了JAVA内存模型的基本结构,简单的了解了内存的交互,这一章继续讲解JAVA内存模型的相关知识,并发过程中的原子性,可见性以及有序性

2.目录

目录

3.JMM相关原则

3.1.原子性

原子性:一个操作或一系列是不可中断的,即使是在多个线程的情况下,操作一旦开始,就不回被其他线程干扰

比如,对于一个静态变量int x两条线程同时对其赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断

JAVA内存模型堆以下操作保证其原子性:read,load,saasign,use,store,write.我们可以大致认为基本数据类型的访问读写是具备原子性的(前面也提到了long和double类型的"半个变量"情况,不过几乎不会发生)

从java内存模型底层来看由上面的原子性操作,但针对用户来说,也就是我们编写Java程序,如果需要更大范围的原子性保障,就需要同步关键字—— synchronized来保障了.也就是说synchronzied中的操作也具有原子性

3.2.可见性

可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改

Java内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,将主内存作为传递媒介

线程间通信

无论普通变量还是volatile变量都是如此,只不过volatile变量保证新值能够立马同步到主内存,使用时也立即从主内存刷新,保证了多线程操作时变量的可见性.而普通变量不能够保证

除了volatile,synchronized和final也能够实现可见性

synchrinized实现的可见性是通过"对一个变量执行unlock操作之前,必须先把此变量同步回主内存中"来保证的

主要有两个原则:线程解锁前,必须把共享变量的最新值刷新到主内存中;线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值

final的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final的值

3.3.有序性

在Java内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的

有序性是指对于单线程的执行代码,执行是按顺序依次进行的.但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现"指令重排",重拍后的指令与原指令的顺序未必一致

因此,上面归纳的前半句指的是线程内保证串行语句执行,后半句则指指令重排现象和工作内存与主内存同步延迟现象

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

3.4.指令重排

计算机执行指令经过编译之后形成指令序列.一般情况,指令是会输出确定的结果,且每一次的执行都有确定的结果

CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化.但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果

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

举个例子说明下下多线程中可能出现的重排现象:

class ReOrderDemo {
    int a = 0;
    boolean flag = false;

    public void write() {
        a = 1;                   //1
        flag = true;             //2
    }

    public void read() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

在上面的代码中,单线程执行时,read方法能够获得flag的值进行判断,获得预期结果.但在多线程的情况下就可能出现不同的结果

比如,当线程A进行write操作时,由于指令重排,write方法中的代码执行顺序可能会变成下面这样:

flag = true;             //2
a = 1;                   //1

也就是说可能会先对flag赋值,然后再对a赋值.这在单线程中并不影响最终输出的结果

但如果与此同时,B线程在调用read方法,那么就有可能出现flag为true但a还是0,这时进入第4步操作的结果就为0,而不是预期的1了

请记住,指令重排只会保证单线程中串行语义执行的一致性,不会关心多线程间语义的一致性.这也就是为什么在写单例模式时需要考虑添加volatile关键词来修饰,就是为了防止指令重排导致的问题

4.JMM提供的解决方案

在了解原子性,可见性以及有序性问题后,看看JMM是提供了什么机制来保证这些特性的

原子性问题,处理JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作外,可以用作自身synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性

而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchrinized关键字或者volatile关键字来解决.他们都可以是一个线程修改后的变量立即对其它线程可见

对于指令重排的可见性问题和有序性问题,则可以利用volatile关键字解决.volatile的另一个作用就是禁止重排序优化

除了靠sychronized和volatile关键字外,JMM内部还定义一套happens-before(先行发生)原则来保证多线程环境下两个操作间的原子性,可见性以及有序性

4.1.先行发生原则

如果仅靠sychronized和volatile关键字来保证原子性,可见性以及有序性,那么编写并发程序会十分麻烦.为此在Java内存模型中,还提供了happens-before原则来辅助保证程序执行的原子性,可见性以及有序性的问题.该原则是判断数据是否存在竞争,线程是否安全的依据

happens-before规则:

  • 1.程序次序规则:在一个线程内,程序前面的操作先于后面的操作
  • 2.监视器锁规则:一个unlock操作先于后面对同一个锁的lock操作发生
  • 3.volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,也就是说读取的值肯定是最新的
  • 4.线程的启动规则:Thread对象的start()方法调用先行发生于此线程的每一个动作
  • 5.线程加入规则:Thread对象的结束先行发生于join()方法返回
  • 6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupt()方法检测到是否有中断发生
  • 7.对象终结规则:一个对象的初始化完成(构造器函数执行结束)先行发生于它的finalize()方法的开始
  • 8.传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C

还拿上面的具体代码来进行说明

class ReOrderDemo {
    int a = 0;
    boolean flag = false;
 
    public void write() {
        a = 1;                   //1
        flag = true;             //2
    }
     
    public void read() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

线程A调用write()方法,线程B调用read()方法,线程A先(时间上的先后)与线程B启动,那么线程B读取到a的值是多少呢?

现在依据8条原则来进行对照:

  • 1.两个方法分别由线程A和线程B调用,不在同一个线程中,因此程序次序原则不适用
  • 2.没有write()方法和read()方法都没有使用同步手段,监视器锁规则不适用
  • 3.没有write()方法和read()方法都没有使用同步手段,监视器锁规则不适用
  • 4.没有volatile关键字,volatile变量原则不适用
  • 5.与线程启动,终止,中断,对象终结规则,传递性都没有关系,不适用

因此,线程A和线程B的启动时间虽然有先有后,单线程B执行结果却是不确定,也就是说上述代码没有适合8条原则中的任意一条,所以线程B读取的值自然也是不确定的,换句话说就是线程不安全的

修复这个问题的方式很简单,要么给write()方法和read()方法添加同步手段,如synchronized.或者给变量flag添加volatile关键字,确保线程A修改的值对线程B总是可见

4.2.关键字volatile和synchronized

volatile修饰的共享变量,就具有了以下两点特性:

  • 保证了不同线程对该变量操作的内存可见性
  • 禁止指令重排序
  • 不能保证原子性
  • 单例模式双重校验锁,避免指令重排,初始化过程先分配内存后构建对象,多线程情况下instance != null,但还未被初始化完成

5.总结

这一章节主要讲解了JAVA内存模型的一些原则,并了解了为了保证这些原则JVM提供的解决方案.并针对不同的方案结合场景进行分析,对于内存模型的相关原则有了整体的认识


原文:
https://www.choupangxia.com/2019/11/06/interview-jvm-gc-06/
https://www.choupangxia.com/2019/11/08/interview-jvm-gc-07/

你可能感兴趣的:(JAVA内存模型相关原则)