这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战
Java对象占用的内存空间都是在堆空间进行分配,由于对象数量庞大,所以堆空间的内存管理十分的重要。
对象如何创建
我们来看一段代码的字节码:
通过观察字节码,我们可以知道JVM执行Object obj = new Object();需要三个步骤:
- new关键字创建对象并返回对象的引用
- 通过引用调用对象的init方法
- 将引用赋值给本地变量(也可以是static变量、类变量)
我们很容想到在new对象时,需要在堆中开辟内存空间操作,并且我们可以通过对象的引用来执行对象的方法或者访问对象的属性,随着时间的推移,我们有时会需要再次创建一个新的对象,然后将新对象的引用赋值给到变量,由此,旧对象的引用就消失,但实际上旧对象还是在占用者内存空间,Java垃圾回收的目的就在于将这部分对象占用的内存空间进行回收。
如何判断对象是个垃圾?
既然是垃圾回收,堆空间是我们的垃圾场,没有再被引用的对象是垃圾,所以用什么样的算法来判断对象是不是垃圾非常的重要。 通常有两种方式:
- 引用计数法
- 可达性分析法
引用计数法
这种方式非常的直观,既然没被引用的对象是垃圾,那给每个对象加一个引用数属性,没赋值一个引用进行+1,每次不在使用引用进行-1。=0则表明该对象需要被回收。这种方法通熟易懂,简单粗暴。
我们简单设计一下,设计时不考虑如何回收垃圾,只考虑如何拿到未被引用的对象。
UML类图
执行流程:
- 将new Object()地方的代码改为
com.sanjin.jvm.判断对象是垃圾.引用计数.ReferenceManager#newReference,其内部会通过supply创建对象并将引用添加到内部维护的list中。 - 需要对象的地方调用
com.sanjin.jvm.判断对象是垃圾.引用计数.Reference#get会进行引用值+1,使用结束后调用com.sanjin.jvm.判断对象是垃圾.引用计数.Reference#release会进行引用值-1。 - 当内存不足触发垃圾回收,通过
com.sanjin.jvm.判断对象是垃圾.引用计数.ReferenceManager#getUnRefReference可以获取到未被引用的对象,即垃圾。
这个方案看似简单,实际上却面临了许多挑战:
- 并发问题,在执行
com.sanjin.jvm.判断对象是垃圾.引用计数.ReferenceManager#getUnRefReference同时需要禁止调用com.sanjin.jvm.判断对象是垃圾.引用计数.Reference#get,否在会导致命名get拿到的对象,但是这个对象却=null。当然release是没有问题的。 - 循环引用问题,有俩对象A、B互相引用,引用计数值总是不为0。这个问题需要用图解决,每个对象作为节点。A对B的引用为A指向B的边,B对A的引用为B->A的边。此时不再是计数,而是边的增加减少。当检测到孤立的环时,则说明这个环中所有节点都是未被引用的对象。
可达性分析法
可达性分析法类似解决循环引用问题,当垃圾回收时,从GCRoot对象开始遍历,生成一个网状的图。变量过程不能执行对象引用的赋值,否则会导致明明对象已经赋值,但是最后调用时,对象=null的情况,不能赋值也即意味着代码无法执行,所以通常称之为STW(stop the word)。遍历结束后,未达到的对象即为垃圾对象,需要进行回收。
总结
其实可达性分析可以看做引用计数为解决循环引用问题产生的新的算法。目钱JVM都是使用可达性分析法进行判断的。
如何回收垃圾?
当JVM拿到一堆垃圾对象,要怎么回收?直接调用C方法释放对象内存?这的确是一个方法,但缺点则是会产生内存碎片。内存碎片过多的情况下会导致明明有这么大的内存空间,但是却无法为对象分配内存(因为这些空间并不连续)。为了解决内存碎片的问题,需要在一个一个释放垃圾对象的内存后(因为垃圾对象不连续所以,需要一个一个释放),注意此时还不能立即停止STW,然后记录存活对象的引用(用于后面替换引用),整理存活对象(从低地址空间开始将对象往前移动,目的是减少存活对象之间的内存空间),最后将存活对象新的引用覆盖给老引用上,然后在停止STW。这种垃圾回收算法我们称之为标记整理算法。
当然整理是是非耗时并且耗费CPU的操作,因为需要一个一个去释放对象内存,在一个一个移动存活对象。那么有没有一种方式能直接清除对象然后直接移动存活对象呢?当然有,标记清除算法值得你拥有。标记清楚将“垃圾场”一分唯二分为A,B两个空间,在内存分配时,只分配A空间内存,当A空间内存不足,将A存活对象复制到B空间,复制可以直接整片复制,因为B中空闲内存连续,然后直接清除A空间即可。其代价是减少了一半的内存空间。
如果针对对象特点优化堆空间?
垃圾回收开发者做了一个测试,刚刚new出来的对象,在内存满了需要进行垃圾回收时,有90%的对象是垃圾。只有很少的对象存活下来。基于这个特点,可以将堆空间划分为新生代与老年代。新生代存储刚刚new出不久的对象。老年代存储经过多次垃圾回收未被标示为垃圾的对象。对于老年代,每次GC很可能不会有太大的内存释放,如果为了性能再分配出一块空闲内存有些得不偿失,所以标记复制算法并不合适,所以老年代一般都是使用标记整理算法。而对于新生代,对象朝生夕死,存活对象少,所以使用标记复制算法能够获取更高的收益。但是如果将新生代按照1:1的比例划分也没有必要,因为只需要保证存活的10%的对象有地方存放即可,所以可以将新生代在分配一块区域作为s1,s2区域,s1,s2用于进行标记整理。新生代对象只存在于新生代和s1区,当垃圾回收时,将存活对象复制到s2。其它空间直接释放即可。
垃圾回收算法总结一下:
- 分代收集: 根据对象朝生夕死的特点进行分代收集。新生代使用标记复制,老年代使用标记整理
- 新生代划分eden和s1/s2区。比例大概为8:2
- 当s区放不下某个对象,直接进入老年代
垃圾回收算法
Serial收集器
Serial收集器特点是单线程,当触发垃圾回收时,单线程从GC Root执行可达性分析。然后执行垃圾回收算法,新生代使用复制,老年代使用整理。整个过程会暂停用户线程即发生STW。
ParNew收集器
ParNew收集器是Serial的升级版本。在新生代的垃圾回收过程中,使用多线程进行可达性分析以及复制回收算法。但是老年代还是以单线程执行。多线程进行垃圾回收并不意味着一定好,更多的线程意味着耗费更多的CPU,会使吞吐量降低。
Parallel Scavenge收集器
新生代多线程收集器,与ParNew相比,特点是可以控制吞吐量。 吞吐量 = CPU执行用户代码时间/CPU执行时间(一段时间内)
Parallel Scavenge提供了两个参数控制吞吐量:-XXMaxGCPauseMillis参数控制最大停顿时间,-XX:GCTimeRatio参数控制吞吐量大小。
停顿时间越短,响应速度越快,而提高停顿时间可以通过降低eden区大小。但是响应的GC的次数就会更多,GC次数更多,可能会导致吞吐量的下降。
吞吐量越高,用户代码计算的越快。
CMS收集器
CMS收集器目标是最短停顿时间。其过程包含:
- 初始标记: 暂停用户线程,扫描GC ROOT
- 并发标记: 根据GC ROOT执行可达性分析,因为并发需要处理同时扫描到节点
- 重新标记: 并发阶段的oopMap变更修复,比如原本死了的,后来又活了的,需要进行变更,否则会导致用户程序运行报错。
- 并发清楚: 标记清除算法清楚垃圾对象
优点: 低停顿,大耗时部分已经完全支持并发执行。
缺点:
1. 如果机器CPU不多的情况下,CMS多线程是个“鸡肋”,设置线程多反而会占用CPU消耗,严重影响用户程序增加上下文切换。
2. 会产生浮动垃圾。并发标记和并发清除阶段,有用户程序执行产生新垃圾对象的情况,这种垃圾称为“浮动垃圾”。只能等待到下次垃圾回收进行清楚。并且由于用户线程不停止,还需要预留足够空间用于分配对象。当预留空间不足时,会发生Concurrent Mode Failure,此时会引发 Full GC。默认情况是当老年代使用68%会触发。如果内存增长不是很块的情况下,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值提高触发百分比减低垃圾回收频率。但也不能太高,太高有触发Concurrent Mode Failure的风险。
3. 大多数情况使用标记清除算法清除垃圾,所以会产生内存不够分配给大对象而导致FULL GC,提供-XX:UseCMSCompactAtFullCollection开关参数,当Full GC 时候会进行内存整理,清理碎片。当然每次等待Full GC整理不太好,可以通过-XX:CMSFullGCsBeforeCompaction参数指定gc多少次后,进行一次整理。
G1收集器
g1收集器追求停顿时间可预测,从g1开始垃圾收集追求的不是一次能收集更多的垃圾,而是垃圾收集速度能和内存分配速度达到一个动态平衡。
g1垃圾收集过程与cms类似:
- 初始标记,单线程,stw
- 并发标记,多线程,便利对象图
- 最终标记,多线程,stw
- 筛选回收,多线程,stw
虽然步骤与cms类似,但是g1收集器与之前任何垃圾收集器不同的是堆的划分不在以新生代,老年代进行划分,而是以块进行划分(书中用rehion表示,但我感觉这个其实就是操作系统是用过的空闲列表内存管理方式,换成region初看不太理解),块的大小有两种,0-32m的小块和用于存储大对象的大块,具体大小如何分配还有待研究。对于对象初始化过程,jvm书中只是提到任何一个region均能当做eden,s区,大块默认当做old区。这里我假想一下对象如何分配过程。
当我们new一个对象时。
- 判断对象是否超过设置的大对象阈值,超过则分配一个大region块存放。如果不超过,线程找到自己正在使用的region判断空间是否足够,若足够,则进行存放。
- 当new对象,堆空间内存不足时,获取所有已经分配region的列表,评估该块region回收时间,回收垃圾百分比。这块与用户线程并发执行。
- 根据设置的停顿时间阈值,选出要进行gc的块。对于小块,使用复制算法进行收集,关键的地方来了,新生代收集有eden,s1,s2。那么此处是否每个小块均需要额外配备2个块或者1个块(8:2分出s区)用于复制算法?目前我也在找资料研究,有答案可以分享下。
读完个人感觉g1转换为region的主要目的是追求更可控的停顿时间,原来估算收集耗时需要扫描整个对空间,而cms可以扫描每个region,从全局到局部,更加的精细可控,这是我觉得g1最大的特点。