如何区分一个C++程序员和Java程序员?答案是看他吃饭完收不收拾碗筷,反正我是不收拾,哈哈哈哈哈哈。

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。

一.内存回收

垃圾收集,Garbage Collection,GC,它1960年诞生于MIT的Lisp语言,历史比Java本身还要久远。

1. 哪些内存需要自动化回收?

再再再再次召唤Java程序员DNA中的那张图
JVM自动化的内存分配与内存回收_生命周期
线程独享的区域不用操心
JVM中的程序计数器、虚拟机栈、本地方法栈三个区域都是线程独享的,它们的生命周期被限制在线程的生命周期中,而且每一个栈帧所需的内存大小在编译期就可以确定(在你类结构确定的时候就可能已经确定了),所以它们内存的分配和回收都具有确定性,不需要过多的考虑内存回收问题。

线程共享的部分需要干预
但是Java堆和方法区不一样,我们只有在程序的运行期间才能准确的知道程序会创建哪些对象,程序会创建多少对象,这部分内存分配和回收是动态的,GC关注的就是这部分内存的回收问题。

2. 什么时候进行回收?

一个对象什么时候应该被我们回收掉,很多人都会想到,那还用说,当然是它不再有用的时候,所以怎么判断一个对象是否还会被用到?下面介绍两个方法,引用计数法可达性分析法

2.1.引用计数法

给对象添加一个引用计数器,每当有一个地方引用它,计数器就+1,引用失效时就-1,任何时刻,计数器为0的对象就是不可能再被利用的。
感觉这个方法很好啊,操作很简单嘛,但是会不会有问题呢?
有,它难以解决对象之间相互循环引用问题
JVM自动化的内存分配与内存回收_老年代_02
这三个对象​​​ABC​​​除了彼此的引用外,再没有其他的引用,实际上这三个对象已经不可能被其他对象访问了,也就是说它们不可能再被外部使用了,但是​​ABC​​的引用计数器都不为0,所以使用该算法无法保证GC可以顺利回收它们,导致内存泄漏。

2.2.可达性分析法

更可靠的办法时采用可达性分析法,这个算法的基本思路就是通过一系列成为​​GC Roots​​​的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称之为引用链(Reference Chain),当一个对象到​​GC Roots​​​没有任何引用链相连接,在图论中,也就是说所有​​GC Roots​​​到这个对象不可达,则表示此对象是不再有用的。
JVM自动化的内存分配与内存回收_生命周期_03

什么样的而对象才是​​GC Roots​​​对象?
A garbage collection root is an object that is accessible from outside the heap
在堆外可以访问的对象可以是GC Roots对象
​​​GC Roots​​对象一般包含以下几种

  • 虚拟机栈中引用的对象,也就是在局部变量表中引用的对象
  • 方法区中类静态属性引用的对象(static field)
  • 方法区中常量引用的对象 (static final field)
  • 本地方法栈JNI引用的对象

即使是在可达性分析法中判定为不可达的对象,也不一定会被马上清理,要真正宣告一个对象死亡,至少需要经历两次标记过程

  1. 如果一个对象对于​​GC Roots​​​不可达,那么它将被以第一次标记并进行筛选,筛选的条件是此对象是否有必要执行​​finalize()​​​方法,当对象没有覆盖​​finalize()​​​方法或者它的​​finalize()​​方法已经被虚拟机调用过,那么这两种情况都视为没有必要执行。
  2. 当该对象被判定为需要执行​​finalize()​​​方法,那么该对象将被置于一个​​F-Queue​​队列之中,稍后就将被一个由虚拟机自动创建的、低优先级的Finalizer线程去终结它。However! 如果对象再​​finalize()​​​方法中与引用链上的任意一个对象建立了关联,那么在第二次标记过程中,它将被移出“即将回收”的集合。但如果两次都被标记了,那真就的从危->死。
    JVM自动化的内存分配与内存回收_jvm_04

