Java_JVM

对于JVM,我们需要学习JVM的组成以及其最大的特性GC

一、JVM介绍

1. JVM的组成

  • 类加载器
  • 运行时数据区
  • 执行引擎
    • 对命令进行解析
  • 本地库接口

2. 类加载

作用:负责读取 Java 字节代码,并转换成java.lang.Class类的一个实例

过程

加载 - 连接(验证、准备、解析) - 初始化 - 使用- 卸载

加载

  1. 由类加载器(ClassLoader)负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在MetaSpace;
  2. 转换为一个java.lang.Class对象实例,这个Class对象在日后就会作为方法区中该类的各种数据的访问入口。

连接

将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中

  • 验证:验证字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
  • 准备
    • 为类静态变量分配内存并设置初始值
    • 初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123
    • public static int value = 123;
  • 解析
    • 将常量池中的符号引用转为直接引用
    • 符号引用:一个标示
    • 直接引用:内存地址

初始化

初始化阶段是执行类构造器方法的过程
方法执行类中的类变量的赋值操作和静态语句块中的语句

双亲委派机制

为了实现分工,使得逻辑更加明确,各司其职,避免多份同样字节码的加载

  • 启动类加载器 -> Bootstrap ClassLoader:加载核心库
  • 扩展类加载器:加载扩展库
  • 应用程序类加载器:加载Java应用的类

3.JVM运行时数据区

MetaSpace元空间

随着JDK8的到来,JVM不再有PermGen(持久代、永久代)。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。以避免类过多导致的内存溢出

  • Metaspace 使用的是堆外内存,默认没有内存使用限制
  • JVM在创建类加载器时,会为其从操作系统中申请内存,并将内存划分成chunk(块),一个类加载器可以对应多个chunk
  • 当 Metaspace 的 committed (内存提交量)达到 MetaspaceSize (初始阈值)时,将会触发GC(一般都是Full GC)

程序计数器

程序计数器是一个记录着当前线程所执行的字节码的行号指示器,每个线程工作时都有属于自己的独立计数器,程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计,它是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域

虚拟机栈

每个线程对应着一个虚拟机栈,元素是栈帧(方法)

  • 局部变量表
    • 八大原始类型
    • 对象引用
    • returnAddress
  • 操作数栈
  • 动态链接
  • 递归过深,栈帧数超出虚拟机栈深度,引发StackOverFlowError
  • 虚拟机栈过多会引发OutOfMemoryError

本地方法栈

本地方法相关

堆区(Heap)

常量池

jdk 1.7常量池从永久代中移到了堆内存中,属于堆内存的一部分。

存放常量(final修饰)、静态变量

字面常量、符号引用、翻译出来的直接引用

符号引用就是编码是用字符串表示某个变量、接口的位置

直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译

在程序里面,为基本数据类型赋值的结果值被称之为字面常量,例如,int a = 10;这个10被称之为字面常量

在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代

在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代

在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

Java中的常量池(字符串常量池、class常量池和运行时常量池)

Old Eden Survivor

在这里插入图片描述

二、GC

1.内存分配

不是特别重要

TLAB

年轻代

bump-the-pointer和TLAB(Thread-Local Allocation Buffers)

在Eden区,HotSpot虚拟机使用了两种技术来加快内存分配

  • bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;
  • TLAB技术是对于多线程而言的,将Eden区分为若干段,每个线程使用独立的一段,避免相互影响。TLAB结合bump-the-pointer技术,将保证每个线程都使用Eden区的一段,并快速的分配内存。

年老代

card table

可能存在年老代对象引用新生代对象的情况,如果需要执行Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。解决的方法是,年老代中维护一个512 byte的块——”card table“,所有老年代对象引用新生代对象的记录都记录在这里。Young GC时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

JVM new对象时堆内存的分配方式有两种模式

  • 指针碰撞
    把指针向空闲对象移动与对象占用内存大小相等的距离,堆内存被一个指针一分为二
  • 空闲列表
    虚拟机维护一个列表,记录可用的内存块,分配给对象列表中一块足够大的内存空间

显然,采用何种方式要基于虚拟机堆内存是否规整,这又由采用的垃圾收集器是否带有压缩整理功能决定,所以类似Serial、ParNes等收集器时采用指针碰撞,而采用CMS这种基于Mark-Sweep算法的收集器时采用空闲列表。

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置

TLAB的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

缺点是会浪费空间

对象分配的过程

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
  2. 如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则3.
  3. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
  4. 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则5.
  5. 执行一次Young GC(minor collection)。
  6. 经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代。

