GC垃圾回收

垃圾回收

1、什么是 垃圾回收机制:

理解Java的垃圾回收机制,就要从:“什么时候”,“对什么东西”,“做了什么”三个方面来具体分析。

​ 第一:“什么时候”即就是GC触发的条件。

​ GC触发的条件有两种。(1)程序调用System.gc时可以触发;(2)系统自身来决定GC触发的时机。系统判断GC触发的依据:根据Eden区和From Space区的内存大小来决定。当内存大小不足时,则会启动GC线程并停止应用线程。

​ 第二:“对什么东西”笼统的认为是Java对象。

​ 但是准确来讲,GC操作的对象分为:通过可达性分析法无法搜索到的对象和可以搜索到的对象。对于搜索不到的方法进行标记。

​ 第三:“做了什么”最浅显的理解为释放对象。

​ 但是从GC的底层机制可以看出,对于可以搜索到的对象进行复制操作,对于搜索不到的对象,调用finalize()方法进行释放。

具体回收过程:当GC线程启动时,会通过可达性分析法把Eden区和From Space区的存活对象复制到To Space区,然后把Eden Space和From Space区的对象释放掉。当GC轮训扫描To Space区一定次数后,把依然存活的对象复制到老年代,然后释放To Space区的对象。

​ 对于用可达性分析法搜索不到的对象,GC并不一定会回收该对象。要完全回收一个对象,至少需要经过两次标记的过程:
​ 第一次标记:对于一个没有其他引用的对象,筛选该对象是否有必要执行finalize()方法,如果没有执行必要,则意味可直接回收。(筛选依据:是否复写或执行过finalize()方法;因为finalize方法只能被执行一次)。
​ 第二次标记:如果被筛选判定位有必要执行,则会放入FQueue队列,并自动创建一个低优先级的finalize线程来执行释放操作。如果在一个对象释放前被其他对象引用,则该对象会被移除FQueue队列。

2、JVM内存管理

​ 根据JVM规范,JVM把内存划分了如下几个区域: 方法区、堆区、 本地方法栈、虚拟机栈、程序计数器 。其中,方法区和堆是所有线程共享的。

2.1 方法区

​ 方法区存放了要加载的类的信息(如类名,修饰符)、类中的静态变量、final定义的常量、类中的field、方法信息,当开发人员调用类对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区。方法区是全局共享的,在一定条件下它也会被GC。当方法区使用的内存超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。
​ 在Hotspot虚拟机中,这块区域对应的是Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难。
​ 运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。

2.2 堆

​ 堆区是理解Java GC机制最重要的区域。在JVM所管理的内存中,堆区是最大的一块,堆区也是JavaGC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区用来存储对象实例及数组值,可以认为java中所有通过new创建的对象都在此分配。

​ 对于堆区大小,可以通过参数-Xms和-Xmx来控制,-Xms为JVM启动时申请的最新heap内存,默认为物理内存的1/64但小于1GB;-Xmx为JVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB,默认当剩余堆空间小于40%时,JVM会增大Heap到-Xmx大小,可通过-XX:MinHeapFreeRadio参数来控制这个比例;当空余堆内存大于70%时,JVM会减小Heap大小到-Xms指定大小,可通过-XX:MaxHeapFreeRatio来指定这个比例。对于系统而言,为了避免在运行期间频繁的调整Heap大小,我们通常将-Xms和-Xmx设置成一样。为了让内存回收更加高效,从Sun JDK 1.2开始对堆采用了分代管理方式,如下图所示:

img

年轻代(Young Generation)

​ 对象在被创建时,内存首先是在年轻代进行分配(注意,大对象可以直接在老年代分配)。当年轻代需要回收时会触发Minor GC(也称作Young GC)。

​ 年轻代由Eden Space和两块相同大小的Survivor Space(又称From Space和To Space)构成,Eden区和Servior区的内存比为8:1:1,可通过-Xmn参数来调整新生代大小,也可通过-XX:SurvivorRadio来调整Eden Space和Survivor Space大小。不同的GC方式会按不同的方式来按此值划分Eden Space和Survivor Space,有些GC方式还会根据运行状况来动态调整Eden、From Space、To Space的大小。

​ 年轻代的Eden区内存是连续的,所以其分配会非常快;同样Eden区的回收也非常快(因为大部分情况下Eden区对象存活时间非常短,而Eden区采用的复制回收算法,此算法在存活对象比例很少的情况下非常高效)。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java Heap Space异常。

老年代(Old Generation)

