Java -- 深入浅出GC自动回收机制
1,去年开春去美团和58同城面试的时候第一个问题基本上都是来说说 Java GC机制,当时年轻的我也很耿直,直接说不会,现在想想还是当时年轻啊。刚好这段时间被各大论坛的面试题刷屏,见到最多的也是也是这个问题,所以想来总结一下。
2,GC简单的了解
GC :Garbage Collections 字面意思是垃圾回收器,释放垃圾占用的空间。让创建的对象不需要像c、c++那样delete、free掉 。对于c、c++的开发人员来说内存是开发人员分配的,也就是说还要对内存进行维护和释放。对于Java程序员来说,一个对象的内存分配是在虚拟机的自动内存分配机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,而且不容易出现内存泄露和内存溢出问题,但是,如果出现了内存泄露和内存溢出问题,而开发者又不了解虚拟机是怎么分配内存的话,那么定位错误和排除错误将是一件很困难的事情。(这里留一个问题给大家思考一下,在什么情况下会出现内存泄露和内存溢出问题)
从上面的了解我们知道,内存的分配是在JVM虚拟机的自动内存分配机制下完成的,所以我们现在来简单的了解一下JVM的内存管理结构,如图:
JVM运行时数据区 ①程序计数器(Program Counter Register) 程序计数器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则程序计数器中不存储任何信息 ②JVM栈(JVM Stack) JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址 ③堆(heap) 它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。 (1)堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的 (2)Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配 (3)TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。 ④方法区(Method Area) (1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。 (2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。 ⑤本地方法栈(Native Method Stacks) JVM采用本地方法栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。 ⑥运行时常量池(Runtime Constant Pool) 存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。JVM在加载类时会为每个class分配一个独立的常量池,但是运行时常量池中的字符串常量池是全局共享的。
JVM将堆分成了二个大区新生代(Young)和老年代(Old),新生代又被进一步划分为Eden和Survivor区,而Survivor由FromSpace和ToSpace组成,也有些人喜欢用Survivor1和Survivor2来代替。这里为什么要将Young划分为Eden、Survivor1、Survivor2这三块,给出的解释是
“Young中的98%的对象都是死朝生夕死,所以将内存分为一块较大的Eden和两块较小的Survivor1、Survivor2,JVM默认分配是8:1:1,每次调用Eden和其中的Survivor1(FromSpace),当发生回收的时候,将Eden和Survivor1(FromSpace)存活的对象复制到Survivor2(ToSpace),然后直接清理掉Eden和Survivor1的空间。”
堆结构图如下:
新生代:新创建的对象都是用新生代分配内存,Eden空间不足时,触发Minor GC,这时会把存活的对象转移进Survivor区。
老年代:老年代用于存放经过多次Minor GC之后依然存活的对象。
新生代的GC(Minor GC):新生代通常存活时间较短基于Copying算法进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和FromSpace或ToSpace之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。
老年代的GC(Major GC/Full GC):老年代与新生代不同,老年代对象存活的时间比较长、比较稳定,因此采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并、要么标记出来便于下次进行分配,总之目的就是要减少内存碎片带来的效率损耗。
这里我们要介绍几种垃圾收集算法:
①Mark-Sweep(标记-清除)算法
这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
②.Copying(复制)算法
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。 很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。我们的新生代GC算法采用的是这种算法
③Mark-Compact(标记-整理)算法
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
在一般厂商JVM中老年代GC就是使用的这种算法,由于老年代的特点是每次回收都只回收少量对象。
上面的是一些常见的垃圾收集算法,垃圾收集算法是内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面有几种创建的垃圾收集器,用户可以根据自己的需求组合出新年代和老年代使用的收集器。下面是常见的划分办法
新生代GC :串行GC(SerialGC)、并行回收GC(ParallelScavenge)和并行GC(ParNew)
串行GC:在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。
并行回收GC:在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
并行GC:与老年代的并发GC配合使用。
老年代GC:串行GC(Serial MSC)、并行GC(Parallel MSC)和并发GC(CMS)。
串行GC(Serial MSC):client模式下的默认GC方式,可通过-XX:+UseSerialGC强制指定。每次进行全部回收,进行Compact,非常耗费时间。
并行GC(Parallel MSC):吞吐量大,但是GC的时候响应很慢:server模式下的默认GC方式,也可用-XX:+UseParallelGC=强制指定。可以在选项后加等号来制定并行的线程数。
并发GC(CMS):响应比并行gc快很多,但是牺牲了一定的吞吐量。
以上我们差不多吧GC的基本只是给了解完了。
3,思考“GC是在什么时候,对什么东西,做了什么事情?”
这是在知乎上看到大牛对GC回答的加单的思考,感觉这样分析思路很清晰,就借鉴了过来
- 什么时候
从字面上翻译过来就是什么时候触发我们的GC机制
①在程序空闲的时候。这个回答无力吐槽
②程序不可预知的时候/手动调用system.gc()。关于手动调用不推荐
③Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。就是
这时候如果你们讲出新生代和老年代的话或许会更细的了解一下Minor GC、Full GC、OOM什么时候触发!
创建对象是新生代的Eden空间调用Minor GC;当升到老年代的对象大于老年代剩余空间Full GC;GC与非GC时间耗时超过了GCTimeRatio的限制引发OOM。
- 什么东西
从字面的意思翻译过来就是能被GC回收的对象都有哪些特征
①超出作用域的对象/引用计数为空的对象。
引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。
②从GC Root开始搜索,且搜索不到的对象
跟搜索算法:以一系列名为 GC Root的对象作为起点,从这些节点开始往下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链的时候,则就证明此对象是不可用的。
这里会提出一个思考,什么样的对象能成为GC Root : 虚拟机中的引用的对象、方法区中的类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中jni的引用对象。
③从root搜索不到,而且经过第一次标记、清理后,仍然没有复活的对象。
- 做什么
不同年代、不同种类的收集器很多,不过总体的作用是删除不使用的对象,腾出内存空间。补充一些诸如停止其他线程执行、运行finalize等的说明。
ok 现在来回答一下我们最上面的问题,上面时候容易发生内存泄露
①静态集合类像HashMap、Vector等
②各种连接,数据库连接,网络连接,IO连接等没有显示调用close关闭,不被GC回收导致内存泄露。
③监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。