Java 垃圾回收机制
在 Java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被引用的对象,并将它们添加到要回收的集合中,进行回收。
GC 垃圾回收流程
堆区是 gc 的主要区域,通常情况下分为两个区块新生代和老年代。新生代又分为 Eden 区和 Survivor 区,From survivor 和 To survivor 保存 gc 后幸存下的对象,默认情况下各自占比 8:1:1。
-
新创建对象时,优先分配在 Eden 区,如果 Eden 内存不足,那么会自动执行一个 Minor GC(Young GC)操作,将 Eden 区的无用内存空间进行清理,Minor GC 的清理范围只在 Eden 区,清理之后会继续判断 Eden 区的内存是否充足?如果充足,则将新对象直接在 Eden 区分配内存。
-
如果执行 Minor GC 之后 Eden 区的内存空间依然不足,就会执行 Survivor 区的判断,如果 Survivor 区有剩余空间,则将 Eden 区部分活跃对象保存在存活区,那么随后继续判断 Eden 园区的内存空间是否充足,如果充足则将新对象直接在 Eden 区进行空间分配。
-
此时如果 Survivor 区没有内存空间,则继续判断老年代。则将部分存活对象保存在老年代,而后 Survivor 区将有空余空间。
-
如果这个时候老年代也满了,那么这个时候将产生 Major GC(Full GC),那么这个时候将进行老年代的清理。
-
如果老年代执行 Full GC 之后,无法进行对象的保存,则会产生 OOM 异常,OutOfMemoryError 异常。
垃圾收集算法
其实所有垃圾回收算法所面临的问题是相同的——找出已经分配的,但是用户程序不可到达的内存块。
GC 最基础的算法有三种: 标记-清除算法、复制算法、标记-压缩算法,我们常用的垃圾回收器一般都采用分代收集算法。
-
标记-清除算法,用在新生代,分为两个阶段,标记,清除,被标记的对象会在标记结束有被清除回收。但由于回收的对象是分散的,所以就会导致内存碎片化严重。
-
复制算法,用在 Survivor 区,将内存分为容量大小相等的两块,当发生 GC 之后,将 from 的对象,复制到 to 区,避免了内存碎片,但也造成空间浪费,使用率只有原来的一半。
-
标记-压缩算法,标记过程仍然与“标记-清除”算法一样,然后让所有存活的对象都向一端移动,最后直接清理掉端边界以外的内存。
-
分代收集算法:按照对象的特性区分年轻代,老年代,根据对象的特性选择对应的回收算法。年轻代,存活时间短选择复制算法,老年代选择标记清除或标记压缩
-
引用计数法:对于一个 对象 A,只要引用一次就加 1,引用失效减一,当计数为 0 时就失效
-
根搜索算法:判断是否可达:需要和根节点有依赖关系。如果没有和我的 GCroots 有关
标记-清除算法
为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
优点: 标记—清除算法中每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。此外,更重要的是,这个算法并不移动对象的位置。
缺点: 效率比较低(递归与全堆对象遍历)。每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。没有移动对象,导致可能出现很多碎片空间无法利用的情况。
复制算法
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。
优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
标记-压缩算法(标记-整理)
标记-压缩法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
优点: 该算法不会像标记-清除算法那样产生大量的碎片空间。
缺点: 如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。
JVM 有哪些垃圾回收器
下图展示了 7 种作用于不同分代的收集器
-
新生代收集器:Serial、PraNew、Parallel、 Scavenge
-
老年代收集器:Serial Old、Parallel Old、CMS
-
整堆的收集器:G1
不同收集器之间的连线表示它们可以搭配使用。
-
Serial 收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
-
ParNew 收集器 (复制算法): 新生代收并行集器,实际上是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现;
-
Parallel Scavenge 收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC 线程时间),高吞吐量可以高效率的利用 CPU 时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
-
Serial Old 收集器 (标记-整理算法): 老年代单线程收集器,Serial 收集器的老年代版本;
-
Parallel Old 收集器 (标记-整理算法):老年代并行收集器,吞吐量优先,Parallel Scavenge 收集器的老年代版本;
-
CMS(Concurrent Mark Sweep)收集器(标记-清除算法):老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短 GC 回收停顿时间。
-
G1(Garbage First)收集器 (标记-整理算法):Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新收集器,G1 收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1 收集器不同于之前的收集器的一个重要特点是:G1 回收的范围是整个 Java 堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
young gc、old gc、full gc、mixed gc
其实 GC 分为两大类,分别是 Partial GC 和 Full GC。Partial GC 即部分收集,分为 young gc、old gc、mixed gc。
-
young gc(Minor GC):指的是单单收集年轻代的 GC。
-
old gc(Major GC):指的是单单收集老年代的 GC。
-
mixed gc:这个是 G1 收集器特有的,指的是收集整个年轻代和部分老年代的 GC。
-
Full GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代。
young gc(Minor GC) 触发条件
大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc。
为什么要说大致上呢?因为有一些收集器的回收实现是在 full gc 前会让先执行以下 young gc。
eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB(本地线程分配缓冲区) 分配内存不够。
Full GC 触发条件
1. 执行 System.gc()
、jmap -dump
等命令会触发 full gc。
System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存
2. 未指定老年代和新生代大小,堆伸缩 时会产生 Full GC,所以一定要配置-Xmx、-Xms
3. 老年代空间不足,常见场景比如大对象、大数组直接进入老年代、长期存活的对象进入老年代等。
为了避免因此引起的 Full GC,应当尽量不要创建过大的对象以及数组。
此外,可以通过-Xmn
虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。
还可以通过-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
在执行 Full GC 后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space
4. JDK 1.7 及以前的(永久代)空间满。
永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。
如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError PermGen space
为了避免因此引起的 Full GC,可增大永久代大小或转为使用 CMS GC。
5. 空间分配担保失败
空间担保,下面两种情况是空间担保失败:
1、每次晋升的对象的平均大小 > 老年代剩余空间
2、Minor GC 后存活的对象超过了老年代剩余空间
注意 GC 日志中是否有promotion failed
和concurrent mode failure
两种状况,当出现这两种状况的时候就有可能会触发 Full GC。
>promotion failed
是在进行 Minor GC 时候,survivor 放不下只能晋升老年代,而此时老年代也空间不足时发生的。
>concurrent mode failure
是在进行 CMS GC 过程,此时有对象要放入老年代,而空间不足造成的,这种情况下会退化使用Serial Old
收集器变成单线程的,此时是相当的慢的。
TLAB(Thread Local Allocation Buffer)本地线程分配缓存
一般而言生成对象需要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的。新对象创建,指针就右移对象大小 size 即可,这叫指针加法
如果多个线程都在分配对象,那么这个指针就会成为热点资源,需要互斥那分配的效率就低了。于是搞了个 TLAB,为一个线程分配的内存申请区域。这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域。
TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,当这块内存用完了之后再去申请。不过每次申请的大小不固定,会根据该线程启动到现在的历史信息来调整,比如这个线程一直在分配内存那么 TLAB 就大一些,如果这个线程基本上不会申请分配内存那 TLAB 就小一些。还有 TLAB 只能分配小对象,大的对象还是需要在共享的 eden 区分配。
所以总的来说 TLAB 是为了避免对象分配时的竞争而设计的。
这种思想其实很常见,比如分布式发号器,每次不会一个一个号的取,会取一批号,用完之后再去申请一批。