枚举GC Roots:
上面说过,​​​GC Roots​​​主要是在全局性的引用和执行上下文中,但是问题是现在有的应用仅仅方法区就有几百兆,GC时要是逐个扫描里面的引用,系统必须停止所有Java线程的STW(Stop the world)时间就过长,需要找一个快捷的方式获取​​GC Roots​​的位置。

HotSpot中,一组成为OopMap的数据结构用来实现这个目的,JRockit里叫做livemap,J9里叫做GC map。

在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的,这些数据是在类加载过程中计算得到的。

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。

安全点Safe point:
但是可能导致引用关系变化的指令太多了,如果为了保险JIT在每一条指令后都生成一个OopMap,那将花费大量的空间,而且很耗时。于是根据​​​safepoint​​​安全点把一个方法的代码分成几段,程序不是在所有地方都可以停下来进行GC,只有到达安全的才能暂停,这样每一段代码只对应一个OopMap。
这些特定的SafePoints位置主要在:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“继续跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全域Safe region:
使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域SafeRegion来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管已经标识为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

3. 如何回收?

确定了哪些垃圾需要被回收后,垃圾收集器就要开始工作了,但是又引出了新的问题,怎样才能高效的回收垃圾?JVM中对如何进行垃圾回收没有明确的规定,所以不同的厂商可以采用不同的方法进行垃圾回收

3.1.垃圾收集算法

3.1.1.标记-清除算法

标记-清除(Mark-Sweep)算法,算法分为标记清除两个阶段:首先标记出所有活的对象,在标记完成后统一回收掉所有被未被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
JVM自动化的内存分配与内存回收_java_05
图片来自阿里云大佬

3.1.2.复制算法

