在Java程序运行时,系统运行过程中产生的大部分实例对象以及数组对象都会被放到堆中存储。
默认情况下,如果不通过参数强制指定堆空间大小,那么JVM会根据当前所在的平台进行自适应调整,起始大小默认为当前物理机器内存的1/64,最大大小默认为当前物理机器内存的1/4。
创建Java堆时,本质上并不是直接在内存中划分了一块完整的空间给JVM,因为在《Java虚拟机规范》中提及到:堆空间在物理上可以是不连续的,只需要逻辑上视为连续即可。所以一个JVM的堆空间在实际的机器内存上,可能是由机器内存中多个不同位置的空间组成的,如下图:
VM的堆空间结构会根据运行时具体采用的GC收集器来决定。在所有的GC收集器中,大体会将堆空间分为分代、分区两大类:
JDK 堆不同版本的区别
Java堆同时也是变化比较频繁的区域,在不同Java版本中,堆空间也发生了不同的改变:
● JDK7及之前:堆空间包含新生代、年老代以及永久代。
● JDK8:堆空间包含新生代和年老代,永久代被改为元数据空间,位于堆之外。
● JDK9:堆空间从逻辑上保留了分代的概念,但物理上本身不分代。
● JDK11:堆空间从此以后逻辑和物理上都不分代。
本质上来说,影响堆空间结构的并不是Java版本的不同,Java堆结构是跟JVM运行时所使用的垃圾回收器息息相关的,由GC器决定了运行时的堆空间会被划分为何种结构。
在JDK1.8及之前的Java版本中,几乎所有的GC器都会把堆空间划分为至少两个区域:新生代和年老代,但在JDK1.9到之后的GC器中,大多数的GC器开始了不分代的路子(具体原因稍后分析)。
分代堆空间
分代的含义是指在JVM运行过程中,堆空间是否会被分为不同的区域分别用于存储不同生命周期的对象实例,JDK1.8之前的堆结构是完全分代的,也就是指逻辑+物理上都分代,在运行时物理内存会被划为几块不同的区域,也就是一个Eden区、两个Survivor 区(Form/To区)以及一个Old区,从物理内存上来说各个区域都是完整且连续的内存,每块区域都用于存储不同周期的对象实例,相互之间并不干扰。
不分代堆空间
到了JDK1.9时,G1正式出道,成为了JVM内嵌的默认GC器,Java堆空间从此出现了不分代的概念,但不分代也分为两种情况,一种是逻辑分代,物理不分代,另一种则是逻辑+物理都不分代。
逻辑分代,物理不分代(G1):对象分配的逻辑上还是存在分代的思想,但是物理内存上不会再分为几块完整的分代空间。
逻辑+物理都不分代(ZGC、ShenandoahGC):无论从对象分配的逻辑上还是物理内存上,都不存在分代的概念。
JDK7及之前的堆内存空间划分
在JDK1.7及之前的JVM中,所有的GC器都是物理+逻辑都分代的,包括内嵌的默认GC器Parallel Scavenge(新生代)+ Parallel Old(老年代)也分代,所以一般堆空间会被划分为三个区域:新生代、年老代以及永久代:
● 新生代:一个Eden区、两个Survivor区(Form/To区),比例:8:1:1
● 年老代:一个Old区
● 永久代:方法区
JDK8堆内存空间划分
到了JDK1.8的时候,JVM将永久代,也就是方法区整合成了元数据空间,并且将其移出了堆,将其放在堆空间外的本地内存中。
JDK1.8的时候没啥好讲的,和1.7差距不大,最大区别在于移除了方法区,在本地内存中加入了元数据空间来存储之前方法区中的大部分数据(原方法区中的数据并不是所有都被迁移到了元空间存储,有些数据被分散到了JVM各个区域)。除此之外,常量池在1.8的时候也被移到了堆外。
JDK9堆内存空间划分
到了JDK1.9时,堆空间慢慢的开始了划时代的改变,在此之前,堆空间的布局都是采用分代存储的方式,无论从逻辑上还是从物理内存上,都是分代的。但是到了jdk9堆分代根据收集器来使用物理和逻辑分代收集器或逻辑分代收集器
- 逻辑分代 (Logical Generations):
○ 在逻辑分代中,堆内存被逻辑上划分为不同的区域(如新生代和老年代),但这些区域的实际物理存储并不一定是连续的或固定的。
○ 这种分代方式使得垃圾收集器可以根据实际需求动态地调整各个区域的大小和分布。 - 物理分代 (Physical Generations):
○ 在物理分代中,堆内存被明确地划分为不同的物理区域(如新生代和老年代),这些区域的边界是固定的。
○ 这种分代方式使得垃圾收集器更容易管理各个区域,但也限制了动态调整的能力。
在 JDK 9 及之后的版本中,尽管堆内存仍然分为新生代和老年代,但垃圾收集器的实现细节和策略有所不同。以下是一些主要的变化:
1. G1 垃圾收集器
G1 垃圾收集器在 JDK 9 及之后的版本中得到了进一步的改进和完善,也是默认收集器。逻辑分代,G1 的主要特点包括:
● 分区式垃圾收集:G1 将堆内存划分为多个固定大小的 Region,每个 Region 的大小通常是 1MB 到 32MB。
● 并发标记:G1 可以在应用程序运行的同时进行标记操作,减少了应用程序的暂停时间。
● 可控的暂停时间:G1 收集器旨在预测性地控制垃圾收集的暂停时间,使其适合大规模应用。
在 G1 中,新生代和老年代的概念仍然存在,但它们是由多个 Region 动态组成的。例如,一部分 Region 可以被分配给新生代,另一部分 Region 可以被分配给老年代。
JDK1.9时,G1将Java堆划分为多个大小相等的独立的Region区域,不过在HotSpot的源码TARGET_REGION_NUMBER定义了Region区的数量限制为2048个(实际上允许超过这个值,但是超过这个数量后,堆空间会变的难以管理)。
一般Region区的大小等于堆空间的总大小除以2048,比如目前的堆空间总大小为8GB,就是8192MB/2048=4MB,那么最终每个Region区的大小为4MB
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是可以不连续物理内存来组成的Region的集合。
G1中的年老代晋升条件和之前的无差,达到年龄阈值的对象会被转入年老代的Region区中,不同的是对于大对象的分配,在G1中不会让大对象进入年老代,在G1中由专门存放大对象的Region区叫做Humongous区,如果在分配对象时,判定出一个对象属于大对象,那么则会直接将其放入Humongous区存储。
Humongous区存在的意义:可以避免一些“短命”的巨型对象直接进入年老代,节约年老代的内存空间,可以有效避免年老代因空间不足时的GC开销。
当堆空间发生全局GC(FullGC)时,除开回收新生代和年老代之外,也会对Humongous区进行回收。
2. 其他垃圾收集器
其他垃圾收集器,如 Serial Collector、Parallel Collector 和 CMS Collector,在 JDK 9 及之后的版本中仍然保留了新生代和老年代的概念。
在 JDK 9 及之后的版本中,JVM 的堆内存仍然保留了分代的概念,分为新生代和老年代。尽管在一些细节上有所变化,但这种分代的概念有助于垃圾收集器更高效地管理内存。
● G1 垃圾收集器:通过分区式垃圾收集、并发标记和可控的暂停时间,使得 G1 成为现代 Java 应用程序中常用的垃圾收集器之一。
● 其他垃圾收集器:仍然保留了新生代和老年代的概念,并在不同场景下具有各自的优势
JDK11的垃圾收集器
默认垃圾回收器G1
jdk11引入的ZGC
在JDK11的时候,Java又推出了一款新的垃圾回收器ZGC,它也是一款基于Region区内存布局的GC器,这款GC器是真正意义上的不分代,无论是从逻辑上还是物理上都不分代。
在ZGC中,也会把堆空间划分为一个个的Region区域,但ZGC中的Region区不存在分代的概念,它仅仅只是简单的将所有Region区分为了大、中、小三个等级:
● 小型Region区(Small):固定大小为2MB,用于分配小于256KB的对象。
● 中型Region区(Medium):固定大小为32MB,用于分配>=256KB ~ <=4MB的对象。
● 大型Region区(Large):没有固定大小,容量可以动态变化,但是大小必须为2MB的整数倍,专门用于存放>4MB的巨型对象。但值得一提的是:每个Large区只能存放一个大对象,也就代表着你的这个大对象多大,那么这个Large区就为多大,所以一般情况下,Large区的容量要小于Medium区,并且需要注意:Large区的空间是不会被重新分配的
实际上,JDK11中的ZGC并不是因为要抛弃分代理念而不设计分代的堆空间的,因为实际上最开始分代理念被提出的本质原因是源于「大部分对象朝生夕死」这个概念的,而实际上大部分Java程序在运行时都符合这个现象,所以逻辑分代+物理不分代是堆空间最好的结构方案。
堆总结
Java堆空间是JVM运行时内存区域中占比最大的一块,此内存区域唯一的目的就是存储运行时创建出的对象实例。同时,随着运行时采用的GC器不同,Java堆也会被分为不同的结构,其中主要可分为分代和不分代的两类结构。相对来说,分代结构是最适合Java对象“朝生夕死”的特性的,如果堆结构是分代的,可以使得JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。
有兴趣的同学可以加群