1. 前言

  Java和C++之间显著的一个区别就是对内存的管理。和C++把内存管理的权利赋予给开发人员的方式不同,Java拥有一套自动的内存回收系统(Garbage Collection,GC)简称GC,可以无需开发人员干预而对不再使用的内存进行回收管理。

  垃圾回收技术(以下简称GC)是一套自动的内存管理机制。当计算机系统中的内存不再使用的时候,把这些空闲的内存空间释放出来重新投入使用,这种内存资源管理的机制就称为垃圾回收。

  其实GC并不是Java的专利,GC的的发展历史远比Java来得久远的多。早在Lisp语言中,就有GC的功能,包括其他很多语言,如:Python(其实Python的历史也比Java早)也具有垃圾回收功能。

  使用GC的好处,可以把这种容易犯错的行为让给计算机系统自己去管理,可以防止人为的错误。同时也把开发人员从内存管理的泥沼中解放出来。

  虽然使用GC虽然有很多方便之处,但是如果不了解GC机制是如何运作的,那么当遇到问题的时候,我们将会很被动。所以有必要学习下Java虚拟机中的GC机制,这样我们才可以更好的利用这项技术。当遇到问题,比如内存泄露或内存溢出的时候,或者垃圾回收操作影响系统性能的时候,我们可以快速的定位问题,解决问题。

  接下来,我们来看下JVM中的GC机制是怎么样的。

2. 哪些内存可以回收

  首先,我们如果要进行垃圾回收,那么我们必须先要识别出哪些是垃圾(被占用的无用内存资源)。

  Java虚拟机将内存划分为多个区域,分别做不同的用途。简单的将,JVM对内存划分为这几个内存区域:程序计数器、虚拟机栈、本地方法栈、Java堆和方法区。其中程序计数器、虚拟机栈和本地方法栈是随着线程的生命周期出生和死亡的,所以这三块区域的内存在程序执行过程中是会有序的自动产生和回收的,我们可以不用关心它们的回收问题。剩下的Java堆和方法区,它们是JVM中所有线程共享的区域。由于程序执行路径的不确定性,这部分的内存分配和回收是动态进行的,GC主要关注这部分的内存的回收。

  对像实例是否是存活的,有两种算法可以用于确定哪些实例是死亡的(它们占用的内存就是垃圾),那么些实例是存活的。第一种是引用计数算法:

2.1 引用计数算法

  引用计数算法会对每个对象添加一个引用计数器,每当一个对象在别的地方被引用的时候,它的引用计数器就会加1;当引用失效的时候,它的引用计数器就会减1。如果一个对象的引用计数变成了0,那么表示这个对象没有被任何其他对象引用,那么就可以认为这个对象是一个死亡的对象(它占用的内存就是垃圾),这个对象就可以被GC安全地回收而不会导致系统出现问题。

  我们可以发现,这种计数算法挺简单的。在C++中的智能指针,也是使用这种方式来跟踪对象引用的,来达到内存自动管理的。引用计数算法实现简单,而且判断高效,在大部分情况下是一个很好的垃圾标记算法。在Python中,就是采用这种方式来进行内存管理的。但是,这个算法存在一个明显的缺陷:如果两个对象之间有循环引用,那么这两个对象的引用计数将永远不会变成0,即使这两个对象没有被任何其他对象引用。

public class ReferenceCountTest {

    public Object ref = null;

    public static void main(String ...args) {
ReferenceCountTest objA = new ReferenceCountTest();
ReferenceCountTest objB = new ReferenceCountTest(); // 循环引用 objA <--> objB
objA.ref = objB;
objB.ref = objA; // 去除外部对这两个对象引用
objA = null;
objB = null; System.gc();
}
}

上面的代码就演示了两个对象之间出现循环引用的情况。这个时候objA和objB的引用计数都是1,由于两个对象之间是循环引用的,所以它们的引用计数将一直是1,而即使这两个对象已经不再被系统所使用到。

由于引用计数这种算法存在这种缺陷,所以就有了一种称为“可达性分析算法”的算法来标记垃圾对象。