2.内存回收

标记算法

  • 可达性分析算法

    • 通过判断对象的引用链是否可达来决定对象是否可以被回收
  • 可以作为GC root的对象

    • 虚拟机栈中引用的对象(栈帧中的局部变量表)
    • 本地方法栈JNI(Java Native Interface)的引用对象
    • MetaSpace中的类静态属性引用的对象

回收算法

停止复制

从GC Root开始,从From中找到存活的对象,拷贝到To中;From/To交换身份,下次内存分配从To开始

  • 没有内存碎片,可以利用bump-the-pointer实现快速内存分配
  • 需要额外空间 survivor

标记清除算法

  • 标记阶段:从根集合进行扫描,对存活的对象进行标记
  • 清除阶段:对堆内存从头到尾进行线性遍历,回收不可达对象内存
  • 特点:两次扫描,耗时严重;会产生内存碎片

标记整理算法

  • 标记

  • 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。

优点: 避免内存的不连续性,不用设置两块内存互换,适用于存活率高的场景

新生代垃圾收集器

评价标准:吞吐量最大时,停顿时间最小

搭配:

  • Serial + Serial Old
  • Parallel Scavenge + Parallel Old
    • 关注吞吐量
  • ParNew + CMS
    • 关注停顿时间
  • G1

Serial收集器 -XX:+UseSerialGC, 复制算法

Parallel Scavenge收集器 -XX: +UseParallelGC, 复制算法

  • 多线程,暂停所有用户线程

ParNew收集器 -XX: +UseParNewGC, 复制算法

  • 多线程,暂停所有用户线程

老年垃圾收集器

Serial Old收集器 标记整理算法

Parallel Old收集器 标记整理算法

CMS收集器

牺牲吞吐量为代价获得最短的回收停顿时间

标记-清除算法

CMS精讲

CMS日志格式

  • 初始标记: stop-the-world,只扫描到和根对象直接关联的对象,并做标记

  • 并发标记:并发追溯标记,程序不会停顿

  • 并发预清理:查找执行并发标记阶段从年轻代晋升到老年代的对象

  • 重新标记:stop-the-world暂停虚拟机,扫描CMS堆中的剩余对象,避免回收不该回收的对象。为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象

  • 并发清理:清理垃圾对象,程序不会停顿

  • 并发重置:重置CMS收集器的数据结构

但是CMS并不完美,它有以下缺点:

  • CMS在收集与应用线程会同时会增加对堆内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以STW的方式进行一次GC,从而造成较大停顿时间;
  • 标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩
  • 无法处理浮动垃圾:在最后一步并发清理过程中,用户线程执行也会产生垃圾,但是这部分垃圾是在标记之后,所以只有等到下一次gc的时候清理掉,这部分垃圾叫浮动垃圾。

CMS 出现FullGC的原因:

  • 年轻带晋升到老年带没有足够的连续空间,很有可能是内存碎片导致的
  • 在并发过程中JVM觉得在并发过程结束之前堆就会满,需要提前触发FullGC
相关参数:
-XX:ConcGCThreads:并发的 GC 线程数
-XX:+UseCMSCompactAtFullCollection:FullGC 之后做压缩
-XX:CMSFullGCsBeforeCompaction:多少次 FullGC之后压缩一次
-XX:CMSInitiatingOccupancyFraction:触发 FullGC
-XX:+UseCMSInitiatingOccupancyOnly:是否动态可调
-XX:+CMSScavengeBeforeRemark:FullGC之前先做 YGC
-XX:+CMSClassUnloadingEnable:启用回收Perm 区

G1

一种兼顾吞吐量和停顿时间的算法

三、GC调优

https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

http://cmsblogs.com/?p=3817#GCViewer

https://blog.csdn.net/losetowin/article/details/78569001

如何减少GC次数

  • 尽量少用静态变量
  • 对象不用时最好显示设置为null
  • 尽量使用StringBuilder 而不是String 减少不必要的中间对象

确定堆的大小

各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下:

  • 总大小3-4 倍活跃数据的大小
  • 新生代1-1 .5活跃数据的大小
  • 老年代2-3 倍活跃数据的大小
  • 永久代1.2-1 .5倍Full GC后的永久代空间占用

例如,根据GC日志获得老年代的活跃数据大小为300MB,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5
老年代: 750MB = 1200MB - 450MB*

在Seck中,是瞬间流量,所以可以预先加大堆大小

