前言
最近在复习JVM的知识,主要参考教材是周志明老师的《深入理解Java虚拟机 JVM高级特性和最佳实现》,文章将记录一些关键笔记。
本文是第一份笔记,主题是“内存管理机制”。
关于内存
运行时数据区
虚拟机在执行程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。
程序计数器 Program Counter Register
- 小块内存空间
- 程序字节码的行号指示器
- 每条线程有独立的程序计数器
虚拟机栈 Virtual Machine Stacks
- 线程私有
- 生命周期和线程相同
- 每个方法在执行的同时,会创建一个帧栈(Stack Frame)用于存放局部变量表、操作数栈、动态链接、方法出口等信息
- 每一个方法从调用到执行完成的过程,对应一个帧栈入栈到出栈的过程
- 请求栈深度过大 -> StackOverflowError
- 扩展内存不足 -> OutOfMemoryError
本地方法栈 Native Method Stack
- 与虚拟机栈非常相似
- 虚拟机栈为Java方法服务,本地方法栈为Native方法服务
堆 Heap
- 虚拟机中最大的一块
- 所有线程共享
- 存放对象实例
- 垃圾回收的主要区域
- 可以处于物理不连续的内存空间,只要逻辑上连续
- 扩展内存不足 -> OutOfMemoryError
方法区 Method Area
- 线程共享
- 储存类信息、常量、静态变量、即时编译器编译后的代码等数据
- 较少出现垃圾回收
- 拓展内存不足 -> OutOfMemoryError
关于对象
创建流程
加载 -> 分配内存 -> 内存初始化 -> 存放对象头 -> 实例初始化
内存布局
对象在内存中存储的布局分为三个区域:对象头 Header、实例数据 Instance Data、对齐填充 Padding。
对象头 Header
- 一部分存储对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志等
- 另一部分存储类型指针,即对象指向它的类元数据的指针
- 如果是数组,还会记录数组长度
实例数据 Instance Data
- 对象真正存储的有效信息
- 包括从父类继承的数据、在子类定义的数据等
对齐填充 Padding
- 不一定存在
- 补全占位符,内存管理系统要求对象起始地址必须是8字节的整数倍
访问定位
程序需要通过栈上的引用(reference)来操作堆上的具体对象,reference有两种方式来定位、访问堆中的对象的具体位置:句柄访问、直接指针。
句柄访问
- 堆中划分一块内存来作为句柄池
- reference存储对象的句柄地址
- 好处:reference存储稳定的句柄地址,对象被移动时只需改变句柄中的指针
直接指针
- 堆对象中放置访问类型数据的信息
- reference直接存储对象地址
- 好处:速度快,节省一次指针定位开销
关于垃圾回收
对象已死?
如何确定堆中的对象已经不被任何途径使用?
引用计数算法
- 每当一个地方引用某个对象,计数器加1,;引用失效时,计数器减1
- 计数器为0,表示对象不再被使用
- 实现简单,判定效率很高
- 难以解决对象之间互相循环引用的问题
- 主流Java虚拟机没采用该算法
可达性分析算法
- 主流商用程序语言的主流实现算法
- 通过一系列 GC Roots 为起点,从节点向下搜索,搜索过的路径称为引用链(Reference Chain)
- 某个对象到 GC Roots 之间没有任何引用链相连,则此对象不可用
- Java中可作为 GC Roots 的对象包括虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象等等
对象引用方式
为了避免狭隘地定义一个对象只有被引用和没被引用两种状态,在JDK1.2之后,Java引入了强引用 Strong Reference、软引用 Soft Reference、弱引用 Weak Reference、虚引用 Phantom Reference四种定义,引用强度依次减弱。
强引用 Strong Reference
- 必须的对象
- GC永远不会回收
- Object obj = new Object()
软引用 Soft Reference
- 有用但非必须对象
- 发生OOM前,会对软引用进行回收
- Object obj = new SoftReference<Object>(new Object())
弱引用 Weak Reference
- 非必须对象
- 进行GC时,无论内存是否足够,都会对弱引用进行回收
- Object obj = new WeakReference<Object>(new Object())
虚引用 Phantom Reference
- 对象无论是否存在虚引用,都不会对其生存时间构成影响
- 无法通过虚引用来取得对象实例
- 唯一目的是能在对象被回收是收到系统通知
垃圾收集算法
标记 - 清除算法
- 标记阶段,标记处所有需要回收的对象;清除阶段,统一回收所有被标记的对象
- 标记和清除效率都不高
- 标记清除后会产生大量不连续的内存碎片
复制算法
- 将可用内存分为大小相等的两块,每次只用其中一块
- 一块内存用完时,将还存活的对象复制到另外一块,再全部清除已用过的前一块内存空间
- 实现简单,运行高效
- 内存分配时不用考虑内存碎片情况
- 内存缩小为只有原来一半
研究表明,新生代中98%的对象都是“朝生夕死”的,所以不需要按照1:1的比例来划分,一般分为一个空间较大的 Eden 区和两个空间较小的 Survivor 区( HotSpot 默认 Eden 和 Survivor 的大小比例是8:1),每次使用 Eden 和其中一块 Survivor 。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚用过的 Survivor 。
标记 - 整理算法
- 标记过程与“标记 - 清除算法”一样,后续不清理可回收对象,而是让所有存活对象向一端移动,然后清理端边界以外的内存
分代收集算法
- 根据对象存活周期的不同,将内存划分为几块,一般Java堆分为新生代和老年代
- 新生代每次垃圾回收有大批对象死去,使用复制算法
- 老年代对象存活率高,使用“标记 - 清除算法”或者“标记 - 整理算法”
- 当代商业虚拟机大都采用该算法
GC前准备
除了对象存活判定算法和垃圾收集算法,还有一些关键点的考量会影响虚拟机执行效率。
枚举根节点
- 可作为GC Roots的节点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,要逐个检查里面的引用会消耗很多时间
- GC分析工作必须在能保持一致性的快照中进行,不可以出现分析过程中对象引用关系还在不断变化,所以GC进行时必须挺短所有Java执行线程
- 考虑到上面两点,HotSpot使用一组称为OopMap的数据结构,在类加载完成时记录对象内什么偏移量上是什么类型的数据,在JIT编译过程时在特性位置记录栈和寄存器中哪些位置是引用,这样,GV扫描时可以直接得知这些信息
安全点
- 非常多指令会导致引用关系变化,如果每一条指令都生成OopMap,会需要大量额外空间,所以HopSpot只在安全点(Safepoint)记录这些信息,程序只有到达安全点时才能暂停下来GC
- 安全点不能太少以致GC等待过久,也不能太频繁以致增大运行负荷,所以安全点的选定以“是否具有让程序长时间执行的特征”为标准,最明显的特征就是指令序列复用,如方法调用、循环跳转、异常跳转等
- 要让GC时所有线程在安全点上暂停有两种方案:抢先式中断和主动式中断。抢先式中断在GC时马上中断所有线程,如果有线程不在安全点,则恢复该线程并运行到安全点,现在几乎没有虚拟机采用该方案;主动式中断则在需要GC时设置一个标记,各线程执行时轮询该标记,如果标记为真则中断挂起
安全区域
- 如果程序没有分配CPU时间,如处于Sleep或者Blocked状态,则无法运行到安全点中断挂起
- 安全区域(Safe Region)指在一段代码片段中,引用关系不会变化,在该区域任意地方都可以安全GC
- 线程执行到安全区域中代码时,标识自己进入安全区,虚拟机GC时就不用管该线程;线程离开安全区时,要检查系统是否已完成根节点枚举或者整个GC过程,未完成的话需要等待信号
垃圾收集器
垃圾收集器是内存回收的具体实现,JDK 1.7 Update 14之后的HotSpot虚拟机包含了以下若干个收集器。
- 图中上半部分是新生代收集器,下半部分是老年代收集器
- 收集器之间的连线表示它们可以搭配使用
- 没有最好或者万能的收集器,每个收集器都有适用的具体应用
- ?即G1收集器
Serial 收集器
- 最基本、最悠久的收集器,使用复制算法
- 单线程收集器,进行垃圾收集时会暂停所有工作线程直到收集结束
- 运行在Client模式下的默认新生代收集器
- 简单而高效,对单CPU环境可以获得最高单线程收集效率
Serial Old 收集器
- Serial 收集器的老年代版本,使用单线程和标记 - 整理算法
- 主要给Client模式虚拟机使用,在Server模式下常用于与Parallel Scavenge收集器搭配使用或者作为CMS收集器的后备预案
ParNew 收集器
- Serial 收集器的多线程版本,使用复制算法
- 运行在Server模式下的默认新生代收集器
- 随着CPU数量增加,更能有效利用GC时资源
Parallel Scavenge 收集器
- 和 ParNew 收集器类似,使用复制算法,并行多线程
- 目标是达到可控制的吞吐量(Throughput),吞吐量即CPU用于运行用户代码的时间与CPU总消耗时间的比值,吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
- 吞吐量越小则响应速度越快,适合需要与用户交互的程序;吞吐量高则可以高效利用CPU时间,尽快完成运算任务,时候再后台运算不需要太多交互的任务
Parallel Old 收集器
- Parallel Scavenge 收集器的老年代版本,使用多线程和标记 - 整理算法
- 在注重吞吐量和CPU资源敏感的场合,可以优先考虑Parallel Scavenge + Parallel Old
GMS(Concurrent Mark Sweep) 收集器
- 目标是最短回收停顿时间,基于标记 - 清除算法
- 整个过程分为4个步骤:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)
- 初始标记、重新标记这两个步骤需要停止用户线程,但这两个步骤用时远比并发标记和并发清除短,所以总体上可以说内存收集过程是与用户线程一起并发执行的
- 优点:并发收集、低停顿
- 缺点:对CPU资源敏感、无法处理并发清理阶段用户线程产生的浮动垃圾、标记 - 清除算法会产生大量空间碎片
C1 收集器
- 面向服务端应用,具备以下特点:并行和并发、分代收集、空间整合、可预测的停顿
- 将整个Java堆分为多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,而是一部分Region的集合
- 分为4个步骤:初始标记(Initial Marking)、并发标记(Concurrent Marking)、最终标记(Final Marking)、筛选回收(Live Data Counting and Evacuation)
关于内存分配
给对象分配内存的规则不是固定的,其取决于使用的垃圾收集器组合和虚拟机的参数设置等,以下是几条普遍的内存分配规则。
1、对象优先在 Eden 分配
- 对象优先在新生代 Eden 区中分配
- Eden 区空间不足将发起 Minor GC
Minor GC:新生代GC,非常频繁,回收速度比较快
Major GC/Full GC:老年代GC,常伴随至少一次Minor GC,一般比Minor GC慢10倍以上
2、大对象直接进入老年代
- 大对象指需要大量连续内存空间的对象,典型的是很长的字符串和数组
3、长期存活的对象将进入老年代
- 虚拟机给每个对象定义一个年龄(Age)计数器
- 如果对象在Eden出生经过第一次Minor GC后仍然存活并能被Survivor容纳,则将对象移到Survivor中并设置年龄为1
- 对象在Survivor中每经历一次Minor GC并存活,年龄加1
- 年龄增加到设置的值(默认15)时,会被晋升到老年代
4、动态判定对象年龄
- 如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代
5、空间分配担保
- 在Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间
- 如果可用空间大于总空间,Minor GC可以确保安全
- 如果可用空间小于总空间,虚拟机会查看HandlePromotionFailure设置是否允许担保失败
- 如果允许担保失败,会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果可用空间大于平均大小,会尝试进行有风险的Minor GC
- 如果Minor GC冒险失败,或者可用空间小于平均大小,或者不允许担保失败,则进行一次Full GC
- Minor GC冒险是指如果大量对象在Minor GC后仍然存活,则一个Survivor空间会不足以轮换备份,这时就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代
小结
内存管理能力是Java与一些其他语言的重大区别,也是影响Java运行性能、效率的重要因素。想要深入学习JVM的知识,内存管理方面的内容必须了解并熟悉。
书本对于内存管理部分的知识点写的非常详细,也通过很多个例子通俗易懂地讲解清楚了,因为篇幅原因,没办法在笔记中全部记录,有兴趣的同学可以自行阅读原文。