前言

性能优化专题共计四个部分,分别是:

本节是性能优化专题第三部分 —— JVM 性能优化篇,共计六个小节,分别是:

  1. JVM介绍与入门
  2. 类文件讲解
  3. 字节码执行引擎
  4. GC算法与调优
  5. Java内存模型与锁优化
  6. Linux性能监控与调优

通过这六节的学习,你将学到:

➢ 了解JVM内存模型以及每个分区详解。
➢ 熟悉运行时数据区,特别是堆内存结构和特点。
➢ 熟悉GC三种收集方法的原理和特点。
➢ 熟练使用GC调优工具,快速诊断线上问题。
➢ 生产环境CPU负载升高怎么处理?
➢ 生产环境给应用分配多少线程合适?
➢ JVM字节码是什么东西?

官方文档

寻找JDK文档过程

我们说,学习一门开源技术,看其官网文档就是最好的资料!

www.oracle.com -> 右下角Product Documentation -> 往下拉选择Java -> Java SE documentation-> Previous releases -> JDK 8 -> 此时定位到https://docs.oracle.com/javase/8/

The relation of JDK/JRE/JVM

Reference -> Developer Guides -> 定位到:https://docs.oracle.com/javase/8/docs/index.html

Oracle有两种实现Java Platform Standard Edition(Java SE)8的产品:Java SE Development Kit(JDK)8和Java SE Runtime Environment(JRE)8。

JDK 8是JRE 8的超集,包含JRE 8中的所有内容,以及开发小程序和应用程序所需的工具,例如编译器和调试器。JRE 8提供了库,Java虚拟机(JVM)和其他组件,以运行用Java编程语言编写的小程序和应用程序。请注意,JRE包含Java SE规范不需要的组件,包括标准和非标准Java组件。

映入眼帘的就是关于JVM、JDK、JRE 三者之间的关系与联系,下图中涉及到的知识点如果有不清楚的地方,可以直接点击查看官方文档的说明。

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化

  • JVM只是JRE的一部分。
  • JRE(Java Runtime Environment,Java运行环境),包含了Java虚拟机、Java基础类库,是使用Java语言编写的程序运行时所需要的软件环境,是提供给想运行Java程序的用户使用的,普通用户只是运行已开发好的Java程序的话,安装JRE即可。
  • JDK(Java Development Kit,Java开发工具包),Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK。作为一个Java程序员,JDK是必须安装的。

为什么将官网的学习放在JVM的第一步? 因为一流的程序员往往看的是规范,规范只有一套,走遍天下都不怕,这就像我们为什么我们要学习设计模式的道理一样,约定优于配置,没有规矩不成方圆!二流的程序员才看开源实现,三流的程序员那就是面向 Google 编程啦~

JVM体系结构概述

JVM全称 Java Virtual Machuine,即Java虚拟机。是一个虚构出来的计算机,它屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码,ByteCode), 就可以在多种平台上不加修改地运行。这背后其实就是JVM把字节码翻译成具体平台上的机器指令,从而实现“一次编写,到处运行(Write Once, Run Anywhere)”。

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JDK_02

下图描述了JVM整套流程的体系结构,至关重要,接下来,我们依据各个组件进行系统化讲解。
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_03

类加载器(Class Loader)

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JRE_04
类加载器,负责加载class文件,class文件在文件开头有特定的文件标示, 并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

类加载器流程图:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JDK_05
通常我们说类加载器整个体系:

•虚拟机自带的加载器。

•启动类加载器(Bootstrap):C++实现此功能。

•扩展类加载器(Extension):Java实现此功能。

•应用程序类加载器(AppClassLoader) :Java实现此功能,也叫系统类加载器,加载当前应用的classpath的所有类。

•用户自定义加载器:Java.lang.ClassLoader的子类,用户可以定制类的加载方式。

试思考,为什么可以直接使用Object类?

public class ClassLoaderTest {

    public static void main(String[] args) {
        System.out.println(new ClassLoaderTest().getClass().getClassLoader().getParent().getParent());
        System.out.println(new ClassLoaderTest().getClass().getClassLoader().getParent());
        System.out.println(new ClassLoaderTest().getClass().getClassLoader());
        //双亲委派
        System.out.println(new Object().getClass().getClassLoader());
    }
}

