深入理解 Java 内存模型

深入理解 Java 内存模型(一)

从Java代码到CPU指令

  1. 最开始,我们编写Java代码,是java文件。
  2. 在编译(javac命令)后,从刚才的java文件会编出一个新的Java字节码文件(*.class)。
  3. JVM会执行刚才生成的字节码文件(*.class),并把字节码文件转换为机器指令
  4. 机器指令可以直接在cpu上执行,也就是最终的执行程序。

为什么需要JMM(JMM 是一组规范) ?

因为不同的厂商的JVM实现会带来不同的“翻译”,不同的cpu平台的机器指令又千差万别,无法保证并发安全的效果一致,为此需要规范JVM的实现。
需要各个JVM的实现遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序。其次,volatile, synchronized, Lock等的原理都是JMM。因为有JMM,我们只需要同步工具栏和关键字就可以开发并发程序。

并发编程模型的分类

并发编程中,我们需要处理两个关键问题:

  1. 线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。
  2. 通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

共享内存

共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。

消息传递

消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息显式进行通信。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行

消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java内存模型的抽象

可见性

在 java 中,所有实例域静态域数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

Java 线程之间的通信Java 内存模型(本文简称为 JMM)控制JMM 决定一个线程对共享变量的写入何时对另一个线程可见

抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:

共享变量.png

从上图来看,线程 A线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程 A本地内存 A更新过的共享变量刷新到主内存中去。
  2. 然后,线程 B主内存中读取线程 A 之前已更新过的共享变量。
线程通信.png

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

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

因此我们把一个线程对共享变量的修改,另外一个线程能够立刻看到的现象,称为可见性。

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。也正是由于这个原因,对于共享变量,由于多个线程之间,数据通信不及时,而造成数据的不可见性

下面我们再用一段代码来验证一下多核场景下的可见性问题。


/**
 * 描述: 案例1    演示可见性带来的问题
 */
public class FieldVisibility {

    int a = 1;
    int b = 2;
    // 按顺序执行第一个线程称作线程1,第二个为线程二
    // TODO: 情况1:线程一先执行,线程二其次。结果为:a = 3, b = 3
    //  情况2:线程二先执行,线程一其次。结果为:a = 1, b = 2
    //  情况3:线程一执行到change方法,给a赋值为3,然后切换线程,执行线程二,调用print方法。这样结果为:a = 3, b = 2
    //  情况4:还有种特殊的情况线程一调用了change方法后,线程二在调用print方法时,两个线程之间的数据并没有及时同步过来(发生了可见性问题)结果为:a = 1, b = 3
    //  对于情况4这样的问题,如何解决呢?可以在变量字段前加上关键字:volatile
    private void change() {
        a = 3;
        b = a;
    }


    private void print() {
        System.out.println("b=" + b + ";a=" + a);
    }

    public static void main(String[] args) {

        while (true) {
            FieldVisibility test = new FieldVisibility();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }

    }


}

下面我们再演示一段代码,每执行一次 add()方法,都会循环 10000 次 count+=1 操作。在 testAdd()方法中我们创建了两个线程,每个线程调用一次add() 方法,我们来看看执行 testAdd()方法得到的结果 ?

package background;
/**
 * 案例 2 
 */
public class Test {
    private static long count = 0;

    private void add() {
        int index = 0;
        while(index++ < 10000) count += 1;
    }

    public static long testAdd() {
        final Test test = new Test();
        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(test::add);
        Thread th2 = new Thread(test::add);
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        try {
            th1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            th2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return count;
    }

    public static void main(String[] args) {
        System.out.println(testAdd());
    }

}

其执行结果是不是很意外?为什么呢?这就是可见性而造成的问题。

我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

为什么会有可见性问题?

CPU缓存结构

如上图所示:
cpu有多级缓存,导致读的数据过期。

  • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以cpu和主内存之间多了Cache层。
  • 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。
  • 如果所有核心都只用一个缓存,那么也就不存在内存可见性问题了。
  • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值。

主内存和本地内存的关系

JMM有以下规定:

  1. 所有的变量都存储在内存中,同时每个线程也有自己独立工作内存,工作内存中的变量内容是主内存中的拷贝
  2. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
  3. 主内存多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成

所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

happens-before

  • happens-before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看见A,这就是happens-before
  • 两个操作可以用happens-before来确定他们的执行顺序:如果一个操作happens-before于两一个操作,那么我们说第一个操作对于第二个操作是可见的`。
  • 两个线程没有相互配合的机制,所以代码X和Y的执行结果并不能保证总被对方看到的,这就具备happens-before.

原子性

现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成

例如上面代码中的count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

synchronized修饰的代码块里,会出现线程切换么?

在同步块里,线程也可能被操作系统剥夺cpu的使用权,但是其他线程此时是拿不到锁,所以其他线程不会执行同步块的代码。

重排序

案例演示

package jmm;

import java.util.concurrent.CountDownLatch;

/**
 * 描述:演示重排序的现象 (直到达到某个条件才停止,测试小概率事件)
 *      这4行代码的执行顺序决定了最终x和y的执行结果
 *      第一种情况:x = 0, y=1(线程1先进行赋值操作,其次线程2再开始赋值操作:a=1;x=b;b=1;y=a)
 *      第二种情况:x = 1, y=0(线程2先执行赋值操作,其次线程1开始执行赋值操作:b=1;y=a;a=1;x=b)
 *      第三种情况:x = 1, y=1(线程1,2先执行第一行赋值操作,其次再执行第二行赋值操作:b=1;a=1;x=b;y=a)
 *      第四种情况:x = 0, y=0(此时存在一种执行顺序,那就是y=a;a=1;x=b;b=1.当出现这样的一种执行顺序时,这就意味着重排序发生 )
 */
public class OutOfOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            CountDownLatch latch = new CountDownLatch(3);

            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.countDown();
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    a = 1;
                    x = b;  // x = b = 0
                }
            });
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.countDown();
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    b = 1;
                    y = a; // y = a = 0
                }
            });
            one.start();
            two.start();
            latch.countDown();
            one.join();
            two.join();

            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}


什么是重排序 ?

如图,通过重排序,进行了指令优化。

通过重排序进行指令优化

定义:

在线程内部的两行代码的实际执行顺序和代码在java文件中的顺序不一致,代码指令并不是严格要求按照代码语句顺序执行的,他们的顺序被改变了,这就是重排序(如上图所示)。

为什么要重排序 ?

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

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

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

重排序

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

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

JVM内存结构

参考链接

参考链接

你可能感兴趣的:(深入理解 Java 内存模型)