[文章转移]2.JVM垃圾收集器与内存分配策略
原文也是我自己:https://blog.csdn.net/w635614017/article/details/65968051
参考书籍:《深入理解Java虚拟机》
Java的程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;(生命周期)
这几个区域的内存分配和回收都具备确定性,因为方法结束的时候,内存就跟着回收了。
Java堆和方法区和他们不同,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收是动态的!
如何判断对象是否存活?
1.引入计数算法
给对象添加一个引用计数器,每当有一个地方引用他们的时候,计数器值就加1;当引用失效的时候,计数器值就减1。任何时候,计数器为0的对象是不可以再被使用的。
这个算法实现很简单,应用也非常广泛。但是!java虚拟机并没有!使用这个方式去管理内存!主要原因就是:它很难解决对象之间互相引用的问题!
2.可达性分析算法
基本思路是通过一系列的称为“GC Roots”的对象最为起始点,从节点开始向下搜索,搜索时所走过的路径称为引用链。当对象达到CG Roots没有任何引用链相连的时候,证明此对象不可用。于是认定其是可回收的。
GC Roots对象包括以下几种:
1.虚拟机栈中引用的对象。
2.方法区中类静态属性引用的对象。
3.方法区常量引用的对象。
4.本地方法栈中JNI引用的对象。
引用
我们希望能描述这样一类对象:当内存空间还足够,能保留在内存之中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
JDK1.2后,对引用类型概念进行了扩充,分为了强引用,软引用,弱引用,虚引用。引用强度依次减弱。
1.强引用:代码中普遍存在的东西,只要强引用还在,垃圾收集器就不会回收被引用的对象。
2.软引用:描述一些还有用,但是并非必需的对象。对于软引用关联着的对象,在系统要发生内存溢出之前,会把这些对象列进二次回收。如果回收了还没有足够内存,则抛出内存溢出异常。
3.弱引用:描述非必须对象,强度比软引用还弱,被弱引用关联的对象。
4.虚引用:称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间够长影响。那它为啥存在?!答案是:在这个对象被收集器回收时能收到一个系统通知!
一个对象的死亡
一个对象如果要确定死亡,需要两次标记过程:
1.如果对象在可达性分析后发现没有与GC Roots相连的引用链,标记第一次!
2.对象没有覆盖finalize()方法,或者该方法被虚拟机调用过了,则这个对象已经要死了。
如果判定对象有必要执行finalize()方法,那么将会把它放在一个F-Queue队列中,稍后会有一个虚拟机建立的,低优先级别的Finalizer线程去执行它。
也就是说finalize()方法给了对象一个最后的机会:
GC会对F-Queue中的对象进行二次标记,如果对象想存活,只需要重新与引用链上任何一个对象建立关系即可!比如说:将自己赋值给某个类变量或者对象的成员变量~这时候他就会被送出即将被回收的集合,死里逃生!
但是注意!任何对象的finalize()方法都只会被系统调用1次,再被回收就只能Say GoodBye了。
回收方法区
永久代回收两部分:1.废弃常量 2.无用的类
废弃常量的回收很简单,只要关注是否被引用即可。
回收无用的类比较麻烦,需要满足三点:
1.java堆不存在该类的实例
2.加载该类的ClassLoader已被回收
3.无法再任何地方通过反射的方式访问该方法
想想就知道,只要满足以上三点,那么这个类不用多说,他基本上已经是个废类了✧(≖ ◡ ≖✿)
垃圾收集算法
(敲黑板!这里很重要很重要!)
垃圾已经确定了,我们需要一个方式去收集他们,那么这时候就需要一些算法:
1.标记-清除算法——最基础的收集算法
分为“标记"和"清除"两个阶段:首先标记需要回收的对象,在标记完成之后统一回收所有被标记的对象。
后续的收集算法都是基于这个思路并改进的。它有两点不足:1.效率低下 2.浪费空间
2.复制算法
将可用内存按容量划分大小相等的两块,每次只用一块。这一块内存用完了,就将还存活的对象复制到另一块上面,然后把使用过的内存空间一次清理掉。这个算法很高效,实现起来也很简单:每次都是半区进行回收,内存分配也不用考虑碎片的情况。但是也有缺点:将内存缩小到原来的一半,有点浪费性能。
新生代的对象基本都是”朝生夕死“,生命周期很短,所以基本上对新生代都用复制算法。
3.标记-整理算法
复制收集算法在对象存活率高的情况下要进行较多的复制操作,效率会变低。更关键的第一点:若不想浪费50%的空间,需要额外空间进行分配担保,以应对被使用的内存中所有对象都100%存活的情况。所以老年代,一般不能用它。
根据老年代的特点,有人提出了标记-整理算法,标记过程和”标记-清除算法一样“,但是后面不是直接回收,而是让所有存活对象向一端进行移动,然后直接清理到边界以外的内存。
4.分代收集算法
思路差不多,根据对象存活周期不同将内存分块,一般是把java堆分成新生代和老年代。对于新生代(每次收集都有大批对象死去)就自动去选用复制算法。对于老年代(对象存活率高,没有额外空间进行担保)必须使用”标记-清理“或者”标记-整理“算法。
这个分代收集算法是当前商业虚拟机主流。
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
可以说新生代基本采用“复制算法”。老生代采用“标记-整理”算法。CMS采用“标记-清除”算法。
1.Serial
Serial收集器是最基本,发展历史最久的收集器,曾经是虚拟机新生代收集器的唯一选择!(复制算法)
Serial是个单线程的收集器。它在进行回收垃圾的动作时候,其他的工作线程必须都要停止,等着它收集结束。这样一点都不酷!设想你在打LOL团战,打到一半突然卡那不动两分钟,你说难受不难受?但是它依然是Client模式下的默认新生代收集器~~~
但是它的优点也很明显:简单、效率高
2.ParNew
它是并行收集器,是Serial的多线程版本,(复制算法)与Serial的区别就在于它是多线程的,其余都一样。然而ParNew在单CPU环境并没有比Serial强,线程的切换还会增加开销。
3.Parallel Scavenge
“吞吐量优先”收集器,它是并行收集器,是个新生代收集器(采用复制算法),它与众不同的一点在于它的目的和其他收集器不太一样——达到一个可控制的吞吐量。等等。。。什么是吞吐量?
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间)
高吞吐量就是CPU可以高效的利用CPU时间,从而完成运算任务,适合后台运算多,交互少的任务。
4.Serial Old
Serial收集器的老年代版本,是串行的,利用了单线程和“标记-整理”算法。它可以和Parallel Scavenge配合使用,也可以作为CMS收集器的备选。
5.Parallel Old
是Parallel Scavenge的老年代版,并行收集器,用多线程和“标记-整理”算法。这个收集器的出现解决了Parallel Scavenge的尴尬局面,因为Parallel Scavenge必须和Serial Old配合,但是Serial Old还在性能上拖了后腿!以至于Parallel Scavenge吞吐量优化的优势没法发挥出来~但是!!!Parallel Old出现了,解决了这个问题,完全可以使用Parallel Old和Parallel Scavenge进行组合。
6.CMS
CMS收集器是基于“标记-清除”算法,它的目标是:获取最短回收停顿时间。这对于B/S系统服务端来说非常重要!因为这是与用户体验息息相关的!
它工作流程比较复杂:
- 初始标记(需要Stop the World)
- 标记一下GC Root直接关联的对象!时间很短~
- 并发标记(需要Stop the World)
- 开始可达性分析
- 重新标记
- 修正因为用户操作导致标记的改变
- 并发清除
- 就是清除掉~
并发标记和并发清除的时间会多一些,但是他们是并发的,并不影响用户线程的正常工作。
CMS很强大但是!!但是它也有缺点:
- CMS收集器对CPU资源太过于敏感。会占用部分线程导致吞吐量降低
- CMS收集器无法处理浮动垃圾,可能会导致Full GC
- 既然用“标记-清除“算法,就要承担它的缺点——收集结束后会产生大量的空间碎片,给分配对象带来麻烦
7.G1
G1收集器是最新的!整体基于”标记-整理",局部使用”复制算法“
特点:
1.并行与并发:用多CPU缩短停顿时间,仍然可以通过并发,让Java程序继续执行!
2.分代收集:G1可以不需要其他收集器配合,独立管理GC堆!
3.空间整合:整体上使用“标记-整理”算法,局部使用“复制”算法。
4.可预测停顿:可以建立可停顿的时间模型。
G1收集器将整个Java堆划分为多个大小相等的独立区域,取代了新生代和老年代的物理隔离!它避免了在整个Java堆进行全区域的垃圾收集。
G1收集器的工作流程:
- 初始标记
- 标记GC Roots,修改TAMS的值
- 并发标记
- 可达性分析,找出存活的对象。
- 最终标记
- 做标记修正
- 筛选回收
- 指回收一部分Region,时间是用户可控的