(面试被问到,想到之前有个笔记,整理一下发出来。)
内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。
Java内存模型(Java Memory Model,JMM)是来屏蔽各种硬件和操作系统的内存访问差异,以让Java程序在各种平台下都能达到一致的内存访问效果。
主内存/工作内存
JMM的主要目的是定义程序中各个变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
这里的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数(这些是线程私有的。如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个线程共享,但是reference本身在Java栈的局部变量表中是线程私有的)。
JMM规定所有变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。
- 线程的工作内存保存了被该线程使用的变量的主内存副本,线程堆变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。(volatile变量是有特殊的操作顺序性,看起来如同直接在主内存中读写)
- 不同线程之间无法直接访问对方工作内存中的变量。线程间传递变量值需要通过主内存完成。
内存间交互操作
8种基本操作
即主内存与工作内存之间具体的交互协议,JMM定义了8种操作。
JVM实现时必须保证这每一种操作都是原子的、不可再分的,只对于double和long在某些平台上有例外。
8种操作为:
- lock,锁定,作用于主内存的变量,把一个变量标识为一条线程独占的状态。
- unlock,解锁,作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read,读取,作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
- load,载入,作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use,使用,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。(每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行这个操作)
- assign,赋值,作用于工作内存中的变量,把一个从执行引擎接受的值赋给工作内存的变量。(每当虚拟机遇到一个给变量赋值的字节码指令时就会执行这个操作)
- store,存储,作用于工作内存的变量,把工作内存中的一个变量值传送到主内存,以便write使用。
- write,写入,作用于主内存的变量,把store操作中从工作内存中得到的变量的值放入主内存的变量中。
对基本操作的规定
JMM对这些基本操作有一些规定,通过这些规定,以及专门针对volatile的规定,就能描述出Java程序中哪些内存访问操作在并发下是安全的。
这些规定很繁琐,之后Java的设计团队将JMM的操作简化了read、write、lock、unlock四种,但这只是语言描述上的等价简化,JMM的基础设计没变。
除了JVM的开发人员外,大概没有其他开发人员会以这种方式来思考并发问题,之后通过先行发生原则来确定一个操作在并发环境下是否安全。
对基本操作的规定:
不允许read和load、store和write操作之一单独出现。JMM只要求read和load,store和write操作按顺序执行,但不要求连续执行。
不允许一个线程丢弃它最近的assign操作,变量在工作内存中改变了之后必须把变化同步回主内存。【# assign了之后必须store write】
不允许一个线程没有发生过assign操作就把数据从工作内存同步回主内存。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量。即,对一个变量use或store之前,必须先执行assign和load操作。
【# 初始化变量就是分配内存空间,并给它一个初始值,assign操作是赋值操作,注意6里面是“初始化变量的值”,变量的初始化操作不知道是不是我没仔细读书,有点迷惑,我在下一段中引用了bing对此的讲解】
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次lock后,要执行相同次数的unlock操作变量才会解锁。
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新load或assign操作以初始化变量的值。
如果一个变量事先没有被lock锁定,就不允许unlock,也不允许unlock被其他线程锁定的变量。
对一个变量unlock前,必须把此变量同步回主内存中,即执行store和write。
变量的初始化过程(来自bing的解释,略作修改)
- 首先,JVM会为变量分配一个内存空间,这个空间可以是主内存或者工作内存,取决于变量的类型和作用域。如果变量是一个类的静态字段,那么它会被分配在主内存中,因为它属于类的共享数据。如果变量是一个类的实例字段,那么它会被分配在工作内存中,因为它属于对象的私有数据。如果变量是一个局部变量,那么它也会被分配在工作内存中,因为它属于方法的栈帧。
- 其次,JVM会为变量赋予一个初始值,这个初始值可以是一个默认值或者一个显式值,取决于变量是否被显示地初始化。如果变量没有被显示地初始化,那么它会被赋予一个默认值,这个默认值是根据变量的类型来确定的。例如,如果变量是一个int类型,那么它的默认值是0。如果变量是一个引用类型,那么它的默认值是null。如果变量被显示地初始化,那么它会被赋予一个显式值,这个显式值是根据变量的初始化表达式来确定的。例如,如果变量是一个int类型,而且被初始化为1,那么它的显式值就是1。如果变量是一个引用类型,而且被初始化为一个新的对象,那么它的显式值就是这个对象的引用。
- 最后,JVM会根据变量的类型和作用域,决定是否需要将变量的值同步到主内存中,以便其他线程可以看到。如果变量是一个类的静态字段,那么它的值必须同步到主内存中,因为它属于类的共享数据。如果变量是一个类的实例字段,那么它的值是否需要同步到主内存中,取决于它是否被volatile修饰。如果变量被volatile修饰,那么它的值必须同步到主内存中,因为它属于线程间的可见数据。如果变量没有被volatile修饰,那么它的值不一定需要同步到主内存中,因为它属于线程内的私有数据。如果变量是一个局部变量,那么它的值不需要同步到主内存中,因为它属于方法的栈帧,只有当前线程可以访问。
原子性、可见性与有序性
JMM是围绕着在并发过程中如何处理原子性、可见行和有序性这三个特征来建立的。
1. 原子性
基本数据类型的访问、读写都是具备原子性的。
如果应用场景需要一个更大范围的原子性保证,JMM提供了lock和unlock操作。
JVM未把lock和unlock操作直接开放给用户使用,但是提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用这两个操作。
这两个字节码反映到代码中是synchronized关键字。
2. 可见性
JMM是通过在变量修改后将新值同步回主内存(之前的规则,assign了之后必须store write),在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。
volatile,synchronized和final能实现可见性。
-
普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
-
synchronized的可见性是:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(store, write)。
-
final的可见性:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去【# 另一本书里有,this引用逃逸很危险,需要再翻出来看看】
3. 有序性
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
即,“线程内表现为串行语义”(within-thread as-if-serial semantics),“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java提供volatile和synchronized关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。
对volatile型变量的特殊规则
volatile是JVM提供的最轻量级的同步机制。当一个变量被定义成volatile之后,有两个特性:
一、保证此变量对所有线程的可见性。当一个线程修改了这个值之后,其他线程立即得知。
volatile变量在各个线程的工作内存中是不存在一致性问题的,但是Java中的运算操作符并非原子操作,导致volatile变量的运算在并发下是不安全的。
下面代码的结果总是小于200000。
class Solution {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int i1 = 0; i1 < 10000; i1++) {
increase();
}
});
threads[i].start();
}
// 大于2是因为idea在运行main函数时会启动两个线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}
}
之所以这样,在字节码层面就可以很好解释了(不过即使编译出来只有一条字节码指令,也并不意味着就是一个原子操作),字节码层面的increase()
有4条指令,大概酱紫:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值此时是正确的,但是在执行iconst_1和iadd时,其他的线程可能已经把race的值改变了,操作栈顶的值就成了过期的数据,putstatic指令执行后就把较小的race值同步回了主内存。
于是不符合这样两种情况仍需要加锁,(符合就不需要加锁,我看书时就看错了
标签:Java,变量,模型,线程,内存,操作,volatile From: https://www.cnblogs.com/eisenji/p/jmm.html