jvm专题系列—详解垃圾回收机制及其算法

引言

  相信大家都知道,java的内存是由java虚拟机,也就是jvm自动管理的,与c和c++不同,java开发者不需要为每一个new操作去配置delete/free,而且不容易发生内存泄漏和内存溢出,但也正因如此,如果java开发者不了解java虚拟机的运行机制,当发生内存方面的问题时,排查工作将会十分困难。另外,程序的性能跟GC(垃圾回收器)也密切相关,如果想要进行系统调优,有时候只在代码层面进行优化是不够的,这就要求我们必须对垃圾回收的原理,算法有所了解。同时对于jvm的了解程度也是区分中、高级程序员的重要标志之一。本篇博客以理论介绍为主(下篇博客将以实例的方式讲解垃圾回收器,常用的工具以及jvm调优),毕竟理论是实践的基础,需循序渐进,心急不得。下面就让我们走进jvm的大门,了解java程序运行背后的秘密。

java内存区域

  在讲解垃圾回收之前我们首先需要对java的内存区域有个基本的了解,java程序在运行的过程中主要分为以下几个内存区域,如下图所示:
内存区域
  相信看过<<深入理解java虚拟机>>的朋友对这幅图会感觉似曾相识,好了,简单介绍一下每个内存区域吧。
  方法区:线程之间共享,存储类信息,常量,静态变量等数据,方法区其实是堆的一个逻辑部分,但通常我们把它作为一个独立的部分,我们所熟悉的常量池也存在于方法区当中。
  堆:堆是jvm中内存区域最大的一块,被所有线程共享,几乎所有的对象实例和数组都要在堆上分配各自的内存,注意是几乎,并不是所有,这里为了方便理解,可以认为是所有(由于JIT和逃逸分析技术的发展,这其中发生着一些微妙的变化,我也是最近才知道)。
  虚拟机栈:这也就是我们常说的栈了,线程私有,随线程生而生,随线程死而死,每个方法执行会创建一个栈帧,存储局部变量(基本数据类型、对象引用)、动态链接,方法出口等信息。
  程序计数器:线程私有,可以理解为记录程序执行位置的内存区域。
  本地方法栈:跟虚拟机栈类似,只不过存储的是native方法相关的信息。
  到这里我们对jvm内存区域有了一个基本的认知,下面我们便开始对垃圾回收进行讲解。

垃圾回收机制

  通过上节分析我们已经知道除了堆(方法区视为堆的一部分)以外,其他区域的生死存亡=线程的生命周期,需要为它们分配多少内存在类结构确定下来时即为已知,所以不需要过多考虑内存的分配和回收问题,但堆就不一样了,只有程序运行起来jvm才能知道哪些对象需要被创建以及分配的存储空间,所以这部分内存是动态的,我们常说的jvm垃圾回收所说的也是堆上的内存。
  那么回收对象,是根据什么来判定这个对象可以被回收呢?这就涉及到垃圾回收的机制,换言之:如何判断对象的死活?
  很多人都会回答引用计数法,顾名思义,这种判断方法十分简单粗暴:为每个对象创建一个引用计数器,当有地方引用它时,计数器+1,引用失效时,计数器-1,当计数器为0时则表示该对象可以被回收。但是,事实上主流的jvm并没有选用引用计数法来管理内存,这主要是因为它难以解决循环引用的问题。
  让我们来看下面一段代码:

public class testGc{
	public Object instance = null;
	
	public static void test(){
		testGc a = new testGc();
		testGc b = new testGc();
		a.instance = b;
		b.instance = a;
		a = null;
		b = null;
		System.gc();
	}
}

  这是一个非常经典的循环引用的实例,a和b互相引用着对方,但是实际上这两个对象已经不会再被使用,但它们互相引用着,引用计数器永远都不会为0,因此如果jvm采用的是引用计数法,这两个对象无法通过GC进行回收。可事实不是这样,通过查看GC日志(先不用关心日志怎么来的,以后会进行讲解)可以清楚的看到GC成功的对他们进行了回收。
  其实不止java,包括c#,它们都是通过可达性分析算法来判定对象死活的。这个算法的基本思想是通过很多称为“gc roots”的对象为起点,从这些节点往下搜索,走过的路径称为引用链,当某个对象没有任何一个引用链与gc roots相连接时,则证明此对象可以被回收,如下图:

