Java JVM——12. 垃圾回收理论概述
1.前言
1.1
在提到什么是垃圾之前,我们先看下面一张图:
从上图我们可以很明确的知道,Java 和 C++ 语言的区别,就在于垃圾收集技术和内存动态分配上,C++ 语言没有垃圾收集技术,需要我们手动的收集。
垃圾收集,不是 Java 语言的伴生产物,早在1960年,第一门开始使用内存动态分配和垃圾收集技术的 Lisp 语言诞生。 关于垃圾收集有三个经典问题:
★ 哪些内存需要回收?
★ 什么时候回收?
★ 如何回收?
垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
那什么是垃圾呢?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。
1.2 磁盘碎片整理
机械硬盘需要进行磁盘整理:
因为在我们使用磁盘的过程中,许多零碎的文件分布不均,造成了大量可用空间碎片化,同时还有坏道。
1.3 为什么需要GC?
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象。
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。
1.4 早期垃圾回收
在早期的 C/C++ 时代,垃圾回收基本上是手工进行的。开发人员可以使用 new 关键字进行内存申请,并使用 delete 关键字进行内存释放。比如以下代码:
MibBridge *pBridge= new cmBaseGroupBridge(); //如果注册失败,使用Delete释放该对象所占内存区域 if(pBridge->Register(kDestroy)!=NO ERROR) delete pBridge;
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
有了垃圾回收机制后,上述代码极有可能变成这样:
MibBridge *pBridge=new cmBaseGroupBridge(); pBridge->Register(kDestroy);
现在,除了 Java 以外,C#、Python、Ruby 等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和回收方式已经成为了线代开发语言必备的标准。
1.5 Java垃圾回收机制
oracle官网关于垃圾回收的介绍:官方介绍。
1.5.1 优点
✔ 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。
✔ 没有垃圾回收器,Java 也会和 C++ 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。
✔ 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
1.5.2 担忧
对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutOfMemoryError 时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
1.5.3 GC主要关注的区域
GC主要关注于方法区和堆中的垃圾收集:
垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。其中,Java 堆是垃圾收集器的工作重点,从次数上讲:
★ 频繁收集:Young 区
★ 较少收集:Old 区
★ 基本不收集:Perm 区(元空间)
2.1 System.gc()的理解
在默认情况下,通过 system.gc() 或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
然而, system.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)
JVM 实现者可以通过 system.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之前调用 System.gc() 。
代码演示是否触发 GC 操作:
public class SystemGCTest { public static void main(String[] args) { new SystemGCTest(); // 提醒JVM进行垃圾回收 System.gc(); //System.runFinalization(); } @Override protected void finalize() throws Throwable { System.out.println("SystemGCTest 执行了 finalize方法"); } }
但是, system.gc() 不一定会立即触发销毁的方法, System.runFinalization() 会强制调用失去引用的对象的 finalize() 。
2.2 内存溢出
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。
大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Fu11 GC 操作,这时候会回收大量的内存,供应用程序继续使用。
javadoc 中对 OutOfMemoryError 的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存。通俗来讲,就是:所分配对象的内存 > 此时可供分配的空闲内存,此时若要给该对象分配内存就会出现内存溢出。
没有空闲内存,说明 Java 虚拟机的堆内存不够,原因有二:
1、Java虚拟机的堆内存设置不够。比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数 -Xms 和 -Xmx 来调整。
2、代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。
对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“ java.lang.OutOfMemoryError:PermGen space "。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“ java.lang.OutofMemoryError:Metaspace "。直接内存不足,也会导致 OOM。
这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。当然,也不是在任何情况下垃圾收集器都会被触发的。比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。
2.3 内存泄漏
也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 00M,也可以叫做宽泛意义上的“内存泄漏”。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
以买房子为例:80平的房子,但是有10平是公摊的面积,我们是无法使用这10平的空间,这就是所谓的内存泄漏。
Java 使用可达性分析算法,最上面的数据不可达,就是需要被回收的。后期有一些对象不用了,按道理应该断开引用,但是存在一些链没有断开,从而导致没有办法被回收,造成内存泄漏。
2.4 Stop The World
Stop-The-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。
可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。
➹ 分析工作必须在一个能确保一致性的快照中进行。
➹ 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。
➹ 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。
STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。哪怕是 G1 也不能完全避免 Stop-The-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
STW 是 JVM 在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。
开发中不要用 System.gc() ,会导致 Stop-The-World 的发生。
2.5 垃圾回收的并行与并发
2.5.1 并发
在操作系统中,指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
2.5.2 并行
其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。
并行适合科学计算,后台处理等弱交互场景。
2.5.3 并发和并行对比
★ 并发:指的是多个事情,在同一时间段内同时发生了。
★ 并行:指的是多个事情,在同一时间点上同时发生了。
并发的多个任务之间是互相抢占资源的,并行的多个任务之间是不互相抢占资源的。
只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。
并行和串行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:
♦ 并行(Paralle1):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel old。
♦ 串行(Serial):相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收,回收完,再启动程序的线程。
并发,在谈论垃圾收集器的上下文语境中,它们可以解释如下:
2.6 安全点与安全区域
程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safe Point)”。
Safe Point 的选择很重要,如果太少可能导致 GC 等待时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。
如何在 GC 发生时,检查所有线程,使其都跑到最近的安全点停顿下来呢?
✁ 抢先式中断:首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点。(目前没有虚拟机采用了)
✁ 主动式中断:设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)
2.6.2 安全区域
Safe Point 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safe Point。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safe Point。
执行流程:
当线程运行到 Safe Region 的代码时,标识该线程已经进入了 Safe Relgion 状态,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程。
当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待,直到收到可以安全离开 Safe Region 的信号为止。
2.7 四种引用概述
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:
★ 强引用(Strong Reference)
★ 软引用(Soft Reference)
★ 弱引用(Weak Reference)
★ 虚引用(Phantom Reference)
这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
✎ 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“ object obj = new Object(); ”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
✎ 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
✎ 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
✎ 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
终结器引用:
它用于实现对象的 finalize() 方法,无需手动编码,其内部配合引用队列使用,在 GC 时,终结器引用入队,由 Finalizer 线程通过终结器引用找到被引用对象,一个一个的弹出对象并调用其 finalize() 方法,第二次 GC 时才回收被引用的对象。