老年代用于存放在年轻代中经多次垃圾回收仍然存活的对象,可以理解为比较老一点的对象,例如缓存对象;新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。

​ 当老年代满了的时候就需要对老年代进行垃圾回收,老年代的垃圾回收称作Full GC。老年代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。

2.3 本地方法栈(Native Method Stack)

​ 本地方法栈用于支持native方法的执行,存储了每个native方法调用的状态。本地方法栈和虚拟机方法栈运行机制一致,它们唯一的区别就是,虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。

2.4 程序计数器(Program Counter Register)

​ 程序计数器是一个比较小的内存区域,可能是CPU寄存器或者操作系统内存,其主要用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。 每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。

​ 如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。

2.5 虚拟机栈(JVM Stack)

​ 虚拟机栈占用的是操作系统内存,每个线程都对应着一个虚拟机栈,它是线程私有的,而且分配非常高效。一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。

​ 局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

​ 虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)。

3、虚拟机中GC的过程

​ 1,在初始阶段,新创建的对象被分配到Eden区,survivor的两块空间都为空。

imgimg

​ 2,当Eden区满了的时候,minor garbage 被触发 。

img

​ 3,经过扫描与标记,存活的对象被复制到S0,不存活的对象被回收

img

​ 4,在下一次的Minor GC中,Eden区的情况和上面一致,没有引用的对象被回收,存活的对象被复制到survivor区。然而在survivor区,S0的所有的数据都被复制到S1,需要注意的是,在上次minor GC过程中移动到S0中的两个对象在复制到S1后其年龄要加1。此时Eden区S0区被清空,所有存活的数据都复制到了S1区,并且S1区存在着年龄不一样的对象,过程如下图所示:

img

​ 5,再下一次MinorGC则重复这个过程,这一次survivor的两个区对换,存活的对象被复制到S0,存活的对象年龄加1,Eden区和另一个survivor区被清空。

img

​ 6,再经过几次Minor GC之后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是8),就会被从年轻代Promotion到老年代。

img

​ 7,随着MinorGC一次又一次的进行,不断会有新的对象被promote到老年代。

img

​ 8,上面基本上覆盖了整个年轻代所有的回收过程。最终,MajorGC将会在老年代发生,老年代的空间将会被清除和压缩。

img

​ 从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下(基于大部分对象存活周期很短的事实)高效,如果在老年代采用停止复制,则是非常不合适的。

​ 老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-压缩算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生Minor GC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。

​ 关于方法区即永久代的回收,永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:

  1. 类的所有实例都已经被回收。2. 加载类的ClassLoader已经被回收。3. 类对象的Class对象没有被引用(即没有通过反射引用该类的地方)。

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。

4、Minor GC ,Full GC 触发条件

Minor GC触发条件:当Eden区满时,触发Minor GC。

Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

动手验证垃圾回收

测试代码

public class ReferenceCountingGC {

    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存, 以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void main(String[] args) {
        testGC();
    }

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 假设在这行发生GC, objA和objB是否能被回收?
        System.gc();
    }

}

例子来自于《深入理解Java虚拟机》中引用计数算法章节。

例子要说明的结果是,相互引用下却已经置为null的两个对象,是否会被GC回收。如果只是按照引用计数器算法来看,那么这两个对象的计数标识不会为0,也就不能被回收。但到底有没有被回收呢?

这里我们先采用 jvm 工具指令,jstat来监控。因为监控的过程需要我手敲代码,比较耗时,所以我们在调用testGC()前,睡眠会 Thread.sleep(55000);。启动代码后执行如下指令。

E:\itstack\git\github.com\interview>jps -l
10656
88464
38372 org.itstack.interview.ReferenceCountingGC
26552 sun.tools.jps.Jps
110056 org.jetbrains.jps.cmdline.Launcher

E:\itstack\git\github.com\interview>jstat -gc 38372 2000
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0    0.0   65536.0   6561.4   175104.0     0.0     4480.0 770.9  384.0   75.9       0    0.000   0      0.000    0.000
10752.0 10752.0  0.0   1288.0 65536.0    0.0     175104.0     8.0     4864.0 3982.6 512.0  440.5       1    0.003   1      0.000    0.003
10752.0 10752.0  0.0    0.0   65536.0   437.3    175104.0    1125.5   4864.0 3982.6 512.0  440.5       1    0.003   1      0.012    0.015
10752.0 10752.0  0.0    0.0   65536.0   437.3    175104.0    1125.5   4864.0 3982.6 512.0  440.5       1    0.003   1      0.012    0.015
  • S0C、S1C,第一个和第二个幸存区大小
  • S0U、S1U,第一个和第二个幸存区使用大小
  • EC、EU,伊甸园的大小和使用
  • OC、OU,老年代的大小和使用
  • MC、MU,方法区的大小和使用
  • CCSC、CCSU,压缩类空间大小和使用
  • YGC、YGCT,年轻代垃圾回收次数和耗时
  • FGC、FGCT,老年代垃圾回收次数和耗时
  • GCT,垃圾回收总耗时