结果输出:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_06
第一个打印结果为空,说明根Bootstrap通过非Java实现的,C++的对象不能在Java里获取。
第二个打印结果为可扩展的Classloader。
第三个打印结果为系统级的Classloader。
第四个打印结果为空,说明获取到的是Bootstrap级别的Classloader,结合上面类加载器的流程图,得知,Bootstrap里的ClassLoader是加载 $JAVAHOME/jre/lib/rt.jar目录下的,并且Object类就是在rt.jar里。
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_GC调优_07
所以,Object就是利用双亲委派机制,当JVM运行的时候,会把所有的 $JAVAHOME/jre/lib/rt.jar 里的内容都被根级Bootstrap的Classloader加载了,这就是为什么可以直接使用Object类的原理!

关于双亲委派机制,我们写一个demo

这里我们模仿String类,自己手动创建java.lang.String类。

package java.lang;

/**
 */
public class String {

    public static void main(String[] args) {
        new String();
    }
}

运行结果如下:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JDK_08
很有意思的是,明明写了main方法,控制台却提示找不到main函数,说明可能是调用了 $JAVAHOME/jre/lib/rt.jar目录下的java.lang.String类的main函数,于是去源码里找确实没有main函数。
那么为什么会出现这种情况?jvm不会去找我们自己实现的,而是找jdk自带的类?
依旧是回到刚才的类加载器的流程图,JVM启动后会自动委派给父级去寻找当前需要执行的方法,只有当任何父级都加载不到,才会交给下一级处理,而在Bootstrap根级别里就找到了String类,由于其没有main函数,所以报错,这就是双亲委派机制。

运行时数据区(Runtime Data Area)

运行时数据区是整个 JVM 流程中最重要的一环,共计分为五个部分:

  • 程序计数器(Program Counter Register)
  • Java虚拟机栈(JVM Stack)
  • 本地方法栈(Native Method Stack)
  • Java堆(Heap)
  • 方法区(Method Area)

程序计数器

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

方法区

方法区(Method Area),是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

方法区存储什么?

• 类信息:类的版本、字段、方法、接口
• 静态变量
• 常量
• 类信息(构造方法/接口定义) • 运行时常量池

对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)” ,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。

常量池(Constant Pool)是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,这部分内容将在类加载后进入方法区的运行时常量池中存放。

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。简单说,所有定义的方法的信息都保存在该区域,此区属于共享区间。

静态变量+常量+类信息(构造方法/接口定义)+运行时常量池存在方法区中。

永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。

如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。

Jdk1.6及之前: 有永久代, 常量池1.6在方法区

Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆

Jdk1.8及之后: 无永久代,常量池1.8在元空间

栈区

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。

栈存储什么?

  • 局部变量表:输入参数和输出参数以及方法内的变量类型;局部变量
    表在编译期间完成分配,当进入一个方法时,这个方法在帧中分配
    多少内存是固定的
  • 栈操作(Operand Stack):记录出栈、入栈的操作;
  • 动态链接
  • 方法出口

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_GC调优_09
图示在一个栈中有两个栈帧:栈帧 2是最先被调用的方法,先入栈,然后方法 2 又调用了方法1,栈帧 1处于栈顶的位置,栈帧 2 处于栈底,执行完毕后,依次弹出栈帧 1和栈帧 2,线程结束,栈释放。

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_GC调优_10

每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的顶部,顶部栈就是当前的方法,该方法执行完毕后会自动将此栈帧出栈。

一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存分为三部分:

项目 英文
Young Generation Space 新生区 Young/New
Tenure generation space 养老区 Old/ Tenure
Permanent Space 永久区 Perm

一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。
类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。

堆内存逻辑上分为三部分:新生+养老+方法区

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_11

Java7堆的实现:

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_12
Java8堆的实现:
JDK 1.8之后将最初的永久代取消了,由元空间取代。
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_13

栈+堆+方法区的交互关系?
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JVM_14
HotSpot是使用指针的方式来访问对象:Java堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址。

本地方法栈

与Java虚拟机栈的作用非常相似,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。此区域也会抛出StackOverflowError和OutOfMemoryError异常。

HotSpot虚拟机中,直接把Java虚拟机栈和本地方法栈合二为一。

执行引擎(Execution Engine)

执行引擎负责解释命令,提交操作系统执行。
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_15

执行字节码指令,该区域包括解释器、编译器和垃圾回收器。

  • 解释器:解释器更快地解释字节码,但执行缓慢。解释器的缺点是当一个方法被调用多次时,每次都需要一个新的解释
  • JIT编译器:JIT编译器消除了解释器的缺点。执行引擎将在转换字节码时使用解释器的帮助,但是当它发现重复的代码时,将使用JIT编译器,它编译整个字节码并将其更改为本地代码。这个本地代码将直接用于重复的方法调用,这提高了系统的性能
  • 垃圾收集器:收集和删除未引用的对象,来释放内存空间。