活跃区是160M

  • 总堆大小:700M

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求

如何确认老年代存活对象大小?

  • 方式1(推荐/比较稳妥):
    JVM参数中添加GC日志,GC日志中会记录每次FullGC之后各代的内存大小,观察老年代GC之后的空间大小。可观察一段时间内(比如2天)的FullGC之后的内存情况,根据多次的FullGC之后的老年代的空间大小数据来预估FullGC之后老年代的存活对象大小(可根据多次FullGC之后的内存大小取平均值)

  • 方式2:(强制触发FullGC, 会影响线上服务,慎用)
    方式1的方式比较可行,但需要更改JVM参数,并分析日志。同时,在使用CMS回收器的时候,有可能不能触发FullGC(只发生CMS GC),所以日志中并没有记录FullGC的日志。在分析的时候就比较难处理。
    BTW:使用jstat -gcutil工具来看FullGC的时候, CMS GC是会造成2次的FullGC次数增加。 具体可参见之前写的一篇关于jstat使用的文章
    所以,有时候需要强制触发一次FullGC,来观察FullGC之后的老年代存活对象大小。
    注:强制触发FullGC,会造成线上服务停顿(STW),要谨慎,建议的操作方式为,在强制FullGC前先把服务节点摘除,FullGC之后再将服务挂回可用节点,对外提供服务
    在不同时间段触发FullGC,根据多次FullGC之后的老年代内存情况来预估FullGC之后的老年代存活对象大小

  • 如何触发FullGC ?
    使用jmap工具可触发FullGC
    jmap -dump:live,format=b,file=heap.bin 将当前的存活对象dump到文件,此时会触发FullGC

    • jmap -histo:live 打印每个class的实例数目,内存占用,类全名信息.live子参数加上后,只统计活的对象数量. 此时会触发FullGC

GC优化案例

https://blog.csdn.net/shang_xs/article/details/88052906

案例一:Major GC和Minor GC频繁

确定目标

服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。
由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。

tex(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

优化目标:降低TP99、TP90时间。

优化

首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。

这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系?

首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)

  • 扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。
  • 扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。

可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:

通过上图GC日志中两处红色框标记内容可知:

  1. new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。
  2. Major GC后老年代使用空间为300Mb+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。

由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。

优化结果

通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。

小结

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

更多思考

关于上文中提到晋升年龄阈值为2,很多同学有疑问,为什么设置了MaxTenuringThreshold=15,对象仍然仅经历2次Minor GC,就晋升到老年代?这里涉及到“动态年龄计算”的概念。

动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age<=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。

JVM引入动态年龄计算,主要基于如下两点考虑:

  1. 如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:
    a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
    b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。
  2. 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。

案例二:请求高峰期发生GC,导致服务可用性下降

确定目标

GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。

优化

解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。

  1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。
  2. Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
  3. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
  4. 并发清理,进行并发的垃圾清理。

可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:

  1. 如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。

新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。

灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。

分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。

新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2Mb时启动,直到Eden区空间使用率达到50%时中断,当然2Mb和50%都是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。

除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。

根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。

对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

优化结果

经过增加CMSScavengeBeforeRemark参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。

小结

通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。

更多思考

案例中只涉及老年代GC,其实新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。

JVM是如何避免Minor GC时扫描全堆的?

经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

主案例三:发生Stop-The-World的GC

确定目标

GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。

优化

首先,什么时候可能会触发STW的Full GC呢?

  1. Perm空间不足;
  2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
  3. 统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;
  4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。

然后,我们来逐一分析一下:

  • 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。
  • 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。
  • 排除原因4:因为当时没有相关命令执行。
  • 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久带空间不足容量扩展导致的。

找到原因后解决方法有两种:

  1. 通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久带的容量固定下来,避免运行时自动扩容。
  2. CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。

由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。

优化结果

调整参数后,服务不再有Perm区扩容导致的STW GC发生。

小结

对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

总结

结合上述GC优化案例做个总结:

  1. 首先再次声明,在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过GC优化令其性能达到一个质的飞跃。
  2. 其次,通过上述分析,可以看出虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反。
  3. 最后,GC优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。GC优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果。

4 JDK原生工具

一个Java应用上线后, 关注哪些性能指标

  1. 响应时间与吞吐量
  2. 平均负载
  3. 错误率(及如处理)
  4. GC率和中止时间
  5. 业务指标
  6. 正常运行时间和服务运行状况
  7. 日志规模

jinfo jstat jmap jstack

命令参数官方文档

