GC
Java 与 C++ 直接的一个区别就是内存动态分配和垃圾收集(Garbage collection)机制。对于从事 C/C++ 程序开发的开发人员来说,他们既要管理数据再内存上的分配,也要管理这些数据的回收。但是对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写配对的 delete/free 代码,不容易出现内存泄漏和内存溢出的问题。之所以要了解虚拟机的垃圾回收机制,是因为一旦出现内存泄漏或者内存溢出问题,在不了解这些机制的情况下很难发现问题所在。
运行时数据区域(Run-Time Data Areas)
要了解 Java 的 GC 机制,首先要知道 Java 虚拟机将运行时数据分成了哪几个区域。
Java 虚拟机规范(Java Virtual Machine Specification)将运行时数据区分为五个部分:程序计数器(The PC Register)、本地方法栈(Native Method Stacks)、虚拟机栈(Java Virtual Machine Stacks)、方法区(Method Area)、堆(Heap)。
-
程序计数器(The PC Register)
Java 虚拟机可以同时支持许多执行线程。每个Java虚拟机线程都有自己的 程序计数器。在任何时候,每个 Java 虚拟机线程都在执行单个方法的代码,即该线程的当前方法。 如果该方法不是 Native Method ,则程序计数器包含当前正在执行的 Java 虚拟机指令的地址。 如果线程当前正在执行的方法是 Native Method ,则 Java 虚拟机的程序计数器的值是未定义的(The value of the Java Virtual Machine’s pc register is undefined)。Java 虚拟机的程序计数器足够大,可以保存 returnAddress 或本地方法的指针。
程序计数器上未定义任何的异常。
-
虚拟机栈(Java Virtual Machine Stacks)
每个 Java 虚拟机线程都有一个私有的虚拟机栈,与线程同时创建。 Java 虚拟机栈的存储单元叫做栈帧(Stack Frame)。由于除了入栈和出栈之外,不会再有其他的操作会直接操作 Java 虚拟机栈,因此栈帧可以在堆上进行分配。 Java 虚拟机栈的内存不需要是连续的。
栈帧创建于方法调用,销毁于方法调用完成(无论是正常返回还是抛出异常)。栈帧种存储着局部变量表(Local Variables)、操作数栈( Operand Stacks)和对当前方法类的运行时常量池(Run-Time Constant Pool)的引用(Dynamic Linking)。这些栈帧都是线程私有的,只对创建它的线程可见。
Java 虚拟机栈支持固定大小和动态扩展。如果线程中的计算需要比允许的虚拟机栈深度更深,则 Java 虚拟机会抛出 StackOverflowError 异常。如果在动态扩展时无法申请到足够的内存或者可用内存不足无法为新线程创建初始 Java 虚拟机栈,则 Java 虚拟机会抛出 OutOfMemoryError 异常。
-
堆(Heap)
堆对所有在 Java 虚拟机上的线程都是共享的。堆是运行时数据区,所有类实例和数组的内存都在堆上分配,但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换等优化技术使得某些对象可以不再堆上进行分配。堆上存储的对象会被垃圾收集器(garbage collector)自动回收。堆支持固定大小和动态扩展,堆的内存不需要是连续的。
如果需要的堆的空间超过可用堆空间,并且无法进行动态扩展,则 Java 虚拟机会抛出 OutOfMemoryError 异常。
-
方法区(Method Area)
方法区对所有在 Java 虚拟机上的线程都是共享的。方法区存储已被虚拟机加载的类信息、运行时常量池、字段、方法和构造函数等。方法区域是在虚拟机启动时创建的,在逻辑上是堆的一部分。方法区同样支持固定大小和动态扩展。方法区域的内存不需要是连续的。
如果无法使方法区域中的内存满足分配请求,则 Java 虚拟机将抛出 OutOfMemoryError 异常。
-
本地方法栈(Native Method Stacks)
Java 虚拟机可以使用传统的堆栈,俗称“C堆栈”,来支持Native Method(用 Java 编程语言以外的语言编写的方法)。本地方法栈于虚拟机栈所发挥的作用时非常相似的,他们之间的区别不过是虚拟机栈位虚拟机执行 Java 方法提供服务,二本地方法栈为虚拟机使用到的本地方法提供服务。本地方法栈支持固定大小和动态扩展。
如果线程中的计算需要比允许的栈深度更深,则 Java 虚拟机会抛出 StackOverflowError 异常。如果在动态扩展时无法申请到足够的内存或者可用内存不足无法为新线程创建初始本地方法栈,则 Java 虚拟机会抛出 OutOfMemoryError 异常。
对象引用分析
在运行时数据区域种,程序计数器、本地方法栈、虚拟机栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出二有条不紊的执行入栈和出栈的操作。每一个栈帧的大小在编译为字节码的时候就基本确定下来了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而堆和方法区内的数据基本是要到运行时才能确定,只有到运行时才能知道会创建哪些对象,多态也使得只有在运行时才能知道调用的方法是父类的还是子类的,这些都需要内存的动态分配和回收。
在进行回收之前,我们需要知道哪些对象可以被回收,这些可以被回收的对象都是在以后的程序中不会再被使用到的。为了寻找这些对象,有两种算法:引用计数算法和可达性分析算法。
- 引用计数算法的思想就是给对象添加一个引用计数器,每当增加一个对该对象的引用是,它的引用计数器就 +1 ;当引用失效时,它的引用计数器就 -1 。当引用计数器的值为 0 时,就表示该对象不可能再被使用。但是这个算法很难解决对象之间相互应用的问题。 obj1 与 obj2 相互应用,同时 referenceA 和 referenceB 引用 obj1 和 obj2 。在执行 referenceA = null 和 referenceB = null 之后, obj1 和 obj2 的引用计数器的值并不为 0 ,但这两个对象又不会再被使用,因此他们就永远都不会被回收。
- 可达性分析算法的思想就是使用一些被称为 “ GC Roots ” 的对象作为起点,从这些起点开始向下搜索,搜索所走过的路径就成为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链存在时,则这个对象就是不可用的。在 Java 语言种,能作为 GC Roots 的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性和常量引用的对象、本地方法栈中 JNI(即一般说的本地方法) 引用的对象。在进行可达性分析的时候,对象之间的引用关系是不可以改变的,因此需要 Stop the Wrold ,及所有线程都会被阻塞,直到可达性分析完成。下图中 object 1 、 object 2 、 object 3 、 object 5 都存在与 GC Roots 的引用链;虽然 object 6 和 object 4 存在应用,但是与 GC Roots 不存在任何引用链,因此它们时不可用的。
垃圾收集算法
-
标记-清除(Mark-Sweep)算法
标记-清除算法时最基础的手机算法,该算法分为两步:标记和清除。首先进行对象可达性分析,将不可用的对象标记出来,标记完成后对这些对象进行统一回收。这个算法虽然简单,但有两个主要的不足之处:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行中需要分配较大对象时,无法找到足够的连续存储空间而不得不提前触发另一次 GC 。
-
复制算法
这种算法将内存按大小分块,每次只使用其中的一块。当这一块的内存使用完时,就出发一次 GC ,将任可用的对象复制到另一块内存区域,然后把已使用过的那一块内存区域全部清理掉。这一算法解决了内存碎片的问题,但随之而来的是可用内存的减小,如果内存被平均分成两块,那么可用内存就只剩原来的一半了。
-
标记-整理算法
标记-整理算法就是在标记-清除算法的基础上将 GC 后的内存区域中的存活对象连续存放,以消除内存碎片。
-
分代收集算法
该算法根据对象存活的周期不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用适当的收集算法。在新生代,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选择复制算法,只需要付出少量对象的复制成本就可以完成 GC 。而老年代的存活率高、没有额外空间对他进行分配担保(下面解释),就必须使用标记-清除算法或者标记整理算法。
HotSpot 虚拟机中的分代垃圾回收
HotSpot 虚拟机将 Java 堆分成新时代和老年代,其中新生代又分成三块:Eden 区和两块 Survivor 区。在默认情况下 Eden 和 Survivor 的大小比例为 8 :1。两块 Survivor 区又可一分成 From 区和 To 区,两块的大小相同。
在对象分配时,首先分配在Eden区上,在Eden区满时,会触发一次 Minor GC ,将不可用的对象清除,可用的对象复制到 From 区中,此时如果 From 区的大小不足以接收 Eden 区的存活对象,那么这些对象就会直接进入老年代,这就是所谓的分配担保(Handle Promotion)。
之后的每一次 Minor GC ,都会将 Eden 区 和 From 区 的存活对象复制到 To 区。然后互换两个 Survivor 区,即 To 改名为 From , From 改名为 To 。此时如果 To 区不够存放 Eden 区 和 From 区的存活对象,那么就会进行分配担保,直接将存活对象移动到老年代。
当一个对象从 From 区被移动到 To 区时,它的年龄就会增加 1 岁,当它存活的时间达到一定值时(可以通过 -XX:MaxTenuringThreshold 设置),就会被移动到老年代中去。
当老年代的空间也满之后,就会出发一次 Major GC / Full GC。 Major GC / Full GC 和 Minor GC 的主要区别在于:
- Minor GC :指发生在新生代的垃圾回收动作,因为 Java 对象大多数都具备朝生夕死的特点,所以 Minor GC 非常频繁,也非常快。
- Major GC / Full GC :指发生在老年代的 GC, 出现了 Major GC ,经常会伴随至少一次的 Minor GC (但并非绝对,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Minjor GC 的策略选择过程)。 Major GC 的速度会比 Minor GC的速度慢很多。