JVM06-运行时数据区-堆

1 堆

1.1 堆的概述

1,一个Java进程,JVM实例中堆是唯一的,是所有线程共享的,JVM规范规定堆可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的

2,堆的大小可以通过参数调整,堆在JVM启动时被创建,创建后空间大小不可更改,是JVM管理的最大一块内存空间

3,因为所有的线程共享堆,如果所有线程对堆中数据进行操作,那么堆就需要保证线程安全,同步加锁等手段会让堆的并发性降低,为此可以在堆中给每个线程划分一部分空间,即堆中线程私有的缓冲区(Thread Local Allocation Buffer TLAB)

4,所有的对象实例以及数组都应该分配在堆内,“几乎所有的”对象实例都在堆中分配内存,数组和对象可能永远不会存储在栈上,因为栈帧中的局部变量表理应保存对象的引用,这个引用指向了对象或数组在堆中的位置

5,在方法结束后,当前栈帧出栈后,堆中被引用的对象不会立马被移除,这些对象仅在垃圾回收过程中才会被移除,垃圾回收是不能频繁执行的,频繁的进行垃圾回收会导致用户线程的执行效率降低
堆是GC(垃圾回收器 Grabage Collection)执行垃圾回收的重点区域

看一个简单的示例,在主方法中创建了两个对象:
在这里插入图片描述
对应的指令:
在这里插入图片描述
当执行到new指令时,就会在堆中分配好空间创建空对象(所有值为默认值),执行到invokespecial指令,执行构造方法来初始化对象,将对象的引用入栈,更新程序计数器

大致对应的虚拟机栈和堆:
在这里插入图片描述

1.2 堆空间的细分

现代GC大部分都基于分代收集理论,堆空间由此被细分为:

Java7之前:
堆在逻辑上分为 年轻代+老年代+永久区

年轻代 Young Generation Space(young)
	又被划分为 Eden区和Survivor区
	
老年代 Tenure Generation Space(old)

永久区 Permanent Space(perm)
Java8及以后:
堆在逻辑上分为 年轻代+老年代+元空间

年轻代 Young Generation Space(young)
	又被划分为 Eden区和Survivor区
	
老年代 Tenure Generation Space(old)

元空间 Meta Space(meta)

在这里插入图片描述

永久代和元空间虽然在逻辑上是堆中的一部分,但是实际上是方法区的不同落地实现,方法区本质上是一个逻辑上的概念,堆本质上还是年轻代+老年代

1.3 设置堆的大小和OOM

默认情况下堆的大小(年轻代+老年代)

堆初始内存大小: 物理电脑可用内存大小 / 64
堆最大内存大小: 物理电脑可用内存大小 / 4

在JVM启动前可以通过参数来设置堆的大小,使用 -Xms 和 -Xmx 来进行设置

-Xms 表示堆的初始内存 等价于 -XX:InitialHeapSzie

-Xmx 表示堆的最大内存 等价于 -XX:MaxHeapSize

通常会将-Xms-Xmx两个参数配置相同的值如: -Xms100M -Xmx100M
其目的为了GC执行垃圾回收时不需要重新分隔计算堆区的大小,从而提高性能
在IDEA中可以直接在Help->Edit Custom VM Options中更改JVM默认参数并重启使用

当前堆空间大小一旦超出了-Xmx所指定的最大内存时,将会抛出OutOfMemoryError
在这里插入图片描述
可以使用jdk下bin目录中的Java VisualVM来查看JVM包括堆的当前状况
在这里插入图片描述
还可以查看堆中各类数据出现的频率,推测OOM出现的原因
在这里插入图片描述

1.4 年轻代与老年代

存储在JVM中的对象可以大致分为两类,一类是生命周期较短的瞬时对象,这些对象的创建和消亡都非常迅速,另一类对象生命周期较长,在某些极端情况下甚至和JVM的生命周期一致

JVM堆区可以分为年轻代和老年代,其中年轻代又可以分为Eden空间,Survivor0空间,Survivor1空间
在这里插入图片描述
堆的总体大小可以通过-Xms -Xmx来设置,年轻代和老年代的占比也可以进行设置,使用 -XX: NewRetion=n 设置,n是老年代/年轻代