垃圾回收机制
  相信你已经一目了然,图中的d e f对象被判定为可以回收的对象。介绍完了垃圾回收机制,下面介绍一下垃圾回收的主要算法。

垃圾回收算法

  想要高效的回收垃圾,自然离不开算法的支持,下面介绍一下垃圾回收几种主要的算法思想。

标记-清除算法

  首先是最基础的标记-清除算法,根据名字我们可以从中得知此算法包含两个阶段,标记和清除,与gc roots不存在引用链的对象即可以进行标记,标记完毕之后就可以挨个清除,算法很好理解,过程如下图所示:
标记清除
  算法简单自然存在不足,主要有两点不足:1.标记和清除的效率不高。其实也很好理解,标记的时候挨个标记,清除的时候也要挨个清除。2.容易造成大量的内存碎片。经过清除的内存与存活对象占有的内存混在一起,造成了不连续的空间碎片,当需要分配较大的对象时,因为无法找到连续的足够空间便会不得不触发GC回收。

复制算法

  为了解决效率问题,复制算法应运而生,它将内存分为大小相同的两块,每次使用其中的一块,当其中一块的内存用尽之时,把存活的对象复制到另一块上面,然后把使用过的内存空间一次性(无需挨个清理,提升了效率)清理干净,循环往复,每次都只对半块区域进行回收,这样自然也不会存在空间碎片的问题。只是这种算法每次只能使用一半内存,代价未免过于高昂了。
  事实上这种算法被运用于回收jvm新生代(jvm内存分为新生代、老生代、永久代,新生代可以理解为刚生成不久的对象,老生代可以理解为经过多次垃圾回收依然坚挺着存活下来的对象,永久代可以理解为长生的对象,但也并非绝对长生,只是很少对其进行回收)的内存,研究表明,新生代中的对象98%都死的很快,所以无需按照1:1的比例来分配内存空间,而是分成了一块内存较大的eden空间和两块survivor空间(from survivor、to survivor),发生垃圾回收的时候,将eden和其中一块survivor中的对象复制到另一块survivor上,最后清理掉eden和使用过的survivor,默认eden和survivor的内存分配比例为8:1:1,所以这样只有百分之10的内存被浪费,当survivor中的内存不足时,就会通过分配担保机制将本次存活下来的对象分配到老生代之中,老生代再不够,触发full gc,再不够,坐等oom就可以了,如果你的程序经常进行full gc操作,那么很可能发生了内存泄漏。复制算法过程如下图(凑合看吧,实在不想画图):
复制算法

标记-整理算法

  标记整理算法跟标记清除算法相同,只不过它在回收之前会先将存活的对象移动到一端,然后再清理掉边界以外的内存,过程如下图所示:标记整理算法
  如果对象的存活率较高,复制收集算法便不再适用,首先复制效率变低,每次都要复制大量活对象,其次,如果存活率高,必然需要浪费更多的内存,当内存不够的时候还要进行分配担保,所以对于老生代来说,标记整理算法更加适合。

分代收集算法

  现在商用的虚拟机采用的均为分代收集算法,其实它就是复制收集算法和标记整理算法的结合,新生代对象存活率低,采用复制算法,老生代对象存活率高,采用标记整理算法进行收集,分代内存模型图如下:
分代模型图

小结

  好,本次的博客就到此为止,只有真正理解这些理论之后才能继续接下来的学习,下次我会介绍垃圾回收器以及jvm调优相关的知识,感谢您的观看,再见!

注:本文多处参考<<深入理解java虚拟机>>

发布了26 篇原创文章 · 获赞 99 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/m0_37719874/article/details/103552397
今日推荐