4-1 JVM的参数类型

  • X参数
    • 非标准化参数,在各个版本可能会变化
    • -Xint : 解释执行,所有字节码都被解释执行,这个模式的速度最慢的。
    • -Xcomp:第一次使用就编译成本地代码
    • -Xmixed:混合模式,JVM 自己决定是否编译成本地代码
  • XX参数
    • boolean类型
      • 格式:-XX:[±]<name>表示启用或禁用 name 属性
        • -XX:+UseConcMarkSweepGC
          -XX:+UseG1GC # 启用G1垃圾回收器
    • 非Boolean类型 格式:-XX:=表示name属性的值是value.比如
      -XX:MaxGcPauseMillis=500 ,
      -XX:GCTimeRatio=19
  • -Xmx -Xms
    1. 不是X参数,而是XX参数
    2. -Xms 等价于 -XX:InitialHeapSize 初始化的堆大小
    3. -Xmx 等价于 -XX:MaxHeapSize 最大化的堆大小
重要
1. -Xms -Xmx 堆的容量 # 常用
2. . -XX:NewSize -XX:MaxNewSize 新生代的大小 # 常用
3. -XX:NewRatio 设置Yong 和 Old的比例 # 常用
4. -XX:SurvivorRatio 设置两个Survivor区和Eden的比 # 常用

不重要
5. -XX:+PrintFlagslnitial(表示打印出所有XX选项的默认值) 显示的参数如果是:=则表示是修改过的,=则表示是默认值
6.  -XX:+PrintFlagsFinal 表示打印出XX选项在运行程序时生效的值
7. -XX:+UnlockExperimentalVMOptions解锁实验参数(先决条件)
8. -XX:+UnlockDiagnosticVMOptions解锁诊断参数
9. -XX:+PrintCommandLineFlags打印命令行参数
10. -XX:MetaspaceSize -XX:MaxMetaspaceSize metaspace 大小 (下面的参数都是小弟,主要调整老大即可,容量大了,下面的也会变大)
11. -XX:+UseCompressedClassPointers 使用压缩短指针 # 常用
12. -XX:CompressedClassSpaceSize 默认1G 可以设置
13. -XX:InitialCodeCacheSize codeCache 最小值
14. -XX:ReservedCodeCacheSize codeCache 最大值
15. -XX:+printGC 打印GC日志
16. -XX:+printGCDetails 打印GC详细日志
17. -XX:+PrintGCTimeStamps 打印GC发生的时间戳 
18. -XX:+PrintHeapAtGC 每次GC后,打印堆的信息
19. -XX:+HeapDumpOnOutMemeryError
20. -XX:HeapDumpPath
21. -XX:+DisableExplicitGC
22. -XX:+UseG1GC

4-2 jinfo 查看JVM运行时参数

jinfo -flag MaxHeapSize
jinfo -flag ThreadStackSize 

4-3 jstat查看JVM统计信息

jstat -gc -t 2428 1s
  • 类装载
  • 垃圾收集
jstat [Options] vmid [interval] [count]

-gc:统计 jdk gc时 heap信息,以使用空间字节数表示
-class:统计 class loader行为信息
-compile:统计编译行为信息
-gccapacity:统计不同 generations(新生代,老年代,持久代)的容量使用的最大最小值,例如使用到的最大值、最小值、当前使用值等等
-gccause:统计引起 gc的事件
-gcnew:统计 gc时,新生代的情况
-gcnewcapacity:统计 gc时,新生代 heap容量
-gcold:统计 gc时,老年代的情况
-gcoldcapacity:统计 gc时,老年代 heap容量
-gcpermcapacity:统计 gc时, permanent区 heap容量
➜  ~ jstat -class 5536 # 类装载
Loaded  Bytes  Unloaded  Bytes     Time
  3528  6188.0       46    67.5       2.60
➜  ~ jstat -gc 5536 # 垃圾收集信息
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
10752.0 10752.0  0.0    0.0   65536.0   6525.6   175104.0    5338.1   18688.0 18075.6 2304.0 2142.8      8    0.045   7      0.167    0.212
0C: 第1个Survivor空间的容量 Current survivor space 0 capacity (kB).
S1C: 第2个Survivor空间的容量(kB).
S0U: 第1个Survivor空间中已经使用的容量 Survivor space 0 utilization (kB).
S1U: 第2个Survivor空间中已经使用的容量(kB).
EC: Eden空间的容量(kB).
EU: Eden空间中已经使用的容量(kB).
OC: 老年代Old空间的容量(kB).
OU: 老年代Old空间已经使用的容量(kB).
MC: 元空间Metaspace的容量(kB).
MU: 元空间Metaspace已经使用的容量(kB).
CCSC: 压缩类空间的容量 Compressed class space capacity (kB).
	- 长指针、短指针
