前言

很高兴遇见你~ 欢迎阅读我的文章。

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

垃圾收集(Garbage Collection ,也称为GC),是虚拟机中一个永恒不变的话题。上述那句话堪称经典,同时也点出了垃圾回收这个问题的重要性。在c/c++中,开发者对内存有至高无上的权利,同时也需要自己对对象负责到底,每一块内存使用完之后都需要调用free方法来释放内存。而JVM采用自动化技术,开发者无需关心内存的分配以及释放,当一个对象不再使用时,垃圾回收机制会这个对象进行回收。然而,这一切看起来很美好的垃圾回收机制,背后却“暗藏杀机”。垃圾回收机制并不是完美的,若开发不当很容易产生内存泄露的内存溢出等问题;同时当内存泄漏或内存溢出问题时,如果对JVM的垃圾回收机制不了解,也很难去排查问题。因而,学习垃圾回收机制不仅是需要应付面试,更重要的是能够让自己写出更加健壮的代码、解决垃圾回收机制带来的问题。

垃圾回收机制有四个关键问题:

  1. 什么是垃圾回收机制?
  2. 回收哪个区域的垃圾?
  3. 什么时候回收?
  4. 如何回收?

这四个问题可以说是垃圾回收机制的核心,弄懂这四个问题,也就了解了垃圾回收机制。关于第一个问题上面已经有讲述了,总的来说就是虚拟机中一套可以自动对不再使用的对象进行回收释放内存的机制 。那么下面,将围绕另外三个问题来展开讲解。

哪些内存需要回收

JVM基础(二):运行时数据区一文中讲到JVM的运行时数据区主要有线程私有的方法栈和程序计数器和线程共享的堆区和方法区。

方法栈和程序计数器随着线程生而生,同时也随着线程亡而亡。每个方法被调用时会压入一个栈帧,每个栈帧的大小在编译阶段也已经确定,随着方法的调用与返回,栈帧有条不紊地执行入栈与出栈操作,这部分的内存分配和回收具有确定性,因为不需要进行垃圾回收。而相对的,Java堆和方法区则不确定性非常高。在运行时,方法区需要加载的类、常量的添加、对象的创建,这些都充满了不确定性,无法在编译阶段确定下来。因而,程序在运行时会在这两个区域不断进行创建对象。对这两个区域内存进行管理就显得非常重要。

而在这两个区域中,堆区的管理显得比方法区更加重要且常见。方法区的垃圾回收事实上非常少,且回收的时机非常苛刻,如类的卸载。而堆区是对象实例的存储地,程序在运行时会频繁进行创建然后丢弃对象,因而下面我也主要围绕堆区来展开讲述垃圾回收。

方法区有必要进行回收吗

有。

方法区存储的数据,貌似是在整个程序运行期间都必须存在的数据:类信息、常量、各种符号引用等。但,事实上并不是。但是这些需要回收场景相对较特殊且少见,很难引起注意,而这往往也是内存泄漏的隐患所在。方法区需要回收的对象少,回收条件苛刻,以至于回收性价比低下,在一些虚拟机也并没有实现方法区的回收机制,在Java虚拟机规范中也没有明确要求实现方法区的垃圾收集。但,很难,并不意味着就可以不做。在一些场景下,对方法区的回收还是非常有必要的。

方法区回收的数据主要有两种:类信息、常量。不同的类加载器加载同一个类在虚拟机角度来看是属于完全不同的两个类,而在一些频繁使用字节码操作、大量反射、动态代理等等的情景下,加载进来的类信息非常多却通常只需要创建一个对象就不再使用了,那么就很有必要对这些类信息进行回收来减少方法区的压力,也就是,类卸载。同样,常量也并不是在整个程序运行期间都需要使用到,对于一些不再使用的常量也可以进行回收。那么如何判断一个类或者一个常量需要被回收呢?

常量相对来说较为简单,废弃的常量或者不再使用的常量可以被回收。怎么判断?在虚拟机任何地方都没有引用这个常量,那么他就是个废弃的常量了。

