跳至主要內容

JVM常见问题

soulballadJavaJVMJVM约 5408 字大约 18 分钟

方法区和永久代的区别

  • 方法区属于JVM规范,它规定:方法区主要用于存储类的信息、常量池、方法数据、方法代码等。所有虚拟机必须遵守。常见的JVM 虚拟机 Hotspot、JRockit(Oracle)、J9(IBM)
  • PermGen space 则是 HotSpot 虚拟机对方法区的一个落地实现。强调: 只有 HotSpot 才有 PermGen space。
  • 在 HotSpot 虚拟机中,堆方法区的实现变迁:
    • JDK6 及之前方法区的实现为永久代,静态变量存放在永久代中,字符串常量池(StringTable)位于运行时常量池中。
    • JDK7 方法区的实现为永久代,但已经逐步“去永久代”,静态变量、字符串常量池移除,保存在堆中
    • JDK8 方法区的实现为本地内存的元空间,字符串常量池、静态变量仍在堆中
  • 为什么用元空间替换永久代?
    • 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM:比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误;而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存(堆外内存/直接内存),因此,默认情况下,元空间的最大大小仅受本地内存限制。
    • 对永久代进行调优是很困难的
  • 使用堆外内存的优点
    • 减少了垃圾回收
      因为垃圾回收会暂停其他的工作。
    • 加快了复制的速度
      堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。

JVM8内存区域

img

  • Register(线程私有): 标记每个线程执行位置
  • Stack(线程私有):
    • 局部变量表: 局部变量表中存储着方法里的java基本数据类型(byte/boolean/char/int/long/double/float/short)以及对象的引用(注:这里的基本数据类型
    • 指的是方法内的局部变量)
    • 操作数栈
    • 动态连接
    • 方法返回地址
  • Native Stack(线程私有): 执行 native 方法
  • Heap(线程共享):
    • 对象实例: 对象、数组
    • 字符串常量池: jdk7时从方法区迁移至堆中
    • 静态变量: jdk7时从方法区迁移至堆中
    • 线程分配缓冲区(TLAB)
  • Method Area(线程共享): 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等

元空间会产生内存溢出么?在什么情况下会产生内存溢出?

  • 永久代(java8 后被元空间Metaspace取代了)存放了以下信息:
    • 虚拟机加载的类信息
    • 常量池
    • 静态变量
    • 即时编译后的代码
  • 出现问题原因
    • 错误的主要原因, 是加载到内存中的 class 数量太多或者体积太大。
  • 解决办法
    • 增加 Metaspace 的大小 -XX:MaxMetaspaceSize=512m

方法区和堆的区别

  • 方法区是各线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,它的实例的物理内存空间可以是不连续的。
  • 方法区的大小,可以选择固定大小或者可扩展。如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,会导致方法区溢出,虚拟机会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者 java.lang.OutOfMemoryError: Metaspace 比如,以下情况:
    • 加载大量的第三方的jar包;
    • Tomcat部署的工程过多(30-50个);
    • 大量动态的生成反射类。
  • 关闭JVM就会释放该区域的内存

对象内存占用分析

  • 内存大小=对象头(12字节)+各个类型字段大小 -> 然后 4字节对齐

  • 对象头一般包含2部分:

    • 标记字(mark word),占用一个机器字,也就是8字节。
    • 类型指针,占用一个机器字,也就是8个字节;如果堆内存小于32GB,JVM默认会开启指针压缩,则只占用4个字节;所以2部分加起来12字节。
    • 如果是数组,对象头中还会多出一个部分:数组长度, int值,占用4字节。
  • 例子 MyOrder 类

    public class MyOrder{
      private long orderId;
      private long userId;
      private byte state;
      private long createMillis;
    }
    

    占用内存=12字节+8字节*3+1字节=37字节,按照4字节对齐,则实际占用40个字节。

常见垃圾收集器

思路: 一定要记住典型的垃圾收集器,尤其cms和G1,它们的原理与区别,涉及的垃圾回收算法

1)几种垃圾收集器:

  • Serial收集器: 单线程的收集器,收集垃圾时,必须stop the world,使用复制算法。
  • ParNew收集器: Serial收集器的多线程版本,也需要stop the world,复制算法。
  • Parallel Scavenge收集器: 新生代收集器,复制算法的收集器,并发的多线程收集器,目标是达到一个可控的吞吐量。如果虚拟机总共运行100分钟,其中垃圾花掉1分钟,吞吐量就是99%。
  • Serial Old收集器: 是Serial收集器的老年代版本,单线程收集器,使用标记整理算法。
  • Parallel Old收集器: 是Parallel Scavenge收集器的老年代版本,使用多线程,标记-整理算法。
  • CMS(Concurrent Mark Sweep) 收集器: 是一种以获得最短回收停顿时间为目标的收集器,标记清除算法,运作过程:初始标记,并发标记,重新标记,并发清除,收集结束会产生大量空间碎片。
  • G1收集器: 标记整理算法实现,运作流程主要包括以下:初始标记,并发标记,最终标记,筛选标记。不会产生空间碎片,可以精确地控制停顿。

2)CMS收集器和G1收集器的区别:

  • CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用;
  • G1收集器收集范围是老年代和新生代,不需要结合其他收集器使用;
  • CMS收集器以最小的停顿时间为目标的收集器;
  • G1收集器可预测垃圾回收的停顿时间
  • CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
  • G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。

CMS收集器

CMS特点:

  • 老年代:CMS(Concurrent mark sweep)收集器是一种年老代垃圾收集器;
  • 标记-清理算法:和其他年老代使用标记-整理算法,CMS 使用标记-清除算法;
  • 多线程:CMS 采用的是多线程并发的标记-清除算法;
  • 停顿时间端: CMS最主要目标是获取最短垃圾回收停顿时间,最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。

GC过程:
CMS 工作机制相比其他的垃圾收集器来说更复杂。整个过程分为以下 4 个阶段:
初始标记
只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
并发标记
进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
重新标记
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。
并发清除
清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作, 所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

G1收集器

G1特点:

  • 无分代:G1 将新生代,老年代的物理空间划分取消了。这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够;
  • 标记-整理算法:G1 收集器采用标记-整理算法,无内存碎片产生;
  • 分区回收:G1 虽然没有了新生代与老年代的物理限制,但是 G1 采取内存分区策略,将堆内存划分为大小固定的几个独立区域。在分区中,同时存在新生代与老年代;

分区:

  • 新生代区域:G1 收集器中新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者 Survivor 空间;
  • 老年代区域:G1 收集器通过将对象从一个区域复制到另外一个区域,以此来完成老年代的清理工作;
  • Humongous区域:巨型对象区域。如果一个对象占用的空间超过了分区容量 50% 以上,G1 收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC

对象分配策略
说起大对象的分配,我们不得不谈谈对象的分配策略。它分为3个阶段:

  1. TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
  2. Eden区中分配
  3. Humongous区分配

TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。
如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。
在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。
对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。
如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

GC模式

G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的

G1 Young GC
Young GC 主要是对 Eden 区进行 GC ,它在 Eden 空间耗尽时会被触发。在这种情况下,Eden 空间的数据移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到年老代空间。Survivor 区的数据移动到新的 Survivor 区中,也有部分数据晋升到老年代空间中。最终 Eden 空间的数据为空,GC 停止工作,应用线程继续执行。

G1 Young GC 阶段:

  1. 根扫描:静态和本地对象被扫描;
  2. 更新RS: 处理 Dirty Card 队列更新 RS(Remembered Set,作用是跟踪指向某个 Heap 区内的对象引用);
  3. 处理RS: 检测从年轻代指向年老代的对象;
  4. 对象拷贝:拷贝存活的对象到 Survivor/Old 区域;
  5. 处理引用队列: 软引用,弱引用,虚引用处理。

G1 Mix GC
Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

G1 Mix GC运行步骤:

  1. 全局并发标记(global concurrent marking)
    1.1. 初始标记(initial mark,STW):在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关;
    1.2. 根区域扫描(root region scan):G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收;
    1.3. 并发标记(Concurrent Marking):G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断;
    1.4. 最终标记(Remark,STW): 该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB缓冲区,跟踪未被访问的存活对象,并执行引用处理;
    1.5. 清除垃圾(Cleanup,STW):在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

  2. 拷贝存活对象(evacuation)
    G1 收集器与 CMS 收集器相比,G1 收集器两个最突出的改进是:

    1. 基于标记-整理算法,不产生内存碎片;
    2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收
    -XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200
    

什么时候会触发FullGC?

除了以上4种状况外,对于使用RMI来进行RPC或管理的Sun JDK应用而言,默认情况下会一小时执行一次Full GC。
可通过在启动时通过- java-Dsun.rmi.dgc.client.gcInterval=3600000来设置Full GC执行的间隔时间或通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc

1. 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2. 未指定老年代和新生代大小,堆伸缩时会产生fullgc,所以一定要配置-Xmx、-Xms

3. 老年代空间不足

老年代空间不足的常见场景比如大对象、大数组直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。
除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。
还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

在执行Full GC后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space

4. JDK 1.7 及以前的(永久代)空间满

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。

如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError PermGen space
为避免以上原因引起的 Full GC,可采用的方法为增大Perm Gen或转为使用 CMS GC。

5. 空间分配担保失败

空间担保,下面两种情况是空间担保失败:

1、每次晋升的对象的平均大小 > 老年代剩余空间
2、Minor GC后存活的对象超过了老年代剩余空间

