前言
在前面的文章里,我们已经讲了程序计数器、Java虚拟机栈、本地方法栈、方法区。这篇文章我们将对剩下的一个数据区域也就是Java堆(Java Heap)做一个深入了解。堆是Java程序中最主要的内存工作区域,也是虚拟机所管理的内存中最大的一块。
什么是Java堆
我们先来看下《Java虚拟机规范》里面对堆的描述:《Java虚拟机规范》2.5.3 堆
定义如下:
- 堆区是被所有线程共享的
- 所有的对象实例以及数组都应当在堆上分配
- 堆在虚拟机启动时创建
- 堆里面的对象由垃圾回收器自动管理回收
- 堆的内存不需要是连续的 (可以在物理上不连续,但在逻辑上他应该被视为连续的)
- 如果对象放不下了则会抛出OOM异常
都说栈管运行,堆管存储,那么看到上面的定义里面也能明白堆管存储。在java里面,万物皆对象,而对象都是被存储在堆上的,因此才会有堆管存储这么一个说法。
虽然上面提到所有的对象实例以及数组都应当在堆上分配。不过随着Java语言的发展,即时编译技术的发展,尤其是逃逸技术的日渐强大,栈上分配、标量替换已经导致了一些微妙的变化悄然发生,因此Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
堆区分代
堆区是虚拟机管理的最大的一块,也是垃圾回收最频繁的一块区域。我们程序几乎所有的兑现都放在堆内存中。而为了更好的进行垃圾回收。Java虚拟机根据对象存活的周期不同,把堆内存划分为两大块,一般为新生代、老年代。
注意:这边这些区域划分仅仅是一部分垃圾收集器的共同特性或者说涉及风格而已。而非某个Java虚拟机具体实现的固有内存布局。本身《Java虚拟机规范》没有对垃圾收集器的实现要求或限制,不同的虚拟机可以自行选择不同的实现。而业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代”来设计的,需要新生代、老年代搭配收集器才能工作。或许在不久后,就会有更牛逼的垃圾收集器方案出现来取代目前的分代设计。
为什么要分代呢?
简单来说:分代的目的是为了提高对象内存分配和垃圾回收的效率。
我们知道堆内存中几乎存放了所有的对象,这也导致堆区是垃圾回收最频繁的一块区域。如果所有的对象都放在一起,那么每次GC时都要遍历所有的对象,并进行垃圾清理。对于传统的垃圾收集器,其在工作的时候会STW(stop-the-world),也就是会暂停用户线程。GC时间长,那么STW的时间也长。
因此缩短GC一次的工作时间长度就很重要了,如果说遍历整个堆空间耗时长,那是否可以只遍历其中一部分呢?于是就有了堆区划分的概念。基于这个概念,提出了分代收集理论。这也就是分代的由来。
而大部分的对象生命期很短,于是划分出一个新生代来存放生命期短的对象,垃圾收集器优先收集这个区域。而对于长时间存活的对象则放到另一个区域,也就是老年代。
也正是Java堆被分区后,垃圾收集器才能根据不同的区域进行不同的垃圾收集算法,以此来更进一步提高垃圾收集的效率。
分代收集理论
- 弱分代假说(Weak Generational Hypothesis):绝大多数都是朝生夕灭的
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数
新生代和老年代
从上面我们知道了基于分代收集理论划分出了新生代和老年代,下面我们分别来说说这两者。
新生代
新生成的对象优先放在新生代中,新生代里面的对象存活时间都很短,也就是所谓的朝生夕死。在新生代中,常规应用进行一次垃圾收集一般可以回收75% ~ 90%的对象。基于这个特点,并且为了更进一步提高垃圾收集的效率,于是在这块区域选择效率较高的复制算法进行回收。
复制算法在应对大量可以被回收对象的情况下效率比较高,这个我们后面的文章再来细说。
而按照复制算法的要求,需要将内存区域划分为两块大小相等的区域。每次只使用其中一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把一使用过的内存空间一次清理掉。
但是由于新生代每次回收后只剩余少量对象,因此,就将区域划分内存区域大的Eden区和内存区域小的Survivor区。新生成的对象放到Eden区,执行垃圾回收后,就将存活的对象复制到Survivor区。但是如果仅是这样的话就会出现另一个问题:
下次GC的时候,没有区域来存储存活的对象了,因为我们的Survivor区里存放着上次存活的对象。Eden区域存放着刚创建的对象。
于是针对这个问题,又将Survivor区分成两块,也就是Survivor From区和Survivor To区(这边也可以称为1区和0区)。
当新生代进行GC的时候,会将Eden区和Survivor From区存活的对象复制到Survivor To区,然后清除Eden区和From区里面的对象,接着将To区和From区交换,以保证每次To区都是空的,这边可以这么来记:谁空谁是To。
新生代的GC又被称为Minor GC或Young GC。
老年代
老年代里面一般放着生命期较长的对象,也就是经过了好几次的GC仍然存活的对象。在HotSpot上,默认是15次,这个次数是可以配置的。
由于老年代中的对象生命期较长,存活率比较高,老年代中相比于年轻代进行GC的频率比较低,而且回收的速度也比较慢。
老年代采用的垃圾回收算法与年轻代不同,针对对象存活率较高的情况,选择了标记-压缩算法。
老年代的GC又被称为Major GC或Old GC。
分代收集GC名称说明
上面我们有提到了不同区域的GC叫法。不过这边的Major GC会有歧义,因为网上不同的资料对这个的描述不一样。为了避免歧义,这边使用《深入理解Java虚拟机》里面的叫法。
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
区域大小
默认情况下的比例:
- 新生代 : 老年代 = 1 : 2
- eden区 :survivor from区 :survivor to区 = 8 :1 :1
补充说明
前面在讲GC时,我们只针对普通情况进行说明,也就是对象在新生代各个区和老年代之间的移动的普遍情况,而其实为对象分配内存是一件非常严谨和复杂的过程,下面简单做下补充:
- 对象优先分配到Eden区
- 大对象直接分配到老年区
- 长期存活对象分配到老年区 (默认为经过了15次GC)
- 动态年龄原则:如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到15次。
- 如果在Mimor GC后存活的对象servivor区放不下,这种情况会把存活的部分对象移到老年代,部分可能还会在survivor区。
下面放一张宋红康老师视频里的一张截图,方便大家理解
什么是TLAB
从分配内存的角度看,所有线程共享的Java堆还可以划分出多个线程的分配缓冲区(Thread Local Allocaion Buffer),简称TLAB。每个线程会从Eden区分配出一大块空间,例如说100KB,作为自己的TLAB。也就是说TLAB 是从堆上 Eden 区的分配的一块线程本地私有内存
我们一般认为Java中new的对象都是在堆上分配的,但是如果在细下去的话,从分配内存上来看的话,应该是大部分的对象是在堆上的TLAB上分配。
线程初始化的时候,如果 JVM 启用了 TLAB(默认是启用的, 可以通过 -XX:-UseTLAB
关闭),则会创建并初始化 TLAB
到触发GC的时候,无论是Minor GC还是Full GC,要收集Eden区的时候,里面的空间无论是属于某个线程的TLAB还是不属于任何TLAB都一视同仁,把Eden当作一个整体来收集里面的对象。在GC结束之后,每个java线程又会重新从Eden分配自己的TLAB。
堆区的变化
- jdk7以前,堆区里面还会划分出一块区域永久代,来作为方法区的落地实现。
- jdk7的时候,永久代还在,不过永久代里面的静态变量和字符串常量池被直接放到堆区里面
- jdk8开始,永久代被移除。
结束语
关于运行数据区的Java堆就介绍到这里,感谢大家的阅读。文中如果有写的不好的地方,请在评论区指出,再次感谢。