类信息比较复杂,需要符合三个条件:

  1. 该类包括子类所有的对象实例全部被回收。
  2. 加载该类的类加载器被回收。
  3. 该类的Class对象没有被引用。

这个时候,就可以对这个类进行卸载了。事实上我们很多的类都是使用应用类加载器或者系统类加载器来加载,那么第二个条件就很难去满足了。所以一般情况下,自定义类加载器加载的类才有更大的可能被回收。关于类加载器的内容后面再讲。

如何识别垃圾

前面讲到,垃圾回收主要的区域是Java堆区,而这个区域主要存储的是对象实例。要进行垃圾回收,首先要判断一个对象是否是垃圾,那么如何判断一个对象是否是垃圾呢?这里有两种很常见的解决方法:引用计数法、可达性分析法。

引用计数法

引用计数法可能是读者最先认识的一种垃圾标记法,也是最为被广泛认知的一种算法。引用计数法的概念相对简单:

使用一个额外的内存对对象的引用进行计数。当一个地方对这个对象进行引用时,那么引用计数+1,当一个引用失效时,引用数-1。如果一个对象的引用数为0,说明这个对象没有被引用,标记为垃圾。

引用计数法有两个优点:原理简单、判断效率高,也是因为这两个原因被采用以及学习。但是,他有一个致命的缺点:无法解决循环引用问题

例如:A持有了B,B持有了A,那么如果A、B不再使用,但是两者却无法被释放。可能你会觉得,这种两个对象的互相引用很少,但是,当对象越来越多、逻辑越来越复杂的时候,对象之间彼此引用,可能造成整个堆区的对象都无法被释放,从而造成了严重的内存泄露。

所以,引用计数法需要配合大量额外的的处理才能保证正确工作。同时每个对象需要额外的空间来存储计数器,虽然不多,但也是一种内存代价。

可达性分析法

可达性分析法很好地解决了引用计数法的循环引用问题,也是目前大多虚拟机采用的算法。可达性分析法的概念是:

从一系列被称为GCRoots的对象出发,根据他们所持有的引用向下检索,检索走过的路线称为“引用链”。如果一个对象与GCRoots之间没有引用链,那么该对象被标记为垃圾。

这种算法的优点是解决了循环引用的问题。但带来了两个问题:如何确定GCRoots、引用膨胀。

首先是GCRoots的确定。什么对象可以作为GCRoots?存在且能被虚拟机直接访问到的对象(关于GC Roots笔者并未找到最严谨的定义,这个结论可能并不准确,事实上确定GCRoots对象考虑到的因素很多,而这只是我的一个总结,方便理解)。举个最明显的例子。栈帧中的对象引用所指向的对象。这些对象目前正在使用,说明他是肯定是必须存活的,同时虚拟机可以通过栈帧来访问到这些对象,那么由这些对象所引用的对象也是可能会被使用到,那么他们就不是垃圾。可作为GCRoots的对象有以下类型:

  • 方法栈中引用的对象,包括虚拟机栈和本地方法栈。
  • 方法区类静态属性引用的对象。
  • 常量引用的对象。
  • 虚拟机内部引用的对象。
  • 被同步锁持有的对象。
  • 反应虚拟机内部情况的JMXBean、JCMTI中注册的回调、本地代码缓存等。
  • 根据具体区域临时加入的对象,主要是跨代引用的加入。

类型非常多,记住我的那条可能不太严谨的总结:该对象一定存在且可以被虚拟机直接访问到,那么该对象就可以作为GCRoots。这些对象在运行时都是一定会存在的,那么他们就可以作为GC Roots。

第二个问题是引用膨胀。当内存中的对象越来越多,这个时候一个GC Root的引用链会变成一棵巨大无比的树,那么遍历这个树的性能消耗就比较高了。那么需要一定的额外操作,来优化引用膨胀问题,例如分代收集等等,后续会慢慢讲到这个问题。

Java四大引用类型

上面两种算法都和一个概念分不开:引用。 Java根据不同的场景需要,设计了4中不同的引用类型。设计引用的目的是让开发者可以控制对象的生命周期,同时有利于GC机制的运行,提高性能。日常中我们使用得最多的应该是强引用和弱引用。正确理解垃圾标记算法和引用类型,可以帮助我们减少内存泄露问题。