2.2 可达性分析算法

  通过可达性分析算法来判断对象存活,可以克服上面提到的循环引用的问题。在很多编程语言中都采用这种算法来判断对象是否存活。

  这种算法的基本思路是,确定出一系列的称为“GC Roots”的对象,以这些对象作为起始点,向下搜索所有可达的对象。搜索过程中所走过的路径称为“引用链”。当一个对象没有被任何到“GC Roots”对象的“引用链”连接的时候,那么这个对象就是不可达的,这个对象就被认为是垃圾对象。

  从上面的图中可以看出,object1~4这4个对象,对于GC Roots这个对象来说都是可达的。而object5~7这三个对象,由于没有连接GC Roots的引用链,所以这三个对象时不可达的,被判定为垃圾对象,可以被GC回收。

  在Java中,可以作为GC Roots的对象有以下几种:

  • 虚拟机栈中的本地变量表中引用的对象,也就是正在执行函数体中的局部变量引用的对象。
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中(Native方法中)引用的对象  

2.3 怎么判一个对象"死刑"

  当通过可达性分析算法判定为不可达的对象,我们也不能断定这个对象就是需要被回收的。当我们需要真正回收一个对象的时候,这个对象必须经历至少两次标记过程:

  当通过可达性分析算法处理以后,这个对象没有和GC Roots相连的引用链,那么这个对象就会被第一次标记,并判断对象的finalize()方法(在Java的Object对象中,有一个finalize()方法,我们创建的对象可以选择是否重写这个方法的实现)是否需要执行,如果对象的类没有覆盖这个finalize()方法或者finalize()已经被执行过了,那么就不需要再执行一次该方法了。

  如果这个对象的finalize()方法需要被执行,那么这个对象会被放到一个称为F-Queue的队列中,这个队列会被由Java虚拟机自动创建的一个低优先级Finalizer线程去消费,去执行(虚拟机只是触发这个方法,但是不会等待方法调用返回。这么做是为了保证:如果方法执行过程中出现阻塞,性能问题或者发生了死循环,Finalizer线程仍旧可以不受影响地消费队列,不影响垃圾回收的过程)队列中的对象的finalize()方法。

  稍后,GC会对F-Queue队列中的对象进行第二次标记,如果在这次标记发生的时候,队列中的对象确实没有存活(没有和GC Roots之间有引用链),那么这个对象就确定会被系统回收了。当然,如果在队列中的对象,在进行第二次标记的时候,突然和GC Roots之间创建了引用链,那么这个对象就"救活"了自己,那么在第二次标记的时候,这个存活的对象就被移除出待回收的集合了。所以,通过这种两次标记的机制,我们可以通过在finalize()方法中想办法让对象重新和GC Roots对象建立链接,那么这个对象就可以被救活了。

  下面的代码,通过在finalize()方法中将this指针赋值给类的静态属性来"拯救"自己:

public class FinalizerTest {
private static Object HOOK_REF; public static void main(String ...args) throws Exception {
HOOK_REF = new FinalizerTest(); // 将null赋值给HOOK_REF,使得原先创建的对象变成可回收的对象
HOOK_REF = null;
System.gc();
Thread.sleep(1000); if (HOOK_REF != null) {
System.out.println("first gc, object is alive");
} else {
System.out.println("first gc, object is dead");
} // 如果对象存活了,再次执行一次上面的代码
HOOK_REF = null;
System.gc();
if (HOOK_REF != null) {
System.out.println("second gc, object is alive");
} else {
System.out.println("second gc, object is dead");
}
} @Override
protected void finalize() throws Throwable {
super.finalize();
// 在这里将this赋值给静态变量,使对象可以重新和GC Roots对象创建引用链
HOOK_REF = this;
System.out.println("execute in finalize()");
}
} #output:

  execute in finalize()
  first gc, object is alive
  second gc, object is dead

  可以看到,第一次执行System.gc()的时候,通过在方法finalize()中将this指针指向HOOK_REF来重建引用链接,使得本应该被回收的对象重新复活了。而对比同样的第二段代码,没有成功拯救的原因是:finalize()方法只会被执行一次,所以当第二次将HOOK_REF赋值为null,释放对对象的引用的时候,由于finalize()方法已经被执行过一次了,所以没法再通过finalize()方法中的代码来拯救对象了,导致对象被回收。