CCSU: 压缩类空间已经使用的容量(kB).
YGC: Young GC发生的次数.
YGCT: Young GC花费的时间.
FGC: Full GC发生的次数.
FGCT: Full GC花费的时间.
GCT: 所有的GC花费的总时间.

4-4 jmap导出内存映像

jhat 分析dump

查看堆中各个类的类名,实例数量,内存占用大小

jmap -heap <pid>
Attaching to process ID 5932, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.91-b15

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 1073741824 (1024.0MB)
   NewSize                  = 42991616 (41.0MB)
   MaxNewSize               = 357564416 (341.0MB)
   OldSize                  = 87031808 (83.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 60293120 (57.5MB)
   used     = 44166744 (42.120689392089844MB)
   free     = 16126376 (15.379310607910156MB)
   73.25337285580842% used
From Space:
   capacity = 5242880 (5.0MB)
   used     = 0 (0.0MB)
   free     = 5242880 (5.0MB)
   0.0% used
To Space:
   capacity = 14680064 (14.0MB)
   used     = 0 (0.0MB)
   free     = 14680064 (14.0MB)
   0.0% used
PS Old Generation
   capacity = 120061952 (114.5MB)
   used     = 19805592 (18.888084411621094MB)
   free     = 100256360 (95.6119155883789MB)
   16.496143590935453% used

20342 interned Strings occupying 1863208 bytes.
jmap -dump:format=b,file=heap.hprof 16940 # 生成快照文件,会暂停应用,线上慎用
// 生成heap文件
jhat heap
在网页上查看
  1. 内存溢出自动导出
    • -XX:+HeapDumpOnOutOfMemoryError
    • -XX:HeapDumpPath=./ (路径)

4-5 jstack与线程的状态

top -p <pid> -H查看占用高的线程
显示线程方法调用栈
线程状态:
- prio:线程的优先级
- tid:线程id
- nid: 操作系统映射的线程id

转换为16进制 在jstack文件中查找
jstack一般会追踪出死锁
root@instance-64x155ng:~# jstack 14183
2019-08-16 11:33:35
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.11-b03 mixed mode):

"Scheduler-1883840933" #61 prio=5 os_prio=0 tid=0x00007f4b34025800 nid=0x3927 waiting on condition [0x00007f4b20fa2000]
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x00000000eb914dd8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
	at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1093)
	at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:809)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:745)


"Java2D Disposer" #18 daemon prio=10 os_prio=0 tid=0x00007f4b547a7800 nid=0x377c in Object.wait() [0x00007f4b3caf7000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000eba90878> (a java.lang.ref.ReferenceQueue$Lock)
	at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:142)
	- locked <0x00000000eba90878> (a java.lang.ref.ReferenceQueue$Lock)
	at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:158)
	at sun.java2d.Disposer.run(Disposer.java:148)
	at java.lang.Thread.run(Thread.java:745)


"VM Thread" os_prio=0 tid=0x00007f4b54091000 nid=0x3769 runnable

"VM Periodic Task Thread" os_prio=0 tid=0x00007f4b540d8800 nid=0x3770 waiting on condition

JNI global references: 531

4-6 JVisualVM

5.jvm内存泄漏

不再会被使用的对象的内存不能被回收,就是内存泄露
https://blog.csdn.net/anxpp/article/details/51325838

如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。我们举一个简单的例子:

public class Simple {
    Object object;
    public void method1(){
        object = new Object();
    //...其他代码
    }
}

这里的object实例,其实我们期望它只作用于method1()方法中,且其他地方不会再用到它,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。解决方法就是将object作为method1()方法中的局部变量。当然,如果一定要这么写,可以改为这样:

public class Simple {
    Object object;
    public void method1(){
        object = new Object();
        //...其他代码
        object = null;
    }
}

所以要尽量减小对象的作用域

二要及时将不用的对象设置为null

在jdk源码中很多这样的例子:如LinkedList、ArrayList等等

ArrayList

public E pop(){
    if(size == 0)
        return null;
    else
        return (E) elementData[--size];
}

写法很简洁,但这里却会造成内存溢出:elementData[size-1]依然持有E类型对象的引用,并且暂时不能被GC回收。我们可以如下修改:

