Java并发编程-Java 内存模型

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。

Java的内存模型(Java Memory Mode, JMM)指定了Java虚拟机如何与计算机的主存(RAM)进行工作,理解Java内存模型对于编写行为正确的并发程序是非常重要的。在JDK1.5以前的版本中, Java内存模型存在着一定的缺陷,在JDK1.5的时候,JDK官方对Java内存模型重新进行了修订, JDK1.8及最新的JDK版本都沿用了JDK1.5修订的内存模型。

Java 的内存模型决定了一个线程对共享变量的写入何时对其他线程可见,Java 内存模型定义了线程和主内存之间的抽象关系,具体如下。

  • 共享变量存储于主内存之中,每个线程都可以访问。
  • 每个线程都有私有的工作内存或者称为本地内存。
  • 工作内存只存储该线程对共享变量的副本。
  • 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存。
  • 工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在,它涵盖了缓存、寄存器、编译器优化以及硬件等。

假设主内存的共享变量为0,线程1和线程2分别拥有共享变量X的副本,假设线程1 此时将工作内存中的 x 修改为 1,同时刷新到主内存中,当线程 2 想要去使用副本 x 的时候,就会发现该变量已经失效了,必须到主内存中再次获取然后存入自己的工作内容中,这一点和 CPU 与 CPU Cache 之间的关系非常类似。

Java 的内存模型是一个抽象的概念,其与计算机硬件的结构并不完全一样,比如计算机物理内存不会存在栈内存和堆内存的划分,无论是堆内存还是虚拟机栈内存都会对应到物理的主内存,当然也有一部分堆栈内存的数据有可能会存入 CPU Cache 寄存器中。

如下所示的是 Jave内存模型与 CPU 硬件架构的交互图。

当同一个数据被分别存储到了计算机的各个内存区域时,势必会导致多个线程在各自的工作区域中看到的数据有可能是不一样的。

下面讨论如何解决:

 

1.Java 的内存模型

JVM 采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,以实现让Java程序在各种平台下达到一致的内存访问效果,比如C语言中的整型变量,在某些平台下占用了两个字节的内存,在某些平台下则占用了四个字节的内存,Java则在任何平台下,Int类型就是四个字节,这就是所谓的一致内存访问效果。

Java 的内存模型规定了所有的变量都是存在于主内存(RAM)当中的,而每个线程都有自己的工作内存或者本地内存(这一点很像 CPU 的 Cache),线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接对主内存进行操作,并且每一个线程都不能访问其他线程的工作内存或者本地内存。比如在某个线程中对变量i的赋值操作 i=1,该线程必须在本地内存中对i进行修改之后才能将其写入主内存之中。

JMM 体现在以下几个方面:

  • 原子性 – 保证指令不会受到线程上下文切换的影响
  • 可见性 – 保证指令不会受 cpu 缓存的影响
  • 有序性 – 保证指令不会受 cpu 指令并行优化的影响

 

2.JMM 与可见性

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定的。

Java提供了以下三种方式来保证可见性。

  • 使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。
  • 通过 synchronized 关键字能够保证可见性,synchronized 关键字能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还会确保在锁释放之前,会将对变量的修改刷新到主内存当中。
  • 通过JUC 提供的显式锁 Lock也能够保证可见性,Lock 的 lock 方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放(Lock的unlock方法)之前会将对变量的修改刷新到主内存当中。

可见性

退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

package cn.yutian.test;

import lombok.extern.slf4j.Slf4j;

import static cn.yutian.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.Test32")
public class Test32 {
    // 易变
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                if (!run) {
                    break;
                }
            }
        });
        t.start();

        sleep(1);
        log.debug("试图停止");
        run = false; // 线程t不会如预想的停下来
    }
}

 

1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

 

2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

 

3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

 

解决方法,就是给变量加上volatile,

volatile static boolean run = true;

volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

当然synchronized也可以解决

package cn.yutian.test;

import lombok.extern.slf4j.Slf4j;

import static cn.yutian.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.Test32")
public class Test32 {
    // 易变
    static boolean run = true;
    final static Object obj = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                synchronized (obj){
                    if (!run) {
                        break;
                    }
                }
            }
        });
        t.start();

        sleep(1);
        log.debug("试图停止");
        synchronized (obj){
            run = false;
        }

    }
}

