Java 内存模型
开场白
今天要讲解的是 Java 内存模型,这属于一个较高级的话题,但又是每个 Java 学习者要掌握的基本的知识,在本文中主要以以下问题为主线,来给大家说明 Java 的内存模型是如何设计的。
1. 什么是内存模型
2. Java 为什么要定义自己的内存模型
3. Java 内存模型是怎么设计
内存模型
说到内存模型,最基本的一个问题:什么是内存模型?
从字面来看,内存模型应该是和内存有关,可能是用于定义内存中的数据存储格式。其实不然,在这里要理清对象模型和内存模型的区别。刚刚说到的,其实是指对象模型,它定义了系统中数据在内存中是如何布局的。
在多进程系统中,一般的计算机都有多个 CPU ,其内部的设计可能会存在差别,并且每个 CPU 又会存在多级高速缓存,数据访问需要先从主存读取到缓存中再进行操作,这又会导致内存可见性问题,此时为了解决差异带来的问题,就产生了内存模型,通过内存模型来定义统一的数据的访问规则。
Java 内存模型
第二个问题来了,Java 为什么要定义自己的内存模型?
我们都知道 Java 程序是运行在虚拟机中的,细想一下如果直接在平台上运行,不同平台可能有的支持 32 位,有的支持 64 位,那么我们在开发时可能就需要针对不同的平台进行开发,这样的工作量会有多大。因此为了实现平台无关性,Java 决定设计虚拟机,从而定义一套统一数据存储格式的内存布局。
既然 Java 需要自己实现虚拟机,那么也就同时需要定义虚拟机的数据访问规则,于是 JMM(Java Memory Model) 就诞生了。因此 Java 内存模型(JMM)是 Java 用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,实现一次编写,多次运行的目标。
那么下一个问题,Java 的内存模型时如何设计的呢?
内存结构
首先先来介绍一下 Java 的内存布局,Java 将内存分为两大类,一类是线程私有的,一类是线程共享的,如下图:
线程私有的内存包含程序计数器和栈,前者是用于标识当前线程执行到哪一条指令,而后者是用于执行方法调用(虚拟机栈执行 Java 方法,本地方法栈执行 native 方法)。
线程共享的内存包含堆和方法区,堆主要是用于存放对象,GC 也是主要针对这块内存进行回收;方法区主要是用于存放类加载的符号引用等一些静态属性;
线程模型
前面我们已经了解了 Java 的内存布局,知道它要么是线程私有的,要么就是线程共享的,那么什么是线程?学习过操作系统的都知道,线程是调度基本单位,一个进程内部可以有多个线程,线程共享进程的地址空间,多个线程协同合作完成进程的目标任务,它就相当于车间(进程)的一条流水线(线程)。
在 Java 中,线程之间是相互独立的,每一个线程都有自己的工作内存,它们互不干扰,而主内存(堆+方法区等区域)为他们所共享,当需要访问共享变量时,则需要先从主内存中加载到线程的工作内存,再进行操作;操作完毕后,若修改了则同步回主内存。如下图,JMM 的工作目标则是定义工作内存和主内存的数据交互访问规则。
8 种基本操作
对于主内存和工作内存的数据交互规则,JMM 定义了以下 8 种基本操作来完成,通过基本操作的组合和限制来完成对变量的访问,比如 lock 和 unlock 要成对出现,use 前要先进行 read、load 操作,assign 操作一定要进行 store、write 操作。
以下为 8 种基本操作的定义:
1. lock:锁定主内存的变量,独占该变量的使用权
2. unlock:释放主内存的变量锁定
3. read:将变量的值从主内存复制到工作内存中,以便 load 使用
4. load:将 read 过来值赋值到工作内存的变量副本中
5. use:将工作内存中变量的值传给执行引擎
6. assign:将执行引擎收到的值赋值给工作内存的变量
7. store:将工作内存中变量的值复制到主内存中,以便 write 使用
8. write:将 store 过来的值赋值到主内存的变量中
线程安全问题
上面提到,对数据访问修改是通过基本操作从主内存和工作内存来回传递,细想如果同时有多个线程同一个变量进行访问修改,(组合的基本操作并不是原子操作,但每一个基本操作都是原子操作),如 use 时,要先进行 read 和 load,但加载之后,变量被其他线程修改了,这是原线程读取的可能就是一个过时的数据,那么此时就会由于竞争条件导致线程安全问题。
在多线程开发中,线程安全问题是一个不可避免会遇到的问题,要编写正确的多线程程序,则需要正确地分析线程的行为的先行关系以及作出正确的同步操作。
happen-before 关系
在说先行关系前,首先要了解原子性、可见性与有序性这三个特性,因为 Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性来建立的。
- 原子性:原子性是指操作是一个原子单元,不可被分割的处理单元,不允许执行了一半就结束;前面所说的 8 种基本操作的原子性由虚拟机来保证,而对于复合操作的原子性可能则需要通过同步来保证。
- 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。我们知道 JMM 在变量存取访问时需要先将变量从主内存读取到工作内存,修改后又会将新值刷新回主内存。由于读入和读进操作的发生时机不确定,因此可能会导致其他线程不能够几时感知到其他线程对共享变量的修改。
- 有序性:有序性是指线程之间操作的有序性。在单线程程序中,程序最终看到的结果与代码编写顺序是一致的;而在多线程程序中,由于存在指令重排序的现象,因此其他线程看到的次序可能是乱序的。要保证线程的有序性,可以使用 volatile 和 synchronized 关键字(关于 volatile 关键字的原理介绍可以查看我的这篇文章volatile 关键字)。
现在进入正题了,什么是先行关系呢?
考虑现在有两个线程 A、B
int share_value = 0;
线程 A {
share_value = 1; // 动作 1;
}
线程 B {
System.out.println(share_value); // 动作 2;
}
如果动作 1 先行发生于动作 2,则表明当线程当执行动作 2,能够对动作 1 所带来的修改可见,即线程 B 会输出 1。如果不满足先行关系,由于线程调度的不确定性,那么线程 B 可能输出 0,也可能会输出 1。
先行关系并不是指动作间在时间上的先后发生关系,它是动作上的偏序关系,动作 A 先行发生于 动作 B,则动作 A 产生的影响对动作 B 可见。要编写正确的多线程程序,则需要通过分析线程间行为的先行关系来进行。
以下是 Java 内存模型规定的先行发生规则:
1. 程序顺序规则:如果程序中操作 A 在操作 B 之前,那么在线程中 A 操作将在操作 B 之前执行;
2. 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器的加锁操作之前执行;
3. volatile 变量规则:对 volatile 变量的写入操作必须在对该变量的读操作之前执行;
4. 线程启动规则:在线程上对 Thread.start 的调用必须在该线程中执行任何操作之前执行;
5. 线程结束规则:线程中任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false;
6. 中断规则:当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行;
7. 终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成
8. 传递性规则:如果操作 A 在操作 B 之前执行,并且操作 B 在操作 C 之前执行,那么操作 A 必须在操作 C 之前执行。
这些规则明确地规定了在何种情况动作 A 会先行发生于动作 B,通过规则的组合判断可以更加地清晰地分析线程行为。
总结
Java 内存模型(JMM)是 Java 用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,实现一次编写,多次运行的目标。
在 JVM 中,内存被分为两大类,一类是线程共享的,另一类则是线程私有的。每一个线程都有自己的工作内存,当线程存取修改变量时,需要先从主内存中将变量读取到线程的工作内存中,此后都直接对工作内存的副本进行操作,最后再将修改后的值保存回主内存,因此这可能会导致出现线程安全问题。
Java 内存模型是围绕着在并发过程中如何处理数据在主内存和工作内存之间的传递过程的原子性、可见性和有序性来建立的,要想编写正确的多线程程序,就必须对 JMM 的内部规则了解透彻。 他定义了 8 种基本操作来操作数据,每一个操作都是原子性,通过基本操作的组合来完成对数据的访问。另外可以使用 happen-before 规则分析线程的行为并且做出正确的同步操作来保证程序的正确性,从而编写出正确的多线程程序。