复制(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

JVM自动化的内存分配与内存回收_生命周期_06

3.1.3.标记-整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种标记-整理(Mark-Compact)算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对未被标记的可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
JVM自动化的内存分配与内存回收_编程语言_07

3.1.4分代收集算法

GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。当然这个假设也是成立的,据IBM一个正儿八经的调查可知98%的对象生命周期是非常短暂的。

分代收集(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,将对象从Eden区复制到Survivor区,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
JVM自动化的内存分配与内存回收_java_08

3.2.垃圾收集器

3.2.1 Serial收集器

Serial收集器是最基本、历史最悠久的收集器了,是JDK 1.3.1之前新生代收集的唯一选择,它每次只会使用一条线程去进行收集工作,STW时间会很长,但是它简单高效,至今依然是虚拟在Client模式下的默认新生代收集器。

Serial + Serial Old运行图:
JVM自动化的内存分配与内存回收_生命周期_09

3.2.2 ParNew收集器

ParNew收集器是Serial收集器的多线程版本,多个线程并行进行垃圾回收,可控参数,收集算法,对象分配规则、回收策略都和Serial收集器一模一样。它虽然除了多线程之外相比于Serial收集器并无太多新颖之处,但它仍然是很多Server模式下虚拟机的首选新生代收集器。
ParNew + Serial Old运行图:
JVM自动化的内存分配与内存回收_老年代_10

3.2.3 Parallel Scavenge收集器

Parallel Scavenge收集器和ParNew收集器一样,多线程并行收集,用于新生代,采用复制算法,它和ParNew收集器最大的区别在于Parallel Scavenge目标是达到可控制吞吐量。​​吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)​​。

3.2.4 Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,使用标记-压缩算法,也是单线程。它主要用于Client模式下的JVM,或于前面的Parallel Scavenge收集器搭配使用,或者在CMS收集器Concurrent Mode Failure时使用。
Serial + Serial Old运行图:
JVM自动化的内存分配与内存回收_生命周期_09

3.2.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-压缩算法。
Parallel Scavenge + Parallel Old运行图
JVM自动化的内存分配与内存回收_老年代_12

3.2.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的老年代收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清理(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

初始标记:
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记:
并发标记阶段就是进行GC Roots Tracing的过程,因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代;并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理
重新标记
重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段会导致第二次stop the word,停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
并发清理
并发清理这个阶段主要是清除那些没有标记的对象并且回收空间,由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。CMS虽然可以做到并发收集、低停顿,但是仍然有三个明显缺点。
JVM自动化的内存分配与内存回收_编程语言_13

  • 无法处理浮动垃圾,可能出现Concurrent Mode Failure导致另一次Full GC的产生。
  • 对CPU资源敏感并发阶段会降低吞吐量
  • 基于标记-清除算法,会产生大量内存碎片

3.2.7 G1收集器

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  • 空间整合,G1收集器采用标记压缩算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  • 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
JVM自动化的内存分配与内存回收_java_14
G1中每个Region大小是固定相等的,Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
G1的运作大致可分为如下几步:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

二.内存分配

JVM自动化的内存分配与内存回收_java_08
Eden Space:
由于大多数对象生命周期都很短,所以大多数对象都被分配在年轻代的Eden Space中(Eden Space of the young generation),当Eden Space空间不足时,JVM就会发起一次Minor GC/Young GC,Eden Space将会被清理,大部分对象都将被垃圾收集器回收掉,剩下的部分对象会被复制到Survivor Space中的From Space区中,如果From Space内存也不足,则会直接被复制至Old Space(老年代)中。Eden Space空间占整个年轻代空间的8/10左右,可通过参数设置。

Survivor Space:
Survivor Space的作用主要类似于年轻代和老年代之间的缓冲区,这部分又被分为From Space和To Space,分别在Eden Space和From Space经历了一次Minor GC的对象将会进入到To Space中,如果To Space的空间不足,则会直接进入到Old Space中。From Space和To Space各占年轻代的1/10。

为什么需要Survivor Space?
假设没有这个缓冲区,只要在Eden区中经历了一次Minor GC便可直接进入老年代,那么毫无疑问老年代很快就会被填满,但问题是,可能一个经历了Minor GC的存活对象只能再经历个两三次Minor GC就会被回收了,所以只经历一次Minor GC便直接进入老年代是很不明智的,Survivor Space这层缓冲保证只有经历了16次Minor GC的对象才能进入老年代中。

为什么Survivor Space还需要分区?
Survivor Space分成From Space和To Space的主要是为了解决内存碎片问题。
现在假设Survivor Space不进行分区,从Eden Space复制过来的对象只能利用标志-清除算法进行收集了,但是那样会很快造成严重的内存碎片化。如果分为From Space和To Space我们就可以进行复制算法进行收集,每次Minor GC后,From Space 和 To Space的身份交换,这样就总可以保证有一个区是空的,复制算法总可以交替进行。

Old Space:
占了堆内存2/3的老年代到底是何方神圣?为何只有Major GC/Full GC才能降伏得了它?

老年代,Old Generation,它所拥有的空间大,发动一次针对它的GC所需要的STW(Stop the world)时间就越长,所以只有发动Major GC/Full GC时,才对它进行清理,注意每一次Major GC会自带进行一次Minor GC,于Major GC相比,Minor GC所花费的时间就非常少了。而且由于老年代所存放的对象生命周期长占用内存大,进行一次标记-清除或者复制算法的效率非常低,所以,在老年代中通常采取标记-压缩算法进行垃圾收集。

老年代存放大的对象,这里指的是那些需要大的连续内存的对象,一个大对象可以无视其生命周期的长短直接进入到老年代中,这样大对象就不用再Servivor Space进行复制操作,那将非常消耗资源。
老年代存放生命周期长的对象,每当一个对象经历一次Minor GC,它的年龄就加一,当一个对象的年龄达到15岁时(豆蔻之余又二年就算老了,哎~),它们就会进入到老年代中,这里的年龄限制可通过JVM参数设置。

三.内存分析工具

可以使用JDK bin中自带的​​JConsole​​​来进行内存分析
JVM自动化的内存分配与内存回收_生命周期_16

在JDK 6~8中,bin文件中是自带​​VisualVM​​​工具的,但是到JDK 9之后就被移除了,需要的自己下载,​​下载地址​​,如果你是使用Idea IDE,可以直接下载一个VisualVM插件,更方便快捷。

还有一种工具叫APPDynamics,它虽然收费,但是界面简洁,功能强大。

三种分析工具的对比如下,按需选择:
JVM自动化的内存分配与内存回收_java_17