时代发展到现在,如今的内存动态分配与内存回收技术已经相当成熟,一切看似进入了“自动化”时代,不免发出疑问:"为啥我们还要了解垃圾收集和内存分配?"
答案很简单,当需要排查各种内存溢出/泄漏问题的时候,当垃圾收集成为系统达到更高并发量的瓶颈的时候,我们必须对"自动化"技术进行必要的监控和调节。
所以,我们要了解下 GC
&内存分配,为工作中或者是面试中实际的需要打好基础。
在了解对象存活的判定之前,我们先来了解下四种引用类型
StrongReference
GC
JVM
宁愿抛出 OutOfMemoryError
使程序异常终止,也不会随意回收具有强引用的对象 SoftReference
GC
,如果 回收之后内存仍不足,才会抛出 OOM
异常 WeakReference
GC
PhantomReference
仅持有虚引用的对象,在任何时候都可能被 GC
(和弱引用一样)
主要作用是为了垃圾收集器回收时收到一个系统通知( PhantomRefernece
类实现虚引用)
与弱引用的区别:不同之处在于弱引用的 get
方法,虚引用的 get
方法始终返回 null
, 弱引用可以 使用 ReferenceQueue
, 虚引用必须 配合 ReferenceQueue
使用
必须和 引用队列 ( ReferenceQueue
)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用 加入到与之关联的引用队列 中
(想要了解 虚引用详细用法 的读者,可以看下这篇文章: 强软弱虚引用,只有体会过了,才能记住 )
定义:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的
然而在主流的Java虚拟机里未选用引用计数算法来管理内存,主要原因是它难以解决对象之间 相互循环引用 的问题,所以出现了另一种对象存活判定算法
//相互循环引用的DEMO 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 testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc(); } } 复制代码
定义:通过一系列被称为『 GC Roots
』的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链 ,当一个对象到 GC Roots
没有任何引用链相连时,则证明此对象是不可用的
可作为GC Roots的对象:
Native
方法引用的对象 JVM
内部的引用(基本数据类型对应的 Class
对象) synchronized
关键字)持有的对象 JVM
内部情况的 JMXBean
、 JVMTI
中的注册的回调、本地代码缓存等 Q:可达性分析算法中被判定不可达的对象真的被判『死刑』了吗?
A:在可达性分析算法中被判定不可达的对象还未真的判『死刑』,一共至少要经历两次标记过程:
GC Roots
判断对象是否有必要执行 finalize()
方法;若被判定为有必要执行 finalize()
方法,之后还会对对象再进行一次筛选,如果对象能在 finalize()
中重新与引用链上的任何一个对象建立关联,将被移除出“即将回收”的集合。
引申:有关 方法区 的 GC
,可分成两部分
废弃常量与回收 Java
堆中的对象的 GC
很类似,即在任何地方都未被引用的常量会被 GC
。
需满足以下三个条件才会被 GC
:
A.该类所有的实例都已被回收,即Java堆中不存在该类的任何实例;
B.加载该类的 ClassLoader
已经被回收;
C.该类对应的 java.lang.Class
对象没在任何地方被引用,即无法在任何地方通过 反射 访问该类的方法。
前文讲了 JVM
会回收哪些对象,下文笔者将探究 JVM
如何回收这些对象
在新生代上建立一个全局的数据结构( 记忆集 ),将老年代划 分成若干小块 ,标识出老年代哪一块内存存在跨代引用, Minor GC
时,在跨代引用的内存里的对象才会加入到 GC Roots
进行扫描
Java
Q3:如何根据各个年代的特点选择算法呢?
这三种算法,笔者将在下文为您详细解析
Appel
式回收 分为一块较大的 Eden
空间和两块较小的 Survivor
空间,在 HotSpot
虚拟机中默认比例为8:1:1。每次使用 Eden
和一块 Survivor
,回收时将这两块中存活着的对象一次性地复制到另外一块 Survivor
上,再做清理。可见只有 10%
的内存会被“浪费”,倘若 Survivor
空间不足还需要依赖其他内存(老年代)进行分配担保
GC
,影响系统性能 Stop The World
) 解决方法:大部分时间使用标记-清除算法,当内存空间的碎片程度影响到内存分配,再使用标记-整理算法进行收集
HotSpot
算法实现&垃圾回收器 接下来介绍如何在 HotSpot
虚拟机上实现对象存活判定算法和垃圾收集算法,并保证虚拟机高效运行
主流 JVM
使用的都是 准确式 GC
,在执行系统停顿之后无需检查所有执行上下文和全局的引用位置,而是通过一些办法直接 获取到存放对象引用的地方 ,在 HotSpot
中是通过一组称为 OopMap
的数据结构来实现的,完成类加载后会计算出对象某偏移量上某类型数据、 JIT
编译时会在 特定的位置 记录栈和寄存器中是引用的位置。这样 GC
在扫描时就可直接得知这些信息,并快速准确地完成 GC Roots
的枚举
上述“特定的位置”被称为安全点,即程序执行时并非在所有地方都停顿执行 GC
,只在到达安全点时才暂停,降低 GC
的空间成本
安全点的选定标准:可让程序 长时间执行 的地方,如方法调用、循环跳转、异常跳转等具有指令序列复用的特征
使所有线程在 最近的安全点上再停顿 的方案:
GC
发生时把所有线程全部中断,若线程中断处不在安全点上就恢复线程,让它“跑”到安全点上。现在 几乎没有虚拟机实现采用抢先式中断 来暂停线程从而响应 GC
事件 GC
要中断线程时不直接对线程操作,而是设置一个中断标志,让各个线程在执行时主动轮询它,当中断标志为真时就自己中断挂起 安全点机制只能保证程序执行时,在不太长的时间内遇到可进入 GC
的安全点,但在程序不执行时(如线程处于 Sleep
或 Blocked
状态)线程无法响应 JVM
的中断请求,此时就需要安全区域来解决
安全区域: 引用关系不会发生变化的一段代码片段 ,在安全区域中的任意地方开始 GC
都是安全的(因为引用关系不变),可看做是扩展的安全点
执行过程:
当线程执行到安全区域中的代码时就标识一下,如果这时 JVM
要发起 GC
就不用管被标识的线程;
在线程要离开安全区域时检查系统是否已经完成了根节点枚举,若完成则线程可以继续执行,否则等待直到收到可以安全离开安全区域的信号为止
JVM
中七种回收器 序号 | 收集器 | 收集范围 | 算法 | 执行类型 |
---|---|---|---|---|
1 | Serial |
新生代 | 复制 | 单线程 |
2 | ParNew |
新生代 | 复制 | 多线程并行 |
3 | Parallel |
新生代 | 复制 | 多线程并行 |
4 | Serial Old |
老年代 | 标记整理 | 单线程 |
5 | CMS |
老年代 | 标记清除 | 多线程并发 |
6 | Parallel Old |
老年代 | 标记整理 | 多线程 |
7 | G1 |
全部 | 复制算法,标记-整理 | 多线程 |
注意并发和并行的概念:
在 GC
中:
在普通情景中:
CPU
**上同时运行,任意一个时刻可以有很多个程序同时运行,互不干扰 CPU
**上运行, CPU
在多个程序之间快速切换,微观上不是同时运行,任意一个时刻只有一个程序在运行,但宏观上看起来就像多个程序同时运行一样,因为 CPU
切换速度非常快,时间片是 64ms
(每 64ms
切换一次,不同的操作系统有不同的时间),人类的反应速度是 100ms
,你还没反应过来, CPU
已经切换了好几个程序了 对象的内存分配广义上是指在堆上分配,主要是在 新生代 的 Eden
区上,如果启动了 TLAB
,将按线程优先在 TLAB
上分配,少数情况下也可能会分配在老年代中。分配细节还是取决于所使用的 GC
收集器组合以及虚拟机中与内存相关的参数的设置。以下介绍几条普遍的内存分配规则
Eden
分配 :大多数情况下对象在新生代 Eden
区中分配,当 Eden
区没有足够空间进行分配时虚拟机将发起一次 Minor GC
GC
:发生在新生代的垃圾收集动作。较频繁、回收速度也较快 老年代 GC
( Major GC/Full GC
):发生在老年代的垃圾收集动作。出现 Major GC
经常会伴随至少一次的 Minor GC
。速度一般比 Minor GC
慢10倍以上
大对象直接进入老年代:对于需要大量连续内存空间的 Java
对象(如很长的字符串以及数组),如果大于虚拟机设定的 -XX:PretenureSizeThreshold
参数值将直接在老年代分配。这样做的目的是避免在 Eden
区及两个 Survivor
区之间发生大量的内存复制
长期存活的对象将进入老年代:虚拟机会给每个对象定义一个年龄计数器,当对象在 Eden
出生并经过第一次 Minor
GC
后仍存活且能被 Survivor
容纳的话,将被移动到 Survivor
空间中并将对象年龄设为1;当对象在 Survivor
区中每“熬过”一次 Minor GC
年龄就+1,直至增加到一定程度(默认为 15岁
,可通过 -XX: MaxTenuringThreshold
设置)就会被晋升到老年代中
动态对象年龄判定:为了能更好地适应不同程序的内存状况,虚拟机并不要求一定要达到 -XX: MaxTenuringThreshold
设置值才能晋升到老年代,当 Survivor
空间中相同年龄所有对象大小的总和大于 Survivor
空间的一半,那么年龄大于或等于该年龄的对象可以直接进入老年代
空间分配担保:在发生 Minor GC
之前虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,若是,说明可确保 Minor GC
是安全的,反之虚拟机会查看 -XX:HandlePromotionFailure
设置值是否允许担保失败;若允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;若大于,将尝试进行一次 Minor GC
,若小于或者不允许担保失败,将改为进行一次 Full GC
解释:当大量对象在 MinorGC
后仍然存活的情况时,需要借助老年代进行分配担保,把 Survivor
无法容纳的对象直接进入老年代,但前提是老年代本身还有容纳这些对象的剩余空间,由于在完成内存回收之前无法预知实际存活对象,只好取之前每次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,从而决定是否进行 Full GC
来让老年代腾出更多空间
恭喜你!已经看完了前面的文章,相信你对 JVM GC
&内存分配已经有一定深度的了解,下面,进行一下课堂小测试,验证一下自己的学习成果吧!
Q1:垃圾回收算法你了解几种?请你简要分析一下,并说明其优缺点?
Q2: Java
的引用机制有几种?请简要分析下,并说明其在 Android
中的应用场景有哪些?
Q3:安全点你了解过吗?安全区呢?请你介绍下安全区相对安全点的优势在哪里?
Q4:怎么判断对象是否存活呢?有几种方法?
上面问题的答案,在前文都提到过,如果还不能回答出来的话,建议回顾下前文
如果文章对您有一点帮助的话,希望您能点一下赞,您的点赞,是我前进的动力
本文参考链接: