线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

时间:2022-12-05 21:04:52

 

之所以引入关于JVM的篇章,是发现多数项目发生的线上问题很大的几率源自JVM调优配置不当引起。对JVM的内存模型、GC垃圾回收机制、调优方式有一个系统化的了解后,可以快速处理或避免以下两类问题:

  1. 以最合适的JVM参数配置生产环境项目,可以大大减少后期引起服务异常、宕机的风险。事前的思考与计划将降低未来产生的风险。
  2. 常见的问题排查和调试技术中,各项信息指标都与JVM和GC息息相关。对于JVM、GC有了系统性的了解,有助于快速定位问题点,减少不必要的盲目分析,降低时间和成本。

*本章节内容基于HotSpot虚拟机,查看您当前所使用的虚拟机:java -version

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

 

内存模型

 

Java虚拟机在执行Java程序的过程中会将其管理的内存划分为若干个不同的数据区域,这些区域都有各自的专用用途、创建时间、销毁(回收)时间。有些区域随虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束来建立和销毁。

Java虚拟机所管理的内存包括以下几个运行时数据区域,如图:

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

 

 

堆是存储单位,用于存储对象的实例(包括数组),即解决数据存储在哪里,如何存储。所有new出来的对象实例都放在堆内存里面消耗堆内存,数组也是存放在堆中。堆中分为新生代、老年代等不同区域,同样GC回收不同区域机制也各不相同。新生代和老年代比例为 1 : 2(新生代1/3,老年代2/3)。新生代又进一步分为Eden、From Survivor和To Survivor,默认比例为 8 : 1 : 1。此区域垃圾回收性价比极高,常规回收一次可达70%~95%的空间。

异常:java.lang.outofmemoryerror:java head space。堆溢出问题处理首要是检查设置的堆参数是否合适,如果确定堆参数已经给到合适的大小,则需要拿到堆内存快照分析溢出的元凶。

参数调整:Eden+s0+s1+old:-Xms(起始堆) -Xmx(最大堆)。

Eden+s0+s1:-Xmn。

 

也叫虚拟机栈。栈是运行时单位,即解决程序如何执行,如何处理数据,栈描述的是java方法执行的动态内存模型。

每个线程启动时都会创建一个栈,并且是该线程私有的,其存储的变量只能在其所属线程中可见。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。栈的生命周期与线程一致,线程结束栈内存也就释放了,所以栈不存在垃圾回收,但并不意味着栈的参数不需要调整。整个JVM运行时数据区包含多个栈区。每个栈区包含多个栈帧。栈与栈帧的关系是一对多。

异常:java.lang.statckOverFlowError。如线程请求分配的栈容量[深度]超过了栈允许的最大容量[深度](例如递归循环深度过大,太多的方法要入栈导致栈溢出)。在允许栈可以动态扩展情况下,以下两种情况,将会抛出OutOfMemoryError。

  • 尝试扩展的时无法申请到足够的内存
  • 创建新的线程时没有足够的内存去创建对应的栈

参数调整:JVM允许通过计算结果动态来扩容和收缩大小,也可以使用-Xss设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度,同时-Xss也决定了一个线程栈最大占用的内存大小。

 

缺省值

操作系统

缺省值

Linux/x64

1024 KB

macOS/x64

1024 KB

Solaris/x64

1024 KB

Windows

默认值取决于虚拟内存

栈帧

 

栈帧存在栈中,是栈的单位元素。在编程语言层面叫java方法,在jvm栈中java方法一律叫栈帧。所以栈帧=java方法。线程每调用一个方法就会创建一个栈帧,栈帧随着方法的调用而创建,随着方法的结束而消亡,出栈即表示方法运行完成。每一个方法调用就是一个压栈的过程,方法的结束就是一个弹栈的过程,压栈将会将该栈帧置于栈顶。在一条活动线程中每个栈不会同时操作多个栈帧,只会操作栈顶。当栈顶操作结束时,会将该栈帧弹出,同时会释放该栈帧内存,其下一个栈帧将变为栈顶。

