第八章:堆
堆空间概述

方法区和堆都是对应着一个唯一的进程的。
堆的核心概述:
一个java应用程序对应一个JVM实例,一个JVM实例只存在一个堆内存,堆也是java内存管理的核心区域。
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。并且是JVM管理的最大的一块内存空间。
堆内存的空间大小是可以调节的。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
一个进程(java应用程序)所有的线程都共享java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。使得并发性更好一些。
关于对象创建和GC的概述

数组和对象(实例)可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆,是GC进行垃圾回收的重点区域。
简单的例子:
类SimpleHeap中


栈中的引用指向堆中的实例对象。
堆的细分内存结构
分代收集理论

Java8中永久区变成了元空间。

堆空间内部结构:

堆空间大小的设置和查看
- 设置堆空间大小的参数
-Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
-X 是jvm的运行参数
ms 是memory start
-Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小 - 默认堆空间的大小
初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存大小 / 4 - 手动设置:-Xms600m -Xmx600m
开发中建议将初始堆内存和最大的堆内存设置成相同的值。
避免运行中调整堆内存大小造成额外的时间开销。 - 查看设置的参数:
方式一: jps / jstat -gc 进程id
方式二:-XX:+PrintGCDetails
会存在一种情况:
![]()
![]()
![]()
即通过配置参数设置的堆大小,与运行中通过函数得到的堆大小是不相符的,要大一些。
通过jps和jstat命令来查看:

其中S0C表示S0区的大小,S1C表示S1区的大小,EC表示Eden区的大小,OC表示老年代的大小。
另外例如S0U中的U表示used,也就是指明S0C区中使用了多少。
计算:25600+25600+153600+409600=614400
614400/1024=600M,等于我们通过虚拟机参数设置的堆大小。
但是在实际使用中,S0和S1区同一时刻只能有一个区域在使用,因此实际使用的大小是25600+153600+409600=588800
588800/1024=575M
此外,通过设置虚拟机参数-XX:+PrintGCDetails,也可以得到这些信息:

OOM的说明与举例
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
list.add(new Picture(new Random().nextInt(1024 * 1024)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
}
在while循环中,不断新建类对象,并且类对象中有byte[]数组。使得堆空间慢慢被占满。
新生代与老年代中相关参数的设置
存储在JVM中的java对象可以被划分为两类:
① 生命周期短的瞬时对象,这类对象的创建和消亡都非常迅速
② 生命周期非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区再进一步细分的话,可以划分为年轻代(YoungGen)和老年区(OldGen)。
其中年轻代又可以分为Eden空间、Survivor0和Survivor1空间(或者称为from和to区)

(1)配置新生代与老年代在堆空间中的比例:

默认:-XX:NewRation=2,表示新生代占比1,老年代占比2,新生代占整个堆的1/3
可以修改-XX:NewRation=4,可以参照上面来理解。
可以通过运行程序时,使用JvisualVM工具来查看。
或者在cmd中通过命令行来查看:


(2)自使用内存分配策略
-XX:[+/-]UseAdaptiveSizePolicy:+表示开启自适应的内存分配策略,-表示关闭。
(3)Eden区与Survivor区的比例
在默认情况下,HotSpot中,Eden空间和Survivor区的比例是8:1:1。但是实际运行程序发现,却是6:1:1,即使关闭自适应的内存分配策略,也不管用。

此时需要显式的指定:-XX:SurvivorRation:设置新生代中Eden区与Survivor区的比例。

(4)-Xmn:设置新生代的空间大小(一般不设置,而是通过-Xms –Xmx和NewRatio来确定)
几乎所有的Java对象都是在Eden区被new出来的。
绝大部分的Java对象的销毁都在新生代进行了。

图解对象分配的一般过程
为新对象分配内存是一件非常严谨和复杂的事情。JVM的设计者们不仅要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存垃圾回收算法密切相关,所以还需要考虑GC执行完垃圾回收后是否会在内存中产生内存碎片。





总结:
① 针对Survivor区S0和S1:复制之后有交换(from和to的标识交换),谁空谁是to。
② 关于垃圾回收:频繁发生在新生代,很少在养老区,几乎不在永久区/元空间。
对象分配的特殊情况:

FGC:Full GC 或者 Major GC,老年代的垃圾回收
第一个特殊情况:在YGC过程中,Survivor区放不下,则直接晋升(Promption)到老年代
第二个特殊情况:如果YGC完了之后,Eden区还是放不下,在判断Old区是否能放下:
①能放下:则在老年代申请分配空间
②放不下:则进行FGC,如果完了之后,老年代能放下了,则分配空间,否则OOM。
代码举例与JvisualVM演示对象的分配过程
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
while (true) {
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
不断的新建HeapInstanceTest对象,里面保存不固定大小的buffer数组,最终OOM。

可以看到,Eden区的大小逐步增长,在到达峰值时,进行YGC,然后先是S1区装入了幸存的数据。然后第二次峰值YGC时,幸存的数据被装入S0中。
与每次YGC的同时,一部分数据也被移入到了老年代中,但是老年代中的数据由于进行FGC不能被回收掉,最终导致了OOM。
常用的调优工具

MinorGC(YGC)、MajorGC和FullGC的对比
JVM在进行GC时,并非每次都对三个空间(新生代、老年代、方法区)进行回收,大部分的时候回收的是新生代。
针对HotSpot的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)。
部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:
① 新生代收集(Minor GC/Young GC):只是新生代(Eden/S0/S1)的垃圾收集
② 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
a) 目前,只有CMS GC会有单独的收集老年代的行为
b) 注意,很多时候Major GC和Full GC会混淆使用,需要具体分辨是老年代回收还是整堆回收。
③ 混合收集(Mixed GC):收集整个新生代以及部分老年代。
a) 目前,只有g1 GC会有这种行为
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
Minor GC的触发机制:
当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的就是Eden区满,Survivor区满不会引起GC(每次进行YGC时,Survivor区会被动的和Eden区一起进行垃圾回收)。
因为Java对象大都具有朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也很快。
Minor GC会引发STW(stop-the-world),暂停其他用户的线程,等垃圾回收结束用户线程才恢复运行。
Major GC的触发机制
指发生在老年代的GC,对象从老年代消失时,就是Major GC或者Full GC发生了。

