首页 > 编程语言 > 详细

Java内存模型(JMM)与 Volatile 关键字

时间:2020-07-24 00:28:00      阅读:104      评论:0      收藏:0      [点我收藏+]

JMM 的全称是 Java Memory Model(Java内存模型)

JMM 的关键技术点都是围绕着多线程的 原子性可见性  有序性 来建立的,这也是 Java 解决多线程并行机制的环境下,定义出的一种规则,意在保证多个线程间可以有效地、正确地协同工作。

JMM 关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁时同一把锁

?? 全面理解Java内存模型(JMM)及volatile关键字

1. 主内存和工作内存

JMM规定了内存主要划分为 主内存  工作内存 两种。

此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来, 主内存对应的是Java堆中的对象实例部分  工作内存对应的是栈中的部分区域 

从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

技术分享图片

JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

2. 内存交互操作

由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节。

2-1. Java内存模型定义了以下八种操作来完成:

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

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

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

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

这8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。

3. 模型特征

● 原子性:

JMM保证的原子性变量操作包括read、load、assign、use、store、write,而long、double非原子协定导致的非原子性操作基本可以忽略。

如果需要对更大范围的代码实行原子性操作,则需要JMM提供的lock、unlock、synchronized等来保证。

● 可见性:

每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。

volatile关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。因此volatile可以保证可见性。

除了volatile以外,synchronized和final也能实现可见性。synchronized保证unlock之前必须先把变量刷新回主内存。

final修饰的字段在构造器中一旦完成初始化,并且构造器没有this逸出,那么其他线程就能看到final字段的值。

● 有序性:

java的有序性跟线程相关。如果在线程内部观察,会发现当前线程的一切操作都是有序的。

如果在线程的外部来观察的话,会发现线程的所有操作都是无序的。因为JMM的工作内存和主内存之间存在延迟,而且java会对一些指令进行重新排序。

volatile 和 synchronized 可以保证程序的有序性,很多程序员只理解这两个关键字的执行互斥,而没有很好的理解到 volatile 和 synchronized 也能保证指令不进行重排序。

4. Volatile变量规则

关键字 volatile 是 JVM 中最轻量的同步机制。volatile 变量具有 2 种特性:

  • 保证变量的可见性:对一个volatile变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入,这个新值对于其他线程来说是立即可见的。
  • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,下文有详细的分析。

volatile 语义并不能保证变量的原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 i + +、i - - 这种复合操作不具有原子性,因为自增运算包括读取i的值、i 值增加 1、重新赋值 3 步操作,并不具备原子性。

由于 volatile只能保证变量的可见性和屏蔽指令重排序 ,只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁(使用 synchronized、lock 或者 java.util.concurrent 中的 Atomic 原子类)来保证并发中的原子性。

  • 运算结果不存在数据依赖(重排序的数据依赖性),或者只有单一的线程修改变量的值(重排序的as-if-serial语义)
  • 变量不需要与其他的状态变量共同参与不变约束

因为需要在本地代码中插入许多内存屏蔽指令在屏蔽特定条件下的重排序,volatile 变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。

代码演示

- 保证可见性

public class JmmTest01 {

    // volatile 可以保证可见性
    public volatile static int num = 0;

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> { // 线程 1 对主内存的变化不知道的
            while (num == 0) {
                System.out.println(Thread.currentThread().getName() + " run >->->");
            }
        }).start();

        TimeUnit.SECONDS.sleep(1);

        num = 1;
        System.out.println(num);
    }
}

- 不保证原子性

public class JmmTest02 {

    // volatile 不保证原子性
    public volatile static int num = 0;

    public static void add() {
        num++; // 不是一个原子性操作
    }

    public static void main(String[] args) {


        // 理论上 num 应该等于 100000
        for (int x = 0; x < 100; x++)
            new Thread(() -> { for (int i = 0; i < 1000; i++) add(); }).start();

        while (Thread.activeCount() > 2) { // main gc
            Thread.yield();
        }

        // main - 99608
        System.out.println(Thread.currentThread().getName() + " - " + JmmTest02.num);
    }
}

保证原子性:如果不加 lock 和 synchronized ,则使用 原子类 解决原子性问题!!!

技术分享图片

public class JmmTest02 {

    // 原子类的 Integer
    public static AtomicInteger num = new AtomicInteger();

    public static void add() {
        num.getAndIncrement(); // AtomicInteger + 1 方法, CAS
    }

    public static void main(String[] args) {

        // 理论上 num 应该等于 100000
        for (int x = 0; x < 100; x++)
            new Thread(() -> { for (int i = 0; i < 1000; i++) add(); }).start();

        while (Thread.activeCount() > 2) { // main gc
            Thread.yield();
        }

        // main - 100000
        System.out.println(Thread.currentThread().getName() + " - " + JmmTest02.num);
    }
}

这些原子类的底层都直接和操作系统挂钩!在内存中修改值!Unsafe 类是一个很特殊的存在!

5. 重排序

什么是 指令重排:你写的程序,计算机并不是按照你写的那样去执行的

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

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

技术分享图片

处理器在进行指令重排的时候,考虑:数据之间的依赖性!

int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

// 我们所期望的:1234 但是可能执行的时候回变成 2134 1324,可不可能是 4123!

重排代码实例

声明变量:int a,b,x,y=0

线程A 线程B
x=a y=b
b=1 a=2
结果 x = 0; y = 0;

如果编译器对这段程序代码执行重排优化后,可能出现如下情况:

线程A 线程B
b=1 a=2
x=a y=b
结果 x = 2; y = 1;

这个结果说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的

volatile实现禁止指令重排,从而避免了多线程环境下程序出现乱序执行的现象

避免指令重排:Volatile 

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,他的作用有两个:

  1. 保证特定的操作的执行顺序!
  2. 可以保证某些变量的内存可见性 (利用这些特性volatile实现了可见性)

由于编译器和处理器都能执行指令重排优化。如果在指令前插入一条Memory Barrier,则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排顺序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

技术分享图片

Volatile 是可以保持 可见性。不能保证原子性,由于内存屏障,可以保证避免指令重排的现象产生!

Java内存模型(JMM)与 Volatile 关键字

原文:https://www.cnblogs.com/Dm920/p/13362306.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!