volatile关键字不保证数据的原子性,synchronized关键字保证

可见性与原子性对比:

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i– ,volatile只能保证看到最新值,不能解决指令交错

//线程2
// 假设i的初始值为0 
getstatic i // 线程2-获取静态变量i的值 线程内i=0 

//线程1
getstatic i // 线程1-获取静态变量i的值 线程内i=0 
iconst_1    // 线程1-准备常量1 
iadd        // 线程1-自增 线程内i=1 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1 

//线程2
iconst_1    // 线程2-准备常量1 
isub        // 线程2-自减 线程内i=-1 
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

 

注意:

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了:

源码:

 

3.JMM 与原子性

在Java 语言中,对基本数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值的操作也是原子性的,因此诸如此类的操作是不可被中断的,要么执行,要么不执行。

举例:

x = 10

赋值的操作是原子性的,执行线程首先会将x=10写入工作内存中,然后再将其写入主内存(有可能在往主内存进行数值刷新的过程中其他线程也在对其进行刷新操作,比如另外一个线程将其写为 11,但是最终的结果肯定要么是 10,要么是 11,不可能出现其他情况,单就赋值语句这一点而言其是原子性的)。

y = x

这条操作语句是非原子性的,因为它包含两个步骤。第一,执行线程从主内存中读取x的值(如果x已经存在于执行线程的工作内存中,则直接获取)然后将其存入当前线程的工作内存之中。第二,在执行线程的工作内存中修改y的值为x,然后将y的值写入主内存之中。虽然第一步和第二步都是原子类型的操作,但是合在一起就不是原子操作了。

总结:

  • 多个原子性的操作在一起就不再是原子性操作了。
  • 简单的读取与赋值操作是原子性的,将一个变量赋给另外一个变量的操作不是原子性的。
  • Java内存模型(JMM)只保证了基本读取和赋值的原子性操作,如果想要使得某些代码片段具备原子性,需要使用关键字synchronized,或者JUC中的lock。如果想要使得int等类型自增操作具备原子性,可以使用JUC包下的原子封装类型 java.util.concurrent.atomic.
  • volatile关键字不具备保证原子性的语义。

 

4.JMM 与有序性

在Java的内存模型中,允许编译器和处理器对指令进行重排序,在单线程的情况下,重排序并不会引起什么问题,但是在多线程的情况下,重排序会影响到程序的正确运行,

Java提供了三种保证有序性的方式,具体如下。

  • 使用 volatile 关键字来保证有序性。
  • 使用synchronized关键字来保证有序性。
  • 使用显式锁Lock来保证有序性。

 

有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; 
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。

这种特性称之为指令重排,多线程下指令重排会影响正确性。

 

指令重排序优化

现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,

例如,每条指令都可以分为: 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 这 5 个阶段

术语参考:

instruction fetch (IF)
instruction decode (ID)
execute (EX)
memory access (MEM)
register write back (WB)

指令重排的前提是,重排指令不能影响结果。

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行

 

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。

解决方法:

volatile 修饰的变量,可以禁用指令重排,禁止volatile 修饰的变量之前的指令重排

 

5.volatile 原理

被volatile修饰的实例变量或者类变量具备如下两层语义。

  • 保证了不同线程之间对共享变量操作时的可见性,也就是说当一个线程修改volatile修饰的变量,另外一个线程会立即看到最新的值。
  • 禁止对指令进行重排序操作。

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

 

保证可见性
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
}

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

 

如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
}

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

 

volatile关键字不能解决指令交错:
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序

也就是volatile关键字不具备保证原子性的语义。

 

内存屏障会为指令的执行提供如下几个保障。

  • 确保指令重排序时不会将其后面的代码排到内存屏障之前。
  • 确保指令重排序时不会将其前面的代码排到内存屏障之后。
  • 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
  • 强制将线程工作内存中值的修改刷新至主内存中。
  • 如果是写操作,则会导致其他线程工作内存(CPU Cache)中的缓存数据失效。

 

以著名的 double-checked locking 单例模式为例

首先说明,下面的代码看是优化的好,但是是错误的:

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() { 
        if(INSTANCE == null) { // t2
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                } 
            }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

 