3. 怎么回收内存

  上面我们已经知道了怎么识别出可以回收的垃圾对象。现在,我们需要考虑如何对这些垃圾进行有效的回收。垃圾收集的算法大致可以分为三类:

  1. 标记-清除算法
  2. 标记-复制算法
  3. 标记-整理算法

  这三种算法,适用于不同的回收需求和场景。下面,我们来一一介绍下每个回收算法的思想。

3.1 标记-清除算法

  "标记-清除"算法是最基础的垃圾收集算法。标记-清除算法在执行的时候,分为两个阶段:分别是"标记"阶段和"清除"阶段。

  在标记阶段,它会根据上面提到的可达性分析算法标记出哪些对象是可以被回收的,然后在清除阶段将这些垃圾对象清理掉。

  算法思路很简单,但是这个算法存在一些缺陷:首先标记和清除这两个过程的效率不高,其次是,直接将标记的对象清除以后,会导致产生很多不连续的内存碎片,而太多不连续的碎片会导致后续分配大块内存的时候,没有连续的空间可以分配,这会导致不得不再次触发垃圾回收操作,影响性能。

  

3.2 复制算法

  复制算法,顾名思义,和复制操作有关。该算法将内存区域划分为大小相等的两块内存区域,每次只是用其中的一块区域,另一块区域闲置备用。当进行垃圾回收的时候,会将当前是用的那块内存上的存活的对象直接复制到另外一块闲置的空闲内存上,然后将之前使用的那块内存上的对象全部清理干净。

  这种处理方式的好处是,可以有效的处理在标记-清除算法中碰到的内存碎片的问题,实现简单,效率高。但是也有一个问题,由于每次只使用其中的一半内存,所以在运行时会浪费掉一半的内存空间用于复制,内存空间的使用率不高。

3.3 标记-整理算法

  标记-整理算法,思路就是先进行垃圾内存的标记,这个和标记-清除算法中的标记阶段一样。当将标记出来的垃圾对象清除以后,为了避免出现标记-清除算法中碰到的内存碎片问题,标记-整理算法会对内存区域进行整理。将当前的所有存活的对象移动到内存的一端,将一端的空闲内存整理出来,这样就可以得到一块连续的空闲内存空间了。

  这样做,可以很方便地申请新的内存,只要移动内存指针就可以划出需要的内存区域以存放新的对象,可以在不浪费内存的情况下高效的分配内存,避免了在复制算法中浪费一部分内存的问题。