Major GC的速度一般比Minor GC的速度慢10倍以上,STW的时间更长。
Full GC的触发机制:
触发情况有以下五种:
① 调用System.gc()时,系统建议执行Full GC,但是不一定必然执行。
② 老年代空间不足
③ 方法区空间不足
④ 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
⑤ 由Eden区、from向to区复制时,对象大小大于to区的可用内存,则把该对象转到老年代,且老年代的可用内存小于该对象大小。
Full gc是开发或者调优中要尽量避免的,这样stw时间会短一些。
GC举例与日志分析

要加上参数:+PrintGCDetails
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
System.out.println("遍历次数为:" + i);
}
}
运行结果:

新生代区 2560k = 2048k + 512k + 512k
老年代区 7168k

前3次GC还没有涉及到老年代
第一次: 
因为新生代中的Eden区满了引起GC,1992k表示当时新生代的大小(使用),YGC后,新生代使用大小变为480k(数据存在survivor区中)。
表示堆空间在YGC前后的使用大小,YGC之前,还没有使用老年代,所以整个堆空间使用的大小就等于新生代使用的大小。回收后发现836k比480k大,说明有些数据放到了老年代中。
第二、三次同理。
第四次是Full GC,涉及到老年代的内存回收。
新生代大小回收后变为0k。
老年代大小回收后变为4854k。
整个堆空间的使用大小回收后变为4854k。原来的7905k=1285k + 6620k。
堆空间分代的思想
为什么需要把java堆分代?——>经过研究发现,不同对象的生命周期不同,70-90%的对象是临时对象。
其实不分代完全可以,分代的唯一理由就是优化GC性能。
如果没有分代,那么所有的对象都放在一起,GC的时候要找到哪些对象没用,需要对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一个地方,当GC的时候优先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
内存分配策略(对象提升(Promotion)规则)
如果对象在Eden区出生,并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每经历过一次Minor GC后,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代。
针对不同年龄段的对象分配规则如下所示:
① 优先分配到Eden
② 大对象直接分配到老年代(可以写代码来验证一下)
a) 尽量避免程序中出现过多的大对象(更可怕的是过多的大对象还是朝生夕死的。。。)
③ 长期存活的对象分配到老年代
④ 动态对象年龄判断:
a) 如果Survivor区中的相同年龄的所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
⑤ 空间分配担保:
a) –XX:HandlePromotionFailure
堆空间为每个线程分配的TLAB(Thread Local Allocation Buffer)
先问个问题:堆空间都是私有的吗?——》不是!
为什么有TLAB?
堆区是线程共享区域,任何线程都可以访问访问到堆区中的共享数据。
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
为避免多个线程操作同一地址,需要使用加锁等机制,进行影响分配速度。
什么是TLAB:
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有的缓存区域,它包含在Eden空间内。

多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题。
同时还能提升内存分配的吞吐量,因此可以将这种内存分配方式称为快速分配策略。
TLAB进一步说明

加入TLAB的分配图解

堆空间的参数设置


参数解释说明:

通过逃逸分析看堆空间的对象分配策略
堆是分配对象存储空间的唯一选择吗?
随着JIT编译期的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在java堆中分配的,这是普遍的常识。
但是,有一种特殊情况,如果经过逃逸分析(Escape Analysis)后发现,一个对象没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就无需在堆上分配内存,也无须进行垃圾回收了。
此外,基于OpenJDK定制的TaoBaoVM,其中创新的GCIH(GC Invisible heap)技术实现off-heap,将生命周期较长的java对象从heap中移至heap外,并且GC不能管理GCIH内部的java对象,以此达到降低gc的回收频率和提升GC的回收效率的目的。
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:
① 当一个对象在方法中被定义后,对象只在方法内部被使用,则认为没有发生逃逸,则使用栈上分配(虚拟机栈里的方法栈帧)
② 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
逃逸分析可以有效减少java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法(?尼玛)
参数设置:

结论:
开发中能使用局部变量(栈上分配)的,就不要使用在方法外定义。
代码优化之栈上分配
将堆分配转换为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
JIT在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了(栈上没有GC)。
常见的栈上分配场景:
① 成员变量赋值
② 方法返回值
③ 实例引用传递
代码优化之同步省略(消除)
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步,叫做同步省略,也叫锁消除。
代码优化之变量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到。那么对象的部分(或者全部)可以不存储在内存,而是存储在CPU寄存器中。
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些可以被分解的数据叫做聚合量(Aggregate),java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。
在JIT编译阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成其中包含的若干个成员变量来代替,这个过程就是标量替换。
标量替换参数设置:
-XX:+EliminateAllocations:开启标量替换(默认打开),允许将对象打散分配在栈上。
例如:


逃逸分析小结
逃逸分析技术并不成熟

明确一点:目前Oracle Hotspot JVM没有将那些不逃逸的对象分配在栈上,所以认为所有的对象实例都是创建在堆上。
堆空间总结





浙公网安备 33010602011771号