共计 5446 个字符,预计需要花费 14 分钟才能阅读完成。
Java 与 C ++ 之间有一堵由内存动态分配和垃圾回收技术所围成的“高墙”,墙外的人想进去,墙外的人想出来。——《深入理解 Java 虚拟机》
前言
上一章看了高墙的一半,接下来看另一半——GC。
为什么需要 GC 和内存分配策略?当需要排查各种内存溢出、内存泄漏问题时,当垃圾回收成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的控制和调节。
程序计数器、虚拟机栈、本地方法栈生命周期时伴随着线程的,所以更多的需要考虑 Java 堆和方法区的垃圾回收。我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。
对象已死吗?
如何判断对象是没有用了,该块内存可以被 GC 回收掉了。主要有两个方法。
引用计数算法
就是每个对象都有个计数器,如果有一个地方对该对象有引用,计数器就加 1,否则就减 1,知道计数器的值为 0 的时候就说明这个对象没有被使用了,可以回收之。但是,主流的 Java 虚拟机都没有使用这个方法,因为无法解决循环引用的问题。比如有个对象 A,引用了对象 B,同时对象 B 又引用了对象 A,此时两个对象的计数器都是 1,但是这两个对象在逻辑上已经没有用了,白白占用了内存空间。
可达性分析算法
主流的虚拟机使用的都是这个算法来判断对象是否存活(或者被使用)。这个算法的基本思路就是通过一系列的被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所经过的路径被称为引用链(搜索的是引用,不是对象本身)。当一个对象到 GC Roots 没有任何引用链相连接的时候,就被视为不可用了。例如大佬书中非常经典的图,Object5、Object6、Object7 都是可以被回收的对象。
作为 GC Roots 的对象包括一下几种:
- 虚拟机栈(栈帧中的本地本地变量表)中引用的对象。
- 方法去中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNL(Native 方法)引用的对象。
引用类型
Java 引用的定义很传统:如果 reference 类型的数据中储存的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但是有些引用符合引用定义,但是此引用所指向的对象可能已经不可用了。所以对传统定义增强的解释就是:<mark> 当内存空间还足够时,则能保存在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象,很多系统的 缓存功能 都符合这个定义。</mark>
所以,引用就被分成了 4 种类型。
- 强引用:最常见的引用,就是 new 个对象,该对象可达 GC Roots。只要又强引用在,GC 永远不会回收该空间。
- 软引用:软引用用来描述一些还有用但不是必须的对象。软引用在内存溢出异常之前,将会对这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够空间,才会抛出内存溢出异常。
- 弱引用:弱引用也是用来描述非必须的对象的,只是强度弱于软引用,弱引用所关联的对象只能生存到下一次垃圾回收发生之前,无论内存是否够用,都会回收之。
- 虚引用:形同虚设的引用。一个对象是否虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收器回收时收到一个系统通知。
finalize()的作用
被检测到可达性不可达的对象,并不是立即就被收回内存,至少需要经历两次标记。第一次标记并进行一次筛选,筛选条件是是否重写了 finalize()方法,如没有,或者此对象已经执行过 finalize()方法 (<mark> 一个对象最多只能执行一次 finalize() 方法 </mark>)了,虚拟机将它视为“没有必要执行”。
如果此对象重写了 finalize()方法,并且没有执行,此对象就会被放到一个 F -Queue 队列中,并且根据低优先级的 Finalizer 线程去执行它。<mark> 由于 Finalizer 线程优先级很低,所以需要在执行线程中 sleep 一会儿等待它的执行。</mark>Finalizer 线程的执行也不一定要等它执行完才进行垃圾回收,毕竟这里面执行的任务可能是非常耗时的。
在重写的 finalize()方法,此对象有一次 (只有一次机会,毕竟 finalize() 方法只能执行一次)机会挽救自己,此时可以将自己 (使用 this 关键字) 重新与引用链上的对象建立关联,可达性可达就好。
但是 finalize()方法机会很少有业务上的需求,毕竟它的功能 try-finally 也可以完成,毕竟这对于你的某个方法来说更具有实时性,并且更好控制。
回收方法区
这部分不是重点,毕竟现在流行的 JDK1.8 已经没有了方法区,并且这块空间的垃圾回收效率极低。只需要知道这块空间只要被回收的是两部分,废弃常量和无用的类 就好。
废弃常量好理解,就比方说一个字符串 ”abc”, 没有再被引用,根据可达性算法这个很好判断。对于无用的类判断条件需要符合以下三条:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的类加载器 ClassLoader 已经被回收。
- 该对象的 java.lang.Class 对象没有在任何一个地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
标记 - 清除算法
最基础的算法就是“标记 - 清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记清除算法有两点不足:第一就是效率问题,两个阶段效率都不高。第二个问题就是空间问题,标记清楚会产生大量碎片,让物理空间不连续,导致给较大对象分配空间的时候,很容易触发一次垃圾回收机制。
复制算法
复制算法将空间分成两个部分,一次只是用一个部分,当这一部分的空间用完了,直接将还存活的对象复制到另外一部分上去,然后将这一部分使用过的空间一次性清理掉。这样就是每次只对空间的一般及逆行 GC 操作。这样就不需要考虑碎片整理的问题了,只要移动堆顶指针,按顺序分配内存就行了。
现在的商业虚拟机基本都用这种算法回收 新生代 的数据。当一次 GC,新生代分为两部分,一个 Eden 空间 和两个 Survivor,这两部分大小比一般是 8:1:1。当一次 GC 操作存活的对象超过新生代的 Survivor 时,就需要老年代 分配担保,来补充不足的空间。
标记 - 整理算法
“标记 - 整理”算法首先将不可达的对象进行标记,然后将存活的对象向一端移动,然后直接清除掉端边界以外的内存。这样空间物理上就是连续的了。
分代收集算法
分代收集算法,是指不同的空间根据自己的实际情况选择不同的回收机制。一般来说新生代使用复制算法。老年代一般使用标记 - 整理算法。
HotSpot 算法实现
枚举根节点
由于 JVM 管理的内存十分的大,对象引用所占的空间可能很小并且十分零散,避免在一次 GC 消耗过长的时间,所以需要有种方式快速获取到对象引用。在 HotSpot 的虚拟机实现里面,有一个叫做 OopMap 的数据结构来储存这些对象引用,用于快速定位。在执行一个方法的时候,字节码层面会遇到一个 OopMap 记录,它通过偏移量记录着本次方法操作的字节码什么位置有引用,这样就可以找到引用了。
安全点
虽说 OopMap 可以快速找到所有的引用,但是不可能为每一条指令都添加 OopMap 记录,毕竟这样的内存消耗是十分大的。只有在一些特定的地方才会添加 OopMap 记录,这些地点被称为 安全点 。安全点的选取需要符合“是否让程序长时间执行”的特征。“长时间执行”的最明显的特征就是指令序列的复用。比方说,方法调用、循环跳转、异常跳转等功能上。这里还需要注意一个问题,某一个线程就是当达到安全点了,要开始启动 GC 了,需要让整个程序都停下来,防止在 GC 的过程中产生新的垃圾,让本次垃圾回收不彻底。所以需要让所有的线程都到安全点,然后进行统一的垃圾回收。这里又两种机制, 抢先式中断 和主动式中断。
抢先式中断:在 GC 发生时,先把所有线程中断,如果发现有些线程没有在安全点,让它们恢复活跃,重新跑到安全点再中断,然后进行垃圾回收。
主动式中断:不直接对线程操作,仅仅简单设置一个标志,各个线程去轮询访问这个标志,当某个线程执行到安全点就去轮询一下,发现标志是中断状态,就将自己挂起,当所有线程都挂起的时候,就进行一次 GC 操作。
安全区域
进行一次 GC,都需要在安全点完成,但是有些线程是没有办法等它到达安全点的,比如说 sleep(),不可能所有线程都等它睡完了再继续执行。所以除了安全点,还要引入安全区域的概念。<mark> 安全区域是指在一段代码片段之中,引用关系不会再发生变化,所以 GC 是安全的。</mark> 在某个线程执行在安全区域的时候,可以随意 GC,当这个线程要离开安全区域的时候,需要查看此时是否又 GC 操作,没有的话就可以离开,如果有 GC 操作,就需要等待 GC 完成后再离开安全区域。
垃圾收集器
几个简单的垃圾回收器
Serial 收集器:这个垃圾回收器是线程工作的,当它开始回收的时候,所有线程都需要中断,用于新生代。
ParNew 收集器:ParNew 就是 Serial 的多线程版本。除了 Serial 以外,ParNew 是唯一可以与 CMS 收集器配合工作的。ParNew 在单线程或者数量较少的多线程下(CPU 数量少)性能并不比 Serial 优秀,毕竟切换线程也很需要成本。此收集器也是用在新生代。
Parallel Scavenge 收集器:也是用在新生代,这个收集器更在乎吞吐量,即用户代码运行的时间占用用户代码和垃圾回收总时间的比重。此收集器可以动态调整参数来保证适当的停顿时间和最大的吞吐量。
Serial Old 收集器:单线程的用于老年代的收集器。
Parallel Old 收集器:多线程的老年代收集器。
CMS 收集器
CMS 是一种获取最短回收时间为目标的收集器,目前很大一部分的 Java 应用集中在互联网站或者 B / S 系统的服务器上,这类应用尤其重视服务的响应,希望更短的停顿时间。
CMS 需要四个步骤:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记都需要让所有线程都终止。并发标记可以让用户的工作线程同时运行,所以可能出现新的垃圾,重新标记就是为了解决这个问题的。
CMS 有三个明显的缺点:
- CMS 收集器对 CPU 资源非常敏感,当 CPU 数量少的时候性能极差。
- CMS 阈值低,由于需要一部分空间留给并发,所以不能达到 100% 就需要开启 GC。现在最高占用空间达到 92%。
- 由于使用的“标记 - 清除”功能,所以会产生大量的碎片。
G1 收集器
G1 收集器是一款面向服务端应用的垃圾收集器。G1 收集器可以作用于新生代和老年代。并且有非常好的并发并行机制,可以进行空间整理,还有个非常优秀的特点是可以预测停顿时间,可以让使用者指定在固定的时间 M 毫秒内,垃圾回收所占用的时间不能超过 N 毫秒。
G1 收集器可以让 Java 堆划分成多个 Region 空间(其中仍然有新生代和老年代)独自管理,这样就可以根据某个区域内进行垃圾回收。并且后台维护者一个优先列表,指定哪些 Region 空间先被手机。
同时为了解决不同的 Region 通讯问题,比如 ARegion 中的对象引用了 BRegion 内的对象,每个 Region 维护着一个 Remembered Set 记录着这些信息。
内存分配与收回策略
对象主要分配在新生代的 Eden 区上,或者分配在 TLAB(线程独享)上,少数情况也可以直接分配在老年代上。这取决于你使用的垃圾收集器和参数设定。下面有几条普遍的内存分配规则。
对象有限在 Eden 上分配
如果发现 Eden 上的空间不够了,会进行一次新生代 GC。2 个 Survivor 一个叫 FROM,一个叫 TO。当进行新生代 GC 的时候,Eden 中的数据会复制到 TO 中,FROM 内的数据根据年龄看是去往 TO 还是进入老年代。接着 TO 和 FROM 互换姓名,然后清空 Eden 和 TO 的数据。另外老年的 GC 收集是新生代时间的 10 倍。
大对象直接进入老年代
一般来说新生的对象会在新生代,过了一段时间,一定数量的新生代 GC(默认 15 次)之后,存活下来的对象再被放进老年代中。但是有些比较大型的对象,比如字符串或者非常大的数组就直接放到老年代了,这样就避免了多次新生代 GC,来回复制这种超长的空间了。
长期存活的对象进入老年代
一定数量的新生代 GC(MaxTenuringThreshold 默认 15 次)之后,存活下来的对象再被放进老年代中。
动态对象年龄判定
如果再 Servivor 空间中相同年龄(经历 GC 次数)所有对象大小的总数大于 Servivor 空间的一半的时候,年龄大于或等于这一数值的对象直接进入老年代,无需等待 MaxTenuringThreshold 要求的年龄。
空间担保机制
空间担保机制就是在新生代 GC 的时候,如果 Servivor 空间不够放来自 Eden 的对象,可以由担保人老年代来放些数据。
在新生代 GC 之前,虚拟机会区检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,这次 GC 是安全的。如果不大于,就回去看是否开启了空间担保机制,如果开启了就会继续检查老年代最大可用的连续空间是否大于历次晋升老年代对象的平均大小,如果大于就可以冒险试一下 GC,如果不大于,就会触发全局的 GC(Full GC)。
为什么会有这样的冒险?因为新生代多出来的数据老年代不一定放的下,毕竟没人为老年代做担保了。究竟多出来的数据能不能放下呢,这就需要经验来判断,算下历次从新生代过来的数据平均值,假定频率等于概率,来和老年代剩余的空间作比较。