4. 分代收集

  在现代虚拟机实现中,会将整块内存划分为多个区域。用"年龄"的概念来描述内存中的对象的存活时间,并将不同年龄段的对象分类存放在不同的内存区域。这样,就有了我们平时听说的"年轻代"、"老年代"等术语。

  顾名思义,"年轻代"中的对象一般都是刚出生的对象,而"老年代"中的对象,一般都是在程序运行阶段长时间存活的对象。将内存中的对象分代管理的好处是,可以按照不同年龄代的对象的特点,使用合适的垃圾收集算法。

  对于"年轻代"中的对象,由于其中的大部分对象的存活时间较短,很多对象都撑不过下一次垃圾收集,所以在年轻代中,一般都使用"复制算法"来实现垃圾收集器。

  在上图中,我们可以看到"Young Generation"标记的这块区域就是"年轻代"。在年轻代中,还细分了三块区域,分别是:"eden"、"S0"和"S1",其中"eden"是新对象出生的地方,而"S0"和"S1"就是我们在复制算法中说到了那两块相等的内存区域,称为存活区(Survivor Space)。

  这里用于复制的区域只是占用了整个年轻代的一部分,由于在新生代中的对象大部分的存活时间都很短,所以如果按照复制算法中的以1:1的方式来平分年轻代的话,会浪费很多内存空间。所以将年轻代划分为上图中所示的,一块较大的eden区和两块同等大小的survivor区,每次只使用eden区和其中的一块survivor区,当进行内存回收的时候,会将当前存活的对象一次性复制到另一块空闲的survivor区上,然后将之前使用的eden区和survivor区清理干净,现在,年轻代可以使用的内存就变成eden区和之前存放存活对象的那个survivor区了,S0和S1这两块区域是轮替使用的。

  HotSpot虚拟机默认Eden区和其中一块Survivor区的占比是8:1,通过JVM参数"-XX:SurvivorRatio"控制这个比值。SurvivorRatio的值是一个整数,表示Eden区域是一块Survivor区域的大小的几倍,所以,如果SurvivorRatio的值是8,那么Eden区和其中Survivor区的占比就是8:1,那么总的年轻代的大小就是(Eden + S0 + S1) = (8 + 1 + 1) = 10,所以年轻代每次可以使用的内存空间就是(Eden + S0) = (8 + 1)  = 9,占了整个年轻代的 9 / 10 = 90%,而每次只浪费了10%的内存空间用于复制。

  并不是留出越少的空间用于复制操作越好,如果在进行垃圾收集的时候,出现大部分对象都存活的情况,那么空闲的那块很小的Survivor区域将不能存放这些存活的对象。当Survivor空间不够用的时候,如果满足条件,可以通过分配担保机制,向老年代申请内存以存放这些存活的对象。

  对于老年代的对象,由于在这块区域中的对象和年轻代的对象相比较而言存活时间都很长,在这块区域中,一般通过"标记-清理算法"和"标记-整理算法"来实现垃圾收集机制。上图中的Tenured区域就是老年代所在的区域。而最后那块Permanent区域,称之为永久代,在这块区域中,主要是存放类对象的信息、常量等信息,这个区域也称为方法区。在Java 8中,移除了永久区,使用元空间(metaspace)代替了。

5. 总结

  在这篇文章中,我们首先介绍了采用最简单的引用计数法来跟踪垃圾对象和通过可达性分析算法来跟踪垃圾对象。然后,介绍了垃圾回收中用到的三种回收算法:标记-清除、复制、标记-整理,以及它们各自的优缺点。最后,我们结合上面介绍的三种回收算法,介绍了现代JVM中采用的分代回收机制,以及不同分代采用的回收算法。