强引用

强引用也称为直接引用,例如Person p = new Person(),此时的p就是对Person实例的强引用。在对象拥有强引用的情况下,对象永远不会被回收,如果内存不足则抛出OutOfMemoryError异常。

软引用

软引用比强引用更加弱一点。使用方法是SoftReference<Person> pS = new SoftReference<>(new Person);。当一个对象拥有软引用时,当内存不足时会被系统回收。而如果内存充足则不会被回收。

这个引用一般是用于对象持有的一些资源,但这些资源又不是绝对必要,在程序崩溃和释放资源的选择下,选择了释放资源。例如缓存资源。当内存不足的时候,肯定是先把缓存清理了,不至于发生OOM。

弱引用

弱引用比软引用更加弱一个档次。使用方法是WeakReference<String> s = new WeakReference<>("");。对于一个只有弱引用的对象,无论内存是否充足,都会进行回收。

弱引用强调两个对象的关系是很弱的,他解决的问题是:A确实需要引用到B,但当B需要回收的时候,却因为被A引用了而无法回收。这个时候使用弱引用就可以很好地解决问题了。例如Android中的MVP设计模式,Activity持有Presenter的引用可以直接调用Presenter的接口来做请求,同样Presenter需要异步回调Activity的接口来更新UI。但是,如果Presenter直接持有Activity的引用,那么当退出界面后,就会导致Activity无法被及时回收而导致内存泄露。所以这个时候,Presenter只能持有Activity的弱引用,他在的时候,我随时可以找到他,当他想走的时候,我拦都拦不住,道理是一样的(咳咳,还没到点呢)。

虚引用

对于对象的生命周期没有任何的影响,也无法通过虚引用来获取对象实例,只用接收对象被回收时的通知。他的使用方法是:

ReferenceQueue queue = new ReferenceQueue();
PhantomReference<String> reference = new PhantomReference<String>("一只修仙的猿", queue);

通过这个reference我们无法获取到对象的引用。当对象被回收时,该对象的相关信息会被放到队列中,我们只需要从队列中获取信息即可。

小结

不同类型的引用有不同的适用场景,并没有孰好孰坏,也没必要觉得强引用就一定会造成内存泄露而刻意去使用弱引用等。更重要的是注意这几种引用适用的场景以及解决的问题。那么在遇到这些场景的时候,就可以使用恰当的引用类型。

如何收集垃圾

前面我们分析了如何找到需要回收的对象,一共有两种算法:引用计数法和可达性分析法。现在问题来了,那既然通过这两个算法可以判断一个对象是否需要被回收,那直接遍历堆中所有的对象并标记所有需要回收的对象,然后回收垃圾对象不就可以了吗?当然不可以。GC机制是为了释放内存,减少内存压力,同时GC本身作为一个内存管理的自动化工具,肯定不能造成太高的性能压力。而在收集这些垃圾的过程中,直接遍历堆中所有的对象是性能非常低的做法,会严重影响程序的性能。 要了解如何才能提高垃圾收集的性能,还要从一个很重要的理论谈起:分代收集理论。

分代收集理论

分代理论是建立在两个假说上的:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

这两个假说指出了两种不同年龄的对象拥有不同的特性:刚创建的对象大都会被回收,而在多次回收中存活的对象则后续也很少被回收。这虽然是两个假说,但也在实践中得到了证实。而事实上也是很好理解,大部分创建的对象都是临时对象,而存活的时间越久说明这个对象的作用域越大或者更加重要,后续也会继续存活下来。那么我们可以针对这些不同特性的对象执行不同的回收算法来提高GC性能:

  1. 对于新创建的对象,我们需要更加频繁地对他们进行GC来释放内存,且每次只需要记录需要留下来的对象即可,而不必要去标记其他大量需要被回收的对象,提高性能。
  2. 对于熬过很多次GC的对象,则可以以更低的频率对他门进行GC,且每次只需要关注少量需要被回收的对象即可。