public E pop(){
    if(size == 0)
        return null;
    else{
        E e = (E) elementData[--size];
        elementData[size] = null;
        return e;
    }
}

三要对提供了close()方法的对象及时close()

单例模式导致的内存泄露

单例模式,很多时候我们可以把它的生命周期与整个程序的生命周期看做差不多的,所以是一个长生命周期的对象。如果这个对象持有其他对象的引用,也很容易发生内存泄露

finalize

finalize()究竟是做什么的呢?它最主要的用途是回收特殊渠道申请的内存

finalize的作用

  • GC在回收对象之前调用该方法。
  • finalize()与C++中的析构函数不是对应的。C++中的析构函数调用的时机是确定的,但Java中的finalize的调用具有不确定性
  • 不建议用finalize方法完成“非内存资源”的清理工作,但建议用于:① 清理本地对象(通过JNI创建的对象);② 作为确保某些非内存资源(如Socket、文件等)释放的一个补充:在finalize方法中显式调用其他资源释放方法。其原因可见下文[finalize的问题]

finalize的问题

  • Java语言规范并不保证finalize方法会被及时地执行、而且根本不会保证它们会被执行
  • finalize方法可能会带来性能问题。因为JVM通常在单独的低优先级线程中完成finalize的执行

finalize的执行过程(生命周期)

当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。

对象逃逸

https://www.cnblogs.com/theRhyme/p/9528761.html

当能够明确对象不会发生逃逸时,就可以对这个对象做一个优化,不将其分配到堆上,而是直接分配到栈上,这样在方法结束时,这个对象就会随着方法的出栈而销毁,这样就可以减少垃圾回收的压力。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

JVM中,虚拟机栈是线程私有的,JDK5.0每个线程栈大小为1M,这个线程栈包含了这个线程所有的方法帧

public static void main(String[] args) {
  infinitelyRecursiveMethod(1);
}
public static void infinitelyRecursiveMethod(long a){
  System.out.println(count++);
  infinitelyRecursiveMethod(a);
}
// 这个程序可以测试栈深度
// 7043

public static void infinitelyRecursiveMethod(long a){
  HashMap<String, String> map = new HashMap<>();
  map.put("1","2");
  System.out.println(count++);
  infinitelyRecursiveMethod(a);
}
// 5011

逃逸按照行为不同有可以分为方法逃逸和线程逃逸

  • 方法逃逸是指在某一个方法中构造的对象,在该方法外部可以继续访问这个对象。产生方法逃逸一般是由于返回值返回,或者是将对象的引用设置到传入的参数中
  • 线程逃逸则是在一个线程中构造的对象,能够在另一个线程中使用。这种情况是由于同一个对象被多个线程使用,产生资源占用而导致。

Java虚拟机在确定对象不发生逃逸的情况下,所进行的一些高效的优化。

  1. 栈上分配

    众所周知,Java中对象时分配在堆上的,在初始化时,会在堆上分配一块空间,当这个对象不再使用时,会在之后发生垃圾回收时被回收,这是一个Java对象正常的生命周期。但是当能够明确对象不会发生逃逸时,就可以对这个对象做一个优化,不将其分配到堆上,而是直接分配到栈上,这样在方法结束时,这个对象就会随着方法的出栈而销毁,这样就可以减少垃圾回收的压力。

  2. 同步消除

    在多线程中,对于一个变量操作进行同步操作是效率很低的,当我们确定一个对象不会发生逃逸时,那么就没有必要对这个对象进行同步操作,所以如果代码中有对这种变量操作的同步操作,JVM将会取消同步,从而提升性能。

  3. 标量替换

    标量指的是没有办法再分解为更小的数据的类型,即Java中的基本类型,我们平时定义的类都属于聚合量。标量替换即是将一个聚合量拆成多个标量来替换,即用一些基本类型来代替一个对象。如果明确对象不会发生逃逸,并且可以进行标量替换的话,那么就可以不创建这个对象,而是直接使用基本类型来代替,这样也就可以节省创建和销毁对象的开销

在实际生产中,对象逃逸的分析默认是不开启的。这是因为分析一个对象是否会发生逃逸消耗比较大,所以,开启逃逸分析并进行这些优化之后得到的效果,并不一定就比不进行优化更好。如果确定开启逃逸分析效率更好,那么可以使用参数-XX:+DoEscapeAnalysis来开启逃逸分析

参考资料

https://github.com/CyC2018/CS-Notes/blob/master/notes/Java 并发.md#十二锁优化

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值