栈帧是一个数据集,维系着方法执行过程中的各种数据信息,包括局部变量表、操作数栈、指向运行时常量池的引用(动态连接)、方法出口等。PS:局部变量表:存放编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、reference 类型(对象引用 ),和 returnAddress 类型(指向一条字节码指令的地址)

不同线程之间不能共享栈帧,只能共享同一个“进程”中的堆。

方法区与永久代

 

方法区并不是存储java方法的,此处有理解陷阱。方法区存储了已被虚拟机加载的每一个类的结构信息(字段、方法、接口、运行时常量池、方法字节码等)。简单来说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中,即时编译后的代码等数据 。实例变量存在堆内存中,和方法区无关。方法区被所有线程共享。

方法区、堆、栈的关系:方法区相当于一个类的模板,每次new出来的对象都是根据方法区中的模板产生的,栈中引用堆中的对象实例。

特别注意的是方法区不等价于永久代(习惯上方法区 = 永久代 = 元空间),仅HotSpot的垃圾收集器使用Java堆的管理方式来管理永久代(方法区)。严格来讲我们只能说方法区和永久代在部分JDK版本中存在同一区域。在此区域的内存回收目标主要是针对常量池的回收和对类型的卸载(无用的类),此区域的回收性价比极低。

HotSpot 永久代与JDK版本的关系

*BEA JRockit、IBM J9等虚拟机中不存在永久代。

JDK6

存在

JDK6中的方法区(永久代)理论上更容易遇到内存溢出的问题,永久代有-XX:MaxPermSize 的上限,无法使用进程可用内存上限。

JDK7

存在

原本放在永久代的字符串常量池、静态变量等移出。

JDK8

废弃永久代,不再使用堆内存,转用物理内存实现元空间(Metaspace)代替。相对JDK7把永久代剩余的内容(主要是类型信息)全部移到元空间中。

异常:如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

参数调整:JDK7以下:-XX:PermSize -XX:MaxPermSize

JDK8以上:-XX:MetaspaceSize -XX:MaxMetaspaceSize

运行时常量池

 

Java常量池实际分为两种形态:静态常量池和运行时常量池。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容被称为静态常量池。当jvm虚拟机在完成类装载操作后会将静态常量池的内容载入到内存并保存在方法区中,此时称为运行时常量池。常量池的存在是为了避免频繁的创建和销毁对象而影响系统性能。

异常:因为运行时常量池是方法区的一部分,所以同样受到方法区内存的限制。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

本地方法栈

 

只存储native方法(此类方法只有声明没有实现,也就是jdk中使用c或c++实现的部分)。例如使用Thread.start()真正调用的是start0(),而start0()并非java语言实现。

本地方法栈是线程私有的。

程序寄存器

 

也叫PC寄存器。每个线程都有一个独立程序计数器,并且是线程私有的。它是当前线程所执行的字节码的行号指示器(记录正在执行的虚拟机字节码指令的地址),字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,用以完成分支、循环、跳转、异常处理、线程恢复等。程序技术器不会发生内存溢出OutOfMemory错误。

堆与栈与方法区的关系

 

综合以上所述我们需要重点关注三个区域:堆,栈(栈帧),方法区。而这三者的关系可以简单描述为:

线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

  

  • reference存储的只是对象的地址。
  • 堆中的实例对象源自方法区的类结构信息。
  • 栈中引用类型(reference)使用指针指向堆中的实例对象。

GC垃圾收集

 

GC垃圾收集是指JVM根据一定策略自动回收不使用的内存数据,帮助开发人员省去管理内存的繁琐操作,解放生产力。

打印GC日志:-XX:+PrintGCDetails

哪些区域需要垃圾收集

 

程序计数器、Java栈和本地方法栈都是线程私有的,其与线程是共生关系,随线程创建和消亡,栈中的栈帧也随着方法的入栈和出栈操作自动释放内存。所以这几个区域的内存分配和回收都是有很大确定性的,因此这几个区域的内存回收无需回收。

堆和方法区:Java 的对象几乎都是在堆上创建出来的,而方法区存储了被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区中的运行时常量池则存放了各种字面量与符号引用。上述的这些数据大部分都是在运行时才能确定的,所以需要进行动态的内存管理。