默认-XX:NewRation=2,表示年轻代占堆的1/3,老年代占堆的2/3
可设置-XX:NewRation=4,表示年轻代占堆的1/5,老年代占堆的4/5

一般情况不会更改堆内年轻代和老年代所占空间比例

在HotSpot中堆内的年轻代中,Eden空间和另外两个Survivor空间所占比例默认是8:1:1

几乎所有的对象都是在Eden空间被new出来的,绝大多数对象的回收也在年轻代进行,约80%的对象都是“朝生夕死”的
在这里插入图片描述
为什么需要把JVM中的堆分代?

经过研究,不同对象的生命周期不同,70%-99%的对象是临时对象
分代唯一的理由就是优化GC性能,如果没有分代,那所有的对象都被放在一起
垃圾回收时要找到哪些对象没有时,就需要对堆的所有区域进行扫描
如果分代的话,就可以把生命周期短的对象和生命周期长的对象分开存储,分开进行垃圾回收
能极大提高垃圾回收的效率

1.5 为对象分配内存的一般过程

为新对象分配内存是一项非常严谨和复杂的任务,JVM的设计者不仅需要考虑如何内存如何分配,在哪分配等问题,且由于内存分配和垃圾回收算法密切相关,所以还需要考虑GC执行完毕后是否会在内存空间中产生内存碎片
在这里插入图片描述

需要new的对象大部分首先在Eden中被创建
当Eden满后需要new对象时,需要进行Minor GC,此时用户线程停止
Minor GC将垃圾(不再被引用的对象)回收,将剩余幸存的对象存放到S0
每个对象都有一个“年龄计数器”,此时幸存的对象年龄为1
此时Eden清空,S0内有对象,S1为空,用户线程继续newd的对象存放到Eden

在这里插入图片描述

Eden再次满后,进行一次Minor GC
将S0中还幸存的对象放入S1,且年龄+1Eden中幸存的对象放入S1,年龄为1
此时Eden清空,S0清空,S1内存放对象,用户线程继续new的对象存放到Eden

在这里插入图片描述

Eden再次满后,再进行Minor GC
将S1中还幸存的对象放入S0,且年龄+1Eden中幸存的对象放入S0,年龄为1
此时Eden清空,S0内有对象,S1清空,用户线程继续new的对象存放到Eden

循环此过程,总有一个S为空,一个S保存年轻代幸存的对象

在这里插入图片描述

Eden再次满后,再进行Minor GC
将Eden中幸存的对象放到空的S0,年龄为1
将S1中幸存的且年龄大于15的对象直接放到老年代,年龄+1
将S1中幸存的年龄小于15的对象放到S0,年龄+1
此时Eden清空,S0内有对象,S1清空,老年代也存放了年龄大于15的对象

这里的15是年龄计数器的默认阈值,该阈值可设置
在年轻代中年龄超过阈值的,对象需要被放到老年代里

小结:
1,Minor GC只会在Eden满了后触发,即使S满了也不会触发
Minor GC将对整个年轻代(Eden,非空的S)进行垃圾回收

2,S0和S1总有一个为空

3,堆内垃圾回收的过程频繁在年轻代进行,很少在老年代进行

1.6 为对象分配内存的特殊过程

上述为对象分配内存是普适情况,但仍有一些特殊情况:
比如需要创建的对象比整个Eden还大
比如Minor GC开始后,非空的S已满,Eden幸存的对象放不进去等
在这里插入图片描述
对这两种特殊情况的处理是:

1,需要创建的对象比整个Eden还大
尝试将超大对象放到Old中,如果Old都放不下,需要对老年代进行垃圾回收
再尝试将超大对象放入Old,如果还放不下,直接OOM

2Minor GC结束后,非空的S已满,Eden幸存的对象放不进去
将Eden中幸存的对象放到Old

可以在Java VisualVM中安装Visual GC插件来查看当前堆中的年轻代和老年代实时的状态
在这里插入图片描述

1.7 Minor GC Major GC和Full GC

在HotSpot VM中GC分为两种,一种是部分收集,一种是整堆收集

部分收集:对堆中部分区域进行垃圾回收,具体分为
Minor GC 年轻代垃圾收集器 频率最高
Major GC 老年代垃圾收集器