注意:观察后面三行,S1U = 1288.0GCT = 0.003,说明已经在执行垃圾回收。

接下来,我们再换种方式测试。在启动的程序中,加入GC打印参数,观察GC变化结果。

-XX:+PrintGCDetails  打印每次gc的回收情况 程序运行结束后打印堆空间内存信息(包含内存溢出的情况)
-XX:+PrintHeapAtGC  打印每次gc前后的内存情况
-XX:+PrintGCTimeStamps 打印每次gc的间隔的时间戳 full gc为每次对新生代老年代以及整个空间做统一的回收 系统中应该尽量避免
-XX:+TraceClassLoading  打印类加载情况
-XX:+PrintClassHistogram 打印每个类的实例的内存占用情况
-Xloggc:/Users/xiaofuge/Desktop/logs/log.log  配合上面的使用将上面的日志打印到指定文件
-XXHeapDumpOnOutOfMemoryError 发生内存溢出将堆信息转存起来 以便分析

这回就可以把睡眠去掉了,并添加参数 -XX:+PrintGCDetails,如下:

img

测试结果

[GC (System.gc()) [PSYoungGen: 9346K->936K(76288K)] 9346K->944K(251392K), 0.0008518 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 936K->0K(76288K)] [ParOldGen: 8K->764K(175104K)] 944K->764K(251392K), [Metaspace: 3405K->3405K(1056768K)], 0.0040034 secs] [Times: user=0.08 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 1966K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
  eden space 65536K, 3% used [0x000000076b500000,0x000000076b6eb9e0,0x000000076f500000)
  from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
  to   space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
 ParOldGen       total 175104K, used 764K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
  object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ebf100,0x00000006cc900000)
 Metaspace       used 3449K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K
  • 从运行结果可以看出内存回收日志,Full GC 进行了回收。
  • 也可以看出JVM并不是依赖引用计数器的方式,判断对象是否存活。否则他们就不会被回收啦

有了这个例子,我们再接着看看JVM垃圾回收的知识框架!

5、JVM 垃圾回收知识框架

垃圾收集(Garbage Collection,简称GC),

垃圾收集器主要做的三件事:哪些内存需要回收什么时候回收、怎么回收。

而从垃圾收集器的诞生到现在有半个世纪的发展,现在的内存动态分配和内存回收技术已经非常成熟,一切看起来都进入了“自动化”。但在某些时候还是需要我们去监测在高并发的场景下,是否有内存溢出、泄漏、GC时间过程等问题。所以在了解和知晓垃圾收集的相关知识对于高级程序员的成长就非常重要。

垃圾收集器的核心知识项主要包括:判断对象是否存活、垃圾收集算法、各类垃圾收集器以及垃圾回收过程。如下图;

图 27-1 垃圾收集器知识框架

1. Java JVM的引用计数和可达性分析垃圾收集算法

介绍了Java中的垃圾分析算法,包括引用计数法和可达性分析算法的原理!

1、 垃圾收集概述

在C/C++语言中,没有自动垃圾回收机制,是通过new关键字申请内存资源,通过delete关键字释放内存资源。如果,程序员在某些位置没有写delete进行释放,那么申请的对象将一直占用内存资源,最终可能会导致内存溢出。

为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC。有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。

不可能再被任何途径使用的对象,便可称之为垃圾,就可以被回收了。常见的垃圾分析算法有两种,一种是引用计数法,另一种是可达性分析算法。

2 、引用计数算法

引用计数是最简单直接的一种方式,给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,那么此对象就可以作为垃圾收集器的目标对象来收集。

  1. 优点:
    1. 简单,直接,不需要暂停整个应用。
  2. 缺点:
    1. 需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作;
    2. 不能处理循环引用的问题。比如对象 A 中有一个字段指向了对象 B ,而对象 B 中也有一个字段指向了对象 A,而事实上他们俩都不再使用,但计数器的值永远都不可能为 0 ,也就不会被回收,然后就发生了内存泄露。

如下案例:

