>

本身也来谈一谈Java

- 编辑:金沙国际平台登录 -

本身也来谈一谈Java

  关于Java中的GC,简单来说就是垃圾收集器自动回收生命周期结束的对象,释放内存。

以前写c/c++的时候虽然也有shared_ptr这样的自动内存管理机制,但是它内部其实是通过引用计数的原理进行内存管理的,容易产生循环应用的问题,也没有什么实际意义上的内存收集器。和java的内存收集机制差别很大,所以一直对java的内存收集机制抱有很强的好奇心。

  那么怎样确定对象是否存活呢?

最近在看《深入理解 Java 虚拟机-Jvm 高级特性与最佳实践》,里面对java垃圾收集讲的挺不错的。然后再将书中没有讲透的知识在网上搜索了下,整理成了这篇博客,哪天一时半会记不起来的时候还能回来瞧一瞧。

可达性分析算法

GC Roots

在Java虚拟机中,存在自动内存管理和垃圾回收机制,能自动回收没有用的对象的内存。

那怎么去判定一个对象是否还有用呢?java中是通过可达性分析来判定的。

具体的做法就是从一系列被称作"GC Roots"的对象作为起始点,从这些对象开始通过引用关系进行搜索。当GC Roots到某个对象没有任何引用链相连,则证明此对象是不可用的,是不需要存活,可以被清理的。

例如下图的object1、object2、object3、object4就是从GC Roots可达的,不能被回收。而object5、object6、object7虽然相互引用,但从GC Roots不可达,证明程序中无法访问到它们,所以它们是无用的,可以被回收。

图片 1

1.png

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法去中常量引用的对象
  • 本地方法栈中JNI引用的对象

  现在主流的Java虚拟机大多使用这种可达性分析算法来判断对象是否需要进行垃圾回收。具体也就是,从GC Roots出发,向下搜索,形成一个完整的对象引用链。当某个对象没有任何到达GC Roots的引用链时,便认为这个对象的生命周期结束,是可以被回收的。

Java 堆中的内存分配与回收

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。

由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为新生代和老年代。

  图片 2

新生代中的Minor GC

大部分对象被创建时,内存的分配首先发生在年轻代(占用内存比较大的对象如数组,会被直接分配到老年带)。大部分的对象在创建之后很快就不再使用了,因此很快变成不可达的状态,于是被新生代的GC机制清理掉。这个GC机制被称为Minor GC或叫Young GC。

新生代可以分为3个区域:一个Eden区和两个Survivor区。两个Survivor中总有一个是空的,我们叫他Survivor To区,还有一个非空的,我们叫他Survivor From区。Eden区和两个Survivor区的大小为8:1:1。

对象被创建的时候,绝大部分都是被分配在Eden区。Eden区是连续的内存空间,因此在其上分配内存极快。当Eden区满的时候,就会执行Minor GC

  GC Roots对象指的哪些呢?

复制算法

Minor GC时Eden区和Survivor From区还存活着的对象会一次性被复制到Survivor To区。然后Eden区和Survivor From区的内存会被清空。

之后原来的Survivor From区就空了,而原来的Survivor To区就非空。这个时候它们的角色就调换了,空的叫做Survivor To区,非空的叫做Survivor From区。

这种垃圾收集算法叫做复制算法,整个过程如下图所示:

图片 3

2.png

  • 虚拟机栈中引用的对象
  • 元数据区中静态变量引用的对象
  • 元数据区中常量引用的对象
  • 本地方法栈中native方法引用的对象

为什么需要两个Survivor区

复制算法的优点在于,gc完成之后占用的内存会被整理到一个连续的空间中。而空闲的内存也是连续的区域,不会造成内存碎片。

如果只有一个Eden区和一个Survivor区,在Minor GC的时候,Eden区的存活对象可以被复制到Survivor区,这样Eden区可以被清空出一个完整的空闲内存区域。

而Survivor区存活的对象要怎么办呢:

  • 如果直接进入老年代。可能有些对象经过少数的几次GC就能被释放。但在老年代中GC发生的频率比新生代低很多。这样的话就会导致老年代的内存很快被占满。

  • 如果不管存活的对象,只是简单的清除不可达的对象。那么Survivor区就会产生内存碎片

  • 如果进行压缩整理,与从新生代复制过来的存活对象一起整理到Survivor中某个连续的区域的话,消耗的计算资源会比较高。

所以最简单的做法就是拿多一个Survivor To区出来,Eden区和Survivor From区存活的对象会被连续的复制到Survivor To区的一个连续区域中。然后将Eden区和Survivor From区清空就好。

由于新生代大部分的对象生命周期都很短,所以需要复制的对象的数目也不会很多,所以这是比较高效的做法。

分代回收算法

对象进入老年代

对象在下面三种情况下,对象进入到老年代:

  • 占用内存比较大的对象如数组,在创建的时候不会分配到Eden区,而会被直接分配到老年代

  • 当Minor GC时Survivor To区已经放不下还存活的对象的时候,这些对象就会被放到老年代中。

  • 每经历一次Minor GC,对象的年龄会大一岁。当对象的年龄到达某一个值,Minor GC的时候就不会去到Survivor To区,而会进入老年代。

  现在的JVM大多使用分代回收算法,将堆内存分为新生代和老年代。对这两部分分别使用不同的回收算法。

