并发编程-从源码入手来看JMM内存模型

35 阅读5分钟

说到Java内存模型,应该不少人脑海会直接冒出来Java运行内存区域,就是什么堆、方法区、虚拟机栈、本地方法栈一类的…但如果你面试说这个,那可就没了。本文想先从一些概念入手,然后从几个关键字的源码来去讨论JMM的内存模型问题

Java内存模型主要解决的是Java程序中的变量、线程如何和主存以及工作内存进行交互的规则。而且多线程环境下共享变量可见性、指令重排等问题。,而最主要的目标是:

  • 屏蔽底层硬件和操作系统的差异:为Java开发者提供一致的内存访问行为。
  • 定义共享变量的访问规则:确保线程安全地访问共享数据。

JMM通过控制线程与主内存之间的交互,以及线程之间如何通过共享变量进行通信,实现了对共享变量的可见性原子性有序性的保证。

1. JMM的关键特性

JMM有三个特性,分别是原子性、可见性和有序性。理解这三个特性才能掌握Java并发编程。

1.1 原子性

原子性指的是一个操作不可分割,要么全部完成,要么全部不完成。在JMM中,对基本数据类型的变量的读和写操作是原子的。

对于复合操作,比如说i++,实际上包含了读取、修改和写入三个步骤,因此不是原子的。所以这就引出了,如果我们想要保证复合操作的原子性,那我们就可以使用synchronizedatomic包中的原子类。

1.2 可见性

可见性确保当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。JMM是怎么实现这个机制的呢?

  • 变量修改后同步回主内存:线程在修改工作内存中的共享变量副本后,会将新值写回主内存。
  • 变量读取前从主内存刷新:线程在读取共享变量,会从主内存中获取最新的值。

这里使用volatile关键字可以强制保证变量的可见性。

1.3 有序性

有序性保证了操作的执行顺序在多线程环境下是可预测的。JMM通过happens-before原则来定义操作之间的执行顺序。happens-before原则规定:

  • 如果操作A happens-before操作B,那么操作A的执行结果对操作B是可见的。
  • happens-before关系具有传递性。

那什么是happens-before呢?

简单来说,happens-before描述的是两个操作之间的可见性顺序性关系,如果A happens-before 操作B,那么A的结果对B是可见的,A一定在B之前执行。

2. JMM的重要概念

以下是JMM中的2个重要概念,也是需要掌握的知识

2.1 主内存

主内存是所有线程共享的内存区域,存储了共享变量的实际值。所有共享变量的读写最终都会反映在主内存中。

2.2 工作内存

每个线程都有自己的工作内存,存储了线程对共享变量的副本。线程对共享变量的所有操作(读取、写入)都必须在工作内存中进行,不能直接操作主内存。这种设计提高了性能,但也引入了可见性和一致性问题。

3. volatile

说JMM就不得不说volatile,谈到volatile我们首先应该想到的是,volatile解决的是内存可见性问题,然后就是Java中volatile独有的禁止指令重排序来保证内存一致性的问题,大家想必都背过这方面的八股,但这说起来也有点抽象,怎么就能禁止指令重排序呢?

3.1 内存可见性

首先,一个普通的变量在修改的时候一般只会缓存在本地,其他线程是感受不到改动的,我们上面说了,主内存是所有线程共享的,那这时候volatile就做了这样一件事。

当你写入volatile变量的操作会强制刷新主内存,读取的时候就会强制从主内存重新加载,这样就保证了可见性。来看下面这个例子:

public class VisibilityDemo {
    private volatile boolean flag = false;
    
    public void writer() {
        flag = true;  // 写入 volatile 变量,保证其他线程立即可见
    }
    
    public void reader() {
        if (flag) {   // 读取 volatile 变量,总能获取最新值
            // 执行逻辑
        }
    }
}

3.2 禁止指令重排序

编译器或者cpu都会对指令进行优化,优化的方式就是重新排序,但这样就破坏了多线程程序的逻辑,那valatile是怎么做到禁止指令重排序的呢?

首先,valatile是通过插入内存屏障指令来实现,具体的规则是这样的:

  • 写/读顺序:对volatile变量的写操作一定Happens-Before 后续对该变量的读操作。
  • 禁止重排:编译器或者cpu不能将volatile变量的操作与其他内存操作随意重排。

实际上这里有2种屏障:

  • 写操作(Store):插入 StoreStore + StoreLoad 屏障,确保 volatile 写之前的普通写操作对其他线程可见。
  • 读操作(Load):插入 LoadLoad + LoadStore 屏障,确保 volatile 读之后的普通读写操作不会重排到读之前。

3.3 总结

volatile并不保证原子性,并且也不是线程安全的,且性能也很一般。所以选择用它的时候要看对场景。