深入理解 Java 内存模型 JMM 与 volatile

273 阅读11分钟

Java 内存模型(Java Memory Model,简称 JMM)是一种抽象的概念,并不真实存在,它描述的是一组规范或者规则,通过这种规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。

JMM 中的主内存和工作内存

由于代码运行的实体是线程,而 JVM 会为每一个线程创建一个工作内存(有些资料称为栈空间),用于存储线程私有的数据。而 Java 内存模型规定所有变量都存储在主内存中。主内存是共享内存区域,所有线程都可以访问。但是线程不能直接操作主内存中的变量,线程对变量的读取和修改等操作必须在自己的工作内存中进行,首先从主内存中把变量拷贝到线程私有的工作内存,对变量进行操作后,再将变量写回主内存。线程之间的传值必须通过主内存完成。


JMM 中的主内存

JMM 中的主内存存储 Java 实例对象,包括成员变量、类信息、常量、静态变量等。主内存属于数据共享区域,多线程并发操作时会引发线程安全问题

JMM 中的工作内存

  • 存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,每个线程工作内存的本地变量对其他线程不可见
  • 字节码行号指示器、Native 方法等信息
  • 属于线程私有数据区域,不存在线程安全问题

JMM 与 Java 内存区域的划分是不同的概念层次

JMM 描述的是一组规则,通过这组规则控制程序中各个变量在主内存和工作内存访问方式,围绕原子性、有序性、可见性展开。

JMM 与 Java 内存区域划分的相似点是都存在共享区域和私有区域。 JMM 中的主内存属于共享数据区域,应该包括 Java 内存区域中的堆和方法区;JMM 中的工作内存属于私有数据区域,应该包括 Java 内存区域中的程序计数器、虚拟机栈和本地方法栈。

主内存与工作内存的数据存储类型以及操作方式归纳

  • 对于实例对象中的成员方法,方法里的基本数据类型的局部变量将直接存储在工作内存的栈帧结构中。方法里引用类型的局部变量的引用在工作内存中的栈帧结构中,对象实例存储在主内存(堆)中。
  • 对于实例数据的成员变量、静态变量、类信息都会被存储在主内存中
  • 需要注意的是,在主内存中的实例对象可以被多个线程共享,如果两个线程调用了同一个对象的同一个方法,两个线程会将数据拷贝到自己的工作内存中,执行完成后刷新回主内存。

JMM 如何解决可见性问题

忽略硬件中其他复杂的因素,上面的主内存与工作内存执行方式可以理解为 把数据从内存加载到CPU 的寄存器,操作完成之后再写回主内存。在现代多核 CPU 的情况下,线程共享变量就有可能出现不一致,如果运行在 CPU A 上的线程对某个变量进行了修改,而运行在其他 CPU 运行的线程加载的是 CPU 缓存中的旧状态,可能导致数据的不一致。 在执行程序时,为了提高性能,编译器和处理器常常会对指令重排序,但是指令重排序只能保证单线程的语义一致性,不能保证多线程下的语义一致性。多线程共享引入了复杂的数据依赖性,不管编译器和处理器如何对指令重排序,都必须遵从数据的依赖性要求

指令重排序需要满足的条件

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系指令不允许重排序 上面两个条件可以归结为一点:无法通过 happens-before 原则推导出来的,才能进行指令重排序

happens-before 的八大原则

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的规则。这个规则只对单线程有效。
  • 锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。这个规则保证了多线程共享变量的可见性
  • 传递规则:如果操作 A 先行发生于 操作 B,而操作 B 又先行发生于操作 C,那么操作 A 先行发生于操作 C
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程 interrupted() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中的所有操作都先行发生于线程的终止检测,我们可以通过 Thread.join()、Thread.isAlive() 返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于该对象的 finalize() 方法的开始

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。 如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对 B 是可见的 happens-before 原则非常重要,它是判断线程是否安全、数据是否存在竞争的主要依据。 下面是一个分析例子:

private int value=0;
//线程 A 执行该方法
public void write(int input){
    value=input;
}

//线程 B 执行该方法
public int read(){
    return value;
}

线程 A 执行 write() 方法给 value 赋值,线程 B 执行 read() 方法读取 value 的值。 但是这段代码不满足 happens-before 的八大原则,无法保证线程 A 执行的结果对线程 B 是可见的。我们可以通过两个办法解决这个线程安全问题。

  • 给 value 变量加上 volatile 关键字修饰,此时满足 happens-before 八大原则中的 volatile 变量规则
  • 或者给 read() 和 write() 方法加上 synchronized 关键字,此时满足 happens-before 八大原则中的锁定规则

