性能优化专题共计四个部分,分别是:
本节是性能优化专题第二部分 —— JVM 性能优化篇,共计六个小节,分别是:
通过这六节的学习,你将学到:
➢ 了解JVM内存模型以及每个分区详解。
➢ 熟悉运行时数据区,特别是堆内存结构和特点。
➢ 熟悉GC三种收集方法的原理和特点。
➢ 熟练使用GC调优工具,快速诊断线上问题。
➢ 生产环境CPU负载升高怎么处理?
➢ 生产环境给应用分配多少线程合适?
➢ JVM字节码是什么东西?
我们说,学习一门开源技术,看其官网文档就是最好的资料!
www.oracle.com -> 右下角Product Documentation -> 往下拉选择Java -> Java SE documentation-> Previous releases -> JDK 8 -> 此时定位到https://docs.oracle.com/javase/8/
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的第一步? 因为一流的程序员往往看的是规范,规范只有一套,走遍天下都不怕,这就像我们为什么我们要学习设计模式的道理一样,约定优于配置,没有规矩不成方圆!二流的程序员才看开源与实现,三流的程序员那就是面向 Google 编程啦~
JVM全称 Java Virtual Machuine,即Java虚拟机。是一个虚构出来的计算机,它屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码,ByteCode), 就可以在多种平台上不加修改地运行。这背后其实就是JVM把字节码翻译成具体平台上的机器指令,从而实现“一次编写,到处运行(Write Once, Run Anywhere)”。
下图描述了JVM整套流程的体系结构,至关重要,接下来,我们依据各个组件进行系统化讲解。
类加载器,负责加载class文件,class文件在文件开头有特定的文件标示, 并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
•虚拟机自带的加载器。
•启动类加载器(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());
}
}
结果输出:
第一个打印结果为空,说明根Bootstrap通过非Java实现的,C++的对象不能在Java里获取。
第二个打印结果为可扩展的Classloader。
第三个打印结果为系统级的Classloader。
第四个打印结果为空,说明获取到的是Bootstrap级别的Classloader,结合上面类加载器的流程图,得知,Bootstrap里的ClassLoader是加载 $JAVAHOME/jre/lib/rt.jar目录下的,并且Object类就是在rt.jar里。
所以,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();
}
}
运行结果如下:
很有意思的是,明明写了main方法,控制台却提示找不到main函数,说明可能是调用了 $JAVAHOME/jre/lib/rt.jar目录下的java.lang.String类的main函数,于是去源码里找确实没有main函数。
那么为什么会出现这种情况?jvm不会去找我们自己实现的,而是找jdk自带的类?
依旧是回到刚才的类加载器的流程图,JVM启动后会自动委派给父级去寻找当前需要执行的方法,只有当任何父级都加载不到,才会交给下一级处理,而在Bootstrap根级别里就找到了String类,由于其没有main函数,所以报错,这就是双亲委派机制。
运行时数据区是整个 JVM 流程中最重要的一环,共计分为五个部分:
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
方法区(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种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。
栈存储什么?
图示在一个栈中有两个栈帧:栈帧 2是最先被调用的方法,先入栈,然后方法 2 又调用了方法1,栈帧 1处于栈顶的位置,栈帧 2 处于栈底,执行完毕后,依次弹出栈帧 1和栈帧 2,线程结束,栈释放。
每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的顶部,顶部栈就是当前的方法,该方法执行完毕后会自动将此栈帧出栈。
一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存分为三部分:
项目 | 英文 |
---|---|
Young Generation Space 新生区 | Young/New |
Tenure generation space 养老区 | Old/ Tenure |
Permanent Space 永久区 | Perm |
一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。
类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存逻辑上分为三部分:新生+养老+方法区
Java7堆的实现:
Java8堆的实现:
JDK 1.8之后将最初的永久代取消了,由元空间取代。
栈+堆+方法区的交互关系?
HotSpot是使用指针的方式来访问对象:Java堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址。
与Java虚拟机栈的作用非常相似,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。此区域也会抛出StackOverflowError和OutOfMemoryError异常。
HotSpot虚拟机中,直接把Java虚拟机栈和本地方法栈合二为一。
执行字节码指令,该区域包括解释器、编译器和垃圾回收器。
本地接口的作用是融合不同的编程语言为 Java 所用。
提供一个标准的方式让Java程序通过虚拟机与原生代码进行交互,这也就是我们平常常说的Java本地接口(JNI——Java Native Interface)。它使得在 JVM 内部运行的Java 代码能够与用其它编程语言(如 C、C++ 和汇编语言)编写的应用程序和库进行互操作。JNI最重要的好处是它没有对底层 Java 虚拟机的实现施加任何限制。因此,Java虚拟机厂商可以在不影响虚拟机其它部分的情况下添加对JNI的支持。程序员只需编写一种版本的本地应用程序或库,就能够与所有支持JNI 的Java 虚拟机协同工作。
它是执行引擎所需的本机库的集合。
我们测试的机器配置是: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
接下来我们开始访问:
http://localhost:8080/heap
这里我们调用接口即可,由于使用死循环,这里的页面错误可以忽略不计。直接看打印结果:
经典的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日志输出
打印结果:
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
本节代码下载地址为:https://github.com/harrypottry/jvmDemo
更多架构知识,欢迎关注本套系列文章:Java架构师成长之路