整堆收集:收集整个Java堆和方法区的垃圾
Full GC 堆及方法区(永久代/元空间)垃圾收集器

Minor GC的触发机制
	当Eden空间不足时,就会触发Minor GC,S满了不会触发Minor GC
	每次Minor GC都会清理整个年轻代(Eden+S)的垃圾

	因为Java对象大多数生命周期较短,所以Minor GC非常频繁
	一般回收速度也比较快,Minor GC会引发STW(stop the world)即停止用户线程
	等Minor GC结束后,用户线程才恢复运行(收集垃圾的时候停止制造垃圾)
Major GC的触发机制
	出现了Major GC经常会伴随至少一次的Minor GC
	也就是老年代空间不足时,会先触发Minor GC,如果之后空间还是不足则触发Major GC

	Major GC的速度比Minor GC慢10倍以上,STW的时间更长
	如果Major GC后内存还是不足,就会出现OOM
Full GC的触发机制
触发Full GC执行的情况有以下几种:
	1,调用System.gc()时,系统建议执行Full GC,但不一定执行
	2,老年代空间不足
	3,方法区空间不足
	4,通过Minor GC后进入老年代的平均大小大于老年代的可用内存

Full GC是开发或调优中要尽量避免的,这样STW的时间会短一些

很多时候Major GC和Full GC会混淆使用,
需要具体分辨是老年代垃圾回收,还是整堆垃圾回收

注:GC也是一个线程,GC的执行过程中,需要暂停用户线程,所以GC不应该过于频繁

1.8 堆空间为每个线程分配的TLAB

堆区是线程的共享区域,任何线程都可以访问堆区中的共享数据

由于对象的创建过程在JVM中非常频繁,因此在并发环境下从堆中划分内存空间是线程不安全的,如线程A和线程B此时同时在执行,它们都想要在堆中创建对象,但是此时它们选择了堆中相同的地址,此时就出现了问题,为了避免多线程操作堆中同一个地址,需要使用加锁等机制,但加锁又必定降低效率

为此就有了TLAB(Thread Local Allocation Buffer),堆中线程私有的缓冲区
在Eden中为每个线程分配了一个私有的缓冲区,即TLAB

在这里插入图片描述
多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题
同时还能提升内存分配的吞吐量,几乎所有的JVM都提供了TLAB的设计

TLAB的分配过程:
在这里插入图片描述
关于TLAB的补充:

1,可以通过配置参数 -XX:UseTLAB 设置开启TLAB空间

2,默认情况下,每个TLAB空间的内存非常小,仅占整个Eden1%
	可以通过参数 -XX:TLABWasteTargetPercent设置TLAB空间所占Eden空间的百分比大小

3,不是所有对象都能在TLAB中分配成功,但JVM将TLAB作为内存分配的首选

4,一旦对象在TLAB空间分配内存失败时,JVM会通过加锁机制
	来确保数据操作的原子性,从而在Eden中分配内存

1.9 堆中的常用参数小结

1-XX:+PrintFlagsInitial 查看所有参数的默认初始值

2-XX:+PrintFlagsFinal 查看所有参数的最终值

3-Xms 设置堆起始大小

4-Xmx 设置堆最大空间

5-Xmn 设置年轻代的大小

6-XX:NewRatio 配置年轻代与老年代在堆结构的占比

7-XX:SurvivorRatio 设置年轻代中Eden和S0/S1空间的比例

8-XX:MaxTenuringThreshold 设置年轻代中垃圾的最大年龄

9-XX:HandlePromotionFailure 是否设置空间分配担保

1.10 堆是分配对象的唯一选择吗?

在JVM中,对象是在堆中分配内存创建的,这是一个普遍的常识,但是有一种特殊情况,如果经过“逃逸分析”后发现,一个对象并没有逃逸出方法的话,那么可能被优化成栈上分配,也就是说有些对象并没有在堆中创建

栈上分配,就是对该对象进行逃逸分析,如果未发生逃逸,通过标量替换,将该对象打散成若干局部变量存储在栈帧中的局部变量表中

