文章目录
目录
七、对象在内存中分配(选择Serial与Serial Old 搭配进行GC)
前言
主要介绍了垃圾收集器与内存分配策略。
一、垃圾收集器作用的内存区域
- 程序计数器 虚拟机栈 本地方法栈
- 栈帧中的局部变量表、操作数栈等内存空间在预编译阶段就可以确定所需要大小;并且随着方法的进入和退出入栈出栈,无需垃圾回收。
- 堆、方法区
- 堆存放对象实例数据,是垃圾回收器最主要的处理区域
- 方法区存放常量+类型数据,需要进行常量回收以及要求严格的类型卸载
二、寻找需要被回收的死亡对象方法
- 判断对象死亡方法
- 引用节点计数
- 每个对象维护一个引用计数标志,有引用+1,引用删除-1
- 对互相引用,但无法被GC Roots引用的循环引用问题难以解决
- Java中对象需要被回收
- 与GC Roots之间无引用且未能覆盖finalize方法或者覆盖了但是已经被调用过一次,满足两者则标记为需要被回收
- 可达性分析(GC根节点搜索)
- GC Roots
- 虚拟机栈的局部变量表、本地方法栈的JNI、常量、静态数据类型、同步锁、虚拟机内部的引用、反映虚拟机内部情况Bean,本地代码缓存等
- 区域回收时出现跨代引用时,非垃圾回收区中对垃圾回收区对象有引用的对象需要加入GC Roots中
- 若对象与GC Roots之间无引用则可能需要被回收
- GC Roots
- finalize方法
- 一种自救方法,可以将自己与一个GC Roots连接,使对象暂时不被回收,但不建议使用
- 对象可能没有覆盖finalize方法,覆盖也仅能调用一次
- 引用节点计数
- 引用类型
- 强引用(GC时存在就不会被回收)
- 软引用(GC时内存不够时回收,够时留下)
- 弱引用(GC时一定会被回收)
- 虚引用(GC时一定会被回收,虽然无法通过它访问到对象实例,但在GC时会收到一个系统通知)
- 回收方法区
- 常量回收
- 常量池中的常量或者符号引用不被任何地方的字符串对象或其他地方引用时需要回收
- 类型卸载
- 类的所有实例都被回收
- 类加载器都被回收
- java.lang.class对象都被回收,无法通过反射完成动态对象创建
- 常量回收
三、垃圾回收算法
- 分代回收理论
- 弱分代(大多数对象朝生夕灭)
- 强分代(年龄越大的对象越难回收)
- 跨代引用(非GC区对GC区的对象有引用)
- 同代对象倾向于同生同死(老年代引用新生代则新生代不会被收集最后也会进入老年代)
- 记忆集记录跨代引用信息(在有引用关系改变时需要用写屏障维护)
- CMS只有老年代引用新生代,写后屏障
- G1是Region的双向引用,写后屏障和维护原始快照的写前屏障
- 标记
- 清除(低延迟--CMS)
- 标记需要回收的对象后直接清除
- 产生空间碎片导致在分配大对象时触发Full GC造成长时间STW
- 复制(新生代)
- 将内存的A部分用于分配对象,B部分用于接收A部分回收时内部存留的存活对象,最后释放A部分
- 会造成空间利用率低的问题,B部分无法参与对象分配;但是没有空间碎片,大对象分配时有足够的连续空间
- 新生代一般被设计为占比为8的Eden区和两个占比为1的Survival区(一个From一个To),Eden+From用于分配,To用于接收遗留存活对象;当To区存活对象放不下时放在老年代,需要考虑分配担保问题(老年代内存空间大于新生代所有存活对象或者最近晋升到老年代区平均的50%时可以尝试分配到老年代)
- 整理(高吞吐--Parallel Old)
- 将GC后依旧存活的对象移动至紧凑状态,以获得大片连续空间,移动后需要修改对这些对象引用的地方
- 在移动存活对象时若想与用户线程并发执行,需要保证用户线程访问对象时能通过对旧对象的引用指针访问到新对象地址
- 在低延迟GC器出现之前,需要整理回收时都需要STW(G1整理回收时垃圾收集器线程并行),Shenandoah收集器利用转发指针实现GC线程与用户线程并发回收,ZGC使用染色体指针+转发表实现GC线程与用户线程并发回收
- 清除(低延迟--CMS)
四、垃圾回收实现细节
- GC Roots搜索算法
- 根节点枚举
- 必须STW,这个停顿很短,与堆大小无关,只与GC Roots有关
- oopMap(Ordinary Object Pointer Map)
- 准确式垃圾回收(可以明确回收的是普通数据类型还是引用类型)
- 在类加载完成后用oopMap记录偏置位移上的数据类型,在即时编译时也会记录栈与寄存器里引用位置
- oopMap是用于记录堆和栈各处数据的数据类型的数据结构,帮助我们在GC的根节点枚举阶段快速找到对象引用位置以定位GC Roots的位置, 降低该阶段的STW停顿时间
- 初始标记(找到与GC Roots直接相连的对象)
- 并发的可达性分析(并发标记)
- 即通过与GC Roots直接相连的对象查找引用链的过程,可以并发
- 实现并发必须完成两者之一
- 增量更新(CMS)
- 黑色对象添加白色对象的引用时立即变灰
- 原始快照(G1)
- 无论删除了什么对象的引用都用最原始的对象去查找引用链(在灰色对象删除白色对象引用时需要重新扫描它)
- 增量更新(CMS)
- 根节点枚举
- 可以进入GC的地方
- 安全点
- 程序长时间执行的地方即指令复用的地方(方法调用、循环跳转、异常跳转等处)
- 所有线程一起在安全点处等待GC
- 抢占式中断
- 先中断,未进入安全点则再执行一段时间
- 主动式中断
- 设置一个轮询标志,在安全点或者需要创建对象等Java虚拟机分配内存时检查该标志(防止内存不够时需要GC),若需要中断则挂起到安全点等待GC
- 轮询操作使用内存保护陷阱的方式,利用一条指令产生自陷异常信号,将线程挂起等待
- 抢占式中断
- 安全区
- 引用关系不会发生变化的区域
- 离开时要先检查虚拟机是否完成了根节点枚举等需要STW的阶段,完成了则直接离开,否则需要一直等待
- 安全点
- 记忆集与卡表(内存占用)
- 记忆集指跨代引用中非GC区指向GC区对象的指针集合的抽象数据结构
- 卡表指以卡页为单位的卡精度类型的记忆集实现
- 写屏障(额外开销)
- 解决记忆集(卡表)维护的问题(含更新卡表操作),是引用类型字段赋值的AOP切面,产生环形通知(CMS写后,G1写前+写后 )
- 执行到引用类型字段赋值的操作时会被写屏障拦截,在写屏障中修改卡表内容,防止在GC Roots枚举时,跨代引用的GC Roots找不到(普通地方的GC Roots通过oopMap就可定位,跨代引用需要通过卡表确定位置)
- 伪共享
- 多线程修改了位于同个缓存行的独立变量们,导致多次标记卡表元素
- 修改卡表元素将其标记为脏时,先检查该元素是否已经被标记过,无则标有则不标
五、垃圾收集器
- 经典GC
- 串行(一条回收器线程运行)
- Serial
- 用于新生代,标记复制
- Serial Old
- 用于老年代,标记整理
- Parallel Scavange + Serial Old配合做ParNew + CMS失败(预留的空间不足以分配对象)的后备预案
- Serial
- 并行(多条回收器线程一起运行)
- ParNew
- 用于新生代,标记复制
- 主要用于与CMS做配合,曾经的服务端模式建议GC
- 高吞吐为设计目标(高吞吐适合后台运行,低延迟适合与用户交互)
- Parallel Scavange
- 用于新生代,标记复制
- 主要特点:基于高吞吐量的设计,支持自适应调节策略,支持NUMA(非统一内存访问架构)(多核处理器分配内存时优先尝试在当前处理器的本地内存上分配以提高访问效率)
- 参数
- -XX: MaxGCPauseMillis 设置最大GC停顿时间
- -XX: GCTimeRatio 设计吞吐量大小
- -XX: +UseAdaptiveSizePolicy 自适应调节新生代大小,Eden与Survivor比例,晋升老年代大小以提供最适合的停顿时间或者最大吞吐量
- Parallel Old
- 用于老年代,标记整理
- Parallel Scavange
- ParNew
- 并发(回收器线程与用户线程一起运行)
- CMS
- 用于老年代(新生代用ParNew与其配合),标记清除
- 目标
- 最短回收停顿时间
- 过程
- 初始标记
- GC Roots枚举并找到与GC Roots直接相连的对象过程,串行需要STW
- 并发标记
- 并发可达性分析,并发引用链搜索过程
- 重新标记
- 利用增量更新方法解决并发标记过程中用户线程导致的引用关系的改变问题,并行需要STW
- 并发清除
- 无需移动存活对象,直接清除死亡对象可以并发
- 初始标记
- 缺点
- 对处理器资源很敏感
- GC线程会占用一部分处理器资源--增量式CMS让GC线程和用户线程交替占用处理器运行
- 会产生浮动垃圾
- 并发标记过程中用户线程运行时产生的新垃圾需要留待下一次GC
- 用户线程的运行需要预留一定空间进行,若预留的空间不够用户运行时对象创建所需的内存会出现并发失败,临时启用Parallel Scavange + Serial Old后备预案进行Full GC产生长时间STW
- 标记清除会产生大量空间碎片
- 找不到足够连续空间分配大对象时会提前触发Full GC造成长时间停顿
- 参数(JDK9以后都废除了)
- -XX: +UseCMS-CompactAtFullCollection 开启后在触发Full GC时先进行内存碎片合并整理
- -XX: CMSFullGCsBeforeCompaction 开启后垃圾回收器进行几次不整理碎片的Full GC后,在下次Full GC时需要先进行碎片整理
- 对处理器资源很敏感
- G1
- 用于整堆,整体标记整理,细节标记复制
- 目标
- 在用户定义的允许停顿时间内,选择回收收益最大几个的Region回收以提高收集效率获取更大吞吐量
- 在延迟可控的情况下,追求更高的吞吐量
- 细节
- Region分区
- 将内存化整为零,每个Region区域根据需要扮演新生代(Eden、Survivor)、老年代区域,并应用不同的回收策略进行GC
- 用特殊的Region区域即Humongous区域存储大小超过半个Region区域的大对象,大小超过整个Region区的超级大对象用几个连续的Humongous区域存储
- 双向卡表
- 记录各个Region区域之间存在的跨Region引用
- 十分占用内存,对其维护的写屏障也十分占用资源
- 原始快照SATB实现并发标记
- TAMS(Top At Mark Start)指针
- 在每个Region头部预留两个,用于并发标记时用户线程分配对象需要的内存空间
- 默认为存活对象标记为无需进行GC
- 若GC速度跟不上对象创建的速度时会导致Full GC的长时间STW
- 停顿时间预测与回收收益
- 回收收益=回收后获得的空间与回收所需要的时间的经验取值
- 停顿时间预测用衰减平均时间,更接近‘最近’的平均
- G1要求在用户设定的停顿时间之内选择回收收益最大的几个Region回收
- Region分区
- 过程
- 初始标记
- GC Roots枚举并找到与GC Roots直接相连的对象,串行需要STW
- 修改TAMS使并发标记时用户线程可以分配新对象
- 并发标记
- 从初始标记找到的对象出发,并发搜索引用链,需要维护原始快照SATB
- 最终标记
- 根据原始快照SATB(snapshot at the beginning)最终标记(用写前屏障记录引用关系变化以实现SATB),并行需要STW
- 筛选回收
- 计算需要回收Region的回收收益并排序,在用户设定停顿时间内选择N个回收收益最大的Region区域回收
- 将待回收Region区中依旧存活的对象移动到空Region中,然后回收旧Region,GC线程并行完成需要STW
- 初始标记
- 与CMS比较
- G1优点
- 指定最大停顿时间、Region分区内存布局、选择回收收益动态选择回收集带来很多优点
- 不会产生空间碎片,整体标记整理,局部标记复制
- G1缺点
- 内存占用
- 每个Region维护一个双向卡表
- 额外执行负载
- 维护双向卡表需要写屏障,CMS只需要写后屏障,同步操作,适合小内存(6G~8G)
- G1写屏障需要维护卡表的写后屏障 + 实现SATB的写前屏障(跟踪并发时指针变化情况),要用一个消息队列专门存储这些写屏障操作,适合大内存
- 内存占用
- G1优点
- CMS
- 串行(一条回收器线程运行)
- 低延迟GC
- 内存占用、吞吐量、延迟三者不可兼得,前两者可以通过硬件优化,吞吐量只能通过GC高并发解决
- Shenandoah
- 与G1的明显区别(与G1非常像,沿用Region布局,回收选择收益最高的Region回收,共用部分代码)
- G1最后一个阶段(移动存活对象过程)是多个收集器并行工作的筛选回收,Shenandoah是并发回收可进一步降低停顿延迟
- Shenandoah不分代,没有专门的新生代Region或者老年代Region
- 用全局数据结构连接矩阵记录跨Region引用,而不是双向卡表
- G1用写(前、后)屏障维护双向卡表和原始快照,Shenandoah用写(前、后)屏障(引用关系赋值)维护连接矩阵、读屏障(通过引用访问对象)保证并发回收成功实现
- 过程
- 并发标记
- 初始标记(GC Roots枚举并找到与GC Roots直接相连的对象,串行需要STW)
- 并发标记(从初始标记找到的对象出发,并发搜索引用链,遍历对象图,标记全部可达对象,需要维护原始快照SATB)
- 最终标记(扫描原始快照SATB中的对象,同时统计出回收价值最高的Region组成回收集CS,需要STW)
- 并发回收
- 并发清除(并发清除没有存活对象的需回收Region内存)
- 并发回收(通过读屏障和转发指针实现移动存活对象的同时用户线程对这些对象访问读写(旧对象引用找不到新对象位置的问题))
- 并发引用更新
- 初始引用更新(确保所有回收Region中的存活对象已经移动到新的空白Region中,需要STW)
- 并发引用更新(遍历引用,修改堆中已经改变位置的存活对象的引用)
- 最终引用更新(GC Roots中的引用更新,需要STW)
- 并发清除(回收存活对象全部移动成功之后Region空间)
- 并发标记
- 实现并发回收(并发移动存活对象)=读屏障+转发指针
- 转发指针的定义
- 在转发指针出现之前访问移动过的存活对象时会产生自陷异常,在核心态下找到新地址
- 转发指针是指在对象布局(对象头、实例数据、填充字节)前面加入一个引用,不并发回收阶段指向自己,并发回收时该对象存活并被移动时指向新地址
- 当用户线程在与GC线程并发时发起对该对象的读写访问时,读屏障会用转发指针找到正确新地址
- 保证并发时访问、更新对象的正确性
- CAS(Compare And Swap)同步操作保证用户线程和GC线程只有一个可以对转发指针访问成功(两者不可以交替对转发指针操作)
- 保证并发时原对象与复制对象访问一致性
- 读屏障(对对象的读比对对象的写频率高很多),用户线程需要对对象访问时若它属于GC过程中被移动的存活对象,读屏障会通过转发指针找到新地址
- 读屏障开销太大,改用引用访问屏障(只对引用数据类型设置拦截屏障,普通数据类型不设置屏障)
- 转发指针的定义
- 与G1的明显区别(与G1非常像,沿用Region布局,回收选择收益最高的Region回收,共用部分代码)
- ZGC
- 目标
- 在对吞吐量影响不大的情况下,实现不论堆大小有多大都能将停顿延迟控制在十毫秒以内
- 特征
- 以低延迟为首要目标,使用Region内存布局,不设分代,通过读屏障、染色体指针和多重映射技术实现并发的标记整理的垃圾收集器。期待成为服务端、大内存、低延迟应用的首选垃圾收集器中的有力竞争者
- 技术
- 动态Region
- 动态创建、销毁、设置大小
- 小型Region、中型Region、大型Region(动态大小,不会被重分配(移动),因为大对象的复制很耗费时间)
- 并发整理算法的实现
- 读屏障
- 在并发重分配阶段,若用户线程访问到重分配集中的对象,读屏障会捕获这个访问指令,并且通过该对象所处Region的转发表找到新地址,同时会立即修正旧引用,实现‘自愈’。这样重分配集中对象只有第一次被访问时需要进行转发(Shenandoah每次访问都需要转发指针重定位),后续可直接定位到新地址,而且这种‘自愈’功能使得Region区域中存活对象移动完毕后可以立即被用作分配新对象,只要维持好Region转发表,Region中所有移动过的对象都可以顺利被访问到并且自动修正
- 染色指针
- 之前一直分出独立空间记录标记信息,ZGC直接在对象指针上分出几个位置作为标记位,标记(三色标记状态、重分配(移动)、?只能用finalize方法访问),并发标记时不用再遍历对象直接遍历引用
- 优点
- Region上所有存活对象成功移动后即可释放它用作新对象分配,旧引用可‘自愈’
- 减少内存屏障的使用(无需写屏障(不分代没有跨代指针)只需要读屏障)
- 染色指针目前只用了4位但是指针中还有18位没有被利用,日后可以被扩展进一步提高性能
- 多重映射
- 字节码被编译成机器码后,指针中的标记位难以被机器定位,很容易被机器指令忽略
- 将指针中的标志位用作地址的分段符,使得不同标志的多个指针指向同一地址
- 读屏障
- 动态Region
- 工作过程
- 并发标记(初始+最终需要STW,并发搜索引用链时遍历的是引用图,直接修改染色指针的标记位)
- 并发预备重分配(扫描全堆Region(不计算回收收益)根据特定条件选取重分配集,重分配集Region里的所有存活对象复制移动后被回收,回收过程面向全堆标记为需要回收的Region合集,重分配集的只表示其中Region的存活对象需要被复制移动然后回收)
- 并发重分配(垃圾收集器线程移动重分配集中存活对象(每次移动时需要为每个Region维护一个转发表)时与用户线程并发执行,用户线程访问对象时若其标志位表示它在重分配集中,读屏障会将这个访问指令拦截,然后根据该对象所处Region的转发表重新定位到新地址,并同时修正旧引用实现’自愈‘)
- 并发重映射(遍历引用图并修正所有重分配集中(即被移动过的)对象的引用,该过程可以实现不变慢同时释放转发表的功能,但是因为旧引用可‘自愈’,该过程不迫切,所以与下一轮GC中需要遍历引用图的并发标记过程合并)
- G1、CMS、ZGC比较
- CMS对老年代引用新生代的跨代引用需要维护卡表,并用写(后)屏障维护
- G1处理跨Region引用时,需要持有数量非常庞大的记忆集(双向卡表),并用写(前、后)屏障维护
- ZGC不设分代,无需写屏障(只需读屏障维护并发重分配(并发移动)),因为不设分代限制了ZGC所能承受的对象分配速度,通过尽可能增大堆容量可以获得喘息,只有引入分代收集,对新生对象创建区域进行更频繁的收集才能从根本上解决问题。ZGC还支持NUMA(NonUniformMemoryAccess非统一内存访问架构)支持在多核处理器中创建对象时优先在当前处理器的本地内存上分配对象以提高对象访问效率
- 目标
- 适合小规模短时间服务形式的GC
- Epsilon
- 不能进行垃圾回收的一中垃圾收集器,可以帮助实现自动内存管理(如堆的管理和对象的分配),因为小规模短时间的服务应用只需要运行数分钟或者数秒,在堆的内存耗尽之前就可以退出,只需要关注堆的内存管理和对象分配等简单内存管理问题
- Epsilon
六、垃圾收集器的选择
- 垃圾收集器选择的影响因素
- 应用程序主要关注点是什么(数据分析、科学计算--吞吐量 / SLA--停顿延迟时间 / 客户端、嵌入式应用--垃圾收集器的内存)
- 运行的基础设施(如硬件设施)
- JDK发行商和版本号
- 垃圾回收日志(学会看垃圾回收日志处理Java虚拟机内存问题)
七、对象在内存中分配(选择Serial与Serial Old 搭配进行GC)
- 自动内存管理=自动内存分配(实例对象的分配一般都放在堆上,但是通过即时编译优化有一些对象会被拆散分配在栈上)+自动垃圾回收
- 先在Eden+Survivor区的From区去分配
- 创建新对象Eden放不下时,需要进行Minor GC,检查Survivor的To区够不够存放Eden中的存活对象,若不够则需要进行分配担保检查,通过后将存活对象存入老年代,并再Eden中分配新对象,此时Eden中存放了新对象,Suvivor空闲,老年代存放了GC时的存活的老对象;若TO区够则将存活对象移动至TO区,并且在Eden区分配新对象,此时Eden存新对象,Survivor存GC时存活的老对象,老年代空闲
- 空间分配担保
- 老年代最大可用连续空间大于新生代所有对象空间或者屡次晋升到老年代的对象空间的平均值的50%时允许在Survivor空间不足以承受存活对象时将存活对象直接移动至老年代
- 以前需要看参数是否设计允许冒险进行分配担保,现在条件允许直接进行Minor GC+分配担保,不允许或者担保失败(老年代也放不下存活对象)触发Full GC
- 大对象在老年代
- 大对象(长字符串、元素很多的数组)直接分配在老年代,因为大对象的复制移动耗费时间
- 存活时间过长对象去老年代
- 存活对象能被Survivor区储存会被移至Survivor区,在里面每熬过一次GC年龄就增加一次,当其年龄增加到一定程度(默认15)会复制移动到老年代
- 动态定义对象年龄
- 若Survivor区中相同年龄的对象占用内存空间总和占用超过50%的Survivor空间时,年龄大于或等于这些对象年龄的对象会被直接复制移动到老年区(同年对象达到Survivor空间一半提前入老年代规则)