我们可以针对这两种不同的对象,把堆划分为两个区域:一个用来装新创建的对象,一个用来装多次GC后存活的对象。那么前者就被称为新生代,而后者则被称为老年代。这种针对不同年龄的对象执行不同的收集方案的理论就称为分代收集理论。可以发现,这种方案对于直接暴力遍历堆中所有的对象已经性能提高了非常多。当然针对具体的年代对象拥有不同的更加具体的收集算法,我们将在后面聊。这里我们要再聊另外一个话题:分代收集真的就只是把堆区划分为两个部分就可以了吗?


我们先来看一种情况,如图:

如果这个时候我们需要对新生代进行回收,那么遍历新生代的所有对象,最后对象1和对象3是存活的,对象4是需要被回收的。但是,问题来了,在新生代的内存区域中,对象2是GC Root不可达的,但是对象5是存活且引用了对象2,那么对象2就应该是存活。如果只是对新生代进行GC,那么就很有可能“误杀”很多跨代引用的对象,如对象2。那么我们可以怎么做?最简单的办法就是遍历一次老年代,找出所有的跨代引用,还是我们前面讲的,这样的效率太低了,每次GC新生代都要遍历老年代,那么和没有分代的性能就差别不大了。之所以会出现这个“误杀”问题,归根结底在于跨代引用,如果没有跨代引用也就没有这些问题。如何解决这个问题,就要涉及到另一个假说:

跨代引用假说:跨代引用相对于同代引用来说仅占少数。

那,,这条假说是不是说明我们可以不用管这些少数的对象?当然不是~ ,最明显的问题,如果对象2被误杀,那么当对象5调用对象2的方法的时候,就会出现空指针异常了。少数这个关键词说明了两个问题:我们没有必要为了他去大费周章遍历老年代;我们可以通过给他分配一些内存资源来解决这个问题,少数说明占用的内存也是少的,用少量的空间换取时间肯定是划算的。这里提供一个算法:

我们可以在新生代维护一个“记忆集”,该记忆集把老年代划分为不同的区域,把老年代中存在跨代引用的对象放在一块小内存中。那么当下次新生代GC的时候,只需要对这小部分内存进行扫描,而无需遍历所有的老年代对象。

这样就可以相比之前暴力扫描老年代提高了很多性能,但,也是有代价的:一定的空间存放“记忆集”,需要在对象改变引用的时候维护数据的正确性。第一个好理解,第二个代价,例如上图中的对象5,如果他放弃了对对象2的引用,那么“对象集”就应该把他划出这一小块内存。


前面讲到,不同年代需要有不同的算法来收集并不能概一而论。如新生代每次存活的对象很少,每次GC标记需要回收的工作量就比标记存活对象的工作量多得多。那么下面就来介绍不同的收集算法。

标记-清除算法

这个算法简单粗暴,也是最基础的收集算法。该算法概念如下:

每次遍历并标记出需要被回收的对象或者存活的对象,再一个个进行回收标记或者没有标记的对象。

是不是很简单?但是有大问题:

  1. 性能不稳定。在新生代每次需要回收大量的对象的情况下,进行大量标记再一个个去释放内存显然效率非常低。
  2. 空间碎片化。这个好理解,回收之后会剩下很多不连续的空间,这样会导致大对象无法创建。

标记-复制算法

这个算法是针对新生代而设计的:

把内存分为两个等分的部分A和B,同时只使用一个部分,例如使用A部分。
当垃圾回收时,把A部分的活着对象全部复制到另一个部分B。
当前A部分的内存全部释放,然后把B部分当成主要使用内存。

这样做两个好处:只需要转移少量存活的对象,然后统一对一整片内存进行回收;转移后的对象集中,不会出现空间碎片化的问题。但是有个很大的代价:内存只能使用一半。而根据IBM公司的一项研究,每次新生代GC,都有98%的对象被回收。虽然这个数据我们持怀疑态度,但是把内存分为相等的两半肯定是不划算的。那么就有了一个新的解决方案:Appel式回收


Appel式回收的设计背景就是每次新生代GC,都有98%的对象被回收。他把内存分为三个部分:Eden和两个Survivor。Eden占80%内存,而每个Survivor占10%内存。Appel式回收算法也不复杂:

