深入浅出JVM

JVM 是 Java 程序运行基础,面试时一定会遇到 JVM 相关的题。本文会先对面试中 JVM 的考察点进行汇总介绍。然后对 JVM 内存模型、Java 的类加载机制、常用的 GC 算法这三个知识点进行详细讲解。

1. JVM知识点汇总

JVM 知识点汇总

如上图所示,JVM 知识点有 6 个大方向,其中,内存模型、类加载机制、GC 垃圾回收是比较重点的内容。性能调优部分偏重实际应用,重点突出实践能力。编译器优化和执行模式部分偏重理论基础,主要掌握知识点。

在开始下文前,看下你是否能够回答以下知识点:

  • 内存模型:程序计数器、方法区、堆、栈、本地方法栈的作用,保存哪些数据;
  • 类加载:双亲委派的加载机制,以及常用类加载器分别加载哪种类型的类;
  • GC:堆内存划分,分代回收的思想和依据,以及不同垃圾回收算法实现的思路、适合的场景;
  • JVM调优:常用的JVM优化参数的作用,参数调优的依据,常用的JVM分析工具分析哪类问题及适用方法;
  • 执行模式
  • 编译器优化

2. JVM 内存模型

JVM内存模型主要指运行时的数据区,包括如下5个部分


JVM内存模型
  • 栈:
    也叫方法栈,线程私有,线程在执行每个方法时,都会创建一个栈桢,用于存储整个执行过程和状态;调用方法时执行入栈,方法返回时执行出栈;
  • 本地方法栈:
    和方法栈类似,不同的时,执行Java方法使用的是栈,而执行native方法时使用的是本地方法栈;
  • 程序计数器:
    当前线程执行字节码的行号指示器,通过它可以知道下一条要执行的指令,每个线程独占互不影响,保证线程切换后能恢复到正确的执行位置;
  • 堆:
    JVM管理的内存中最大的一块,存放的是对象实例;根据对象存活的周期不同,JVM把堆内存进行分带管理,由垃圾收集器进行对象的回收管理;
  • 方法区:
    存储已被虚拟机加载的类信息、常量、静态变量等数据;JDK8之前使用堆上的永久代作为方法区,而JDK8使用元空间(Meta-space)来代替;运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量与符号引用(类被加载时触发),字符串常量池也在方法区中;
    注意:Class对象(Class.forName)是放在堆中的,而不是方法区,class对象是生成的最终实例,一切实例对象都放在堆中,方法区是存储Class的基本信息;

3. 类加载机制

类的加载过程是指将编译好的class类文件的字节码读入到内存中,将其存在方法区并创建对应的Class对象;类的加载分为加载、链接、初始化,其中链接又包含验证、准备、解析三步,如图所示


JVM类的加载过程
  • 加载
    1)通过类的全限定名获取定义此类的二进制字节流(类加载器做的事);
    2)将字节流说代表的静态存储结构转化为方法区的运行时数据结构;
    3)在内存中创建这个类的Class对象,作为方法区这个类的各种数据的访问入口;
  • 验证
  • 准备
  • 解析
  • 初始化
    主要完成静态代码块的执行和静态变量的赋值,只有对类主动使用时,才进行初始化;初始化的触发条件:
    1)创建类的实例
    2)访问类的静态变量或静态方法
    3)Class.forName反射类
    4)某个子类被初始化
  • 卸载
    当类对象(注意不是类的实例)不再被使用时是会被GC卸载回收的,需要注意的时JVM自带的三个类加载器加载的类在虚拟机的整个生命周期中是不会被卸载的,只有用户自定义的类加载器加载的类才会被卸载;

3.1 类加载器

如下图,JVM自带的三个类加载器分别是:BootStrap启动类加载器、扩展类加载器、应用加载器;以及分别对应的加载目录;


JVM类加载模式

双亲委派

java的类加载使用双亲委派模式,即一个类加载器在加载类时,会递归的委托给父类加载器去执行,直至顶层的启动类加载器,如果父类加载器能够加载,则返回成功,否则子加载器才会自己尝试加载;
对于两个不同的类加载器(自定义的、没有继承关系),加载同一个类,会导致两个类不等;

双亲委派的好处

  1. 避免重复加载
  2. 防止对JDK核心类进行篡改,比如String.class由启动类加载器加载,如果想篡改String类,那么不会生效;