注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当出现这两种状况的时候就有可能会触发Full GC。
promotion failed 是在进行 Minor GC时候,survivor space空间放不下只能晋升老年代,而此时老年代也空间不足时发生的。
concurrent mode failure 是在进行CMS GC过程,此时有对象要放入老年代而空间不足造成的,这种情况下会退化使用Serial Old收集器变成单线程的,此时是相当的慢的。

怎么调优

围绕一个点,策略就是尽量把对象在新生代使用回收,减少晋升老年代的几率。

有没有JVM调优经验?JVM调优方案有哪些?

  1. 调优时机:
    a. heap 内存(老年代)持续上涨,达到设置的最大内存值;
    b. Full GC 次数频繁;
    c. GC 停顿时间过长(超过1秒);
    d. 应用出现 OutOfMemory 等内存异常;
    e. 应用中有使用本地缓存,且占用大量内存空间;
    f. 系统吞吐量与响应性能不高或下降。
  2. 调优原则:
    a. 多数的Java应用不需要在服务器上进行JVM优化;
    b. 多数导致GC问题的Java应用,都不是因为我们参数设置错误,而是代码问题;
    c. 在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合);
    d. 减少创建对象的数量;
    e. 减少使用全局变量和大对象;
    f. JVM优化,是到最后不得已才采用的⼿段;
    g. 在实际使用中,分析GC情况优化代码比优化JVM参数更好;
  3. 调优目标:
    a. GC低停顿;
    b. GC低频率;
    c. 低内存占用;
    d. 高吞吐量;
  4. 调优步骤:
    a. 分析GC⽇志及dump⽂件,判断是否需要优化,确定瓶颈问题点;
    b. 确定jvm调优量化目标;
    c. 确定jvm调优参数(根据历史jvm参数来调整);
    d. 调优⼀台服务器,对比观察调优前后的差异;
    e. 不断的分析和调整,知道找到合适的jvm参数配置;
    f. 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪

聊聊:你们项目如何排查JVM问题的?

对于还在正常运行的系统:

  1. 可以使用jmap来查看JVM中各个区域的使用情况
  2. 可以通过jstack来查看线程的运行情况,比如哪些线程阻塞、 是否出现了死锁
  3. 可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc比较频繁,那么就得进行调优了
  4. 通过各个命令的结果,或者jvisualvm等⼯具来进行分析
  5. ⾸先,初步猜测频繁发送fullgc的原因,如果频繁发⽣fullgc但是⼜⼀直没有出现内存溢出,那么表示 fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对象进⼊到老年代,对于这种情况,就要考虑这些存活时间不⻓的对象是不是比较大,导致年轻代放不下,直接进⼊到了老年代,尝试加大年轻代的大⼩,如果改完之后,fullgc减少,则证明修改有效
  6. 同时,还可以找到占用CPU最多的线程,定位到具体的方法,优化这个方法的执行,看是否能避免某些对象的创建,从而节省内存

对于已经发⽣了OOM的系统:

  1. ⼀般⽣产系统中都会设置当系统发⽣了OOM时,⽣成当时的dump⽂件(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base)
  2. 我们可以利用jvisualvm等⼯具来分析dump⽂件
  3. 根据dump⽂件找到异常的实例对象,和异常的线程(占用CPU⾼),定位到具体的代码
  4. 然后再进行详细的分析和调试

总之,调优不是⼀蹴而就的,需要分析、 推理、 实践、 总结、 再分析,最终定位到具体的问题

常用的JVM启动参数有哪些

# JVM启动参数不换行
# 设置堆内存
-Xmx4g -Xms4g
# 指定GC算法
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
# 指定GC并行线程数
-XX:ParallelGCThreads=4
# 打印GC日志
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
# 指定GC日志文件
-Xloggc:gc.log
# 指定Meta区的最大值
-XX:MaxMetaspaceSize=2g
# 设置单个线程栈的大小
-Xss1m
# 指定堆内存溢出时自动进行Dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/usr/local/
# 指定默认的连接超时时间
-Dsun.net.client.defaultConnectTimeout=2000
-Dsun.net.client.defaultReadTimeout=2000
# 指定时区
-Duser.timezone=GMT+08
# 设置默认的文件编码为UTF-8
-Dfile.encoding=UTF-8
# 指定随机数熵源(Entropy Source)
-Djava.security.egd=file:/dev/./urandom

怎么打出线程栈信息

  • 输入jps,获得进程号。
  • top -Hp pid 获取本进程中所有线程的CPU耗时性能
  • jstack pid命令查看当前java进程的堆栈状态
  • 或者 jstack -l pid > /tmp/output.txt 把堆栈信息打到一个txt文件。
  • 可以使用 fastthread 堆栈定位,fastthread.ioopen in new window

逃逸分析

逃逸分析open in new window

JIT即时编译

JIT即时编译open in new window

每天100w次登录请求JVM参数设置

每天100w次登录请求JVM参数设置open in new window

参考:

上次编辑于:
贡献者: soulballad