Java内存模型

一、简介

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。其中所指的变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的。

工作原理:Java内存模型规定了所有变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Work Memory,可以与高速缓存类比),现成的工作内存保存了该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都发生在工作内存中,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者之间的关系如下图所示:

在这里插入图片描述

其中主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

更底层的,主内存注解对应于物理硬件的内存,为了更好的运行速度,可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

JMM 体现在以下几个方面

  • 原子性 - 一系列操作是一个整体,不会和别的线程的指令交错执行。保证指令不会受到线程上下文切换的影响
  • 可见性 - 当一个线程改变了共享变量时,其他线程能立即感知到这个修改。保证指令不会受 cpu 缓存的影响
  • 有序性 - 所有操作都是有序的。保证指令不会受 cpu 指令并行优化的影响

二、可见性

1、引入:退不出的循环

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

    static boolean run = true;

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

        Thread.sleep(1000);

        run = false;
        // 线程t不会如预想的停下来
    }

分析

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
    在这里插入图片描述
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率

在这里插入图片描述

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

在这里插入图片描述

2、volatile

上面的问题使用volatile就可以解决,volatile保证了变量的可见性。在变量声明前面加上volatile,t线程每次就会去主内存中去读取run的值。(synchronized也可以保证变量的可见性)

    volatile static boolean run = true;

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

3、原子性 VS 可见性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 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只能保证看到新值,不能解决指令交错

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

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

注意

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

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了。这是因为println()源码中使用了synchronized 来保证线程安全:

    public void println(int x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
4、使用volatile改进两阶段终止模式
    //监控线程
    private static Thread monitor = null;
    //设置具有可见性的共享变量
    private volatile static boolean shut = false;

    public static void main(String[] args) throws InterruptedException {
        start();
        Thread.sleep(7000);
        stop();
    }

    //开始监控线程
    private static void start(){
        monitor = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    if (shut){
                        //运行中检测到shut标记改变
                        System.out.println("善后工作");
                        break;
                    }
                    try {
                        //每隔2秒执行一次检测程序
                        Thread.sleep(2000);
                        System.out.println("执行检测程序");
                    } catch (InterruptedException e) {
                        //睡眠中被打断,防止sleep时间过长不能立即结束
                    }
                }
            }
        }, "monitor");

        monitor.start();
    }

    //停止监控线程
    private static void stop(){
        shut = true;
        monitor.interrupt();
    }
5、同步设计模式之 Balking (单例)

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回

对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。

例如,对于上面的检测程序,只需要运行一次,那么要怎么样方式多个线程多次启动?使用Balking 模式就可以解决这个问题:

public class MonitorService {
	 // 用来表示是否已经有线程已经在执行启动了
    private volatile boolean starting = false;
	
	pubilc void start(){
        log.info("尝试启动监控线程..."); 
        synchronized(this){
            if(starting){
                //已启动无需再次启动
                return;
            }
            starting = true;
        }
        // 真正启动监控线程... 
    }
}
双检查锁机制

同时Balking 还经常用于实现多线程安全的单例模式(Double Check Locking 双检查锁机制),参考

public class Singleton {

    private volatile static Singleton instance = null;

    private Singleton(){
    }

    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 这里在声明变量时使用了volatile关键字来保证其线程间的可见性和防止重排列;
  • 由于new操作,需要三步(1. 分配内存空间,2.在内存空间创建对象,3.对象指针赋值)。这个过程中可能发生重排列为1-3-2,这样当执行完3时另外线程执行if(instance == null) 就会发生错误
  • 第一次检查是为了防止直接加锁,提高效率
  • 在同步代码块中使用二次检查,以保证其不被重复实例化。
  • 集合其二者,这种实现方式既保证了其高效性,也保证了其线程安全性。

这里给instance变量加上volatile,除了实现可见性,还为了阻止指令重排序

三、有序性

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

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

可以看到,至于是先执行 i 还是 先执行 j ,对终的结果不会产生影响。所以,上面代码真正执行时,既可以是:

i = ...; 
j = ...;

也可以是:

j = ...; 
i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧

1、指令级重排原理

为了提高性能,编译器和处理器常常会对指令进行重排序

现代处理器会设计为一个时钟周期完成一条执行时间长的 CPU 指令。为什么这么做呢?可以想到指令 还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据 写回 这 5 个阶段。

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

在这里插入图片描述

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

2、Java中指令重排序例子

对于下面的代码,r1可能出现的结果值:

    int num = 0;
    boolean ready = false;

	// 线程1 执行此方法 
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

	// 线程2 执行此方法
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

正常情况下:

  • 线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  • 线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

上面的都是正常情况下会出现的结果,但实际上多线程中还会出现另一种情况:

  • actor2方法中,num = 2 和 ready = true交换了次序, ready = true先执行,而此时切换到了线程1,此时ready为true,num为0 则r1为0。

使用压测工具jcstress可以测出出现重排序的情况,如下图所示:

在这里插入图片描述

3、解决指令重排序

volatile 修饰的变量,可以禁用变量使用位置之上的代码进行指令重排(synchronized可以实现,但是相比volatile更加重量级)

    @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
    @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
    @State
    public class ConcurrencyTest {

        int num = 0;
        volatile boolean ready = false;

        @Actor
        public void actor1(I_Result r) {
            if (ready) {
                r.r1 = num + num;
            } else {
                r.r1 = 1;
            }
        }

        @Actor
        public void actor2(I_Result r) {
            num = 2;
            ready = true;
        }

    }

结果:

在这里插入图片描述

四、volatile原理

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

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障
1、如何保证可见性

写屏障(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;    
    } 
}

在这里插入图片描述

2、如何保证有序性

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

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

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

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

在这里插入图片描述

注意:volatile不能解决指令交错(也就是原子性,注意不是指令重排)

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证跑到它前面去读
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
  • synchronized可以保证原子性、可见性、有序性

五、happens-before

参考

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

其中包括了八个规则:

  • 程序顺序规则:一个线程中的每个操作,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域的写,happens-before于任意后续对这个volatile域的读。
volatile static int x;

new Thread(() -> {
    x = 10;
}, "t1").start();

new Thread(() -> {
    System.out.println(x);
}, "t2").start();
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。也就是线程A start 前对变量的写,对线程B该变量的读可见
static int x;

x = 10;

new Thread(() -> {
    System.out.println(x);
}, "t2").start();
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.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);
    }
  • 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
volatile static int x;
static int y;

new Thread(() -> {
    //y happens-before x (程序顺序规则)
    y = 10;
    x = 20;
}, "t1").start();

new Thread(() -> {
    // x = 20 happens-before System.out.println(x); (volatile变量规则)
    System.out.println(x);
}, "t2").start();
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