JVM 中的垃圾收集器的最主要的关注对象是 Java 堆,堆垃圾收集的性价比极高,尤其在新生代中一次就可以回收 70%~95% 。而方法区由于垃圾收集的判定条件十分严苛(尤其是类型卸载),所以回收的性价比是非常低。因此有些垃圾收集器不支持或不完全支持方法区的垃圾收集,如 JDK 11中的ZGC 收集器不支持类型卸载。

如何判断对象是否可以回收

 

引用计数法

 

引用计数是指在对象中添加一个引用计数器,每当对象被引用时计数器+1,引用失效时计数器-1,对象不被引用时则失效(Null)。目前已经没有使用此算法的GC,如果堆内对象实例互相引用再将对象赋值为Null时则成形循环引用,导致引用计数器无法归零。如下图多个堆对象互相引用,但并没有被栈实际引用,实际已经失效。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

可达性分析法

 

其基本思路是通过一系列GCRoots根对象作为起始节点,根据引用关系向下搜索,搜索过程所经过的路径称为“引用链” 。如果某个对象到GC Roots间没有任何引用链相连则说明此对象不再被使用可以被回收。在枚举根节点过程中,为防止对象的引用关系发生变化需要暂停除垃圾收集线程以外的所有用户线程。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

GCRoots起点:

  • 栈帧中引用的对象,如方法中使用的参数、局部变量、临时变量。
  • 方法区的类的属性所引用的对象,如类的引用类型静态变量。
  • 方法区中常量所引用的对象,如字符串常量池的引用。
  • 本地方法栈中引用的对象,如JNI引用的对象。
  • 所有被同步锁 (synchronized) 持有的对象

常见垃圾收集算法

 

标记清除

 

此算法分为标记和清除两个阶段,首先使用可达性分析法标记出所有需要回收的对象,标记完成后统一回收掉所有被标记的对象。此算法有两个明显的缺点:

  • 执行效率不稳定:如果堆内有大量对象而且其中大部分需要被回收的,则必须进行大量标记和清除动作,标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间碎片化:标记和清除之后会产生大量不连续的内存碎片,碎片太多可能导致程序需要分配较大对象时无法找到足够的连续内存提前触发另一次GC。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

复制清除(针对新生代)

 

在内存中划分出两块大小相同的区域,每次只使用其中一块另一块保持空闲状态,第一块用完的时将存活的对象全部复制到第二块区域,再将第一块全部清空。JVM 中新生代的垃圾收集采用此算法。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

标记整理

 

标记过程与“标记清除算法”一样,但后续步骤不是直接对可回收对象进行清理,而是将所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。由于每次垃圾回收都要移动存活的对象并且对引用这些对象的地方进行更新,因此也需要全程暂停用户线程。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

分代收集

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

 

分代收集算法是一种理论:

  • 绝大多数对象存活时间很短。
  • 经过多次回收仍未被移除的对象属于难以消亡的对象,将被移入老年代。

根据此理论JVM规范将堆分为新生代和老年代。新生代对象多数存活时间较短,每次回收时只需保留少量存活对象而不是去做标记大量即将被回收的对象操作,以较低代价和速度回收内存,所以新生代一般采用标记复制算法进行垃圾收集,频率比较高。而老年代存储的是难以消亡的对象,所以采用标记清除或标记整理算法进行垃圾收集,频率稍低。因此针对新生代和老年代的垃圾收集机制不同,GC可以分为:

Partial GC:部分收集,收集的目标不是完整堆内存。

  • Minor GC(轻量级GC,只收集新生代)
  • Major GC(整理和收集新生代+老年代)

Full GC:全量收集,包括整个Java堆和方法区的垃圾。

堆内存分配与垃圾收集机制

 

堆是 JVM 所管理的内存中最大的一块,也是垃圾收集器主要管理的区域。整体分为新生代和老年代,比例为 1 : 2(新生代1/3,老年代2/3)。新生代又进一步分为 Eden、From Survivor 和 To Survivor,默认比例为 8 : 1 : 1(可通过 SurvivorRatio 参数进行设置)。