老年代

在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以选用复制算法,只需要付出少量对象的复制成本就能完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就需要使用"标记-清理"或者"标记-整理"算法来进行回收。

  老年代中存放大对象和长期存活的对象,且对象较多,一般使用标记-清除算法或者标记-整理算法。新生代存活对象较少,一般使用复制算法。

标记-清理算法

标记-清除算法顾名思义,主要就是两个动作,一个是标记,另一个就是清除。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的缺点有两个

  1. 标记与清除的效率都比较低
  2. 标记清除之后会产生大量不连续的内存碎片

它的执行过程如下图所示:

图片 4

3.png

发生在老年代的GC叫做Major GC,通常当Minor GC晋升到老年代的大小大于老年代的剩余空间时,Major GC就会被触发。

出现了Major GC,通常会伴随着至少一次的Minor GC(不是绝对的,有些收集器有直接进行Major GC的策略)。Major GC的速度一般会比Minor GC慢10倍以上。

除了Minor GC和Major GC之外,还有一个Full GC的概念,它们的区别如下:

  1. Minor GC : 回收新生代的垃圾
  2. Major GC : 回收老年代的垃圾
  3. Full GC : 回收整个堆的垃圾,包括新生代、老年代、持久代等

标记-清除

标记-整理算法

标记过程仍和标记-清理算法一样,但是后续的步骤不是直接对可回收的对象进行清理,而是让所有存活的对象往一端移动,然后再清理掉边界以外的内存。它的过程如下图所示:

图片 5

4.png

  以可达性分析算法遍历所有引用,给活着的对象打上标记。遍历结束后,统一清除需要回收的对象。

垃圾搜集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,所以不同的厂商、不同版本的虚拟机提供的垃圾收集器都可能有很大差别。

下面这张图列出了JDK1.7 Update 14之后HotSpot虚拟机所包含的垃圾收集器:

图片 6

5.png

每种收集器具体的实现方法这里我就不罗列了,感兴趣的同学可以自行搜索。

  这种算法的缺点在于:

finalize方法

即使在可达性分析中不可达的对象,也并非是"非死不可"的。这时它们只是处于"缓刑"阶段,要宣布一个对象死亡,至少要经历两次标记过程:

  1. 第一次可达性分析之后,不可达的对象会被标记出来放到一个"即将回收"的集合中。

  2. 被标记的对象会进行一次筛选,覆盖了finalize方法并且finalize方法没有被调用过的对象会放到一个叫做F-Queue的队列中。虚拟机会创建一个低优先级的Finalizer线程去执行它。如果一个对象想逃脱死亡的命运,只需要在finalize方法中将自己重新连接上引用链就可以了,例如将自己赋给某个类变量或对象的成员变量。

  3. 第二次可达性分析会将被重新连接上引用链的对象移出"即将回收"的集合。

  4. 最后将不可达的对象内存回收

这里有两点需要注意的是:

  • "执行"finalize方法指的是虚拟机会触发这个方法,但不承诺等待它运行结束,这样做的原因是防止某个对象的finalize方法运行缓慢或者发生死循环,导致F-Queue的队列其他对象永久等待甚至导致内存回收系统崩溃。

  • finalize只有一次被调用的机会。如果在finalize中将对象重新连接上引用链,只有在对象在第一次即将被回收的时候,finalize方法会被调用。在下一次GC的标记过程中,因为finalize方法已经被调用过了,所以就不会被放到F-Queue的队列中。

  1. 标记和清除的效率不高
  2. 进行清除以后,内存空间是不连续的。

  使用这种算法的虚拟机,在进行对象内存分配时,会维护一个内存空闲列表,记录哪些内存空间可用,再进行分配。

标记-整理

  与标记-清除类似,先进行标记,之后把活着的对象移动到一端,排在一起,最后清除需要回收的对象。这样的话,可以让内存地址是连续的。

  如果虚拟机使用这种算法进行GC的话,在为对象分配内存时,只需要按对象的大小进行指针移动,分配足够的空间便可。

复制算法

  一般在新生代中使用这种算法。将新生代内存分为一个Eden和两个Survivor区域,比例为8:1:1。

  在初始时,先把对象分配到较大的Eden区域。Eden区满以后,触发Minor GC,将活着的对象复制到一个Survivor区(假如为Survivor0),并且把对象年龄加1,然后清空Eden区。当Eden再次放满以后,再把Eden区和Survivor0中活着的对象复制到另一个Survivor区,加对象年龄,清空Eden区和Survivor0区。如此循环往复。如果对象年龄达到界限,视对象为长生命周期对象,移动到老年代。大对象在内存分配时,直接放到老年代,因为大对象做复制操作费效率。值得注意的是,在复制算法的过程中,如果空闲的Survivor区内存大小无法满足需要,会依赖老年代的空间进行担保。

  复制算法得到的内存空间同样是连续的。

本文由编程发布,转载请注明来源:本身也来谈一谈Java