java内存模型与volatile关键字
java内存模型
主存与工作内存
java内存模型将内存分为两部分:主存和工作内存。前者是所有线程共享的,而后者是每个线程独有的。
内存模型
内存模型包括方法区和堆
方法区
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
java堆
java堆的唯一目的即是存储java对象
根据对象的大小、存活时间可以利用分代技术划分为新生代和老年代(利于利用分代技术的垃圾回收器进行内存回收)。在垃圾回收机制中,新生代回收的频率更高,而老年代因为其本身特性(每次回收并不会回收太多东西,但是会消耗大量计算机资源,显得性价比不高)回收频率比新生代低很多。
新生代
新生代中有三个内存区域:
- eden伊甸园
- to_survivor
- from_survivor
其中Eden用于新的小对象的生成。而后两者则是为了标记-复制技术而才用的大小相同的内存区域。
标记-复制技术简单说明
当进行内存清理时,对eden和from_survivor中的对象进行可达性分析,如果是可达的则可以存活,于是将他们复制到to_survivor中,然后清空from_survivor和eden中的所有对象。如果to_survivor中的空间不足以存放,则会使用“老年担保”,将多余的对象移到老年代进行存储。
eden和survivor的大小比约为4:1。这样的大小分配是由实践数据计算得来的。
老年代
老年代中存储大对象以及在多次垃圾清理中存活下来的对象。
老化
当一个对象存活了多次垃圾回收,我们会将它移入老年代
其背后原理是,如果一个对象在多次垃圾回收后仍然存活,我们可以用以往数据预测未来,认为他在未来的垃圾回收中被回收的概率也不高,因此没有必要将它存放在新生代中占用资源。
新生代和老年代的划分并不是一定的,只有在利用分代技术的垃圾回收器中才有意义。对于一些不分代(比如使用region的ZGC)而言是没有这样的划分的
工作内存时每个线程所私有的内存空间,包括程序计数器、虚拟机栈和本地方法栈
程序计数器
可以认为是PC寄存器在软件中的实现,用于记录当前线程所执行的字节码的行号
如果当前执行的是Native方法,则这个计数器的值应该为0
虚拟机栈
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
当一个方法确定下来后,其所需要的内存大小(即栈帧大小)是可以确定的
还没学完,挖个坑
局部变量表
这里存放了编译器可知的基本数据类型和对象引用(指向堆中对象的指针或者指向对象句柄的指针)和returnAddress,这些数据在表中以局部变量槽(slot)来表示
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
本地方法栈
和虚拟机栈相似,但是是执行本地方法时使用
主存和工作内存
本小节摘抄自《深入理解jvm》
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样
,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程
还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保
存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内
存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变
量,线程间变量值的传递均需要通过主内存来完成
这里所讲的主内存、工作内存与堆、栈、方法区等并不是同一
个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变
量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存
则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了
获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储
于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
并发控制问题
当内存对主存中的数据进行操作时,并不能直接对主存进行操作,而是先将数据拷贝到工作内存中,在工作内存中操作后再将数据写回主存。因此在这里就出现了并发的控制问题。
并发编程中的三个概念
原子性
多个操作要么全部执行要么全不执行
使用synchnized和lock进行实现
可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程可以马上看见这个修改的值
使用volatile关键字
happens-before概念
这8条原则摘自《深入理解Java虚拟机》。
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这八条原则可以保证数据的可见性
有序性
程序执行的顺序按照代码的先后顺序执行
有点类似数据库事务调度的可串行化概念!
volatile关键字
基本说明
volatile关键字强制将修改的值从工作内存写回主存。当变量的值发生了修改后, 操作系统使所有对该变量的缓存都无效,读取该值都不能从工作内存中直接使用,而是要从内存中进行读取。
volatile关键字可以实现以下功能:
- 保证可见性
- 禁止进行指令重排序
但不能保证原子性
具体实现原理
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效
举例
双锁检测(DCL)的单例模式
public class Singleton {
private static volatile Singleton3 instance;
private Singleton3() {}
public static Singleton3 getInstance() {
//首先判断是否为空
if(instance==null) {
//可能多个线程同时进入到这一步进行阻塞等待
synchronized(Singleton3.class) {
//第一个线程拿到锁,判断不为空进入下一步
if(instance==null) {
/**
* 由于编译器的优化、JVM的优化、操作系统处理器的优化,可能会导致指令重排(happen-before规则下的指令重排,执行结果不变,指令顺序优化排列)
* new Singleton()这条语句大致会有这三个步骤:
* 1.在堆中开辟对象所需空间,分配内存地址
* 2.根据类加载的初始化顺序进行初始化
* 3.将内存地址返回给栈中的引用变量
*
* 但是由于指令重排的出现,这三条指令执行顺序会被打乱,可能导致3的顺序和2调换
* 👇
*/
instance = new Singleton3();
}
}
}
return instance;
}
}
因此我们需要添加volatile关键字来禁止指令的重排以确保代码正确性

浙公网安备 33010602011771号