但在多线程环境下,上面的代码是有问题的,

对INSTANCE的读写操作并不都在 synchronized 内,所以, synchronized 无法保证其原子性。

getInstance 方法对应的字节码为:

0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中
17 表示创建对象,将对象引用入栈 // new Singleton
20 表示复制一份对象引用 // 引用地址
21 表示利用一个对象引用,调用构造方法
24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

如下图:

 

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) { 
            synchronized (Singleton.class) { // t2
                // 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

字节码上看不出来 volatile 指令的效果

看看图解:

读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
1.可见性

  • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
  • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据

2.有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

 

happens-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,

抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

happens-before 规则:

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();
new Thread(()->{
    synchronized(m) {
        x = 10; 
    }
},"t1").start();
new Thread(()->{
    synchronized(m) {
        System.out.println(x);
    }
},"t2").start();

 

  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;
new Thread(()->{
    x = 10;
},"t1").start();
new Thread(()->{
    System.out.println(x);
},"t2").start();

 

  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
    System.out.println(x);
},"t2").start();

 

  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x;
Thread t1 = new Thread(()->{
    x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);

 

  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
    Thread t2 = new Thread(()->{
        while(true) {
            if(Thread.currentThread().isInterrupted()) {
                System.out.println(x);
                break;
            }
        }
    },"t2");
    t2.start();
    new Thread(()->{
        sleep(1);
        x = 10;
        t2.interrupt();
    },"t1").start();
    while(!t2.isInterrupted()) {
        Thread.yield();
    }
    System.out.println(x);
}

 

  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
volatile static int x;
static int y;
new Thread(()->{ 
    y = 10;
    x = 20;
},"t1").start();
new Thread(()->{
    // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
    System.out.println(x); 
},"t2").start();

 

线程安全单例习题1
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类。
饿汉式:类加载就会导致该单实例对象被创建
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

public final class Singleton implements Serializable {
    private Singleton() {}
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

问题1:为什么加 final

避免子类继承破坏单例模式

 

问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例

加入一个特殊方法,反序列化自动使用以下方法:

public Object readResolve() {
    return INSTANCE;
}

 

问题3:为什么设置为私有? 是否能防止反射创建新的实例?

因为单例,避免其他直接访问,不能防止反射创建新的实例

 

问题4:这样初始化是否能保证单例对象创建时的线程安全?可以

private static final Singleton INSTANCE = new Singleton();

 

线程安全单例习题2

enum Singleton { 
    INSTANCE; 
}

问题1:枚举单例是如何限制实例个数的

有多少是多少

问题2:枚举单例在创建时是否有并发问题

一个插件:

可以看到反编译后,也是静态成员变量,所以没有。

 

问题3:枚举单例能否被反射破坏单例,不能

问题4:枚举单例能否被反序列化破坏单例

但是枚举考虑到了,所以不会被反射破坏单例。

 

问题5:枚举单例属于懒汉式还是饿汉式

饿汉式

 

问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做

可以写构造方法,和其他方法

 

实现3:

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    // 分析这里的线程安全, 并说明有什么缺点
    public static synchronized Singleton getInstance() {
        if( INSTANCE != null ){
            return INSTANCE;
        } 
        INSTANCE = new Singleton();
        return INSTANCE;
    }
}
性能较低,前面说过

 

实现4:DCL

对上面的优化,说过了

public final class Singleton {
    private Singleton() { }
    // 问题1:解释为什么要加 volatile ?
    private static volatile Singleton INSTANCE = null;

    // 问题2:对比实现3, 说出这样做的意义 
    public static Singleton getInstance() {
        if (INSTANCE != null) { 
            return INSTANCE;
        }
        synchronized (Singleton.class) { 
            // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
            if (INSTANCE != null) { // t2 
                return INSTANCE;
            }
            INSTANCE = new Singleton(); 
            return INSTANCE;
        } 
    }
}

 

参考资料

黑马程序员深入学习Java并发编程:https://www.bilibili.com/video/BV16J411h7Rd

汪文君著《Java高并发编程详解》

翟陆续著《Java并发编程之美》

盘一盘 synchronized (一)—— 从打印Java对象头说起 – 柠檬五个半 – 博客园 (cnblogs.com)

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