运行时数据区域
jdk 1.8 之前与之后的内存模型有差异, 方法区有变化(https://cloud.tencent.com/developer/article/1470519).
java 的内存数据区域划分:
程序计数器
虚拟机栈
本地方法栈
堆
方法区
程序计数器(Program Counter Register)
理解为当前线程所执行的字节码的行号指示器, 字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令, 分支, 循环, 跳转, 异常, 线程恢复等基础功能依赖于此.
每个线程独立存储, 互不影响, 生命周期与线程相同
java 方法的计数器内容时正在执行的虚拟机字节码的指令地址, Native 方法则是空(Undefined), 此区域无 OOM.
虚拟机栈(Java Virtual Machine Stacks)
线程私有, 生命周期与线程相同.
描述的是 Java 方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧 (Stack Frame) 用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 每个方法从调用到执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.
常说的 Java 内存区分为堆 (Heap) 栈(Stack)两块比较粗糙, 实际的内存划分复杂很多. 常用的对象内存分配关系最密切的内存区域是这两块, 其中的栈在这里就是指虚拟机栈的局部变量表部分.
局部变量表存放了编译期可知的各种基本数据类型, 对象引用 (reference 类型, 不等同于对象本身, 可能是指向对象起始地址的引用指针, 也可能是指向一个代表对象的句柄或者其他与此对象相关的位置) 和 returnAddress 类型(指向了一条字节码指令地址)
64 位的 long 和 double 占用 2 个局部变量空间(Slot), 其他占用一个.
这个区域有两种异常:
如果线程请求的栈深度大于虚拟机允许的深度, 将抛出 StackOverflowError.
如果虚拟机可以动态扩展内存, 当扩展时无法申请到足够内存是, 会抛出 OutOfMemoryError.
本地方法栈(Native Method Stack)
与虚拟机栈的作用相似, 虚拟机栈执行的是 java 方法, 而本地方法栈执行的是 Native 方法. Sun HotSpot 将本地方法栈与虚拟机栈合二为一.
抛出的异常也相同
Java 堆(Java Heap)
被所有线程共享, 在虚拟机启动时创建. 唯一的目的就是存放对象, 几乎所有的对象实例都在这里分配内存(栈上分配, 标量替换等优化技术会有影响)(Java 虚拟机规范原文 The heap is the runtime data area from which memory for all class instances and arrays is allocated). 可以通过(-Xmx -Xms 控制堆的扩展)
堆是 GC 管理的主要区域, 现在的收集器基本都采用分待收集算法, 所以堆还可以分为: 新生代和老年代; 再细一点分为 Eden 空间, From Survivor 空间, To Survivor 空间等. 从内存分配的角度来看, 线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
方法区(Method Area)
被所有线程共享的内存区域, 用于存储被虚拟机加载的类信息, 常量, 静态变量, 即时编译期编译后的代码等数据. Java 虚拟机规范把方法区描述为堆的一个逻辑部分, 但是它别名是 Non-Heap(非堆).
很多人将方法区称为永久代 (Permanent Generation), 本质上两者并不等价, 仅仅因为 HotSpot 设计团队将 GC 分代收集扩展至方法区, 或者说使用永久代来实现方法区而已. 这样 HotSpot 的 GC 可以像管理 Java 堆一样管理这部分内存, 省去写专门代码. 其他虚拟机(JRockit,J9) 是不存在永久代的概念.
但是这样容易出现 OOM, 存在(-XX:MaxPermSize 上限, 其他的 VM 只要没有触及进程可用内存上限就不会有问题), 因此有极少数方法可能会出现问题(String.intern()), 这个区域的内存回收主要针对常量池的回收和对类型的卸载.
运行时常量池(Runtime Constant Pool)
是方法区的一部分, Class 文件中除了有类的版本, 字段, 方法, 接口等描述信息外, 还有一项信息是常量池(Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放.
JVM 对于 Class 文件每一个字节用于存储哪种数据都必须符合规范, 对于运行时常量池没有任何细节要求. 运行时常量池具有动态性, 常量并非编译时产生, 运行时也可以产生新的常量, 如 String.intern()
直接内存(Direct Memory)
并非虚拟机运行时数据区的一部分, 也不是虚拟机规范中定义的内存区域.
JDK1.4 中加入了 NIO(New Input/Output), 引入了基于通道 (Channel) 与缓冲区 (Buffer) 的 IO 方式, 使用 Native 函数库直接分配堆外内存, 然后通过存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作. 这部分是不受到 Java 堆大小的限制.
HotSpot 虚拟机对象
对象的创建
普通对象的创建(不包括数组和 Class 对象), 当虚拟机遇到 new 指令时, 首先检查这个指令的参数能否在常量池中定位到一个类的符号引用, 并且检查这个符号引用代表的类是否被加载, 解析和初始化过. 如果没有会先执行相应的类加载过程.
类加载检查通过后, VM 为新生对象分配内存. 对象所需的内存大小在加载后可以完全确定. 若 java 分配和空闲内存区域是绝对规整的, 那分配过程仅仅是将指针向空闲空间移动一个与对象大小相等的距离, 这个称之为 "指针碰撞(Bump the Pointer)". 如果不是规整的, 那 VM 要维护一个列表记录可用内存, 分配时找到足够大的内存空间分配同时更新列表, 这个称之为 "空闲列表(Free List)".
Java 堆是否规整是由 GC 是否带有压缩整理功能决定, 所以 Serial,ParNew 等带 Compact 过程的收集器使用的是指针碰撞, CMS 这种基于 Mark-Sweep 算法的收集器采用空闲列表.
并发情况下的线程安全考虑有两种方案,
一种是堆内存分配空间的动作进行同步处理, 虚拟机会采用 CAS(compare and swap)配上失败重试的方式保证更新操作的原子性.[CAS 操作包含三个操作数 -- 内存位置 (V), 预期原值(A) 和新值(B). 如果内存位置的值与预期原值相匹配, 那么处理器会自动将该位置值更新为新值 . 否则, 处理器不做任何操作. 无论哪种情况, 它都会在 CAS 指令之前返回该 位置的值.(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功, 而不提取当前 值.)]
一种是把内存分配的动作按照线程划分在不同的空间之中进行, 就是每个线程都事先分配一小块内存区域称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB), 只要在要分配新的 TLAB 才需要同步锁定, VM 是否使用 TLAB, 可以使用 - XX:+/-UseTLAB 参数设定
内存分配完成后, 虚拟机会将分配到的内存空间都初始化为零值, 保证了字段再 java 代码中可以不赋值直接使用.
接下来虚拟机设置对象头(Object Header), 如对象是哪个类的实例, 如何找到类的元数据信息, 对象的哈希值, 对象 GC 分代年龄等信息.
对象的内存布局
在 HotSpot 虚拟机中, 对象在内存中存储布局分为 3 块区域: 对象头(Header), 实例数据(Instance Data), 对齐填充(Padding).
对象头包含两部分信息, 第一部分用于存储对象自身的运行时数据, 如 HashCode,GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等, 官方称之为 "Mark Word".
对象投的另外一部分是类型指针, 即对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象时哪个类的实例. 数组还会额外存储一块记录数组长度的数据.
接下来的实例数据部分是对象真正存储的有效信息, 各种字段类型内容. 无论是父类还是子类, 这部分的存储属性会收到虚拟机分配策略参数 (FieldsAllocationStyle) 和字段在 Java 源码中定义顺序的影响.
第三部分对齐填充并不是必然存在的, 也没有特别含义, 仅起着占位符的作用. HotSpot VM 自动内存管理系统要求对象起始地址必须是 8 字节的整数倍, 因此对象实例部分没有对齐时需要用填充来补齐.
对象的访问定位
Java 程序要通过栈上的 reference 数据来操作堆上的具体对象. 目前主流的访问对象的方式分为使用句柄和直接指针两种.
句柄访问
Java 堆中会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址, 句柄中包含了对象实例数据 (存储在堆中) 和类型数据 (存储在方法区中) 各自的具体地址信息.
好处是稳定, 对象移动 (如 GC 时移动) 只会改变句柄中的实例数据指针.
直接指针访问
在 Java 堆对象的布局中放置访问类型数据的相关信息. reference 中存储的直接是对象地址. 速度快, 因为节省了一次指针定位的时间开销.
Sun HotSpot 使用的是直接指针访问的方式.
垃圾收集器与内存分配策略
程序计数器, 虚拟机栈, 本地方法栈 3 个区域是随线程生命周期, 栈中的栈帧随着方法的进入退出执行出入栈. 这几个区域的内存分配和回收都具备确定性.
主要是 Java 堆和方法区是动态的.
对象存活算法
引用计数法(Reference Counting), 添加引用计数器, 引用加一失效减一, 主流 Java 虚拟机没有使用, 因为比较难解决对象相互循环引用.
可达性分析算法(Reachability Analysis), 从 "GC Roots" 对象作为起始点向下搜索, 搜索路径称为 "引用链(Reference Chain)", 不可达则证明对象不可用, 就可以回收.
GC Roots 对象包括如下:
虚拟机栈 (栈帧中的本地变量表) 中引用的对象.
方法区中类静态属性引用的对象.
方法区中常量引用的对象.
本地方法栈中 JNI 引用的对象.
JDK1.2 之后, Java 对引用的概念进行了扩充, 分为
强引用(Strong Reference)
在程序代码中普遍存在 Object o=new Object(), 强引用存在永远不会回收对象
软引用(Soft Reference)
有用但并非必需的对象, 在内存溢出异常前, 会把这些对象列进回收范围进行第二次回收. 这次之后如果没有足够内存才会抛出内存溢出. JDK1.2 之后提供了 SoftReference 类实现软引用
弱引用(Weak Reference)
强度比软引用更弱, 无论当前内存是否足够都会回收. JDK1.2 之后提供了 WeakReference 类实现软引用
虚引用(Phantom Reference)
虚引用不会影响生存时间, 也无法通过虚引用取得对象实例, 仅用于在 GC 时收到一个系统通知. JDK1.2 之后提供了 PhantomReference 类实现软引用
可达性分析算法中不可达的对象, 要经历 2 次标记之后才会真正死亡. 可达性算法计算后第一次标记并且进行一次筛选, 条件是次对象是否有必要执行 finalize()方法. 如果没有该方法, 或者虚拟机调用过该方法, 则视为无需执行.
如果被判定有必要执行, 这个对象会被放在 F-Queue 队列中, 并且稍后在一个由虚拟机自动建立的, 低优先级的 Finalizer 线程去执行. 执行意味着触发但并不会等待. 对象如果与引用链上任何一个对象建立关联, 则第二次标记时将移出 "即将回收" 的集合; 否则就回收.
方法区回收
Java 虚拟机规范中说过可以不要求虚拟机在方法区实现垃圾回收, 因为性价比比较低, 堆中的新生代垃圾回收一次一般可以回收 70%-95% 的空间, 永久代远低于此.
永久代垃圾回收主要包含两部分: 废弃常量和无用的类.
如常量 "abc", 当前系统中没有任何 String 对象或者其他地方引用, 则会被清理出常量池.
无用的类条件则苛刻很多:
该类所有的实例都已经被回收, Java 堆中不存在该类的任何实例.
加载该类的 ClassLoader 已经被回收.
该类的 java.lang.Class 对象没有任何地方引用, 无法在任何地方通过反射访问该类的方法.
在大量使用反射, 动态代理, CGLib 等 ByteCode 框架, 动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能, 防止永久代内存溢出.
垃圾回收算法
标记 - 清除算法 (Mark-Sweep) 分为标记和清除两个阶段. 这是最基础的收集算法, 因为后续收集算法都是基于这种思路并且对其不足进行改进而来.
其主要不足有两个:
效率问题: 标记和清除两个过程效率都不高
空间问题: 标记清除后会产生大量不连续的内存碎片, 可能会导致后续在内存分配较大对象时无法找到连续内存而提前触发另一次垃圾收集动作.
复制算法(Copying), 它将内存划分为大小相等的两块, 每次只用其中的一块. 当这块内存用完就将存活的对象复制到另外一块上, 然后把这块内存直接清理掉. 代价就是内存缩小为原来的一半.
现在的商业虚拟机都采用本算法来收集新生代. IBM 公司研究表明新生代中 98% 对象生命周期短暂, 不需要划分一半的内存, 而是划分为一块较大的 Eden 空间和两块较小的 Survivor 空间. 每次使用 Eden 和一块 Survivor, 回收时将 Eden 和 Survivor 中存活对象一次性复制到另外一块 Survivor.HotSpot 默认 Eden 和 Survivor 的比例是 8:1. 当 Survivor 空间不够时, 需要依赖其他内存 (老年代) 进行分配担保(Handle Promotion).
复制算法在对象存活率较高时需要进行较多的复制操作, 效率变低. 而且如果不想浪费 50% 的空间, 就需要额外的空间进行分配担保(100% 对象存货), 老年代一般不能直接用这种算法
标记 - 整理算法(Mark-Compact), 与标记清除相似, 但是第二步不是直接清理, 而是让所有存活的对象都向一段移动, 然后直接清理掉边界意外的内存.
分代收集算法(Generational Collection), 根据对象存活周期的不同将内存划分为几块, 一般是将 Java 堆分为新生代和老年代, 这样可以根据各个年代采用适当的收集算法.
有关年轻代的 JVM 参数
1)-XX:NewSize 和 - XX:MaxNewSize
用于设置年轻代的大小, 建议设为整个堆大小的 1/3 或者 1/4, 两个值设为一样大.
2)-XX:SurvivorRatio
用于设置 Eden 和其中一个 Survivor 的比值, 这个值也比较重要.
3)-XX:+PrintTenuringDistribution
这个参数用于显示每次 Minor GC 时 Survivor 区中各个年龄段的对象的大小.
4).-XX:InitialTenuringThreshol 和 - XX:MaxTenuringThreshold
用于设置晋升到老年代的对象年龄的最小值和最大值, 每个对象在坚持过一次 Minor GC 之后, 年龄就加 1.
HotSpot 算法实现
枚举根节点
可以作为 GC ROOT 的节点主要在全局性的引用 (如常量或类静态属性) 与执行上下文 (栈帧中的本地变量表) 中. 可达性分析堆执行时间的敏感还体现在 GC 停顿上, 分析工作必须在一个能确保一致性的快照中进行. 一致性是指整个分析期间执行系统像是被冻结在某个时间点上, 不可以发生任何变化. GC 进行时必须停顿所有 java 执行线程 (Sun 称之为 Stop The World) 的其中一个重要原因, CMS 收集器中, 枚举根节点时也是必须要停顿的.
目前的主流 Java 虚拟机都是准确式 GC, 使用一组称为 OopMap(Ordinary Object Pointer)的数据结构存储哪些地方存放着对象引用. 在 JIT 编译过程中, 会在特定的位置记录下栈和寄存器中是哪些位置是引用, GC 扫描时可以直接得知这些信息.
安全点(safepoint)
如果为每一条指令生成 OopMap, 这些可能会需要大量的额外空间, 成本较高. 所以 HotSpot 是在特定位置记录这些信息, 这些位置称为安全点. 安全点的选定基本上是以程序 "是否具有让程序长时间执行的特征", 例如方法调用, 循环跳转, 异常跳转等.
如何在 GC 发生时让所有的线程都在最近的安全点上停顿. 这里有两种方案选择:
抢先式中断(Preemptive Suspension)
GC 发生时首先中断全部线程, 发现有线程中断的点不在安全点上就恢复让其运行到安全点. 现在几乎没有虚拟机使用类似的方式.
主动式中断(Voluntary Suspension)
GC 发生时设置标志, 各个线程执行时主动去轮询这个标志, 为真时中断挂起. 轮询标志与安全点所在的地点重合.
SafePoint 一般出现在以下位置:
循环体的结尾
方法返回前
调用方法的 call 之后
抛出异常的位置
安全区域(Safe Region)
当线程处于 Sleep 或者 Block 时, 线程无法响应 JVM 的中断请求, 这时候就需要安全区域. 安全区域是指在一段代码片段中, 引用关系不会发生变化. 在这个区域中的任意地方开始 GC 都是安全的. 当线程进入 SR 中的代码时, 首先标志自己进入了 SR, 当这段时间 JVM 发起 GC 时, 不用管 SR 状态的线程. 线程要离开 SR 时, 会检查系统是否完成根节点枚举(或者整个 GC 过程), 完成就继续执行否则就等待信号.
垃圾收集器
上图展示了 7 种不同分代的收集器, 如果中间有线, 表示他们可以搭配使用.
Serial 收集器
最基本, 发展历史最悠久的收集器. 在 JDK1.3.1 之前时虚拟机新生代手机的唯一选择. 单线程收集器, Stop The World 的执行者. 虚拟机运行在 Client 模式下的默认新生代收集器, 因为它简单而高效. 在桌面场景中, 新生代内存一般是几十或者一两百兆, 停顿时间控制在几十毫秒最多一百多毫秒.
ParNew 收集器
ParNew 就是 Serial 的多线程版本, 它是 Server 模式下的虚拟机中首选的新生代收集器, 因为除了 Serial, 只有它能和 CMS(Concurrent Mark Sweep)收集器配合工作. 因为 JDK1.5 用 CMS 收集老年代时, 新生代只能选择 Serial 或者 ParNew 中的一个.
并行(Parallel): 指多条垃圾收集线程并行工作, 但此时用户线程仍然处于等待状态
并发(Concurrent): 用户线程与垃圾收集器同时执行(不一定是并行, 可能交替执行)
Parallel Scavenge 收集器
新生代收集器, 复制算法, 并行多线程收集器.
CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间, PS 收集器目标是达到一个可控制的吞吐量(Throughput). 所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 消耗时间的比值, 即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
停顿时间端适合与用户交互的程序, 高吞吐量可以高效利用 CPU 时间, 尽快完成程序运算任务, 适合后台运算而不需要太多交互的任务.
Parallel Scavenge 提供了两个参数用于精确控制吞吐量, 最大垃圾收集停顿时间 -XX:MaxGCPauseMillis, 直接设置吞吐量大小 - XX:GCTimeRatio.
还有一个参数 - XX:+UseAdaptiveSizePolicy, 打开之后, 新生代大小, Eden 和 Survivor 区比例, 晋升老年代对象年龄等细节参数不需要设置. 虚拟机会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量, 这种调节方式称之为 GC 自适应的调节策略(GC Ergonomics)
Serial Old 收集器
这是 Serial 收集器的老年代版本, 这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用. 在 Server 模式下有两大用途: 一种是用在 JDK1.5 以及之前的版本与 Parallel Scavenge 收集器搭配使用, 另一种就是作为 CMS 收集器的后备预案, 在并发收集发生 Concurrent Mode Failure 时使用.
Parallel Old 收集器
这是 Parallel Scavenge 收集器的老年代版本, 使用多线程和 "标记 - 整理" 算法. 是在 JDK1.6 提供. 注重吞吐量以及 CPU 资源敏感的场合, 可以考虑 Parallel Scavenge 加 Parallel Old 收集器.
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以捉去最短回收停顿时间为目标的收集器. 运作过程分为 4 个步骤:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记, 重新标记两个步骤仍然需要 "Stop The World", 初始标记仅标记 GC Roots 能直接关联的对象, 速度很快. 并发标记就是进行 GC Roots Tracing 的过程, 重新标记阶段则是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记, 这个阶段停顿时间一般比初始标记阶段稍长一些, 但是比并发标记的时间短.
CMS 的优点: 并发收集, 低停顿
3 个明显缺点:
CMS 收集器对 CPU 资源非常敏感. 其实面向并发设计的程序对 CPU 资源都比较敏感, 在并发阶段虽然不会暂停用户线程, 但是会因为占用了一部分线程 (CPU) 资源导致应用程序变慢, 总吞吐量会降低. CMS 默认启动回收线程是(CPU 数量 + 3)/4, 也就是 CPU 在 4 个以上时会占用不少于 25% 的 CPU 资源并且随着 CPU 数量增加而下降, 当 CPU 不足 4 个是, 影响可能就很大.
CMS 收集器无法处理浮动垃圾(Floating Garbage), 可能出现 Concurrent Mode Failure 失败导致另一次 Full GC 的产生. CMS 并发清理阶段用户线程还在运行, 还会由新的垃圾产生. 这部分在标记过程后, CMS 无法在当次收集中处理掉他们, 要等到下一次 GC 时再清理, 这些垃圾称为 "浮动垃圾".CMS 在 GC 过程中用户线程还要继续运行, 因此需要预留一部分内存供用户使用, 因此老年代不能等到完全填满.
因为 Mark-Sweep 算法本身可能会引发的空间碎片.
G1 收集器
G1(Garbage-First)收集器是最前沿的成果之一. 目标是替换 CMS, 有如下特点:
并行与并发: G1 能充分利用多 CPU, 多环境下的硬件优势, 缩短 StopTheWorld 停顿时间.
分代收集: 不需要其他收集器配合能够独立管理整个 GC 堆
空间整合: 整体看来是基于 "标记 - 整理" 实现, 局部 (2 个 Reginon 之间) 上来看是基于 "复制" 算法实现. 这意味这 G1 运作期间不会产生内存空间碎片, 收集后可以提供规整的可用内存.
可预测的停顿: 能够建立可预测的停顿时间模型, 能让使用者明确指定在一个长度为 M 毫秒的时间片段内, 消耗在垃圾收集上的时间不能超过 N 毫秒, 这个几乎是实时 java(RTSJ,Real-Time Java Specification)的垃圾收集器特征.
使用 G1 收集器时, Java 堆内存被划分为多个大小相等的独立区域 (Region), 虽然保留新生代和老年代的概念, 但是新生代和老年代不再是物理隔离的了, 他们是一部分 Region(不需要连续) 的集合.
G1 能够建立可预测的停顿时间模型, 是因为它可以有计划的避免在整个 Java 堆中进行全区域的垃圾收集. G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值), 在后台维护一个优先列表, 根据允许的收集时间, 有限回收价值最大的 Region.
G1 算法实现从 2004 年 Sun 实验室发表第一篇 G1 论文, 直到 jdk7u4 才移除了 "Experimental" 标识达到商用程度.
JDK9 之后 G1 称为默认收集器,
JDK11 开始引入 ZGC, 是一种可扩展的低延迟垃圾收集器, 旨在实现以下目标:
暂停时间不超过 10 毫秒
暂停时间不会随堆或实时设置大小而增加
处理堆范围从几百 M 到几 T 字节大小
JDK12 开始引入 Shenandoah GC,Shenandoah 是一款 concurrent 及 parallel 的垃圾收集器; 跟 ZGC 一样也是面向 low-pause-time 的垃圾收集器, 不过 ZGC 是基于 colored pointers 来实现, 而 Shenandoah GC 是基于 brooks pointers 来实现.
内存分配与回收策略
大部分情况下, 对象在新生代 Eden 区分配, 当 Eden 没有足够空间, 虚拟机发起一次 Minor GC. 之后仍然不足进行 Full GC.
大对象直接进入老年代, 所以代码中出现大对象是要很慎重, 特别是一堆生命周期很短的大对象.
长期存活的对象进入老年代. 每熬过一次 Minor GC, 年龄增加 1 岁, 默认 15 岁就会晋升到老年代.
动态对象年龄判定, 如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半, 年龄大于或等于该年龄的对象可以直接进入老年代, 无需等待 MaxTenuringThreshold 中要求的年龄.
空间分配担保, Minor GC 之前虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间, 如果成立可以确保 Minor GC 安全. 如果不成立, 检查老年代最大连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于则尝试进行 Minor GC. 如果虚拟机设置值不允许冒险, 或者小于平均大小, 则改为一次 Full GC. 担保失败 (Handle Promotion Failure) 后, 会发起一次 Full GC.
Java 工具
jps(JVM Process Status Tool)
可以列出正在运行的虚拟机进程, 并显示虚拟机执行主类 (MainClass,main() 函数所在的类)以及本地虚拟机唯一 ID(Local Virtual Machine Identifier, LVMID)
- [[email protected] ~]$ jps -lv
- 6610 org.apache.catalina.startup.Bootstrap -Djava.util.logging.config.file=/home/jira/atlassian-jira-software-7.5.2-standalone/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Xms1384m -Xmx3768m -Djava.awt.headless=true -Datlassian.standalone=JIRA -Dorg.apache.jasper.runtime.BodyContentImpl.LIMIT_BUFFER=true -Dmail.mime.decodeparameters=true -Dorg.dom4j.factory=com.atlassian.core.xml.InterningDocumentFactory -XX:-OmitStackTraceInFastThrow -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Xloggc:/home/jira/atlassian-jira-software-7.5.2-standalone/logs/atlassian-jira-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -Dcatalina.base=/home/jira/atlassian-jira-software-7.5.2-standalone -Dcatalina.home=/home/jira/atlassian-jira-software-7.5.2-standalone -Djava.io.tmpdir=/home/jira/atlassian-jira-software-7.5.2-standalone/temp
- 25652 synchrony.core -Xss2048k -Xmx1g
- 25431 org.apache.catalina.startup.Bootstrap -Djava.util.logging.config.file=/home/jira/atlassian-confluence-6.3.1/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Xms1256m -Xmx2048m -XX:PermSize=128m -XX:MaxPermSize=512m -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Dconfluence.context.path= -Dorg.apache.tomcat.websocket.DEFAULT_BUFFER_SIZE=32768 -Dsynchrony.enable.xhr.fallback=true -Xms1024m -Xmx2024m -XX:+UseG1GC -Datlassian.plugins.enable.wait=300 -Djava.awt.headless=true -XX:G1ReservePercent=20 -Xloggc:/home/jira/atlassian-confluence-6.3.1/logs/gc-2019-10-27_01-10-04.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=2M -XX:-PrintGCDetails -XX:+PrintGCDateStamps -XX:-PrintTenuringDistribution -Xms1256m -Xmx2048m -XX:PermSize=128m -XX:MaxPermSize=512m -Djava.endorsed.dirs=/home/jira/atlassian-confluence-6.3.1/endorsed -Dcatalina.base=/home/jira/atlassian-confluence-6.3.1 -Dcatalina.home=/home/jira/atlassian-confluence-6.3.1 -Djava.io.tm
- 5372 sun.tools.jps.Jps -Dapplication.home=/usr/java/jdk1.8.0_181-amd64 -Xms8m
jstat(JVM Statistics Monitoring Tool)虚拟机统计信息监视工具
它可以显示本地或者远程虚拟机进程中的类装载, 内存, 垃圾回收, JIT 编译等运行数据.
- [[email protected] ~]$ jstat -gcutil 6610
- S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
- 69.10 0.00 52.43 92.57 90.47 83.80 5613 460.532 12 13.624 474.156
这个是我们 Jira 服务器 GC 情况, E 表示 Eden 区使用了 52.43%, 两个 Survivor 区 (S0(69.10%),S1(0%)), 老年代 O(表示 Old) 占用了 92.57%,jdk1.8 之前永生代是 P(Permanent)之后是 M(Metaspace)90.47%,CCS(Compressed Class Space)83.8%.YGC(Young GC)5613 次, YGCT 总耗时 460 秒, FGC(Full GC)12 次, FGCT 耗时 13 秒, GCT 总 GC 耗时 474 秒.
jinfo(Java infomation)
java 配置信息工具, 可以实时地查看和调整虚拟机各项参数, jps -v 可以查看显式指定参数, jinfo -flag 可以进行默认值查询, jinfo -sysprops 可以把虚拟机进程中 System.getProperties()内容打印出来.
jmap(Memory Map for Java)
用于生成堆转储快照(一般称为 heapdump 或者 dump 文件), 还可以查询 finalize 执行队列, Java 堆和永久代的详细信息, 如空间使用率, 使用的收集器等. jmap -dump:format=b,file = 文件名 [pid] 会生成一个 bin 文件
jhat(JVM Heap Analysis Tool)
虚拟机堆转储快照分析工具, 与 jmap 搭配使用, 来分析 jmap 生成的堆转储快照. 内置了一个 http 服务器, 可以再完成之后启动网站访问. 命令就是直接 jhat xxx.bin
jstack(Stack Trace for Java)
Java 堆栈跟踪工具, 用于生成虚拟机当前时刻的线程快照 (一般称为 threaddump 或者 javacore 文件), 线程快照就是虚拟机内每一条线程当前执行的方法堆栈信息集合. jstatck -l pid. 后续的 JDK1.5 中, java.lang.Thread 类新增了一个 getAllStackTraces() 方法, 可以写个页面直接查询.
HSDIS(JIT 生成代码反汇编)
Sun 官方推荐的 HotSpot 虚拟机 JIT 编译代码的反汇编插件.
在主流商用虚拟机中, HotSpot 和 J9 可以采用混合模式(解释器与编译器配搭使用), 而 JRockit 内部没有解释器, 采用纯编译模式.
Java 程序最初是通过解释器进行解释执行的, 当虚拟机发现某个方法或代码块运行的特别频繁时, 就会把这些代码认定为 "热点代码"(Hot Spot Code). 为了提高热点代码的执行效率, 在运行时, 虚拟机将会把这些代码编译成为本地平台相关的机器码, 并进行优化, 而完成这个任务的编译器称为及时编译器(Just In Time Compiler, 简称 JIT).
解释器与编译器
解释器优势: 当程序需要迅速启动和执行时, 解释器可以首先发挥作用, 省去编译的时间, 立即执行. 同时解释器还可作为编译器激进优化时的一个 "逃生门", 当激进优化假设不成立时可退回到解释状态继续执行.
编译器优势: 编译之后得到优化后的本地代码, 执行效率高, 但优化耗时较长, 且占用内存资源较大.
JDK 可视化工具
最强的两个可视化工具: JConsole 和 Visual VM(All-in-One Java Troubleshooting Tool), 这个是正式的 JDK 成员.
Visual VMI 可以做到:
显示虚拟机进程以及配置, 环境信息(jps,jinfo)
监视应用程序的 CPU,GC, 堆, 方法区以及线程信息(jstat,jstack)
dump 以及分析堆转储快照(jmap,jhat)
方法及的程序运行性能分析, 找出被调用最多, 运行时间最长的方法.
离线程序快照: 收集程序运行时配置, 线程 dump, 内存 dump 等信息建立快照.
调优案例分析与实战
高性能硬件程序部署
4CPU,16G 内存, 64 位 JDK1.5,-Xmx 和 - Xms 固定在 12G, 但是网站长时间失去响应.
排查后发现是 GC 停顿, Server 模式默认使用吞吐量优先, 回收 12GB, 一次 FullGC 停顿高达 14 秒, 读取文档序列号产生大对象直接进入老年代. 因此出现每个十几分钟出现十几秒的停顿.
堆外内存导致的溢出错误
普通 PC 机, 内存 2G,JVM 分配了 1.6G,GC 正常, 堆内存正常, 频繁 OOM. 因为框架 NIO 操作使用到 Direct Memory 内存, 这块是比较难直接回收的, 只有在 FullGC 时顺便回收.
外部命令导致系统缓慢
每个用户请求的处 理都需要执行一个外部 shell 脚本来获得系统的一些信息. 执行这个 shell 脚本是通过 Java 的 Runtime.getRuntime().exec()方法来调用的. 这种调用方式可以达到目的, 但是它在 Java 虚拟机中是非常消耗资源的操作, 即使外部命令本身能很快执行完毕, 频繁调用时创建进程 的开销也非常可观. Java 虚拟机执行这个命令的过程是: 首先克隆一个和当前虚拟机拥有一 样环境变量的进程, 再用这个新的进程去执行外部命令, 最后再退出这个进程. 如果频繁执 行这个操作, 系统的消耗会很大, 不仅是 CPU, 内存负担也很重.
用户根据建议去掉这个 Shell 脚本执行的语句, 改为使用 Java 的 API 去获取这些信息后, 系统很快恢复了正常.
JVM 进程崩溃
BS 系统, 正常运行一段时间之后 JVM 自动关闭, 留下一个 hs_err_pdi###.log 文件. 由于异步启用了 Socket 请求另外一个较慢的站点, 超过虚拟机承受能力后导致崩溃. 改为消息队列后正常.
不恰当的数据结构
在 HashMap<Long,Long > 结构中, 只有 Key 和 Value 所存放 的两个长整型数据是有效数据, 共 16B(2*8B). 这两个长整型数据包装成 java.lang.Long 对 象之后, 就分别具有 8B 的 MarkWord,8B 的 Klass 指针, 在加 8B 存储数据的 long 值. 在这两个 Long 对象组成 Map.Entry 之后, 又多了 16B 的对象头, 然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段, 为了对齐, 还必须添加 4B 的空白填充, 最后还有 HashMap 中对这个 Entry 的 8B 的引 用, 这样增加两个长整型数字, 实际耗费的内存为 (Long(24B)*2)+Entry(32B)+HashMap Ref(8B)=88B, 空间效率为 16B/88B=18%, 实在太低了.
来源: http://www.bubuko.com/infodetail-3259507.html