本地库接口(Native Interface)

本地接口的作用是融合不同的编程语言为 Java 所用。

提供一个标准的方式让Java程序通过虚拟机与原生代码进行交互,这也就是我们平常常说的Java本地接口(JNI——Java Native Interface)。它使得在 JVM 内部运行的Java 代码能够与用其它编程语言(如 C、C++ 和汇编语言)编写的应用程序和库进行互操作。JNI最重要的好处是它没有对底层 Java 虚拟机的实现施加任何限制。因此,Java虚拟机厂商可以在不影响虚拟机其它部分的情况下添加对JNI的支持。程序员只需编写一种版本的本地应用程序或库,就能够与所有支持JNI 的Java 虚拟机协同工作。

本地方法库(Native Libraires)

它是执行引擎所需的本机库的集合。

GC参数调优入门

案例一

我们测试的机器配置是:8G内存+1T硬盘。首先我们先上一个DEMO:

@RestController
public class HeapController {

    List<Person> list=new ArrayList<Person>();

    /**
     * -Xmx32M -Xms32M
     * @return
     */
    @GetMapping("/heap")
    public String heap(){
        while(true){
            list.add(new Person());
        }
    }
}

Person类:

@Data
public class Person {

    private String username;
    private String password;
}

然后设置VM参数为:

-Xmx32M -Xms32M
# 最大堆内存32m、初始化堆内存32m

性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_性能优化_16
接下来我们开始访问:
http://localhost:8080/heap
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_GC调优_17
这里我们调用接口即可,由于使用死循环,这里的页面错误可以忽略不计。直接看打印结果:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JVM_18
经典的OOM的错误,接下来我们写一个工具类来查看详细的内存溢出信息:

public class MemoryCalc {

    public static void main(String[] args) {
        //返回 Java 虚拟机试图使用的最大内存量
        long maxMemory = Runtime.getRuntime().maxMemory();
        //返回 Java 虚拟机中的内存总量
        long totalMemory = Runtime.getRuntime().totalMemory();
        System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、" + getSize(Long.bitCount(maxMemory)));
        System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、" + getSize(Long.bitCount(totalMemory)));
    }

    public static String getSize(int size) {
        //定义GB的计算常量
        int GB = 1024 * 1024 * 1024;
        //定义MB的计算常量
        int MB = 1024 * 1024;
        //定义KB的计算常量
        int KB = 1024;
        //格式化小数
        DecimalFormat df = new DecimalFormat("0.0");
        String resultSize = "";
        if (size / GB >= 1) {
            //如果当前Byte的值大于等于1GB
            resultSize = df.format(size / (float) GB) + "GB   ";
        } else if (size / MB >= 1) {
            //如果当前Byte的值大于等于1MB
            resultSize = df.format(size / (float) MB) + "MB   ";
        } else if (size / KB >= 1) {
            //如果当前Byte的值大于等于1KB
            resultSize = df.format(size / (float) KB) + "KB   ";
        } else {
            resultSize = size + "B   ";
        }
        return resultSize;
    }
}

此时我们将MemoryCalc 工具类的VM参数加入:

-Xmx1024m -Xms1024m -XX:+PrintGCDetails
# 最大堆内存1024m、初始化堆内存1024m、开启了GC日志输出

打印结果:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_GC调优_19
Full GC:对整个堆内存空间的一次垃圾回收
GC:对年轻代空间的一次垃圾回收
Allocation Failure:“分配失败”,即为新对象分派内存不够
System.gc():执行该方法触发的GC

案例二

先上一个Demo:

public class JVMTest01 {

    byte[] bytes = new byte[1 * 1024 * 1024];

    public static void main(String[] args) {
        ArrayList<JVMTest01> list = new ArrayList<>();
        int count = 0;
        try {
            while (true) {
                list.add(new JVMTest01());
                count ++;
            }
        } catch (Throwable e) {
            System.out.println("count:" + count);
            e.printStackTrace();
        }
    }
}

此时我们将MemoryCalc 工具类的VM参数加入:

-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
# 最大堆内存1m、初始化堆内存8m、当内存溢出时触发java.lang.OutOfMemo: Java heap space

运行结果:
性能优化专题 - JVM 性能优化 - 01 - JVM介绍与入门_JVM_20

写在最后

本节代码下载地址为:https://github.com/harrypottry/jvmDemo

更多架构知识,欢迎关注本套系列文章Java架构师成长之路