摘要: gc
JVM 垃圾回收
Jvm 内存结构
运行时数据区
程序运行期间会使用到的运行时数据区
PC寄存器
每一个虚拟机线程都有自己的PC寄存器,保存Java虚拟机正在执行的字节码指令的地址,如果该方法是 native 的,那 PC 寄存器的值是 undefined
Java 虚拟机栈
线程私有的,栈与线程同时创建,生命周期和线程是一样的。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧 ,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从开始调用到执行完成都对应着一个栈帧在虚拟机栈中入栈到出栈的过程
栈帧(Frame)
用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接、方法返回值和异常分派
栈帧随着方法调用而创建,随着方法结束而销毁,无论方法是正常完成还是异常完成
栈帧的存储空间分配在Java虚拟机栈
每一个栈帧都有自己的局部变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用
栈帧容量的大小仅仅取决于Java虚拟机的实现和方法调用时可被分配的内存
一条线程只有正在执行某个方法的栈帧是活动的,叫做当前栈帧,对应的方法叫当前方法,定义这个方法的类叫当前类。对局部变量表和操作数栈的各种操作,通常指的是当前栈帧进行的操作
栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧
如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个新的方法被调用,则会新建一个栈帧并成为当前栈帧,当方法返回时会将结果(当前新的栈帧)返回给上一个栈帧,当前栈帧丢弃,上一个栈帧重新成为当前栈帧。
- 局部变量表
长度由编译期决定,存储于类和接口的二进制表示之中,既通过方法的Code属性保存及提供给栈帧使用
- 一个局部变量可以保存类型boolean、byte、char、short、float、reference和returnAddress的数据
- 两个局部变量可以保存类型为long和double的数据
当方法被调用时候,参数将会传递至从0开始的连续的局部变量表里。如果是实例方法被调用则第0个局部变量一定是this
局部变量使用索引来进行定位访问,0-max
long和double这种需要两个局部变量的类型,索引取最小的那个局部变量。
- 操作数栈
同局部变量表,长度由编译期决定,存储于类和接口的二进制表示之中,既通过方法的Code属性保存及提供给栈帧使用
操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。
Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果,例子参考初识jvm指令执行流程
一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型则会占用一个单位深度
- 动态链接
在Class文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用
类加载的过程中将要解析掉尚未被解析的符号引用,并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移量
由于动态链接的存在,通过晚期绑定(Late Binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响
Java堆
可供各条线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域
Java堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统所管理的各种对象,这些受管理的对象无需,也无法显式地被销毁
方法区
可供各条线程共享的运行时内存区域
方法区在虚拟机启动的时候被创建,存储了每一个类的结构信息,例如运行时常量池(存放编译器生成的各种字面量和符号引用)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法
虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集,在 JDK1.7 中是 Perm Space , 在 JDK1.8 中是Meta Space
运行时常量池
每一个类或接口的常量池的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用
每一个运行时常量池都分配在Java虚拟机的方法区之中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来
本地方法栈
如果支持本地方法栈,则会在线程创建的时候按线程分配
垃圾回收算法
JVM 中内存结构图
堆区 又被划分为 新生代(Young) 和 老年代(Old)
Young 新生代,又被划分为 Eden区 和 S区 ,默认情况下JVM采取的是一种动态分配的策略(-XX:+UsePSAdaptiveSurvivorSizePolicy
),根据对象生成的速率,以及S区使用情况动态调整Eden区和S区的比例,也可以使用参数固定此比例(-XX:SurvivorRatio
) ,比例越低浪费的堆空间就越高(因为S区有一个区域一直为空,S区大了就会浪费一部分空间),至于为什么会有S0与S1,可以参考下面的可达性分析清除方式,这里不过多解释
Metaspace 存放 Class 、Package 、Method 、Field 、字节码 、常量池 、符号引用等等
CCS 压缩类空间,只有启用压缩类短指针的时候才会存在。出现的原因:在堆里面分配每一个对象都会有一个指向自己 Class 的指针,64位的虚拟机每个指针长度是64位的,考虑性能原因我们可以把这个指针使用短指针来引用,使用32位的指针,如果使用短指针,其所引用的Class文件则会存放到CCS区,在 JVM 中是默认开启的 UseCompressedClassPointers
,默认1G大小,可以使用 CompressedClassSpaceSize
设置大小
CodeCache 存放JIT即时编译代码 、Native代码,可以使用 -XX:InitialCodeCacheSize
-XX:ReservedCodeCacheSize
设置初始和最大大小
需要注意的是在我们调用 new 指令时,它会在Eden区划出一块作为存储对象的内存,由于堆空间是共享的(参考上文中 Jvm的内存结构),所以在划空间时候是需要进行同步的,JVM的解决办法是为每一个线程分配一段连续的内存作为线程私有的TLAB(Thread Local Allocation Buffer) ,并且只允许该线程拥有该部分内存,该技术对应参数(
-XX:+UseTLAB
,默认开启)。同样在线程申请内存时候需要加锁,线程主要维护两个指针,一个指向TLAB空余内存的起始Adress,一个指向TLAB末尾。而new的时候便可以通过
bump the pointer
来实现,即将第一个指向起始位置的指针加上请求的字节数,当加上字节数后的值大于指向末尾的指针的值,则当前线程重新申请新的TLAB。当Eden区耗尽则会触发minor GC
分析垃圾的方法
无法被程序引用的在堆上已分配的内存空间称为垃圾
古老的引用计数法
引用计数方式会为每个已分配的内存单元设置计数器,当计数器减少为0的时候意味着再无法被引用,将立即执行释放内存的动作。
引用计数 使用弱引用来解决循环引用带来的问题,弱引用不会影响计数器状态的引用,即使循环引用也不会阻止其被清除
- 问题
- 如果分配的内存单元原本就很小,这个时候计数器所占的内存占比就比较大。而垃圾回收方式只需要设置一个标记位置而已
- 维护计数器的状态会占据整个程序执行时间中明显的部分,相当于将垃圾回收的消耗均摊到程序运行的整个过程
- 计数器代码分布在用户代码各处不易维护
- 当存在循环依赖时候内存释放比较复杂
主流的可达性分析法
基本思想是 标记-清除(mark-and-sweep) ,每隔一段时间或堆空间不足时候才进行一次垃圾回收,每次回收先将所有堆上分配的内存单元标记为 “不可到达” ,然后根据 GC Root 开始扫描,把可达到的内存单元标记为 “可以到达” 。最后回收标记为 “不可到达” 的内存单元。
GC Root: 类加载器、已启动且未停止的Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量
标记-清除 并不会受循环引用的影响,如A与B循环依赖 但是当AB都 不可达 时则会将AB都进行回收。标记-清除垃圾回收同样也提供了弱引用,原因是为了解决可能是人为造成的内存泄露(无意长时间持有了对已经不需要的对象的引用,如private static修饰的类变量)
- 问题
- 在执行垃圾回收时候一般要把整个程序停下来并执行 标记-清除 的过程(不考虑并行回收) ,如果是实时系统一般无法承受这种因为标记过程过长而导致的时间消耗,其宁可选择引用计数均摊到整个运行过程中,分代式的垃圾回收一定程度上缓解了这个问题(例如java 虚拟机新生代与老年代的回收) ,但并没有根除消耗高峰的问题
- 最需要垃圾回收时(堆内存已经快分配尽了,但已分配也就是需要标记的有很多),它运行效果却最差(需要大量时间来标记,实际释放的却未必很多)
- 标记-清除 有两种实现思路,保守式 与 准确式 ,保守式 不需要知道内存的具体布局形式,会把所有看上去像指针的数值看作指针并纳入标记计算中; 准确式 则要求运行时系统清楚的了解内存的布局,能够分辨指针并只标记指针。前者不够准确,后者则需要消耗更多的内存和时间
- 清除的方式
直接清除 把死亡对象所占据的内存标记为空闲内存,新建对象需要内存则从空闲内存中划分
缺点: 非连续内存空间分配效率低,并且会造成内存碎片,体现在现实中就是内存不够分配提前GC
清除完进行压缩 在清除完将存活对象聚集到内存区域的起始位置,保证内存空间是连续的
缺点: 压缩算法的开销
清除完进行复制再进行清理 首先把内存区域分为两部分,用指针from来维护之前需要分配内存的内存区域,在清除完将存活对象复制到to指针维护的内 存区域中,然后清理掉from区,最后重新交换from指针与to指针的内容
缺点: 内存空间使用效率极低,只有50%
现代垃圾回收器会综合上述几种清除方式,根据不同场景选出最合适的方式,例如JVM中会频繁进行Eden区回收,此时则采用复制方式,这样的原因是理想情况下清除垃圾后Eden区的对象基本都死亡了,需要复制的数据很少,使用复制算法效果很好。
存在的问题
如果老年代中的对象引用新生代的对象,在使用可达性分析标记存活对象的时候我们就需要扫描老年代。为了不做耗时较久的全堆扫描,HotSpot 的解决方案是 Card Table
,该技术将堆划分为一个个大小为512字节的 Card
,然后维护了一个 Table
,用来存储每张 Card
的一个标识位,这个标识位代表是否可能存在指向新生代对象的引用,若存在则认为这是一个脏的 Card
。则在新生代 Minor GC 时则不需要扫描整个老年代,而是寻找脏的 Card
将其中的对象加入到 Minor GC 的 GC Root 中,完成扫描则将所有标识位清空
对象分配
- 对象优先在Eden区分配
- 大对象直接进入老年代,可以通过参数(
-XX:PretenureSizeThreshold
)设置大对象的临界值,默认0,表示没有最大值 - 每次S区进行复制算法的时候都会被记录下来,如果一个对象复制次数为 15 (可通过参数
-XX:MaxTenuringThreshold
设置 ),则会进入老年代 - 在第3条的基础上会和 S 区存活对象在50%比例下(可通过参数
-XX:TargetSurvivorRatio
设置比例)的平均复制次数取一个最小值,达到这个值则会进入老年代
可通过参数 (-XX:+PrintTenuringDistribution) 打印 Minor GC 后存活对象的年龄分布情况
垃圾收集器
串行收集器Serial
开启参数
1 | -XX:+UseSerialGC -XX:+UseSerialOldGC |
并行收集器Parallel
多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
开启参数
1 | -XX:+UseParallelGC -XX:+UseParallelOldGC |
自适应调优
- -XX:MaxGCPauseMillis= 最大停顿时间
- -XX:GCTimeRatio= 吞吐量(花在应用时间和花在垃圾收集的时间的占比) 默认99,垃圾收集时间=1/1+N
- -Xmx= 最大堆的大小
JVM会自动调整堆分区的大小来满足以上三个参数条件,当不够了就会变大,变化的比例同样可以通过JVM参数设置:
-XX:YoungGenerationSizeIncrement=<Y>
年轻代适应下大小百分比,默认值20-XX:TenuredGenerationSizeIncrement=<T>
老年代适应下大小百分比,默认值20-XX:AdaptiveSizeDecrementScaleFactor=<D>
变小的百分比,默认4
并发收集器Concurrent
用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾收集线程在执行的时候不会停顿用户程序的运行。适合对响应时间有要求的场景。本文只是简单介绍,之后会单独出对应文章
开启参数
CMS
1
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
G1
1
-XX:+UseG1GC
CMS (Concurrent Mark-Sweep)收集器
核心是 标记-清除 ,优点是停顿时间最短,缺点是内存碎片,适用于Web
- 过程
- 初始标记(STW) 仅标记 GC Root 直接引用的对象
- 并发标记 从 GC Root 出发,标记可达对象
- 重新标记 标记 并发标记 过程中,变更的对象
- 并发清除 清除无用对象
- 降级 Concurrent Mode Failure
- 并发标记、清理过程、work thread 在运行,申请老年代可能失败
- 失败后会 降级 (临时启动 Serial Old 收集器)
缺点:
- CPU敏感 例如在微服务体系中的某个小服务,配置是2h,启动了一个垃圾回收线程,那么就只有一个 CPU 可以响应用户的请求
- 浮动垃圾 用户线程和垃圾回收线程同时工作,在GC时候,用户线程还是在做内存分配的
- 空间碎片
相关参数
1
2
3
4
5
6
7-XX:ConcGCThreads # 并发的GC线程数 default: 0(jdk8)
-XX:UseCMSCompactAtFullCollection # FullGC之后做压缩 default: true
-XX:CMSFullGCsBeforeCompaction # 多少次FullGC之后压缩一次 default: 0
-XX:CMSInitiatingOccupancyFraction # Old区占用百分比后触发FullGC 该参数必须配合UseCMSInitiatingOccupancyOnly使用才有效 default: -1,负值表示使用CMSTriggerRatio->default: 80
-XX:+UseCMSInitiatingOccupancyOnly # 是否开启CMSInitiatingOccupancyFraction占用率 default: false
-XX:+CMSScavengeBeforeRemark # FullGC之前先做YGC 减少gc roots扫描的对象数,从而提高remark的效率 default: false
-XX:+CMSClassUnloadingEnabled # 是否开启类卸载 如果开启 在full gc是会顺带扫描回收metaSpace/PermGen default: true(jdk8)
G1 收集器
Region G1中的块的概念,
SATB Snapshot At The Beginning 通过Root Tracing 得到的,GC开始时候存活对象的快照
RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象)
Young GC
与其他年轻代收集方式差不多,新对象进入 Eden 区,存活对象拷贝到S区,存活时间达到年龄阈值时,对象晋升到Old区
Mixed GC
不是 Full GC,回收所有的 Young 和部分 Old
Global Concurrent Marking
- Initial Marking Phase(STW): 标记 GC Root
- Root region Scanning Phase: 标记存活 Region
- Concurrent Marking Phase: 标记存活的对象
- Remark Phase(STW): 重新标记
- Cleanup Phase: 部分STW
Mixed GC时机
InitiatingHeapOccupancyPercent
堆占有率达到这个数值则触发 Global Concurrent Marking ,默认 45G1HeapWastePercent
在 Global Concurrent Marking 结束之后,可以知道 Region 有多少空间要被回收,在每次YGC之后和再次发生 Mixed GC 之前,会检查垃圾占比是否达到此参数,达到了下次才会发生 Mixed GC ,默认 5(jdk8)G1MixedGCLiveThresholdPercent
Old 区的 region 被回收时候的存活对象占比需要达到多少,默认值 85G1MixedGCCountTarget
一次 Global Concurrent Marking 之后,最多执行 Mixed GC 的次数,默认值 8G1OldCSetRegionThresholdPercent
一次 Mixed GC 最多能回收Old区的 Region ,默认值 10
常用参数
1
2
3
4
5
6
7-XX:G1HeapRegionSize=n # region的大小,1-32M,2048个 default: 0
-XX:MaxGCPauseMillis=200 # 最大停顿时间
-XX:G1NewSizePercent # Young区的最小占比 default: 5
-XX:G1MaxNewSizePercent # Young区的最大占比 default: 60
-XX:G1ReservePercent # 保留空间防止to space溢出 default: 10
-XX:ParallelGCThreads=n # SWT线程数 default: 2(jdk linux)
-XX:ConcGCThreads=n # 并发线程数=1/4*并行 default: 0
G1中避免使用-Xmn、-XX:NewRatio等显式设置Young区的大小,会覆盖停顿时间目标
垃圾收集器搭配
实线的代表可以搭配使用的,例如 Old 区使用 CMS ,Young 区则可以使用 Serial、ParNew
虚线的代表 CMS 可以退化为 SerialOld(可以通过压缩减少碎片、内存使用率增长较快则降低触发FullGC 阈值来避免退化)
选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或者JVM自己选
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
可视化GC日志分析工具
日志打印相关参数
1 | -XX:+PrintGCDetails # 打印GC日志详情 |
日志格式(CMS)
1 | 1. {Heap before GC invocations=40 (full 0): |
日志详解
1 | 中括号的内容是内存地址 |