主要使用Eden部分和一个Survivor部分。
当发生GC时把存活的对象全部复制到另一个Survivor中,Eden和原来的Survivor被释放。
这个时候主要使用的内存变为Eden部分和存活的对象存储的Survivor区域。

画个图帮助理解:

其中左边是使用的是Eden和Suivivor1,经过GC之后,对象1和对象3存活了,那么就把他们复制到Survivor2中。然后使用的内存区域变成了Survivor2和Eden。

这样的好处就是,我们只需要占用10%的内存来复制,而可以使用到90%的内存空间。但是总有意外的那一刻。如果存活的对象超过了Survivor的空间,那么就会把无法承载的对象先托管到老年代中。等到回收有足够的空间就可以把对象转移回来,或者直接升级为老年代对象。如果老年代也没有多余的空间,那么就会触发一次老年代的GC 来腾出足够的空间。

标记-整理算法

前面的标记-复制法是针对新生代而言,而对于老年代,则就完全不适合了。老年代每次存活的对象非常多,如果进行复制,则每次都需要进行大量的复制操作,效率非常低;而且如果对象全部存活,有可能另一半空间不够存放,就会直接导致出问题了,因为没有另外的空间来暂时存放。所以需要有另一种更加符合老年代特性的方案:标记-整理法

把存活的对象标记下来,然后全部往一侧的的内存移动,把边界以外的所有内存全部释放

这其实也比较好理解,老年代存活的对象比较多,那么每次清理的对象其实不多,如果仅仅采用标记-清除法,会产生碎片化空间,而标记整理法标记之后不是立即把对象回收,而是把存活的对象往一侧移动,再释放边界外的内存,这样可以把碎片化空间放到一起。可能细心的读者发现了一个问题:那这样,岂不是几乎每次GC老年代都需要把所有或者大部分对象都移动一下位置,效率不会很低吗?这正是这种方法的弊端。而事实上他的弊端不止如此。当对象移动位置的时候,整个应用程序必须停止操作等待GC完成,否则可能会发生引用地址错误的问题,这样不仅性能问题降低了,且还可能会发生间歇性卡顿。

可不可以不移动对象来解决碎片化空间问题?可以,通过类似硬盘分区的概念来利用碎片化空间。这样带来的新问题是:需要更加复杂的内存分配器和内存访问器。而这不仅是代码复杂难以实现的问题,更重要的是会导致访问内存的性能降低。因而不管是移动或者不移动,都会造成性能的开销。而事实上,程序访问内存的频率比GC的频率高得多,因而综合考虑下,采取标记-整理法的总体性能要更好。注意,这里写的是整体性能,如果注重GC的低延迟,那么标记-整理这种需要停止应用程序来GC的方法就显得不适合了。因此,每个虚拟机采取什么GC收集器使用什么GC收集算法,都是一种具体情景具体需求下权衡的结果。

但这还有另一种优化标记-整理的方案:“和稀泥法”。

在内存足够使用的时候仅采用标记-清除法,等到碎片化程序影响对象的创建时再执行一次标记-整理法。

这样就可以明显降低移动对象的次数,提高标记-整理算法的性能。

被标记为垃圾就肯定会被回收吗

这个问题笔者在学习的时候感觉我们的日常开发是使用不到的。《深入理解Java虚拟机》一书中也强调不要去使用相关的方法。那为什么写这部分呢?两个原因:面试、拓宽知识面。“面试造火箭”,当然什么问题都不会放过,特别是这种问题,面试官很喜欢问。其次我认为更重要的是能够对GC 机制的知识有更加全面的学习。

既然我问了这个问题,那肯定不是的。我们先来回顾一下GC的流程:

  1. 首先确定要进行GC的区域。这里更加细分,新声代还是整个堆区?(事实上很少单独对老年代进行GC)
  2. 找出所有需要回收的对象或者存活的对象,并打上标记
  3. GC收集器开始收集。根据不同的算法采取不同的方式释放内存

而在2,3之间缺少了一个步骤,也就是被标记为垃圾不一定会被回收的原因。

