垃圾回收机制让开发者不需要考虑内存管理,这样不仅提高了开发效率,还改善了内存的使用状况。
Java堆内存
堆在JVM启动时被创建,用来维护运行时数据, 运行时创建的对象都是基于这块内存空间,如果动态创建的对象没有得到及时回收,则会持续堆积造成堆空间被占满,最终内存溢出。
因此Java提供了一种垃圾回收机制,在后台创建一个守护进程。会自动把堆空间的垃圾全部进行回收,从而保证程序的正常运行。
垃圾收集算法
Java语言规范没有明确的说明JVM使用哪种垃圾回收算法,但是垃圾收集算法一般都要做以下两件事:
- 发现垃圾(无用、不再存活的对象);
- 回收被垃圾占用的内存空间。
大多数的垃圾回收算法使用了根集(root set)这个概念;垃圾回收首先会确定从根开始哪些是可达的,哪些是不可达的,从根集可达的对象是活动的对象,它们不能作为垃圾被回收。从根集通过任意路径不可达的对象被认为是应该被回收的垃圾。
根集:正在运行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量)。程序可以使用引用变量访问对象的属性和调用对象的方法。
常见的垃圾回收算法有以下几种:
引用计数法
引用计数法没有使用根集概念。该算法使用引用计数器来区分存活对象和不再使用的对象。每一个创建的对象会对应一个引用计数器,存储该对象被引用的次数。当每一次创建一个对象并赋值给任意变量时,引用计数器的值置为1。当对象被赋给任意变量时,引用计数器值会每次加1。当对象出了作用域后(变量取消引用该对象),引用计数器值会减1,当引用计数器值为零时,意味着没有任何变量再使用这个对象(认为这个对象不再存活)。
基于引用计数器的垃圾收集器运行较快,不会长时间中断程序执行,但引用计数器增加了程序执行开销,且这种方案存在严重的问题,它无法检测“循环引用”:当两个对象互相引用,即使它俩都不被外界任何东西引用,它俩的计数都不为零,永远不会被垃圾回收器回收。
可达性分析
可达性分析算法是为了解决引用计数法的问题而提出的,它使用了根集的概念,把所有对象想象成一棵树,从根节点出发,将可达的对象标记为“存活”。其余的对象则被视为“死亡”(不可达的)。
基于可达性分析算法的垃圾收集也被称为标记和清除垃圾收集算法。
根集本身一定是可达的,这样从它们触发遍历到的对象才能保证一定可达。
Java中一定可达的对象主要有以下四种:
- 虚拟机栈(帧栈中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI引用的对象。
回收垃圾机制
参考下图,黑色的表示垃圾,灰色表示存活对象,绿色表示空白空间。
要如何回收这些垃圾呢?
标记 - 清理
- 标记垃圾对象:所谓“标记”就是利用可达性遍历堆内存,把“存活”对象和“垃圾”对象进行标记,得到的结果如上图;
- 清理垃圾对象:把所有“垃圾”对象所占的空间清理回收。
结果如下:
这便是标记 - 清理方案,方便简单但是容易产生内存碎片。
标记 - 整理
为了解决内存碎片问题,在清理的时候,把所有存活的对象重新扎堆到同一片内存区域,这样就解决了内存碎片的问题。
以上两种方案适合存活对象多,垃圾少的情况。只需要清理掉少量的垃圾。然后挪动存活对象就可以。
复制
复制方法直接把堆内存分成两部分,一段时间内只允许在其中一块内存上进行分配,当这块内存被分配完后,则执行垃圾回收,把所有存活的对象复制到另一块内存上,然后清空当前内存。
最初只使用上部分的内存,直到内存使用完毕,进行垃圾回收,把所有存活对象复制到下部分,然后清空上部分内存。
这种做法不容易产生碎片,也简单粗暴;但是这意味着在一段时间只能使用一部分的内存,如果内存容量较小,意味着堆内存会频繁的复制清空。
复制方案适合存活对象少,垃圾多的情况,这样在复制时就不需要复制多少对象到另一块内存,多数垃圾直接被清空。
Java堆结构
一块Java堆空间一般分成三个部分,这三部分用来存储三类数据:
- 刚刚创建的对象。在程序运行时会持续不断地创造新的对象,这些对象会被统一放在一起。因为有很多局部变量等在新创建后很快就会变成不可达的对象,快速死去,因此这块区域的特点是存活对象少,垃圾多。可以形象的描述这块区域为:新生代;
- 存活了一段时间的对象。这些对象被创建了很久,并且一直活了下来。这些存活时间较长的对象会被放在一起,他们的特点是存活对象多,垃圾少。可以形象的描述这块区域为:老年代;
- 永久存在的对象。比如一些静态文件,这些对象的特点是不需要垃圾回收,永远存活。可以形象的描述这块区域为: 永生代(Java 8中已经把永久代删除,把这块内存空间给了元空间)
结合新生代和老年代的对象特点,对应的回收机制也不同,
新生代 - 复制 回收机制
由于新生代内存区域,会有大量新对象死去,只有少量存活。因此适合采用复制 回收算法,GC时把少量的存活对象复制到另一片内存。
针对复制算法,有以下几种思路:
- 把内存平均分成两等分:这样做的问题是内存空间的利用率很低只有一半的空间可以使用,对于新生代而言垃圾回收事件会发生的很频繁,影响正常程序运行。
- 把内存分成大小不等的两部分:如果划分的两部分的内存大小差异很小,则算法退化成了平分复制算法;如果大小差异很大,会存在一个很严重的问题,假设两个内存块的内存比例为1:9,由于内存空间比例相差很大,这就意味着,当从内存比例为9的内存区复制到内存比例为1的内存区时,由于内存区装不下那么多对象,而使得那些并不老的对象被放到了老年区,这就破坏了规则,且影响了老年区的垃圾回收的效率。
- 将内存分成一大两小三份(例如8:1:1),为了方便解释,将三个分区按序编号为1、2、3。首先1对外提供内存,快满时进行垃圾回收,将存活对象放入2;1清空后对外继续提供堆内存;当1再次被填满,此时对1和2同时进行垃圾回收,将存活对象存入3,同时清空1和2的内存;1继续对外提供内存,填满时进行垃圾回收把存活对象存入2,清空1和3的内存;重复上述操作...(这样便能确保进入老年代内存区的是真正的老对象)
显而易见最优的思路是3,因为觉得没必要展开来讲,偷个懒用文字表述了=V=。
老年代 - 标记整理 回收机制
由于老年代一般存放的是存活时间较久的对象,所以每次GC时,只有少部分对象被回收。因此选择使用标记整理垃圾回收机制,仅仅通过少量地移动对象就能清理垃圾,而且不存在内存碎片问题。
参考资料:
- Java技术之垃圾回收机制
- Java垃圾回收机制(GC)详解