深入理解虚拟机栈
一、内存分配
Java程序运行过程
各个区域详解:
方法区: 方法区是各个内存所共享的内存空间,方法区中主要存放了被JVM加载的类信息(class字节码的信息)、常量、静态变量、即时编译后的代码等数据;方法区的数据放在主内存。
堆区: 用来存储对象实例(几乎所有的对象都在这分配内存),被所有线程共享的一块内存区域,在App启动时创建;堆区的数据放在主内存。
虚拟机栈区: 存储方法的局部变量,每次开启一个线程都会创建一个虚拟机栈,线程私有,生命周期与线程相同;栈区的数据存放在高速缓存区。java方法执行的内存模型,每个方法执行的时候,都会创建一个栈帧用于保存局部变量表,操作数栈,动态链接,方法出口信息等。一个方法调用的过程就是一个栈帧从VM栈入栈到出栈的过程。
本地方法栈: 与VM栈发挥的作用非常相似,VM栈执行java方法(字节码)服务,Native方法栈执行的是Native方法服务。
程序计数器: 每条线程都需要有一个程序计数器,计数器记录的是正在执行的指令地址,如果正在执行的是Native方法,这个计数器值为空(Undefined)。
执行引擎: 将方法区中对应方法的arm指令集加载到栈区,而栈区存在于高速缓冲区中,cpu是直接从高速缓冲区取arm指令,一条一条执行。执行引擎就像一个中介,方法对应的arm指令相当于交易的物品。
方法区跟堆区的生命周期跟jvm生命周期是一致的
Java虚拟机栈、本地方法栈、程序计数器的生命周期跟线程的生命周期一致
一个方法对应一个栈帧。
二、JAVA虚拟机栈
1)虚拟机栈是当前执行线程独占空间,以栈的数据结构形式存在。
2)虚拟机栈是线程执行的区域,它保存着一个线程中方法的调用状态。
3)每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。
如果是递归调用,比如上面例子中a()方法中调用a()方法,无限制递归栈帧会无限制的存放Frame-a,直到内存满,出现StackOverflowError错误。
如下实例:
例如:
String str1 = “a” + “b” + “c”; String
str2 = “abc”;
String str3 = new String(“abc”);
-
执行到第一条语句 String str1 = “a” + “b” + “c”;
常量池中会创建四个对象:“a”、“b”、“c”、“abc”
-
执行到第二条语句 String str2 = “abc”;
这时候str2首先会从常量池中去查找,如果能找到字符系列相同的,则直接定位到这个"abc"的位置,因此str1 和str2 所存放的内存地址是相同的。
-
执行到第三条语句 String str3 = new String(“abc”);
str3 它会直接在映射表中注册,不会在常量池中再次申请空间,它会直接定位到原来在内存中所开辟的“abc”的空间。
“==”比较的是内存中存放的位置
str1 == str2 -->true
str1 == str3 -->false
str2 == str3 --> false
“equals()”比较是字符序列
str1.equals(str2) -->true
str1.equals(str3) -->true
str2.equals(str3) -->true
str1.hasCode()、str2.hasCode()、str3.hasCode()三者的hasCode值是相同的
只有内容是相同的,hasCode()值就相同,就是同一个对象
public int test() {
int a = 1;
int b = 1;
int c = a + b;
return c;
}
局部变量表:比如存放上面的代码中的变量a、b、c
操作数栈:负责具体的计算,比如将int a =1;语句中1压入到操作数栈中。
动态链接:简单来讲就是指向方法区中这个方法定义的指针。链接就是指针,直接指向这个方法的定义。
动态链接主要解决两个方面的问题:1.解决多态问题 2.指向本地方法
方法的返回地址:一个方法被调用时一定会有一个出口,这个出口就是一个返回地址,一个方法被调用的过程中一般会两种不同形式的终结的方式:一种是程序正常执行完了返回,一种是异常返回。
局部变量表:
特别注意: 在一个实例方法的调用过程中,本地变量表的0一定是用于这个对象的引用(this);如果是类方法(静态方法)的引用,本地变量表中的0用于存放参数,没有this的存放。
局部变量表的变量是不可以直接使用的,如果需要直接使用必须通过相关的指令把它加载至操作数栈中,作为操作数使用。
例如:
零地址指令只有操作码,没有操作数。这种指令有两种情况:一是无需操作数,另一种是操作数为默认的(隐含的),默认为操作数在寄存器中,指令可直接访问寄存器。
这些指令存放在方法区
三、深入共享内存:
年青代满了通过执行minor gc进行回收
老年代满了通过full gc(又称Major GC)进行全体回收
Minor GC/Young GC:只回收新生代
Full GC:回收新生代、老年代、方法区
Major GC/Old GC:只回收老年代(这个用的比较少,一般回收老年代都会回收新生代)
非堆:也叫方法区。在该方法区中保存的是一些静态的信息,类的一些结构及地址编译等机器码指令,该区保存是一些不太需要清理的东西。
s0和s1在一个时间节点下,只有一个空间被使用,当s0在使用过程中,s1空间肯定没有对象;当s1在使用过程中,s0空间肯定没有对象。
当发生GC垃圾回收时,如果当前s0正在使用,将Eden区中没有使用的对象进行回收,将Eden区中幸存的对象拷贝到s1中,并且s0也进行GC回收,将s0中存活的对象全部存放到s1中,并将s1置于正在使用,将s0空间全部清空。
如果有存在那种s0和s1迭代过程中无法回收的对象,并超过了分代age(默认值为15),会将该对象从Survivor区存放到Old区。Old区满了会进行GC回收,如果GC回收不了内存满就会出现OOM。
Java堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,Java堆主要分为2个区域----年轻代与老年代,其中年轻代又分Eden区和Survivor区,其中Survivor区又分 From区和To区 2个区。
-
Eden 区
大多数情况下,对象会在新时代Eden区中进行分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC相比Major GC更频繁,回收速度也更快。通过Minor GC之后,Eden会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到Survivor的From区(若From区不够,则直接进入Old区)。 -
Survivor 区
Survivor区相对于是Eden区和Old区的一个缓冲,类似于我们交通灯中的黄灯。Survivor又分为2个区,一个是From区,一个是To区。每次执行Minor GC,会将Eden区和From区存活的对象放到Survivor的To区(如果To区不够,则直接进入Old区)。Survivor的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生。Survivor的预刷选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。 -
Old 区
老年代占据着2/3的堆内存空间,只有在Major GC的时候才会进行清理,每次GC都会触发“Stop-The-World”。内存越大,STW的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记——整理算法。
四、JAVA对象的布局
Java数组实例与java对象实例的区别: 在对象头中,Java数组实例的对象头会多一个4个byte的length字段内容。
markOop: 用于保存分代age信息、hashcord的值、锁的标记;
klassOop: 指向方法区中的class,类信息的指针。
对象实际数据: 包含对象属性的一些相关内容。
对象填充(可能存在): 用于Java对象实例或Java数组实例 8个字节的对齐(即8个字节的整数倍)。
Cpu一次获取的数据是8个字节。
哈希码(hashcord的值):可以用于帮助比较两个对象的唯一性
分代年龄(分代age信息):用于标记对象的年龄信息
锁状态标记:一个对象可能同时被多个线程修改,如果锁住的话,那其他线程就获取不了,这样保证线程安全
如果开启了压缩指针,Class Pointer占用4个字节。
五、JAVA对象的一辈子
大对象进入老年代:
同时满足以下两个条件:
- -XX:PretenureSizeThreshold=4m 大于这个值
- 垃圾回收器选择:serial 或 parnew 这两款才生效
六、如何定位JVM中的垃圾
常规定位垃圾的两种方法:引用计数法和可达性分析
(1)引用计数法
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收,此方法简单,无法解决对象相互循环引用的问题。
(2)可达性分析
从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。
可作为GC Roots的对象:(方法区Method Area、虚拟机栈VM Stack、本地方法栈Native Method Stack)
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中JNI引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 存活的Thread对象
- 正在被synchronized锁定的对象
- 内部引用:class对象、异常对象Exception、类加载器
- 内部对象:JMXBean
- 临时对象:跨代引用
上图中 O9、O7、O8为垃圾
class对象回收条件,比较苛刻,满足所有的条件:
- class new出的所有对象都回收掉
- 对应的类加载 也要被回收掉
- 类 java.lang.class对象 任何地方没有被引用,并且无法通过反射调用这个类的方法
- 参数控制(-Xnoclassgc:禁用类的垃圾回收)
七、垃圾回收算法总结
Mark-Sweep算法: 标记清除算法,先将垃圾对象进行标记,然后将垃圾对象进行清理置空。
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象,之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
该算法:
优点:回收速度快,可以做到不暂停
缺点:位置不连续,会产生大量碎片
Mark-Compact:垃圾整理/压缩算法,先将垃圾对象进行标记,然后将垃圾对象进行清理置空并进行整理。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
该算法:
优点:回收的空间是连续的
缺点:效率慢
特点:没有内存碎片、指针需要移动
流程:标记–》 整理 --》 清除
Mark-Copy:标记复制算法,将内存一分为二,将垃圾内容进行标记,并将存活对象连续的复制到未使用的另外一半内存中,将之前使用的一半内存全部进行清空。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
该算法:(适用于Young区中s0和s1)
优点:可以解决空间连续性问题,并且性能较高、没有内存碎片
缺点:始终会浪费一块空间
新生代–》标记复制算法
老年代–》标记清除算法或垃圾整理/压缩算法
八、垃圾收集器分类
- 串行收集器:只有一个垃圾回收线程,暂停用户代码;Serial,Serial Old
- 并行收集器:[它的关注点在于吞吐量]多个垃圾收集器线程同时工作,暂停用户代码;Parallel Scavenge,Parallel Old;
- 并发收集器:[关注点:时间优先]CMS,G1,完全停止用户代码线程;(停顿时间非常短,如果处于标记阶段,也会有只是暂停线程的可能)
- Serial:针对新生代
- Serial Old:针对老年代
- ParNew:针对新生代
- Parallel Scavenge:(吞吐量优先)是ParNew的升级版,针对新生代
- Parallel Old:(吞吐量优先)针对老年代
- CMS:主要针对老年代,但是也可以适用于新生代
- G1(垃圾优先):既适用于新生代,又适用于老年代
针对新生代的基本上都是用垃圾回收算法的标记复制算法。
针对老年代的基本上都是用垃圾回收算法的标记清除和标记整理算法。
jdk1.3之前:
都是采用Serial垃圾收集器,适用新生代,对于复制算法的实现,单线程收集。只有一个线程在进行垃圾回收工作,停止用户代码的线程执行。
Serial Old适用于老年代,同样也是一个线程在执行,对于标记-整理算法的实现,只有一个线程在进行垃圾回收工作,也会停止其他用户线程。
为什么一直都是单线程处理垃圾,是因为没有多核CPU出现。
ParNew,Parallel Scanvange适用于新生代,仅仅加快了时间而已,多线程收集,在垃圾回收工作会停止用户代码的线程执行。
Parallel Scanvange是ParNew的升级版本
Parallel Old适用于老年代,标记整理算法;仅仅是加快了时间而已,因为使用多个线程在工作;停止用户线程代码。
jvm优化:
CMS、G1优化的方向都是优化停顿时间优先。
CMS:(以最短的暂停时间优先)适用于老年代
特点:停顿时间优先,多次标记,一次性回收
Concurrent Mark Sweep(CMS) : 标记清除算法
- 初始标记 (只标记有GC Roots有关的对象)
- 并发标记(一些复杂线路对象标记,用户线程不暂停)
- 重新标记(标记在并发标记的对象,指引用可能发生变化)
- 并发清除
CMS中的问题
- CPU敏感(CPU核心线程数大于等于4最好才使用CMS,不然会很卡)
- 浮动垃圾 (在并发清理阶段产生的垃圾,就是浮动垃圾,这需要老年代预留一部分内存来存放,只能等到下一次垃圾回收)
- 内存碎片 (可能会导致回收器退化成Serial Old,垃圾回收暂停时间可能会非常长)
CMS细节(可优化点):
1.预清理 操作一次
参数:CMSPrecleaningEnabled
(1) 并发阶段,eden引用了老年代没有标记的对象,做标记
(2) 老年代 内部 引用变化,记录类似于卡表的结构
2.并发可中断预处理 需要循环处理
参数:CMSScheduleRemarkEdenSizeThreshold -->默认值是2M
表示Eden区已使用内存达到2M才会开启
循环处理以下两件事情:
(1) 处理From区和to区对象到老年代可达,导致老年代的并发标记中的引用发生变化
(2)老年代 内部 引用变化,记录类似于卡表的结构
上面循环可中断,中断条件可以有以下3种,满足其一就可中断
(1)CMSMaxAbortablePrecleanLoops, 控制 次数 默认为0,表示没限制
(2)CMSMaxAbortablePrecleanTime 时间 默认为5s
(3)CMSScheduleRemarkEdenPenetration 默认为50,表示 Eden区的内存使用达到50%
预清理和并发可中断处理 都是为了缩短重新标记时间,把这些工作放到并发标记中去处理。
jdk1.7以后,内存8G 选G1, 内存<6G,选CMS
jdk1.7之后,推崇G1(Garbage first,垃圾优先收集器):复制 + 标记/整理算法,适用于新生代和老年代。
G1:会将整个内存划分为大小一致的区域(region) 1M~32M(2的幂次)
GC Root 扫描的时候,扫描整个堆区,很耗时
在每一个region(Remembered Sets):记录对象的引用信息。
优点:
- O、E、S区域的大小可以根据实际需要进行调整它的大小
- 虽然在正式的垃圾回之时,也会完全停止用户代码线程,但是停顿的时间可调整。
上图中的O:表示老年代Old,E:表示新时代Elder,S:表示新时代 S0 和 S1。
回收器 | 回收对象和算法 | 回收器类型 |
---|---|---|
Serial | 新生代,复制算法 | 单线程(串行) |
Parallel Scavenge | 新时代,复制算法 | 并行的多线程回收器 |
ParNew | 新生代,复制算法 | 并行的多线程收集器 |
回收器 | 回收对象和算法 | 回收器类型 |
---|---|---|
Serial Old | 老年代,标记整理算法 | 单线程(串行) |
Parallel Old | 老年代,标记整理算法 | 并行的多线程回收器 |
CMS | 老年代,标记清除算法 | 并发的多线程回收器 |
G1 | 跨新生代和老年代,标记整理 + 化整为零 | 并发的多线程回收器 |
扩容新生代能提高GC效率吗?
答:扩容新生代或增大Eden区能够提高GC效率
假如新生代容量:R
A存活时间:750ms
MinorGC间隔:500ms
处理时间:T1+T2
扩容新生代:2R
A的存活时间:750ms
MinorGC间隔:1000ms(空间增大一倍)
处理时间:2T1
A已消亡,不需要复制,相比来说少了一个T2时间 一般扫描时间比较短,复制的时间比较长
JVM是如何避免Minor GC时扫描全堆的?
答:在对象new的时候会创建一个卡表(card table),卡表是boolean数组的结构,默认大小是512M,每一张卡为512b,,如果有跨代引用,即老年代的引用了新生代的对象,则这个卡表的boolean值为1,表示这块数据为脏数据。
扫描的时候只需要扫描新生代 加上 卡表中标识为脏数据的区域,从而减少扫描范围。
常量池(方法区)
- Class常量池, 也称为静态常量池,存放一些class、存放编译时存放各种字面量以及符号引用
- 运行时常量池
- 字符串常量池