JVM内存模型
内存模型图
四个概念
- class文件
class文件就是我们说的字节码文件,它是由.java、.groovy等文件通过编译器解析产出的文件。class文件才是jvm要使用的文件。 - class content
class content就是class文件读取到内存中的那片内存区域 - Class对象
类加载器将class content里的内容逐个字节读出,按照jvm规范解析成Class对象,放入方法区中。java.lang.String.class 就是一个Class对象 - 对象
由Class对象实例化出来的具体实例
方法区
方法区
方法区是规范,永久代和元空间都是方法区的具体实现。
- 永久代
jdk8以前的方法区实现,处于堆中,因此会产生OOM,会触发GC - 元空间
jdk8以后的方法区实现,处于本地内存中
本地内存?也称为直接内存、操作系统内存、native memory
jdk8后,方法区的实现为什么用元空间取代了永久代??
- 硬件的发展
32bit机时代,内存最大支持为4G,其中内核层使用2G,应用层使用2G。说白了就是内存不够,若将方法区实现在本地内存中时,当运行时创建大量Class时如cglib,方法区会撑爆本地内存,这会影响系统中其他程序的运行。
64bit机器,理想情况下内存可以扩展到2^48 (为什么不是2^64呢?)大小即256T,此时本地内存容量大增,即有条件将方法区搬到本地内存中
64bit操作系统,其中有16位是保留位,内存寻址空间只使用了48位,因此64位操作系统,最大支持到2^48内存大小
- 降低jvm的GC复杂度
永久代中有字符常量和Class对象等,其GC算法复杂难度较大。
替换成元空间后,元空间放在操作系统内存中,由操作系统进行回收,字符串移到了堆中。
元空间
默认大小
最小 20.75M、最大 256T 无限
*元空间调优
- 最小、最大值设置成一样,防止内存扩展导致内存抖动
- 设置成物理内存的 1/32 (经验值),具体按实际确定。 (相关工具:arthas、visualvm)
- 元空间大小一般按占用大小预留20~30%的空间,高并发防止?
程序计数器
JVM的程序计数器是程序模拟出来的,和操作系统的程序计数器(EIP)不一样
上图中绿框中的就是java中的程序计数,如上图当JVM 处理new操作时,会将3置入程序计数器(是3还是0???)
虚拟机栈
虚拟机栈、数据结构:栈、栈帧
- 栈,是数据结构理论
- 虚拟机栈是数据结构栈的一个实现,下文中出现的栈若无特殊说明那指的就是虚拟机栈
- 栈帧是虚拟机栈中一个单元,存储执行方法所需要的信息
虚拟机栈
栈是线程私有的,创建线程时就会创建一个栈,栈的生命周期和线程同步;
栈中的变量不需要JVM进行GC回收,而是随着作用域的结束而释放,如一个方法执行完就会清理对应栈帧;
栈的大小和深度是固定的,在编译期就确定了。
栈帧
栈包含多个栈帧,栈帧由方法调用创建,方法结束释放;
调用方法时,创建栈帧并压入栈顶。方法结束时栈会弹出栈顶栈帧。该栈帧会被jvm释放;
栈只会操作栈顶栈帧,不会同时操作多个栈帧;
栈帧组成:局部变量表、操作数栈、动态链接、返回地址
动态链接 ????
动态链接,存放的是这个方法在方法区的内存地址
返回地址
恢复现场应该表达的更准确,a方法内调用b方法结束后,需要返回,这里要分两种情况,方法调用正常完成、方法调用异常完成。
- 方法调用正常完成
b方法正常完成(遇到方法返回字节码指令)时,当前栈帧承担着恢复调用者a状态的责任,恢复a的局部变量表和操作数栈,正确递增程序计数器到a调用b的下一步动作,以跳过刚才执行的方法调用指令等。b的执行结果压入a栈帧的操作数栈后,a会正常执行。(那返回地址(恢复现场)到底是个什么结构,存的是什么内容,怎么就能恢复现场了??) - 方法调用异常
从异常抛出的地方转换至处理异常的地方。(细节是什么样的?)
一个方法执行完,JVM需要做哪些事情(上图标识2的那条线)??
- 恢复局部变量表指针(当前线程的局部表开始指针)
- 恢复操作数栈当前指针 (当前线程的操作数栈指针)
- 恢复程序计数器 (按上图,程序计数器从8恢复到上个方法调用处即12,5->12)
- 如果方法有返回地址,需要返回(???)
- 清理栈帧(程序计数器判断方法执行完成,调用清理栈帧的函数)(栈帧???)
局部变量表
每个栈帧内都包含一组局部变量列表。局部变量表长度从编译期就确定,存储在class文件的二进制表示中;
当调用类方法时,参数会依次传递到局部变量表从0开始的连续位置上;
当调用实例方法时,第0个局部变量永远存储的都是该实例方法所在对象的引用,即this,后续参数传递到1开始的连续位置。
知识点:非静态方法及构造方法中,局部变量表index=0的位置存放的都是this
操作数栈
顾名思义,操作数栈就是供字节码指令操作的数据栈。栈帧刚创建时,操作数栈是空的,一些字节码指令从局部变量表或对象实例的字段中复制常量或变量到操作数栈中,也有一些字节码指令可以从操作数栈中取走数据、操作数据或将操作结果重新入栈。调用其他方法时,操作数栈也用于准备调用方法的参数及接收方法的返回结果
》通过一个简单案例字节码执行过程分析,了解局部变量表和操作数栈的流转情况
java和字节码内容
本例中,局部变量表状态
字节码分析
- main方法中实例化Hzw对象,栈帧变化过程
- main方法中调用hzw对象add方法,栈帧变化过程
结合上图和字节码指令手册,调用add和add执行的过程应该很好理解了(懒,不想画了,后面画了再更吧)
堆
-
堆,新生代和老年代默认分配比例是1/3
-Xms30M、-Xmx30M、-Xmn10M
把Java堆大小设置为30MB,不可扩展(防止内存抖动)。其中10M分配给新生代,另外20M分配给老生代。 -
新生代分Eden区、From区、To区,比例8/1/1
-XX:SurvivorRatio=8
来分配新生代各区的比例,设置为8,表示eden与一个survivor区的空间比例为8:1 -
堆的默认大小,最大是物理内存的1/64,最大是1/4
-
From区和To区会相互转换,???
-
内存担保机制 JVM内存分配担保机制
-XX:+HandlePromotionFailure
允许新生代收集担保,JDK1.5及以前内存担保默认关闭,1.6以后默认开启
内存担保是在JVM在内存分配的时候,新生代内存不足时,把新生代的存活的对象搬到老生代,然后新生代腾出来的空间用于为分配给最新的对象。这里老生代是担保人。在不同的GC机制下,也就是不同垃圾回收器组合下,担保机制也略有不同。在Serial+Serial Old的情况下,发现放不下就直接启动担保机制;在Parallel Scavenge+Serial Old的情况下,却是先要去判断一下要分配的内存是不是>=Eden区大小的一半,如果是那么直接把该对象放入老生代,否则才会启动担保机制
对象的大小
对象的内存布局
对象头
- Mark Word
32位机,占4字节;64位机,占8字节。
主要存储对象运行时的一部分数据: hashcode,GC 分代年龄,锁状态标志位,线程锁标记,偏向线程ID,偏向时间戳等 - 类型指针(Klass point)
指向方法区当前对象的Class类
开启指针压缩:指针大小为4B;未开启指针压缩:指针大小位8B - 数组长度
普通对象没有数组长度,数组对象才有数组长度 - 对齐填充(上第二个图,特殊情况下会有,见下面数组对象的演示)
数组对象在关闭指针压缩时,对象头会有填充
实例数据
对齐填充
JVM中所有对象大小都按8字节倍数对齐,填充的那部分就是填充
巩固上述内容,现在看一下空对象的大小
空对象:没有任何普通属性的类生成的对象
- 开启指针压缩 16B
=8B(Mark Word) + 4B(Klass point) + 0B(数组长度) + 0B(实例数据) + 4B(对齐填充)- 未开启指针压缩 16B
=8B(Mark Word) + 8B(Klass point) + 0B(数组长度) + 0B(实例数据) + 0B(对齐填充)
指针压缩(优化)
开启(默认)/关闭指针压缩 -XX:+/-UseCompressedOops
目的
通常64位JVM消耗的内存会比32位的大1.5倍,这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址)。
对于那些将要从32位平台移植到64位的应用来说,多了1/2的内存占用
节省空间,提升jvm运行效率
指针压缩效果对比(注意区分对象的内存组成)
- 空对象
- 普通对象
- 数组对象
注意上例数组对象关闭指针压缩时,出现了两个alignment(对齐填充),为什么会有两个???
指针压缩实现原理
调优参数
-XX:+/-UseCompressedOops
(默认开启指针压缩)
oop:普通对象指针(ordinary object pointer),注意不是OOP(面向对象)
指针压缩原理(不拽复杂逻辑)
未开启指针压缩时,指针长度为8B,寻址单位是1B。
通过上文我们得知,jvm中对象的大小都是按8B对齐的,这能让我们有什么想法?
为什么我们不能将寻址单位扩大为对齐单位呢(想想,对齐单位的大小能调整吗?)
开启指针压缩时,指针长度为4B,寻址单位是8B(默认)
!!!!!希望能找到open jdk的实现逻辑来佐证一下,立个flag!!!!!!
思考1:开启指针压缩时,指针最大能表示的堆空间大小是多大?
指针压缩:指针8B长度(1B/单位) -> 4B长度(8B/单位)
- 8B长度指针支持的寻址内存大小是?
8B * 8bit/B = 64bit 寻址长度
2^64 * 1B ??操作系统的寻址位只用了48位,最大支持2^48的寻址单位,jvm能超了这个?疑问?同上,有时间找open jdk源码看一下吧,这里有个flag
反正支持的寻址内存很大!
- 4B长度指针支持的寻址内存大小是?
4B * 8bit/B = 32bit 寻址长度
2^32 * 8B = 32GB (32位操作系统支持最大4G内存,其寻址单位是1B,这里的单位是8B,那这里当然就是8*4G了)
哎呀,开启了指针压缩,堆支持的大小还变小了??
思考2:开启指针压缩时,若将堆内存大小设置成32G,会发生什么?
java -Xmx32g -XX:+PrintFlagsFinal hzw | grep -e UseCompressed
和下图命令同效果,因为默认开启指针压缩
上图除了Oop压缩以外,还发现一个ClassPointers?(普通对象指针和类指针)
什么现象?堆内存分配大于32G时,指针压缩设置失效了?为什么呢?
哈哈,我也不知道,我猜有两点
a. 8B指针寻址方式,内存范围更大,能超过32G;
b. (瞎猜)指针压缩的目的是防止新生代担保到老年代,从而导致频繁的fullGC。那你想想,这堆都分配了32G了,家里有钱何必再抠搜搜省这么点空间?
思考3:思考一下,能否通过开启指针压缩,然后扩大补齐单位从8B到16B,实现内存扩容支持?
我也不清楚了,就是有这个疑问(待确认)!
若扩大的补齐单位到16B的话,指令压缩支持内存是不是就能扩展到64G了,是否有jvm的参数控制这个?
虚拟机栈与调优 (内容不够丰满)
jvm栈大小
-Xss
,指设定每个线程的堆栈大小
虚拟机栈默认大小为1M,如下
上图1024的单位是1k,下图演示通过
-Xss
指定栈大小为228
jvm限定堆栈最小值
上例显示,1.6限定的最小堆栈大小为160k,1.8限定的最小堆栈大小为228k
思考1: 最小堆栈大小是怎么确定的?哪些因素影响?
??
栈溢出
下例通过无限递归产生栈溢出,通过变量记录下栈溢出时,最大的栈深度
思考:分析下上例栈溢出产生的原因
此例,栈溢出的原因:jvm虚拟机栈中,test()方法都会有一个栈帧,当test()方法内再调用test()方法时栈中就会再创建一个栈帧,当无限递归时,就会产生很多test()方法栈帧,直到给栈塞满,再创建栈帧时栈内存不够就发生栈溢出了。
思考:上例设置堆栈大小为228k,测试出栈深度为1515,那么每个test方法的栈帧大小大概是多少?
计算:(228 * 1024 / 1515)B
Java内存模型
JMM
Java Memory Model,JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。
内存交互操作指令
- 指令说明
- lock(锁定)
主内存中的变量标识为线程独占状态 - unlock(解锁)
将线程独占状态的主内存变量解除锁定 - read(读取)
主内存变量值传输到线程工作内存中 - load(载入)
将read操作传输到工作内存中的值放入工作内存的变量副本中 - use(使用)
使用到变量值的字节码指令执行时,将工作内存中的变量值传递给执行引擎 - assign(赋值)
变量赋值字节码指令执行时,讲执行引擎输出的值赋给工作内存中的变量 - store(存储)
工作内存中变量值传给主内存 - write(写入)
将store操作传输到主内存中的变量值放入到主内存的变量中
- 内存划分及指令图示
- JMM 8指令使用规则
- 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有assign的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存
volatile
开门见山,示例铺路:
-
volatile可见性示例
-
volatile无法保证原子性示例
上例预期输出30000,可结果并不能保证 -
查看字节码文件,发现volatile影响的是属性的标记值,不会影响字节码指令
思考:标记值或者说volatile变量,在jvm将字节码翻译成c++时,都做了什么处理?怎么实现了数据同步? -
可见性示例过程图解
-
无法保证原子性示例图解(结合该图理解上图没显示出来的细节)
额外的图中lock也会锁住cpu3对主内存的read-load动作(StoreLoad),另外cpu2的store也会被锁住(StroeStore)直到后来缓存被抛弃
图中发现两个CPU可以同时执行read-load操作,从而才有后面故事的发生,若让count不能被同时read不就可以了?(synchronized来一波?)
volatile保证变量线程可见性、有序性、无法保证原子性
- 可见性
我理解的是volatile变量保证了变量变化对各线程可见性
非volatile变量,在use前不强制进行read-load操作,即执行引擎使用的变量一直是栈中的变量副本,主内存中的变量变化不会同步到栈中。这样当主内存中的变量被其他线程修改了,当前线程不会感知到,上文"volatile可见性示例"中去掉volatile后秀才眼中的pen就会一直是0,书童即使送来了笔,秀才也会一直在那等着;
volatile变量,第一:变量在use前需先进行read-load操作,也就是执行引擎每次使用变量都是主内存中最新的;第二(原子性、有序性有关):通过java内存屏障操作(写操作完后调用storeload进行内存屏障),我理解storeload就是控制load操作要在store之后执行。
疑问:这里的写操作是不是就是assign? - 有序性
volatile变量控制变量写操作先行发生于读操作
使用storeload内联一段汇编,进行内存屏蔽,防止内存屏蔽位置后的指令被重排序到之前;(原理呢?后面思考中讨论下)
实际操作中是,jvm内对volatile变量写后立刻执行storeload进行内存屏蔽(汇编的lock前缀作用),从而控制该写操作被cpu写到主内存中之前,cup(多核)不能读到内存中该变量(具体细节下文讨论) - 无法保证原子性
上文"volatile无法保证原子性示例"中验证结果,为什么呢?
真正能保证原子性的话,是要控制变量从read-load直到store-write操作之间是原子性的,而volatile只是控制了store到下次read时是顺序的,结合上图理解,多线程下,多线程多cpu时明显能同时进行read-load的。也就是说上例中可能两个线程都读到count=0并执行了count++,但当一个线程写count到内存触发storeload(实际上汇编lock)操作,致其他线程缓存失效,也就是第二个线程的count++结果会被抛弃,这样count最后值明显就会比预期要小。
一般volatile和synchronized结合使用
balabala~说了一堆,我都不知道我在说啥,哈哈,看不懂的往后看,看完后面的回来再见
思考: volitaile的有序性和可见性底层实现和原理是什么?
- 看一下jvm在对volitaile变量进行写的时候做了什么
上图逻辑a.判断volatile b.store操作 c.调用storeload d.storeload方法中内联了一段汇编(实现了内存屏障)- storeload即java中的内存屏障,它是通过内联汇编实现的,如下(我也不懂,先假模假样的看看吧):
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
如上内联汇编后(摘自:深入理解java虚拟机):
LOCK用于在多处理器中执行指令时对共享内存的独占使用。它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效;另外还提供了有序的指令无法越过这个内存屏障的作用,也就解决了。
上面这些能理解这些吗? 理解好像又不理解?额~ 好吧,下面再补点货吧
扩展1:cpu的写操作、lock怎么引起他核无效化Cache的
- cpu的写操作有两中方式
a. 同步写:cpu输出数据直接保存到主内存中;
b. 异步写:cpu输出的数据线保存在缓存中,待cpu空闲时将缓存同步到主内存中。
异步写因为主内存的速度跟不上cpu的处理速度,所以通过高速缓存来提升cpu执行效率。虽然这个异步过程很快但是高压下这个在多核心处理同一变量时依然有问题(什么问题?自己想!),lock顺带解决了这个问题- lock的作用
上面“无法保证原子性示例图解”图中所示,lock通过锁住地址总线从而锁住目标内存区域的read并通过总线嗅探机制使其他cpu抛弃缓存。这就理解了上面提到的lock怎么解决cpu异步写的问题了
扩展2:cpu的缓存知识
- ALU:CPU计算单元,加减乘除都在这里算
- PC:寄存器,ALU从寄存器读取一次数据为一个周期,需要时间小于1ns
- L1:1级缓存,当ALU从寄存器拿不到数据的时候,会从L1缓存去拿,耗时约1ns
- L2:2级缓存,当L1缓存里没有数据的时候,会从L2缓存去拿,耗时约3ns
- L3:3级缓存,一颗CPU里的双核共用,L2没有,则去L3去拿,耗时约15ns
- RAM内存:当缓存都没有数据的时候,会从内存读取数据
- 缓存行:CPU从内存读取数据到缓存行的时候,是一行一行的缓存,每行是64字节(现代处理器)
注意:这里有个缓存行失效问题,会导致性能降低。举例描述就是:[volatile x, volatile y],因缓存行的存在,该数组内两个元素会一起加载到cup缓存中,当cup1对x变量操作并store时会触发lock(汇编),其他线程或者说cup2在操作y时就会因缓存失效而导致y也必须从主内存中重新加载,从而增加性能损耗,这就是缓存行失效。
思考:为什么volatile无法保证原子性?
见上文
疑问:lock(汇编)的范围是什么,StoreStore和StoreLord仅限当前变量?还是当前线程变量?
下文jvm规范能看出范围是控制在当前volatile变量中,具体细节不知道了,有其他补充资料的话再补货吧。。
从JVM规范层面看volatile变量
该小节部分内容摘自:volatile如何保证并发编程中的可见性和有序性?原子性为何不行?
- JVM规范中定义的JSR内存屏障定义:
- LoadLoad屏障:
对于语句Load1;LoadLoad;Load2;Load1和Load2语句不允许重排序。 - StoreStore屏障:
对于语句Store1;StoreStore;Store2;Store1和Store2语句不允许重排序。 - LoadStore屏障:
对于语句Load1;StoreStore;Store2;Load1和Store2语句不允许重排序。 - StoreLoad屏障:
对于语句Store1;StoreStore;Load2;Store1和Load2语句不允许重排序。
- JVM层面volatile的实现要求
- volatile写操作
写操作前面加StoreStoreBarrier
:保证前面所有的store操作都执行完了才能对当前volatile修饰的变量进行写操作;
写操作后面加StoreLoadBarrier
:保证后面所有的Load操作必须等volatile修饰的变量写操作完成。 - volatile读操作
读操作后面加LoadLoadBarrier
:必须等当前volatile修饰变量读操作完成才能读;
读操作后面加LoadStoreBarrier
:必须等当前的volatile修饰变量读操作完成才能写。