happens-before 的实现是依赖于内存屏障,通过禁止某些指令重排序保证内存可见性。

volatile 在并发编程中很常见,下面来谈谈 volatile 的内存语义是如何实现共享变量在多线程中的可见性的。

volatile 是 JVM 提供的轻量级同步机制,由如下两个作用:

  • 保证被 volatile 修饰的共享变量对所有线程总是可见的,当一个线程修改了被 volatile 修饰的变量,其他线程可以立即感知到这个修改
  • 禁止指令的重排序优化

虽然对 volatile 变量的写操作总是能立即反映到其他线程中,但是如果对 volatile 变量的运算操作不是原子性的,那么在多线程环境中不能保证安全性,下面是一个例子:

public class VolatileVisibility {
    public static volatile int value=0;
    public static void increase(){
        value++;
    }
}

在上面的代码中,value 变量被 volatile 修饰,对 value 变量的改变会立刻反映到其他线程中。但是如果多条线程同时调用 increase() 方法时,还是会出现线程安全问题,因为value++这个操作并不具备原子性。 我们可以使用javap指令来查看上面increase()方法的字节码,如下:

  public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field value:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field value:I
         8: return

可以看到value++在字节码层面是如下步骤:

  • 先通过getstatic指令把value的值拷贝到操作数栈顶
  • iconst_1指令把 1 压入操作数栈顶,
  • iadd指令把栈顶的两个数相加
  • putstatic指令把相加后的结果写回 value 变量中。

上述的操作不是原子性的,如果两个线程同时执行`increase()`方法,预料中的结果应该是`value+2`。一个线程把`value`读入到了自己的操作数栈中,但是还没执行 +1 操作,此时另一个线程也读取了`value`到自己的操作数栈中进行 +1 操作,最终两个线程返回的结果都是`value+1`,引发了线程安全的问题。因此必须使用`synchronized`修饰`increase()`方法保证线程安全,使得先获得锁的线程的操作`happens-before`于随后获得这个锁的线程的操作。而且由于`synchronized`也可以保证操作的可见性,这时可以不用`volatile`修饰`value`变量。

volatile 的可见性

如果对volatile变量的运算操作是原子性的。那么就可以保证该变量的线程安全,下面是一个例子

public class VolatileSafe {
    private volatile boolean shutDown;
    public void close() {
        shutDown=true;
    }

    public void doWork(){
        while (!shutDown){
            System.out.println("safe...");
        }
    }
}

在这个例子中,对boolean变量的修改是原子性的,因此对这个变量的修改对其他线程立即可见,保证了线程安全。

对 volatile 变量的修改为什么可以做到立即可见?

  • 当写一个 volatile 变量时,JMM 会把对该线程对应的工作内存中的共享变量值刷新到主内存中
  • 当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,使得线程只能从主内存中重新读取共享变量

volatile 是通过内存屏障来禁止指令重排序优化的。 内存屏障的作用有以下两点:

  • 通过插入内存屏障(Memory Barrier)指令禁止对内存屏障前后的指令执行重排序优化,保证特定操作的执行顺序
  • 强制刷出各种 CPU 的缓存数据,因此在任何 CPU 上都能看到这些数据的最新版本,保证某些变量的内存可见性

下面来分析一个带有隐患的常见的单例写法:

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        //第一次检测
        if (instance == null) {
            //同步
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

一个对象的初始化不是原子性的操作,可以分为 3 步:

  • 1.分配内存空间
  • 2.初始化对象
  • 3.设置 instance 指向分配对象的内存地址

上述流程可能经过重排序。变为如下顺序:

  • 1.分配内存空间
  • 2.设置 instance 指向分配对象的内存地址,但是对象还没初始化,但此时 instance != null
  • 3.初始化对象

我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。 解决方法是使用volatile修饰instance变量,禁止指令重排序即可。

volatilesynchronized的区别

  • volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 这是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,直到该线程完成变量操作为止。
  • volatile 仅能用在变量级别;synchronized 则可以用在变量、方法和类级别
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量修改的可见性和原子性
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
  • volatile 标记的变量不会被编译器优化;synchronized 标记的变量可能会被编译器优化


如果你觉得这篇文章对你有帮助,不妨点个赞,让我有更多动力写出好文章。

我的文章会首发在公众号上,欢迎扫码关注我的公众号张贤同学