学习JVM-GC原理的更多相关文章

  1. 学习JVM虚拟机原理总结

    0x00:JAVA虚拟机的前世今生 1991年,在Sun公司工作期间,詹姆斯·高斯林和一群技术人员创建了一个名为Oak的项目,旨在开发运行于虚拟机的编程语言,允许程序多平台上运行.后来,这项工作就演变 ...

  2. JVM GC原理

    JVM原理 1.分代回收(目前JDK都采用此方式) 采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收.非堆区有CMS Perm Gen(持久化 ...

  3. JVM初探- 内存分配、GC原理与垃圾收集器

    JVM初探- 内存分配.GC原理与垃圾收集器 标签 : JVM JVM内存的分配与回收大致可分为如下4个步骤: 何时分配 -> 怎样分配 -> 何时回收 -> 怎样回收. 除了在概念 ...

  4. JVM相关文章和GC原理算法

    参考推荐: Java内存模型及GC原理 一个优秀的Java程序员必须了解的GC机制 Android 智能指针原理(推荐) Java虚拟机规范 Java虚拟机参数 Java内存模型 Java系列教程(推 ...

  5. 一篇笔记整理JVM工作原理

    首先要了解的 >>数据类型 Java虚拟机中,数据类型可以分为两类:基本类型和引用类型. 基本类型的变量保存原始值,即:他代表的值就是数值本身:而引用类型的变量保存引用值.“引用值”代表了 ...

  6. 【深入JVM内核—原理、诊断与优化】第2期开课了

    [深入JVM内核—原理.诊断与优化]的讲师“葛一鸣”,人称“一哥”,毕业于浙江工业大学,计算机软件与理论专业硕士,是国家认证系统分析师,OCP.2012年出版过<Java程序性能优化>,荣 ...

  7. 应用JConsole学习Java GC

    应用JConsole学习Java GC 关于Java GC的知识,好多地方都讲了很多,今天我用JConsole来学习一下Java GC的原理. GC原理 在我的上一篇中介绍了Java运行时数据区,在了 ...

  8. Java 详解 JVM 工作原理和流程

    Java 详解 JVM 工作原理和流程 作为一名Java使用者,掌握JVM的体系结构也是必须的.说起Java,人们首先想到的是Java编程语言,然而事实上,Java是一种技术,它由四方面组成:Java ...

  9. 一篇笔记带你梳理JVM工作原理

    首先要了解的 数据类型 Java虚拟机中,数据类型可以分为两类:基本类型和引用类型. 基本类型的变量保存原始值,即:他代表的值就是数值本身:而引用类型的变量保存引用值.“引用值”代表了某个对象的引用, ...

  10. 一文读懂Java GC原理和调优

    概述 本文介绍GC基础原理和理论,GC调优方法思路和方法,基于Hotspot jdk1.8,学习之后将了解如何对生产系统出现的GC问题进行排查解决 阅读时长约30分钟,内容主要如下: GC基础原理,涉 ...

随机推荐

  1. C# System.Timers.Timer定时器的使用和定时自动清理内存应用

    项目比较大有时候会比较卡,虽然有GC自动清理机制,但是还是有不尽人意的地方.所以尝试在项目启动文件中,手动写了一个定时器,定时清理内存,加快项目运行速度. public class Program { ...

  2. gRPC官方文档(概念)

    文章来自gRPC 官方文档中文版 gRPC 概念 本文档通过对于 gRPC 的架构和 RPC 生命周期的概览来介绍 gRPC 的主要概念.本文是在假设你已经读过文档部分的前提下展开的.针对具体语言细节 ...

  3. iOS开发之蓝牙使用-建立连接的

    1.大佬笔记 CSDN 2.代码 github

  4. LINQ和Lambda表达式

    前言 前段时间接触了一种新的表达式,但是不知道这个是什么意思,所以就先站在巨人的肩膀用了,现在听师哥说这种写法是Lambda表达式.我一直以为,这个Lambda表达式和LINQ查询有异曲同工之妙,可惜 ...

  5. 条件概率全概率公式-Tribles

    条件概率,全概率公式,贝叶斯公式 条件概率:在另外一个事件 B 已经发生的条件下,事件 A 发生的概率叫做在 A 对于 B 的条件概率,记作 \(p(A|B)\).显然\(p(AB)=p(A|B)p( ...

  6. 剑指offer —— 替换空格

    1.问题:请实现一个函数,将一个字符串中的空格替换成“%20”.例如,当字符串为We Are Happy.则经过替换之后的字符串为We%20Are%20Happy. 2.思路:可能首先想到的应该就是 ...

  7. P2866 [USACO06NOV]糟糕的一天Bad Hair Day

    题意:给你一个序列,问将序列倒过来后,对于每个点,在再碰到第一个比它大的点之前,有多少比它小的? 求出比它小的个数的和 样例: 610374122 output: 5 倒序后:2    12    4 ...

  8. Linq中的group by多表多字段,Sum求和

    //Line to Sql 写法 var data = (from a in Items group a by new { a.GroupId, a.Id } into b //orderby new ...

  9. docker的常用操作

    查看所有的镜像: docker images 查看所有的容器: docker ps -a 查看正在运行的容器: docker ps 移除容器: docker rm -f 容器id 移除镜像: dock ...

  10. Android点击事件(click button)的四种写法

    在学习android开发和测试的时候发现不同的人对于click事件的写法是不一样的,上网查了一下,发现有四种写法,于是想比较一下四种方法的不同 第一种方法:匿名内部类 代码: package com. ...