通过上一篇文章,我们大体了解了JVM的整体架构,其分为:元数据(JDK7是方法区)、堆、虚拟机栈、本地方法栈、程序计数器几个部分。
本篇文章,咱们对堆进行剖析,一探究竟。
1. 什么是Java 堆
对于Java应用程序来说, Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例, Java世界里“几乎”所有的对象实例都在这里分配内存。
“几乎”是指从实现角度来看, 随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持, 即使只考虑现在, 由于即时编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生, 所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
2. 堆的特点
- 是Java虚拟机所管理的内存中最大的一块。
- 堆是jvm所有线程共享的。
堆中也包含私有的线程缓冲区 Thread LocalAllocation Buffer (TLAB)。 - 在虚拟机启动的时候创建。
- 唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
- Java堆是垃圾收集器管理的主要区域。
- 因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden 空间、From Survivor空间、To Survivor空间。
- Java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
- 方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候时候才移除。
- 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
3. 如何设置堆空间的大小
内存大小 -Xmx(最大)/-Xms(最小)
使用示例: -Xmx20m -Xms5m
说明: 当下Java应用最大可用内存为20M, 最小内存为5M
测试代码:
public class TestVm {
public static void main(String[] args) {
// 堆最大空间(如有设置,则和设置的最大值相同)
System.out.print("Xmx=");
System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
// 空闲空间
System.out.print("free mem=");
System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
// 总共使用空间
System.out.print("total mem=");
System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
}
}
在IDEA中设置堆空间:
执行结果:
可以看到,最大20M, 空闲4M多,当前使用了 6M
现在再补充两行代码,给数组分配5M,再看一下结果如何:
public class TestVm {
public static void main(String[] args) {
// 补充, 分配5M给数组
byte[] b=new byte[5*1024*1024];
System.out.println("分配了5M空间给数组");
// 堆最大空间(如有设置,则和设置的最大值相同)
System.out.print("Xmx=");
System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
// 空闲空间
System.out.print("free mem=");
System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
// 总共申请空间
System.out.print("total mem=");
System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
}
}
执行结果:
可以看到,最大还是20M, 空闲4M多,当前使用变成了12M。
那么再将分配数组的5M改成10M呢?
// 补充, 给数组分配10M
byte[] b=new byte[10*1024*1024];
System.out.println("分配了10M空间给数组");
可以看到,最大还是20M, 空闲4M多,当前使用变成了17M。
那么,如果再给数组分配加大到20M呢?
// 补充, 给数组分配20M
byte[] b=new byte[20*1024*1024];
System.out.println("分配了20M空间给数组");
可以看到,因为堆的总大小设置的是20M, 如果再给数组分配20M, 此处自然就会报内存溢出异常。
讲了那么多,有必要解释下代码中的三个核心方法,
1.maxMemory()
- 这个方法返回的是java虚拟机(这个进程)能够从操作系统那里挖到的最大的内存,以字节为单位。
- 如果添加了-Xmx参数,将以这个参数后面的值为准,这里设置了20M,所以挖出来是20M。如果不设置,则是默认值,我电脑是4050M。
2.totalMemory()
- 这个方法返回的是java虚拟机现在已经从操作系统那里挖过来的内存大小,也就是java虚拟机这个进程当时所占用的所有内存。
- 如果在运行java的时候没有添加-Xms参数,那么,在java程序运行的过程的,内存总是慢慢的从操作系统那里挖的,基本上是用多少挖多少,直到挖到maxMemory()为止,所以totalMemory()是慢慢增大的。
- 如果用了-Xms参数,程序在启动的时候就会无条件的从操作系统中挖-Xms后面定义的内存数,然后在这些内存用的差不多的时候,再去挖。
3.freeMemory()
刚才讲到如果在运行java的时候没有添加-Xms参数,那么,在java程序运行的过程的,内存总是慢慢的从操作系统那里挖的,基本上是用多少挖多少,但是java虚拟机100%的情况下是会稍微多挖一点的,这些挖过来而又没有用上的内存,实际上就是 freeMemory(),所以freeMemory()的值一般情况下都是很小的,但是如果你在运行java程序的时候使用了-Xms,这个时候因为程序在启动的时候就会无条件的从操作系统中挖-Xms后面定义的内存数,这个时候,挖过来的内存可能大部分没用上,所以这个时候freeMemory()可能会有些大 。
默认值:
含义:相当于未手动设置-Xmx和-Xms的情况下,操作系统共有4050M可以给java虚拟机申请。虚拟机挖了254M内存出来,挖出来的内存中,真正使用了4M左右,还剩余250M未使用。
以上,可以发现,其实JVM在分配内存过程中是动态的, 按需来分配的。
4. 堆的分类
现在垃圾回收器都使用分代理论,堆空间也分类如下:
在Java7 Hotspot虚拟机中将Java堆内存分为3个部分:
青年代Young Generation:Eden(伊甸元区)、S0(幸存者0区,存放伊甸元区无法销毁的实例)、S1(幸存者1区,存放伊甸元区无法销毁的实例)
老年代Old Generation:S1区经过很多轮GC后,超过一定阈值,实例就会进入老年代,说明这个对象的生命周期比较长。
永久代Permanent Generation: JDK7才有,JDK8换成了元空间。
在Java8以后,由于方法区的内存不再分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了,在2018年9月25日正式发布Java11以后,在关于Java11中垃圾收集器的官方文档中没有提到“永久代”,而只有青年代和老年代。
通过日志查看堆空间:
public class TestVm {
public static void main(String[] args) {
// 堆最大空间(如有设置,则和设置的最大值相同)
System.out.print("Xmx=");
System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
// 空闲空间
System.out.print("free mem=");
System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
// 总共使用空间
System.out.print("total mem=");
System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
}
}
在如上代码中,设置GC日志打印:-XX:+PrintGCDetails
选择JRE11运行
可以看到,有元空间大小的显示,每个JDK版本打印可能不相同,下面是JDK8的控制台显示
同理,将JRE换成1.7,就没有元空间了
5. 年轻代和老年代
1.JVM中存储java对象可以被分为两类
1)年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(from 和to)。
2)年老代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。
2.配置新生代和老年代堆结构占比
- 默认 -XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3
- 修改占比-XX:NewPatio=4, 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5
- Eden空间和另外两个Survivor空间占比分别为8:1:1
- 可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例。 比如 -XX:SurvivorRatio=8 代表Eden区占比80%。
- 几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁.
从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。
其中,新生代 ( Young ) 被细分为 Eden 和 两个Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。
默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to= 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域 是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
案例:
/**
* 1.设置年轻代和老年代的比例 1 : 4
* 2.设置 伊甸园 from to 8: 1: 1
*/
public class YoungOldTest {
public static void main(String[] args) throws InterruptedException {
System.out.println("hello");
Thread.sleep(3000000);
}
}
设置以上程序的堆空间比例分配:年轻代和老年代分配比例为1:4,年轻代Eden区和survivor区占比8:1:1
采用JDK1.8的工具 jvisualvm.exe查看JVM堆的大小(直接点开即可)
安装插件Visual GC
运行程序后,点开这里的对应程序,查看Visual GC窗口中堆的占用情况:
6. 对象分配过程
分配过程
1.new的对象先放在伊甸园区,该区域有大小限制 ;
2.当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园预期进行垃圾回收(Minor GC),将伊甸园区域中不再被其他对象引用的额对象进行销毁,再加载新的对象放到伊甸园区;
3.然后将伊甸园区中的剩余对象移动到幸存者0区;
tips:
Minor GC:YGC(Young GC),即年轻代的垃圾回收。
4.如果再次触发垃圾回收(Minor GC),Eden区的对象直接放到空的幸存者1区。然后上次幸存下来放在幸存者0区的对象,如果还没有回收,也会放到幸存者1区,并且计数加1 。此时幸存者0区就空着了。
tips:
- 始终保持一个幸存者区是空着的,空着的幸存者区叫To Survivor空间,未空的幸存者区叫From Survivor空间。
- 每次GC后幸存的对象会在幸存者0区和1区中来回存放,每存放一次计算加1,直到达到阈值15,才会进入养老区(即老年代)。
5.如果再次触发垃圾回收(Minor GC),Eden区的对象直接放到空的幸存者0区。然后上次幸存下来放在幸存者1区的对象,如果还没有回收,也会放到幸存者0区,并且计数加1 。此时幸存者1区就空着了。
6.如果累计次数到达默认的15次,这会进入养老区。
可以通过设置参数,调整阈值 -XX:MaxTenuringThreshold=N
7.养老区内存不足时,会再次出发GC:Major GC 进行养老区的内存清理
8.如果养老区执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常。
tips:
Major GC:即老年代的垃圾回收。
图解分配对象的流程:
描述:
申请对象时,先判断Eden区域是否装得下,装不下则进行YGC(YGC后保留的对象会在S0和S1区切换存放,直至达到阈值放入老年代),进行YGC后,还装不下,则判断老年代是否装的下,老年代装不下,再进行FGC(FUll GC),进行FGC后,还装不下,则报OOM异常。
7.堆GC
Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:一种是部分收集器(Partial GC)另一类是整堆收集器(Full GC)
部分收集器: 不是完整收集java堆的的收集器,它又分为:
- 新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集
- 老年代收集 (Major GC / Old GC): 只是老年代的垃圾收集 (CMS GC 单独回收老年代)
- 混合收集(Mixed GC): 收集整个新生代及老年代的垃圾收集 (G1 GC会混合回收, region区域回收)
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器
年轻代GC触发条件:
- 年轻代空间不足,就会触发Minor GC, 这里年轻代指的是Eden代满,Survivor满不会引发GC
- Minor GC会引发STW(stop the world) ,暂停其他用户的线程,等垃圾回收接收,用户的线程才恢复.
老年代GC (Major GC)触发机制
- 老年代空间不足时,会尝试触发MinorGC(至少触发一次),如果空间还是不足,则触发Major GC;
- 如果Major GC , 内存仍然不足,则报错OOM ;
- Major GC的速度比Minor GC慢10倍以上.
FullGC 触发机制:
- 调用System.gc() , 系统会执行Full GC ,不是立即执行.
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC进入老年代平均大小大于老年代可用内存
注意:Full GC也会引发stop the world,时间非常长。调优过程中,尽量避免FullGC的执行