java复制代码public class ReferenceCountingGC {
    private Object instance;
    private static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        //objA 中有objB,objB中有objA
        objA.instance = objB;
        objB.instance = objA;
        //虽然objA 和objB 置空,但这是指将他们的引用置空,在堆内存中,这两个对象还是互相持有\依赖的,这就是循环引用。
        objA = null;
        objB = null;
    }

    public static void main(String[] args) {
        testGC();
    }
}
  1. 为每一个对象添加一个引用计数器,统计指向该对象的引用次数。
  2. 当一个对象有相应的引用更新操作时,则对目标对象的引用计数器进行增减。
  3. 一旦当某个对象的引用计数器为0时,则表示此对象已经死亡,可以被垃圾回收。

从实现来看,引用计数器法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但是它的实现方案简单,判断效率高,是一个不错的算法。

也有一些比较出名的引用案例,比如:微软COM(Component Object Model) 技术、使用ActionScript 3的FlashPlayer、 Python语言等。

但是,在主流的Java虚拟机中并没有选用引用技术算法来管理内存,主要是因为这个简单的计数方式在处理一些相互依赖、循环引用等就会非常复杂。可能会存在不再使用但又不能回收的内存,造成内存泄漏

3、 可达性分析算法

现在,在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,即可被回收,又称为GC Roots Tracing算法。如下图所示,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在这里插入图片描述

它的算法思路是通过定义一系列称为 GC Roots 根对象作为起始节点集,从这些节点出发,穷举该集合引用到的全部对象填充到该集合中(live set)。这个过程叫过标记,只标记那些存活的对象 好,那么现在未被标记的对象就是可以被回收的对象了。

GC Roots 包括;

  1. 全局性引用,对方法区的静态对象、常量对象的引用
  2. 执行上下文,对 Java方法栈帧中的局部对象引用、对 JNI handles 对象引用
  3. 已启动且未停止的 Java 线程

两大问题

  1. 误报:已死亡对象被标记为存活,垃圾收集不到。多占用一会内存,影响较小。
  2. 漏报:引用的对象(正在使用的)没有被标记为存活,被垃圾回收了。那么直接导致的就是JVM奔溃。(STW可以确保可达性分析法的准确性,避免漏报)

缺点就是:1. 有可能不知不觉浪费了很多内存。2. JVM花费过多时间来进行内存回收。3. 内存泄露

3.1 可以作为GC Roots对象种类

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

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象。
3.2 两次标记

为了增加垃圾收集的灵活性。实际上,一个到GC Roots没有任何引用链相连的对象有可能在某一个条件下“ 复活” 自己。对象的状态可以简单分成三类:

  1. 可达的: 从根节点开始, 可以到达这个对象。(实际上可触及对象也分为四种:)
  2. 可复活的: 对象的所有引用都被释放, 但是对象有可能在finalize()函数中复活。
  3. 不可触及的:对象对象没有覆盖finalize()方法或者finalize()函数已经被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()函数只会被调用一次。

即使在一次可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法:当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
  2. 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。
  3. 如果这个对象被判定为有必要执行finalize()方法,那么该对象将会被回收。

案例演示:

/**
 1. 此代码演示了两点:
 2. 1.对象可以在被GC时自我拯救。
 3. 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 4.  5. @author zzm
 */
public class FinalizeEscapeGC {
    private static FinalizeEscapeGC SAVE_HOOK = null;

    private static void isAlive() {
        if (SAVE_HOOK != null) {
            System.out.println("yes,i am still alive:)");
        } else {
            System.out.println("no,i am dead :(");
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        //持有引用,对象"复活"
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        /*测试对象第一次成功拯救自己*/
        //首先引用置null,该对象没有其他引用.
        SAVE_HOOK = null;
        //然后尝试进行一次gc,此时会执行回收SAVE_HOOK类,但是会执行finalize方法,在finalize中对其进行复活
        System.gc();
        //因为finalize方法由低优先级的线程finalizer调用,优先级很低,所以暂停0.5秒以等待它执行
        Thread.sleep(500);
        //测试是否复活
        isAlive();

        /*下面这段代码与上面的完全相同,但是这次自救却失败了。*/
        SAVE_HOOK = null;
        //然后再次尝试进行一次gc,此时会执行回收SAVE_HOOK类,但是不会执行finalize方法,因为fiinalize已经执行过一次了,第二次不会执行,这次自救却失败了
        System.gc();
        //因为finalize方法由低优先级的线程finalizer调用,优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        isAlive();
    }
}

代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

Java中只有构造函数并没有析构函数一说,这里finalize()方法看起来像是实现了析构函数,但这只是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。