Java 堆上的无论哪个区域,只能存储对象实例,将Java 堆细分的目的只是为了更好地回收内存和更快的分配内存。

 

新生代中对象的分配与回收

 

新对象在Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。每次GC之后可用内存空间为整个新生代容量的90% (Eden的80% + To Survivor的10%),并且只有From Survivor空间被使用。From Survivor和To Survivor的空间并不是固定的,而是在S0和S1之间动态转换,第一次MinorGC时会选择 S1 作为 To Survivor,然后将 Eden 中存活的对象复制到S1,并将对象年龄+1,在后续的MinorGC中,S0和S1会交替转化为From Survivor和To Survivor,Eden和From Survivor中的存活对象会复制到To Survivor中,并将年龄+1。如下图所示:

  • 复制的意义是让对象堆在一起获得连续的内存空间。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

对象晋升老年代阀值

 

对象在Survivor区中每熬过一次MinorGC年龄就增加1,当它的年龄达到15时(JDK默认阀值可以通过 -XX:MaxTenuringThreshold调整,最大15)会被晋升到老年代中。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

大对象直接进入老年代

 

对于大对象尤其是很长的字符串或者元素数量很多的数组,如果分配在 Eden 中会很容易过早占满Eden空间导致MinorGC,而且大对象在Eden和两个Survivor之间反复复制会有很大的内存开销。我们可以通过-XX:PretenureSizeThreshold设置让大对象直接进入老年代。

动态对象年龄判断

 

HotSpot虚拟机不强制要求对象年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代。如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄>=该年龄的对象可以直接进入老年代,无视-XX:MaxTenuringThreshold。

空间分配担保

 

当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域 (基本就是老年代) 进行分配担保。在MinorGC之前虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果满足,那本次MinorGC可以确定是安全的。如果不满足,则虚拟机会先查看- XX:HandlePromotionFailure参数值是否允许担保失败 (Handle Promotion Failure)。如果允许,那会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,会尝试进行一次风险性的MinorGC,如果小于或者-XX: HandlePromotionFailure设置不允许,那这时就要改为进行一次Full GC。

JVM调优

 

参数优化建议

 

常用参数优化建议 [区域/参数/缺省值/优化建议]

-Xms

缺省物理内存1/64

建议-Xms与-Xmx大小设置相同值,避免GC和应用程序争抢内存导致内存忽高忽低产生稀奇古怪的错误

堆内存调整建议不超过预估可用物理内存的80%

-Xmx

缺省物理内存1/4

-Xmn

新生代最大值

-Xss

Linux/x64

1024 KB

建议512KB至1024KB之间

值越小多线程数量并发越大

值越大递归可达深度越深

MacOS/x64

1024 KB

Solaris/x64

1024 KB

Windows

取决于虚拟内存

方法区

永久代

<=JDK7

-XX:PermSize

256M ~ 1024M之间基本满足项目使用

-XX:MaxPermSize

>=JDK8

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

对象晋升老年代年龄阀值

-XX:MaxTenuringThreshold

15

大对象直接进老年代

-XX:PretenureSizeThreshold

默认0为始终在Eden分配新对象

空间担保分配

-XX:HandlePromotionFailure

默认启用

打印GC日志

-XX:+PrintGCDetails

导出堆内存分析文件

-XX:HeapDumpPath

开启远程调试功能

-Xdebug

堆溢出时自动导出DUMP

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=路径

关于JVM参数设置有几个特点:

  • 布尔型的参数选项:-XX:+表示开启,-XX:-表示关闭,如-XX:+PrintGCDetails
  • 数字型的参数选项:-XX:=参数,如-XX:PermSize=512M

JVM设置小工具

脑图分析

 

使用一张脑图更方便理解、查找JVM优化的对应关系:

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机

 

 

GC日志分析

 

设置堆内存并打印GC日志:-Xms512m -Xmx512m -XX:+PrintGCDetails

图中详细标注了GC日志每一块内容所表示的含义(图片过小请放大Word查看)。

 线上服务异常的定位、处理与优化的探索 - 第三章 Java虚拟机