在对象被第一次标记为垃圾之后,如果该对象重写了finalize方法且没有被执行过,会把对象放到一个队列中,等待虚拟机的调用。如果对象在finalize方法中把自己赋值给了别的引用,那么他就不会被回收.
如果对象已经执行了一次finalize方法,那么下一次标记他就会直接回收了,不会执行第二次finalize方法。

所以当对象被标记为垃圾之后,如果他重写了finalize,那么会进入到一个队列中,等待虚拟机来调用finalize方法。通过调用finalize方法把自己赋值给其他引用来让自己活下去。但是:虚拟机使用一个低优先级的线程来执行此任务,且不会等待该finalize方法运行结束。原因是如果finalize发生了死循环会造成阻塞,导致整个GC无法运行。

所以可以看到finalize方法有两个个非常大缺点:执行时间与完成度不确定、代价高昂。在《深入理解Java虚拟机》一书中作者的观点是:把这个方法忘了,不要去使用它。而我们仅做一些了解即可。那么下面讨论一个很类似的问题,调用了System.gc()就一定会触发回收吗?

调用System.gc()一定会触发回收吗

答案肯定是否。根据官方的注释,这个方法只是通知虚拟机需要进行垃圾回收,虚拟机会尽量帮你做,但是什么时候做、甚至做不做都是不确定的。所以从官方的注释中就已经可以看出来这个回收时不确定的,包括Runtime.getRuntime.gc(),本质上前者调用了后者。而关于System.gc(),有众多的不建议使用的观点:

  1. 当调用了System.gc()之后,我们不确定虚拟机会做什么。完全不确定GC什么时候运行、如何运行、甚至运不运行。
  2. 该方法会降低虚拟机的GC效率。虚拟机拥有自己的垃圾收集策略,而我们的操作会破坏这个策略,降低了性能。例如前面的“和稀泥”法,原本虚拟机应该是在碎片化空间影响对象的创建的时候再进行GC,而如果我们提前进行gc则会破坏这个策略。
    1. 虚拟机的GC机制比我们更懂得如何去进行GC,我们完全没有必要去手动GC。

当然也有赞同使用的观点:

  1. 建议不要依赖它做任何事情是正确的。不要依赖于它的工作,但是暗示现在是一个可以接受的收集时间是完全可以的。
  2. 当试图找出某个特定对象是否泄露时也可以使用此方法。
  3. 如果虚拟机的策略是内存不足再GC,而后续有更重要的任务,那么可以提前进行GC。
  4. 在计算机空闲的时候可以进行GC。

上面我只整理了一部分的观点,有兴趣的读者可以去搜索引擎搜索一下。下面谈谈我自己的观点:

只有当自己明确知道自己在做什么且结果是什么的情况下,才去做。我们会发现,反对使用gc方法的观点都是围绕不确定性和性能降低来展开,而为什么会导致这两个问题,归根结底在于在使用gc()这个方法的时候根本不清楚发生了什么事。gc()执行策略不仅受到虚拟机GC算法的影响,还受到了当前运行状况的影响。当确定需要在一个地方使用gc()方法,且通过全面的测试证实有效,那么使用gc()方法是完全没问题的。而在不确定gc()会对系统造成什么影响的情况下,乱用gc()显然是完全不好的。

总结

好了,到这里关于JVM的GC机制就讲得差不多了。让我们再来回顾一下。关于GC有四个最重要的问题:什么是GC,什么地方需要GC,什么时候GC,如何GC。相信阅读完这篇文章对这些问题已经有了自己的答案。文章第一部分讲了需要回收的内存区域,同时讨论了方法区是否需要回收的问题;第二部分讨论了如何判断一个对象是否是垃圾,也就是垃圾标记算法;第三部分讨论了如何提高垃圾收集的性能,以分代理论为基础讨论了不同的收集算法;最后再探讨了两个常见的问题。

那么到这里关于JVM GC机制的基础理论内容就讲完了。希望文章对你有帮助。

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
笔者能力有限,有任何想法欢迎评论区交流指正。
如需转载请私信交流。

另外欢迎光临笔者的个人博客:传送门