  1. 由于调用的线程优先级很低,因此调用时间是不确定的,我们也无法主动调用。
  2. 改变了要被回收的对象的引用和生命周期,由于加入到队列中导致应该被回收的对象迟迟不被回收,造成内存泄漏。
  3. 某些流比如 InputStreamReader在关闭时,并不会关闭它的嵌套流,此时只能由finalize释放,但是如果开启的流很多,那么由于finalizer是单线程,可能造成释放速度小于加入队列速度,这时就会有大量的Finalizer堆积, 导致内存的异常。

现在,finalize()能做的所有工作,例如关闭外部资源等,使用try-finally或者其他方式都可以做得更好、更及时,因此finalize()方法不建议被使用。

析构函数(destructor): 与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。

析构函数:析构函数是一种特殊的函数,用于在对象销毁时进行清理操作

4、 方法区/永久代的垃圾分析

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。

但是判断一个类是否是“无用的类”却需要同时满足下面3个条件才能行:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理, CGLib等Byt式ode框架、动态生成JSP 以及OSG,这类频繁(自定义ClassLoader的场景都需耍虔拟机具备类卸载的功能,以保证永久代不会溢出。

2. 垃圾回收算法

2.1 标记-清除算法(mark-sweep)

标记-清除算法(mark-sweep)

  • 标记无引用的死亡对象所占据的空闲内存,并记录到空闲列表中(free list)。
  • 当需要创建新对象时,内存管理模块会从 free list 中寻找空闲内存,分配给新建的对象。
  • 这种清理方式其实非常简单高效,但是也有一个问题内存碎片化太严重了。
  • Java 虚拟机的堆中对象,必须是连续分布的,所以极端的情况下可能即使总剩余内存充足,但寻找连续内存分配效率低,或者严重到无法分配内存。重启汤姆猫!
  • 在CMS中有此类算法的使用,GC暂停时间短,但存在算法缺陷。
2.2 标记-复制算法(mark-copy)

标记-复制算法(mark-copy)

  • 从图上看这回做完垃圾清理后连续的内存空间就大了。
  • 这种方式是把内存区域分成两份,分别用两个指针 from 和 to 维护,并且只使用 from 指针指向的内存区域分配内存。
  • 当发生垃圾回收时,则把存活对象复制到 to 指针指向的内存区域,并交换 from 与 to 指针。
  • 它的好处很明显,就是解决内存碎片化问题。但也带来了其他问题,堆空间浪费了一半。
2.3 标记-压缩算法(mark-compact)

标记-压缩算法(mark-compact)

  • 1974年,Edward Lueders 提出了标记-压缩算法,标记的过程和标记清除算法一样,但在后续对象清理步骤中,先把存活对象都向内存空间一端移动,然后在清理掉其他内存空间。
  • 这种算法能够解决内存碎片化问题,但压缩算法的性能开销也不小。

3. 垃圾回收器

3.1 新生代
  1. Serial
    1. 算法:标记-复制算法
    2. 说明:简单高效的单核机器,Client模式下默认新生代收集器;
  2. Parallel ParNew
    1. 算法: 标记-复制算法
    2. 说明:GC线程并行版本,在单CPU场景效果不突出。常用于Client模式下的JVM
  3. Parallel Scavenge
    1. 算法:标记-复制算法
    2. 说明:目标在于达到可控吞吐量(吞吐量=用户代码运行时间/(用户代码运行时间+垃圾回收时间));
3.2 老年代
  1. Serial Old
    1. 算法:标记-压缩算法
    2. 说明:性能一般,单线程版本。1.5之前与Parallel Scavenge配合使用;作为CMS的后备预案。
  2. Parallel Old
    1. 算法:标记-压缩算法
    2. 说明:GC多线程并行,为了替代Serial Old与Parallel Scavenge配合使用。
  3. CMS
    1. 算法:标记-清除算法
    2. 说明:对CPU资源敏感、停顿时间长。标记-清除算法,会产生内存碎片,可以通过参数开启碎片的合并整理。基本已被G1取代
3.3 G1
  1. 算法:标记-压缩算法
  2. 说明:适用于多核大内存机器、GC多线程并行执行,低停顿、高回收效率。
五、总结
  • JVM 的关于自动内存管理的知识众多,包括本文还没提到的 HotSpot 实现算法细节的相关知识,包括:安全节点、安全区域、卡表、写屏障等。每一项内容都值得深入学习。
  • 31
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值