深入解析Java堆:JVM内存管理中最大且关键的部分
[id_659222215]id_2052998572]

上一篇文章让我们对 JVM 的整体架构有了大致的了解,JVM 分为元数据(在 JDK7 中是方法区)、堆、虚拟机栈、本地方法栈、程序计数器这几个部分。
本篇文章,咱们对堆进行剖析,一探究竟。
1. 什么是Java 堆
Java 应用程序中,虚拟机所管理的内存里最大的一块是 Java 堆。Java 堆是被所有线程共同分享的一块内存区域,并且是在虚拟机启动的时候创建的。
此内存区域的目的是存放对象实例。在 Java 世界里,几乎所有的对象实例都在这个内存区域分配内存。
从实现角度而言,随着 Java 语言的发展,如今已能看到一些迹象显示日后或许会出现对值类型的支持。即便仅考虑当下,由于即时编译技术的进步,尤其是逃逸分析技术日益强大,栈上分配以及标量替换优化手段已致使一些微妙的变化在悄然发生。因此,说 Java 对象实例都分配在堆上,这种说法渐渐不再那么绝对了。
堆的特点在于,它是 Java 虚拟机所管理的内存当中最大的一块区域。并且,堆是 jvm 中所有线程都可以共享的。
Java 堆是垃圾收集器管理的主要区域。很多时候,Java 堆被称作“GC 堆”(Garbage Collected Heap)。从内存回收角度来看,因为现在的收集器大多采用分代收集算法,所以 Java 堆能细分为新生代和老年代。其中,新生代又可分为 Eden 空间、From Survivor 空间、To Survivor 空间。Java 堆在计算机物理存储上是不连续的,但在逻辑上是连续的。它的大小是可调节的,可通过 -Xms 和 -Xmx 来控制。方法结束后,堆中的对象不会立即被移出,只有在垃圾回收时才会被移除。如果在堆中没有足够的内存来完成实例的分配,并且堆也无法再扩展,那么就会抛出 OutOfMemoryError 异常。如何设置堆空间的大小呢?
内存大小 -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()
2.totalMemory()
默认值:
含义:在未手动设置 -Xmx 和 -Xms 的情况下,操作系统能够给 Java 虚拟机申请的内存总量为 4050M。虚拟机从中挖出了 254M 内存,在挖出的这部分内存里,实际使用了约 4M,还剩下 250M 未被使用。
可以发现,JVM 在分配内存过程中是动态的,是按需进行分配的。
4. 堆的分类
现在垃圾回收器都使用分代理论,堆空间也分类如下:
Java7 Hotspot 虚拟机把 Java 堆内存划分成 3 个部分。
青年代 Young Generation 包含 Eden(伊甸元区)、S0(幸存者 0 区,用于存放伊甸元区无法销毁的实例)、S1(幸存者 1 区,用于存放伊甸元区无法销毁的实例)
老年代: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 个 Eden Space 和 2 个 Survivor Space,分别是 from 和 to。
- 垃圾回收的频率相对也没有那么高。
2.配置新生代和老年代堆结构占比
新生代 ( Young ) 默认与老年代 ( Old ) 的比例值为 1:2,此值可通过参数 –XX:NewRatio 来指定。也就是说,新生代 ( Young ) 等于堆空间大小的 1/3,老年代 ( Old ) 等于堆空间大小的 2/3。
其中,新生代被细分为 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的对象先放在伊甸园区,该区域有大小限制 ;
当伊甸园区域被填满时,程序需要创建对象。此时,JVM 的垃圾回收器会对伊甸园区域进行垃圾回收(Minor GC)。它会将伊甸园区域中不再被其他对象引用的对象进行销毁,然后把新的对象加载到伊甸园区。
3.然后将伊甸园区中的剩余对象移动到幸存者0区;
tips:
Minor GC 指的是 YGC,也就是年轻代的垃圾回收。
如果再次触发垃圾回收(Minor GC),那么 Eden 区的对象会直接被放置到空的幸存者 1 区。接着,上次幸存下来并放在幸存者 0 区的对象,如果还未被回收,也会被放到幸存者 1 区,同时计数加 1 。在这种情况下,幸存者 0 区就变为空的了。
tips:
如果再次触发垃圾回收(Minor GC),那么 Eden 区的对象会直接被放置到空的幸存者 0 区。接着,上次幸存下来并放在幸存者 1 区的对象,如果尚未被回收,也会被放到幸存者 0 区,同时计数加 1 。在这种情况下,幸存者 1 区就会处于空的状态。
6.如果累计次数到达默认的15次,这会进入养老区。
能够通过设定参数的方式,对阈值 -XX:MaxTenuringThreshold=N 进行调整。
当养老区内存不足时,会再次启动 GC:Major GC 来进行养老区的内存清理。
如果养老区执行了 Major GC ,但仍然无法进行对象的保存,那么就会报 OOM 异常。
tips:
Major GC:即老年代的垃圾回收。
图解分配对象的流程:
描述:
申请对象时,首先判断 Eden 区域能否装下。若装不下,就进行 YGC。YGC 后,保留的对象会在 S0 和 S1 区切换存放,直到达到阈值后放入老年代。进行 YGC 后,如果仍然装不下,就判断老年代能否装下。若老年代也装不下,就进行 FGC。FGC 后,如果还是装不下,就报 OOM 异常。
7.堆GC
Java 中的堆是 GC 收集垃圾的主要区域。GC 包含两种类型,一种是部分收集器,另一种是整堆收集器。部分收集器即 Partial GC,整堆收集器即 Full GC。
部分收集器: 不是完整收集java堆的的收集器,它又分为:
年轻代GC触发条件:
老年代GC (Major GC)触发机制
FullGC 触发机制:
Full GC 会引发 stop the world,并且这个时间很长。在调优过程中,要尽量避免 FullGC 被执行。