GC重点在堆中进行,而任何GC都需要STW(停止用户线程)Major GC/Full GC又尤为耗时,为此减少GC的次数就能提高程序的运行效率
在栈上创建某些对象,就减少了堆中对象的个数,间接减少了GC次数

1.10.1 逃逸分析 Escape Analysis

将对象分配到栈上,需要使用逃逸分析手段
这是一种可以有效减少Java程序中同步负载和内存分配压力的
跨函数全局数据流分析算法

逃逸分析的基本行为就是分析对象的作用域
当一个对象在方法中被定义后,对象只在方法内使用
	则认为没有发生逃逸,就可以使用栈上分配
	该对象将被创建在虚拟机栈上,而非堆中,间接减少了GC的次数
	且该对象从属的方法对应的栈帧出栈后,栈帧被销毁,该对象也会自动销毁

当一个对象在方法中被定义后,它被外部方法所引用
	如作为参数传递到了其它方法中,则认为发生了逃逸

通过逃逸分析,HotSpot VM的编译器能分析出一个新对象的引用范围
从而决定是否将这个对象分配到堆上

逃逸分析示例:
在这里插入图片描述
在这里插入图片描述
判断一个new的对象是否发生了逃逸,看它有没有被外部的方法使用
如果没有被外部方法使用,则没有发生逃逸,可以在栈上分配
该对象被外部方法使用,则发生了逃逸,需要在堆中分配

在HotSpot VM中可以使用参数来查看关于逃逸分析的数据

-XX: +DoEscapeAnalysis 显示开启逃逸分析 默认是开启的

-XX: +PrintEscapeAnalysis 查看逃逸分析的筛选结果

1.10.2 编译器通过逃逸分析来优化代码

开启逃逸分析后,编译器可以对代码做一些优化:

标量替换
标量替换
将对象拆解成若干基本类型
即不创建该对象,创建一堆局部变量来代替该对象

标量指一个无法再分解成更小数据的数据,如Java中的基本数据类型
可以被分解的数据叫聚合量,Java中的对象就是聚合量

标量替换示例:
在这里插入图片描述
将整个对象的创建过程替换成创建若干个标量,经过标量替换后,上述的代码被优化为:
在这里插入图片描述
通过标量替换,可以避免某些对象的创建过程,将局部变量放到栈帧中的局部变量表中,避免在堆中创建对象,标量替换就是栈上分配的基础,标量替换默认是开启的

栈上分配
栈上分配
通过逃逸分析和标量替换,将没有发生逃逸的对象由堆上分配转换成栈上分配

栈上分配能加快创建对象的速度,和减少堆中的GC,提高程序效率

栈上分配示例:
在这里插入图片描述
这里创建了一百万个ClassA对象,经过逃逸分析,确定它们都没有逃逸,为此这些对象大都被分配在栈帧中,栈上分配创建这一百万个对象花费了16ms,如果关闭逃逸分析,会需要数倍的时间,且堆中GC的次数也会很频繁,导致多次的STW

同步省略
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能
如果一个对象被发现到只能被一个线程访问到
那么对这个对象的操作可以不考虑同步,即忽略它的同步块

在动态编译同步块时,JIT编译器可以借助逃逸分析来判断
同步块所使用的对象锁是否只能被一个线程访问而没有被发布到其它线程
如果没有,那么JIT编译器在编译这个同步块时就会取消对这部分代码的同步
这样就能大大提高并发性和性能,这个过程就是同步省略,也叫锁消除

同步省略示例:
在这里插入图片描述
JIT编译器经过逃逸分析,判断classA作为对象锁,只被main线程所使用,判断出这里的同步块是不需要的,为此JIT编译器将会进行同步省略,JIT编译器将把上面的代码优化为:
在这里插入图片描述
JIT编译器通过逃逸分析进行同步省略,能避免不必要的同步操作,进而提高程序整个的并发性和效率

1.10.3 堆是分配对象的唯一选择(HotSpot VM)

总的来说,在HotSpot VM中,堆仍是分配对象的唯一选择,所谓的栈上分配,其实并没有在栈上创建对象,只是将未逃逸的对象通过标量替换将其肢解成局部变量放到了栈帧中的局部变量表中,真正意义上的对象仍然存储在堆中

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值