4. GC

4.1 对象已死?

判定对象的存活都与“引用”有关,有两种方法去判断一个对象已经“死了”;

  • 引用计数算法
    已经被淘汰的算法,通过添加一个引用计数器来判断对象是否还在被引用,解决不了循环引用的问题;
  • 可达性分析算法
    从GC roots往下搜索,所走的路径叫引用链,如果有有对象没有与引用链相连的话,证明对象是不可用的;GC roots包括:
    栈中引用的对象、方法区静态引用指向的对象、方法区常量引用指向的对象、本地方法栈Native引用的对象

再谈引用

如果一个对象只被定义为“被引用”或者“未被引用”两种状态,那么对于一些“食之无味,弃之可惜”的对象就无能无力;由此产生了四种引用类型:

  • 强引用:传统“引用”的定义,只要被强引用关联的对象永远不会被回收;
  • 软引用:内存不够时被回收;
  • 弱引用:比软引用更弱,下一次垃圾收集时被回收;
  • 虚引用:最弱,用来跟踪对象被垃圾回收的活动(对象被回收时收到一个通知);

4.2 分代回收

JVM的堆内存被分代管理,包括新生代和老年代,这样做主要是为了兼顾垃圾收集的时间开销和内存的空间有效利用;大部分对象很快就不再使用;

  • Minor GC:对新生代的对象的收集;
  • Major GC:对旧生代的对象的收集,出现Major GC通常会出现至少一次Minor GC;
  • Full GC:全局范围的GC,程序中主动调用System.gc()强制执行的GC;出发 Full GC的条件有:当年轻代晋升到老年代放不下时、老年代使用率超过阈值、永久代/元空间不足时、System.gc();
GC的执行过程

新生代区分为3个部分:1个eden区、2个Survivor区(from和to,复制算法),新创建的对象都会被分到Eden区,这些对象经过一次Minor GC后,如果仍然存活,则会被分配到Survivor区,然后在Survivor区每熬过一次Minor GC后年龄就会增长一岁,达到一定年龄后,就被移动到老年代中。

  • 详细过程:
    GC开始前,对象只会存在于Eden区和from区,to是空的,当GC开始时,Eden中所有存活的对象都会被移动到To里,而from区域中仍然存活的对象会根据年龄来决定去向,年龄达到阀值的,则移动到老年区,没有的移动到to区域,经过gc后,eden和from区域被清空,然后from和to对换,保证to区域为空。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

4.3 垃圾回收算法

  • 标记-清除
    老年代常用回收算法;最基本的算法,两个阶段:先标记要回收的对象,然后一次性回收
    缺点:效率低,清除后会产生大量的内存碎片(空间碎片太多可能会导致当程序需要分配大对象时无法找到连续的内存而不得不提前触发一次GC);
  • 复制算法
    年轻代常用回收算法;把内存划分为两等分,只使用其中一个区域,垃圾回收时,将使用区域里存活的对象复制到另一个区域中,然后清除使用区域,类似Survivor的from和to;
    缺点:需要两倍内存空间,内存使用率较低
  • 标记-整理
    结合了标记-清除和复制的优点
    将根节点开始标记被引用的对象,然后扫描整个堆,清除未标记对象,然后把存活对象“压缩”到堆的其中一块,顺序排放
    缺点:效率低

4.4 常见垃圾收集器

JVM 中提供的年轻代垃圾收集器 Serial、ParNew、Parallel Scavenge 都是复制算法,而 CMS、G1、ZGC 都属于标记清除算法。

JDK版本默认垃圾收集器

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9、10、11 默认垃圾收集器G1
-XX:+PrintCommandLineFlagsjvm参数可查看默认设置收集器类型
-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

CMS

JDK1.7之前最主流的垃圾回收器;使用标记-清除算法,并发收集停顿小;


CMS

三色标记算法

G1

G1取消了堆中年轻代与老年代的物理划分,但它依然属于分代收集器;G1算法将堆划分为若干Region区域,一部分作为新生代一部分作为老年代;


G1算法

ZGC

JDK11提供的高效垃圾回收算法,针对大堆内存设计;主要特点:着色指针、读屏障、并发处理、基于Region、内存压缩(整理)


ZGC算法

5. JVM调优

5.1 编译优化

5.1.1 方法内联

调用方法要经历压栈和出栈,这会带来一定的时间和空间方面开销,那么对于那些代码体量不大,又频繁调用的方法,这个时间和空间的消耗会很大;
方法内联的优化就是将那些代码体量小的方法代码复制到发起调用的方法之中,避免真实调用;

  • -XX:CompileThreshold:设置热点方法阈值,连续调用多少才能成为热点方法;
  • -XX:MaxFreqInlineSize:经常执行的方法,内联优化最大方法体,默认JVM不会对方法体太大的方法做内联优化;
  • -XX:MaxInlineSize:不经常执行的方法,内联优化最大方法体

热点方法能提高系统性能,提高方法内联的几种方式

  • 通过JVM参数来减少热点阈值或增加方法体阈值,使更多的方法进行内联,但这会增加内存开销;
  • 编程中避免一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用final、private、static关键字修饰方法,编码方法因为继承,会需要额外的类型检查;

5.1.2 逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化.

栈上分配

java对象默认分配在堆上,这会带来垃圾回收的时间和空间消耗,如果一个对象只在方法类使用(未发生逃逸),比如方法类的局部变量,这个时候将对象分配到线程栈上,随着栈空间的回收而回收,带来性能提升;
开启方式:-XX:+DoEscapeAnalysis(JVM默认是开启的)
关闭方式:-XX:-DoEscapeAnalysis

锁消除

当一个线程安全容器,比如StringBuffer,在未发生逃逸时,JIT编译(运行时)会自动进行Synchronized锁消除;比如如下代码,StringBuffer和StringBuilder的性能差别不大

     public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

开启方式:-XX:+EliminateLocks

标量替换

5.2 GC调优

5.2.1 降低Minor GC频率

  • 增加新生代大小

5.2.2 降低Full GC频率

  • 减少大对象的创建
  • 增加堆内存空间

5.2.3 选择合适的GC回收器

  • 如果要求响应速度快,选择CMS和G1
  • 如果如果经常产生大对象推荐使用G1,G1有专门存储巨型对象分区,并且会优先对可回收空间较大的Region进行回收(garbage first);
  • 如果物理机支持大堆内存,可以用ZGC提高效率;

5.3 内存分配及参数调优

根据实际情况设置JVM的启动参数,常用的JVM优化参数:

配置参数 功能
-Xms 初始化堆大小,如:-Xms256m,一般和Xmx保持一样
-Xmx 最大堆大小,最好设置为容器最大内存的80%
-Xmn 新生代大小,推荐设置Xmx的3/8
-Xss 每个线程的堆栈大小,默认1M
-xx:+PrintGCDetail 打印GC日志
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/-dump.hprof 发生OOM时自动生成Dump文件

todo ...

5.4 常用的JVM分析工具

Linux 命令 - top

查看进程的CPU使用率、内存使用率、系统负载

Linux 命令 - vmstat

jstat 命令

检测java应用程序实时运行情况,包括堆内存信息及垃圾回收信息

jstack 命令

查看线程的堆栈信息

jmap 命令

查看堆内存初始化配置及堆内存的使用情况,可以把堆内存中对象的信息、对象的数量等dump到文件中,使用工具进行分析;
jmap -dump:format=b.file=/tmp/heap.hprof 28557

jps 命令

JVM Process Status Tool,显示当前java进程情况以及进程pid,可以看到启动了多少java进程(每个java进程独占一个jvm实例),类比linux的ps命令;

jconsole 命令

阿里出品 - arthas

5.5 OOM的排查

引用1
引用2

出现原因
  1. 内存中加载的数据量过于庞大,如一次性从数据库取出大量数据;
  2. 死循环
  3. JVM参数内存配置太小
  4. 根本原因:经过一次FullGC后老年代中还是满的
排查方式

1、通过IDE运行跟踪(很难找到原因)
2、保存问题现场,发生OOM时记录堆信息(导出Dump文件信息),内存溢出时jvm指令执行bat发送邮件

解决方式
  1. 增加jvm内存大小 -xmx -xms
  2. 观察gc日志,配置新生代老年代大小比例。如果程序new的比较频繁,那么新生代设置大一点
  3. 程序优化,避免死循环。

6. 引用参考

推荐阅读更多精彩内容