mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<audio id="audio" title="21 | 磨刀不误砍柴工:欲知JVM调优先了解JVM内存模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/b8/ba587aeb95138d9bba29bf06b2d42fb8.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
从今天开始,我将和你一起探讨Java虚拟机(JVM)的性能调优。JVM算是面试中的高频问题了,通常情况下总会有人问到:请你讲解下JVM的内存模型,JVM的性能调优做过吗?
|
||||
|
||||
## 为什么JVM在Java中如此重要?
|
||||
|
||||
首先你应该知道,运行一个Java应用程序,我们必须要先安装JDK或者JRE包。这是因为Java应用在编译后会变成字节码,然后通过字节码运行在JVM中,而JVM是JRE的核心组成部分。
|
||||
|
||||
JVM不仅承担了Java字节码的分析(JIT compiler)和执行(Runtime),同时也内置了自动内存分配管理机制。这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出风险,使Java开发人员不需要关注每个对象的内存分配以及回收,从而更专注于业务本身。
|
||||
|
||||
## 从了解内存模型开始
|
||||
|
||||
JVM自动内存分配管理机制的好处很多,但实则是把双刃剑。这个机制在提升Java开发效率的同时,也容易使Java开发人员过度依赖于自动化,弱化对内存的管理能力,这样系统就很容易发生JVM的堆内存异常,垃圾回收(GC)的方式不合适以及GC次数过于频繁等问题,这些都将直接影响到应用服务的性能。
|
||||
|
||||
因此,要进行JVM层面的调优,就需要深入了解JVM内存分配和回收原理,这样在遇到问题时,我们才能通过日志分析快速地定位问题;也能在系统遇到性能瓶颈时,通过分析JVM调优来优化系统性能。这也是整个模块四的重点内容,今天我们就从JVM的内存模型学起,为后续的学习打下一个坚实的基础。
|
||||
|
||||
## JVM内存模型的具体设计
|
||||
|
||||
我们先通过一张JVM内存模型图,来熟悉下其具体设计。在Java中,JVM内存模型主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/8b/dfd02c98d495c4c4ed201ea7fe0e3f8b.jpg" alt="">
|
||||
|
||||
JVM的5个分区具体是怎么实现的呢?我们一一分析。
|
||||
|
||||
### 1. 堆(Heap)
|
||||
|
||||
堆是JVM内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Survivor和To Survivor组成。
|
||||
|
||||
在Java6版本中,永久代在非堆内存区;到了Java7版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了Java8,永久代被元空间取代了。 结构如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/6c/9906824978c891c86524f9394102de6c.png" alt="">
|
||||
|
||||
### 2. 程序计数器(Program Counter Register)
|
||||
|
||||
程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
|
||||
|
||||
由于Java是多线程语言,当执行的线程数量超过CPU核数时,线程之间会根据时间片轮询争夺CPU资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的CPU资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
|
||||
|
||||
### 3. 方法区(Method Area)
|
||||
|
||||
很多开发者都习惯将方法区称为“永久代”,其实这两者并不是等价的。
|
||||
|
||||
HotSpot虚拟机使用永久代来实现方法区,但在其它虚拟机中,例如,Oracle的JRockit、IBM的J9就不存在永久代一说。因此,方法区只是JVM中规范的一部分,可以说,在HotSpot虚拟机中,设计人员使用了永久代来实现了JVM规范的方法区。
|
||||
|
||||
方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。
|
||||
|
||||
JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。在加载类的时候,JVM会先加载class文件,而在class文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。
|
||||
|
||||
字面量包括字符串(String a=“b”)、基本类型的常量(final修饰的变量),符号引用则包括类和方法的全限定名(例如String这个类,它的全限定名就是Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。
|
||||
|
||||
而当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)。
|
||||
|
||||
例如,类中的一个字符串常量在class文件中时,存放在class文件常量池中的;在JVM加载完类之后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池只会存在一份。
|
||||
|
||||
方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
|
||||
|
||||
在HotSpot虚拟机、Java7版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。之前永久代的类的元数据存储在了元空间,永久代的静态变量(class static variables)以及运行时常量池(runtime constant pool)则跟Java7一样,转移到了堆中。
|
||||
|
||||
**那你可能又有疑问了,Java8为什么使用元空间替代永久代,这样做有什么好处呢?**
|
||||
|
||||
官方给出的解释是:
|
||||
|
||||
- 移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为JRockit没有永久代,所以不需要配置永久代。
|
||||
- 永久代内存经常不够用或发生内存溢出,爆出异常java.lang.OutOfMemoryError: PermGen。这是因为在JDK1.7版本中,指定的PermGen区大小为8M,由于PermGen中类的元数据信息在每次FullGC的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有,为PermGen分配多大的空间很难确定,PermSize的大小依赖于很多因素,比如,JVM加载的class总数、常量池的大小和方法的大小等。
|
||||
|
||||
### 4.虚拟机栈(VM stack)
|
||||
|
||||
Java虚拟机栈是线程私有的内存空间,它和Java线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作。
|
||||
|
||||
### 5.本地方法栈(Native Method Stack)
|
||||
|
||||
本地方法栈跟Java虚拟机栈的功能类似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用Java实现的,而是由C语言实现的。
|
||||
|
||||
## JVM的运行原理
|
||||
|
||||
看到这里,相信你对JVM内存模型已经有个充分的了解了。接下来,我们通过一个案例来了解下代码和对象是如何分配存储的,Java代码又是如何在JVM中运行的。
|
||||
|
||||
```
|
||||
public class JVMCase {
|
||||
|
||||
// 常量
|
||||
public final static String MAN_SEX_TYPE = "man";
|
||||
|
||||
// 静态变量
|
||||
public static String WOMAN_SEX_TYPE = "woman";
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
Student stu = new Student();
|
||||
stu.setName("nick");
|
||||
stu.setSexType(MAN_SEX_TYPE);
|
||||
stu.setAge(20);
|
||||
|
||||
JVMCase jvmcase = new JVMCase();
|
||||
|
||||
// 调用静态方法
|
||||
print(stu);
|
||||
// 调用非静态方法
|
||||
jvmcase.sayHello(stu);
|
||||
}
|
||||
|
||||
|
||||
// 常规静态方法
|
||||
public static void print(Student stu) {
|
||||
System.out.println("name: " + stu.getName() + "; sex:" + stu.getSexType() + "; age:" + stu.getAge());
|
||||
}
|
||||
|
||||
|
||||
// 非静态方法
|
||||
public void sayHello(Student stu) {
|
||||
System.out.println(stu.getName() + "say: hello");
|
||||
}
|
||||
}
|
||||
|
||||
class Student{
|
||||
String name;
|
||||
String sexType;
|
||||
int age;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getSexType() {
|
||||
return sexType;
|
||||
}
|
||||
public void setSexType(String sexType) {
|
||||
this.sexType = sexType;
|
||||
}
|
||||
public int getAge() {
|
||||
return age;
|
||||
}
|
||||
public void setAge(int age) {
|
||||
this.age = age;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**当我们通过Java运行以上代码时,JVM的整个处理过程如下:**
|
||||
|
||||
1.JVM向操作系统申请内存,JVM第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给JVM,接下来JVM就进行内部分配。
|
||||
|
||||
2.JVM获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
|
||||
|
||||
3.class文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值(这部分我在第21讲还会详细介绍)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/32/94e6ebbaa0a23d677a4ad752e3e68732.jpg" alt="">
|
||||
|
||||
4.完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM首先会执行构造器<clinit>方法,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>() 方法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/59/29d54f4a8e1ecf388adc6b99cd5e0159.jpg" alt="">
|
||||
|
||||
5.执行方法。启动main线程,执行main方法,开始执行第一行代码。此时堆内存中会创建一个student对象,对象引用student就存放在栈中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/7e/c6702aea3f1aaca60b1cd2e38981ad7e.jpg" alt="">
|
||||
|
||||
6.此时再次创建一个JVMCase对象,调用sayHello非静态方法,sayHello方法属于对象JVMCase,此时sayHello方法入栈,并通过栈中的student引用调用堆中的Student对象;之后,调用静态方法print,print静态方法属于JVMCase类,是从静态方法中获取,之后放入到栈中,也是通过student引用调用堆中的student对象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/23/b7d00191a1d42def9633b8ea8491cf23.jpg" alt="">
|
||||
|
||||
了解完实际代码在JVM中分配的内存空间以及运行原理,相信你会更加清楚内存模型中各个区域的职责分工。
|
||||
|
||||
## 总结
|
||||
|
||||
这讲我们主要深入学习了最基础的内存模型设计,了解其各个分区的作用及实现原理。
|
||||
|
||||
如今,JVM在很大程度上减轻了Java开发人员投入到对象生命周期的管理精力。在使用对象的时候,JVM会自动分配内存给对象,在不使用的时候,垃圾回收器会自动回收对象,释放占用的内存。
|
||||
|
||||
但在某些情况下,正常的生命周期不是最优的选择,有些对象按照JVM默认的方式,创建成本会很高。比如,我在[第03讲](https://time.geekbang.org/column/article/97215)讲到的String对象,在特定的场景使用String.intern可以很大程度地节约内存成本。我们可以使用不同的引用类型,改变一个对象的正常生命周期,从而提高JVM的回收效率,这也是JVM性能调优的一种方式。
|
||||
|
||||
## 思考题
|
||||
|
||||
这讲我只提到了堆内存中对象分配内存空间的过程,那如果有一个类中定义了String a="b"和String c = new String(“b”),请问这两个对象会分别创建在JVM内存模型中的哪块区域呢?
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<audio id="audio" title="22 | 深入JVM即时编译器JIT,优化Java编译" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/09/be9221f38d34da3c01daa85acfc12a09.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
说到编译,我猜你一定会想到 .java文件被编译成 .class文件的过程,这个编译我们一般称为前端编译。Java的编译和运行过程非常复杂,除了前端编译,还有运行时编译。由于机器无法直接运行Java生成的字节码,所以在运行时,JIT或解释器会将字节码转换成机器码,这个过程就叫运行时编译。
|
||||
|
||||
类文件在运行时被进一步编译,它们可以变成高度优化的机器代码,由于C/C++编译器的所有优化都是在编译期间完成的,运行期间的性能监控仅作为基础的优化措施则无法进行,例如,调用频率预测、分支频率预测、裁剪未被选择的分支等,而Java在运行时的再次编译,就可以进行基础的优化措施。因此,JIT编译器可以说是JVM中运行时编译最重要的部分之一。
|
||||
|
||||
然而许多Java开发人员对JIT编译器的了解并不多,不深挖其工作原理,也不深究如何检测应用程序的即时编译情况,线上发生问题后很难做到从容应对。今天我们就来学习运行时编译如何实现对Java代码的优化。
|
||||
|
||||
## 类编译加载执行过程
|
||||
|
||||
在这之前,我们先了解下Java从编译到运行的整个过程,为后面的学习打下基础。请看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/17/8d4ec9c73ec37d69adb105aa7d052717.jpg" alt="">
|
||||
|
||||
### 类编译
|
||||
|
||||
在编写好代码之后,我们需要将 .java文件编译成 .class文件,才能在虚拟机上正常运行代码。文件的编译通常是由JDK中自带的Javac工具完成,一个简单的 .java文件,我们可以通过javac命令来生成 .class文件。
|
||||
|
||||
下面我们通过javap( [第12讲](https://time.geekbang.org/column/article/101244) 讲过如何使用javap反编译命令行)反编译来看看一个class文件结构中主要包含了哪些信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/7e/605dbbcfbbfcc09143d7d9e7fed5ac7e.png" alt="">
|
||||
|
||||
看似一个简单的命令执行,前期编译的过程其实是非常复杂的,包括词法分析、填充符号表、注解处理、语义分析以及生成class文件,这个过程我们不用过多关注。只要从上图中知道,编译后的字节码文件主要包括常量池和方法表集合这两部分就可以了。
|
||||
|
||||
常量池主要记录的是类文件中出现的字面量以及符号引用。字面常量包括字符串常量(例如String str=“abc”,其中"abc"就是常量),声明为final的属性以及一些基本类型(例如,范围在-127-128之间的整型)的属性。符号引用包括类和接口的全限定名、类引用、方法引用以及成员变量引用(例如String str=“abc”,其中str就是成员变量引用)等。
|
||||
|
||||
方法表集合中主要包含一些方法的字节码、方法访问权限(public、protect、prviate等)、方法名索引(与常量池中的方法引用对应)、描述符索引、JVM执行指令以及属性集合等。
|
||||
|
||||
### 类加载
|
||||
|
||||
当一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下,会通过类加载器将字节码文件加载到内存中。
|
||||
|
||||
不同的实现类由不同的类加载器加载,JDK中的本地方法类一般由根加载器(Bootstrp loader)加载进来,JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现加载,而程序中的类文件则由系统加载器(AppClassLoader )实现加载。
|
||||
|
||||
在类加载后,class类文件中的常量池信息以及其它数据会被保存到JVM内存的方法区中。
|
||||
|
||||
### 类连接
|
||||
|
||||
类在加载进来之后,会进行连接、初始化,最后才会被使用。在连接过程中,又包括验证、准备和解析三个部分。
|
||||
|
||||
**验证:**验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全。
|
||||
|
||||
**准备:**为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户的定义值。例如,private final static int value=123,会在准备阶段分配内存,并初始化值为123,而如果是 private static int value=123,这个阶段value的值仍然为0。
|
||||
|
||||
**解析:**将符号引用转为直接引用的过程。我们知道,在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为JVM可以直接获取的内存地址或指针,即直接引用。
|
||||
|
||||
### 类初始化
|
||||
|
||||
类初始化阶段是类加载过程的最后阶段,在这个阶段中,JVM首先将执行构造器<clinit>方法,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>() 方法。
|
||||
|
||||
初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致。例如:
|
||||
|
||||
```
|
||||
private static int i=1;
|
||||
static{
|
||||
i=0;
|
||||
}
|
||||
public static void main(String [] args){
|
||||
System.out.println(i);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
此时运行结果为:
|
||||
|
||||
```
|
||||
0
|
||||
|
||||
```
|
||||
|
||||
再来看看以下代码:
|
||||
|
||||
```
|
||||
static{
|
||||
i=0;
|
||||
}
|
||||
private static int i=1;
|
||||
public static void main(String [] args){
|
||||
System.out.println(i);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
此时运行结果为:
|
||||
|
||||
```
|
||||
1
|
||||
|
||||
```
|
||||
|
||||
子类初始化时会首先调用父类的 <clinit>() 方法,再执行子类的<clinit>() 方法,运行以下代码:
|
||||
|
||||
```
|
||||
public class Parent{
|
||||
public static String parentStr= "parent static string";
|
||||
static{
|
||||
System.out.println("parent static fields");
|
||||
System.out.println(parentStr);
|
||||
}
|
||||
public Parent(){
|
||||
System.out.println("parent instance initialization");
|
||||
}
|
||||
}
|
||||
|
||||
public class Sub extends Parent{
|
||||
public static String subStr= "sub static string";
|
||||
static{
|
||||
System.out.println("sub static fields");
|
||||
System.out.println(subStr);
|
||||
}
|
||||
|
||||
public Sub(){
|
||||
System.out.println("sub instance initialization");
|
||||
}
|
||||
|
||||
public static void main(String[] args){
|
||||
System.out.println("sub main");
|
||||
new Sub();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
parent static fields
|
||||
parent static string
|
||||
sub static fields
|
||||
sub static string
|
||||
sub main
|
||||
parent instance initialization
|
||||
sub instance initialization
|
||||
|
||||
```
|
||||
|
||||
JVM 会保证 <clinit>() 方法的线程安全,保证同一时间只有一个线程执行。
|
||||
|
||||
JVM在初始化执行代码时,如果实例化一个新对象,会调用<init>方法对实例变量进行初始化,并执行对应的构造方法内的代码。
|
||||
|
||||
## 即时编译
|
||||
|
||||
初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。
|
||||
|
||||
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。
|
||||
|
||||
为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。
|
||||
|
||||
### 即时编译器类型
|
||||
|
||||
在HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,这两个编译器的编译过程是不一样的。
|
||||
|
||||
C1编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI应用对界面启动速度就有一定要求。
|
||||
|
||||
C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这两种即时编译也被称为Client Compiler和Server Compiler。
|
||||
|
||||
在Java7之前,需要根据程序的特性来选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工作。
|
||||
|
||||
Java7引入了分层编译,这种方式综合了C1的启动性能优势和C2的峰值性能优势,我们也可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式。**分层编译将JVM的执行状态分为了5个层次:**
|
||||
|
||||
- 第0层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
|
||||
- 第1层:可称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling;
|
||||
- 第2层:也称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数profiling的C1编译;
|
||||
- 第3层:也称为C1编译,执行所有带Profiling的C1编译;
|
||||
- 第4层:可称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
|
||||
|
||||
在Java8中,默认开启分层编译,-client和-server的设置已经是无效的了。如果只想开启C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。
|
||||
|
||||
除了这种默认的混合编译模式,我们还可以使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式下,这时JIT完全不介入工作;我们还可以使用参数“-Xcomp”强制虚拟机运行于只有JIT的编译模式下。
|
||||
|
||||
通过 java -version 命令行可以直接查看到当前系统使用的编译模式。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/11/6ea0938770cccc1b17c46f7b37d20711.jpg" alt="">
|
||||
|
||||
### 热点探测
|
||||
|
||||
在HotSpot虚拟机中的热点探测是JIT优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。
|
||||
|
||||
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
|
||||
|
||||
**方法调用计数器:**用于统计方法被调用的次数,方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,可通过-XX: CompileThreshold来设定;而在分层编译的情况下,-XX: CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译器。
|
||||
|
||||
**回边计数器:**用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发C1编译的阈值,在不开启分层编译的情况下,C1默认为13995,C2默认为10700,可通过-XX: OnStackReplacePercentage=N来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
|
||||
|
||||
建立回边计数器的主要目的是为了触发OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
|
||||
|
||||
### 编译优化技术
|
||||
|
||||
JIT编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。今天我们主要来学习以下两种优化手段:
|
||||
|
||||
**1.方法内联**
|
||||
|
||||
调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。
|
||||
|
||||
这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销。
|
||||
|
||||
那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
|
||||
|
||||
例如以下方法:
|
||||
|
||||
```
|
||||
private int add1(int x1, int x2, int x3, int x4) {
|
||||
return add2(x1, x2) + add2(x3, x4);
|
||||
}
|
||||
private int add2(int x1, int x2) {
|
||||
return x1 + x2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终会被优化为:
|
||||
|
||||
```
|
||||
private int add1(int x1, int x2, int x3, int x4) {
|
||||
return x1 + x2+ x3 + x4;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
JVM会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold来设置热点方法的阈值。但要强调一点,热点方法不一定会被JVM做内联优化,如果这个方法体太大了,JVM将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:
|
||||
|
||||
- 经常执行的方法,默认情况下,方法体大小小于325字节的都会进行内联,我们可以通过-XX:MaxFreqInlineSize=N来设置大小值;
|
||||
- 不是经常执行的方法,默认情况下,方法大小小于35字节才会进行内联,我们也可以通过-XX:MaxInlineSize=N来重置大小值。
|
||||
|
||||
之后我们就可以通过配置JVM参数来查看到方法被内联的情况:
|
||||
|
||||
```
|
||||
-XX:+PrintCompilation //在控制台打印编译过程信息
|
||||
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
|
||||
-XX:+PrintInlining //将内联方法打印出来
|
||||
|
||||
```
|
||||
|
||||
当我们设置VM参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining之后,运行以下代码:
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
for(int i=0; i<1000000; i++) {//方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,我们循环遍历超过需要阈值
|
||||
add1(1,2,3,4);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到运行结果中,显示了方法内联的日志:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/80/eda343938d9fa4ebb6b3331ae7b62680.jpg" alt="">
|
||||
|
||||
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
|
||||
|
||||
- 通过设置JVM参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
|
||||
- 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
|
||||
- 尽量使用final、private、static关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
|
||||
|
||||
2.逃逸分析
|
||||
|
||||
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
|
||||
|
||||
**栈上分配**
|
||||
|
||||
我们知道,在Java中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。
|
||||
|
||||
以下是通过循环获取学生年龄的案例,方法中创建一个学生对象,我们现在通过案例来看看打开逃逸分析和关闭逃逸分析后,堆内存对象创建的数量对比。
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
for (int i = 0; i < 200000 ; i++) {
|
||||
getAge();
|
||||
}
|
||||
}
|
||||
|
||||
public static int getAge(){
|
||||
Student person = new Student("小明",18,30);
|
||||
return person.getAge();
|
||||
}
|
||||
|
||||
static class Student {
|
||||
private String name;
|
||||
private int age;
|
||||
|
||||
public Student(String name, int age) {
|
||||
this.name = name;
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getAge() {
|
||||
return age;
|
||||
}
|
||||
|
||||
public void setAge(int age) {
|
||||
this.age = age;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们分别设置VM参数:Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC以及 -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC,通过之前讲过的VisualVM工具,查看堆中创建的对象数量。
|
||||
|
||||
然而,运行结果却没有达到我们想要的优化效果,也许你怀疑是JDK版本的问题,然而我分别在1.6~1.8版本都测试过了,效果还是一样的:
|
||||
|
||||
(-server -Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/a5/8313c788b71e4df16b29a98bb4b63ca5.jpg" alt="">
|
||||
|
||||
(-server -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/63/259bd540cca1120813146cebbebef763.jpg" alt="">
|
||||
|
||||
这其实是因为HotSpot虚拟机目前的实现导致栈上分配实现比较复杂,可以说,在HotSpot中暂时没有实现这项优化。随着即时编译器的发展与逃逸分析技术的逐渐成熟,相信不久的将来HotSpot也会实现这项优化功能。
|
||||
|
||||
** 锁消除**
|
||||
|
||||
在非线程安全的情况下,尽量不要使用线程安全容器,比如StringBuffer。由于StringBuffer中的append方法被Synchronized关键字修饰,会使用到锁,从而导致性能下降。
|
||||
|
||||
但实际上,在以下代码测试中,StringBuffer和StringBuilder的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候JIT编译会对这个对象的方法锁进行锁消除。
|
||||
|
||||
```
|
||||
public static String getString(String s1, String s2) {
|
||||
StringBuffer sb = new StringBuffer();
|
||||
sb.append(s1);
|
||||
sb.append(s2);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**标量替换**
|
||||
|
||||
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。
|
||||
|
||||
我们用以下代码验证:
|
||||
|
||||
```
|
||||
public void foo() {
|
||||
TestInfo info = new TestInfo();
|
||||
info.id = 1;
|
||||
info.count = 99;
|
||||
...//to do something
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
逃逸分析后,代码会被优化为:
|
||||
|
||||
```
|
||||
|
||||
public void foo() {
|
||||
id = 1;
|
||||
count = 99;
|
||||
...//to do something
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以通过设置JVM参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在JDK1.8中JVM是默认开启这些操作的。
|
||||
|
||||
```
|
||||
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试)
|
||||
-XX:-DoEscapeAnalysis 关闭逃逸分析
|
||||
|
||||
-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)
|
||||
-XX:-EliminateLocks 关闭锁消除
|
||||
|
||||
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试)
|
||||
-XX:-EliminateAllocations 关闭就可以了
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要了解了JKD1.8以及之前的类的编译和加载过程,Java源程序是通过Javac编译器编译成 .class文件,其中文件中包含的代码格式我们称之为Java字节码(bytecode)。
|
||||
|
||||
这种代码格式无法直接运行,但可以被不同平台JVM中的Interpreter解释执行。由于Interpreter的效率低下,JVM中的JIT会在运行时有选择性地将运行次数较多的方法编译成二进制代码,直接运行在底层硬件上。
|
||||
|
||||
在Java8之前,HotSpot集成了两个JIT,用C1和C2来完成JVM中的即时编译。虽然JIT优化了代码,但收集监控信息会消耗运行时的性能,且编译过程会占用程序的运行时间。
|
||||
|
||||
到了Java9,AOT编译器被引入。和JIT不同,AOT是在程序运行前进行的静态编译,这样就可以避免运行时的编译消耗和内存消耗,且 .class文件通过AOT编译器是可以编译成 .so的二进制文件的。
|
||||
|
||||
到了Java10,一个新的JIT编译器Graal被引入。Graal 是一个以 Java 为主要编程语言、面向 Java bytecode 的编译器。与用 C++ 实现的 C1 和 C2 相比,它的模块化更加明显,也更容易维护。Graal 既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现 AOT 编译。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们知道Class.forName和ClassLoader.loadClass都能加载类,你知道这两者在加载类时的区别吗?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
152
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/23 | 如何优化垃圾回收机制?.md
Normal file
152
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/23 | 如何优化垃圾回收机制?.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="23 | 如何优化垃圾回收机制?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/92/bcd855b07fef56d5995519f1505e5792.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
我们知道,在Java开发中,开发人员是无需过度关注对象的回收与释放的,JVM的垃圾回收机制可以减轻不少工作量。但完全交由JVM回收对象,也会增加回收性能的不确定性。在一些特殊的业务场景下,不合适的垃圾回收算法以及策略,都有可能导致系统性能下降。
|
||||
|
||||
面对不同的业务场景,垃圾回收的调优策略也不一样。例如,在对内存要求苛刻的情况下,需要提高对象的回收效率;在CPU使用率高的情况下,需要降低高并发时垃圾回收的频率。可以说,垃圾回收的调优是一项必备技能。
|
||||
|
||||
这讲我们就把这项技能的学习进行拆分,看看回收(后面简称GC)的算法有哪些,体现GC算法好坏的指标有哪些,又如何根据自己的业务场景对GC策略进行调优?
|
||||
|
||||
## 垃圾回收机制
|
||||
|
||||
掌握GC算法之前,我们需要先弄清楚3个问题。第一,回收发生在哪里?第二,对象在什么时候可以被回收?第三,如何回收这些对象?
|
||||
|
||||
### 1. 回收发生在哪里?
|
||||
|
||||
JVM的内存区域中,程序计数器、虚拟机栈和本地方法栈这3个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。
|
||||
|
||||
那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。
|
||||
|
||||
### 2. 对象在什么时候可以被回收?
|
||||
|
||||
那JVM又是怎样判断一个对象是可以被回收的呢?一般一个对象不再被引用,就代表该对象可以被回收。目前有以下两种算法可以判断该对象是否可以被回收。
|
||||
|
||||
**引用计数算法:**这种算法是通过一个对象的引用计数器来判断该对象是否被引用了。每当对象被引用,引用计数器就会加1;每当引用失效,计数器就会减1。当对象的引用计数器的值为0时,就说明该对象不再被引用,可以被回收了。这里强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用的问题。
|
||||
|
||||
**可达性分析算法:**GC Roots 是该算法的基础,GC Roots是所有对象的根对象,在JVM加载时,会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些GC Roots开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。目前HotSpot虚拟机采用的就是这种算法。
|
||||
|
||||
以上两种算法都是通过引用来判断对象是否可以被回收。在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/0a/5c671c5ae73cbb8bc14b38d9e871530a.jpg" alt="">
|
||||
|
||||
### 3. 如何回收这些对象?
|
||||
|
||||
了解完Java程序中对象的回收条件,那么垃圾回收线程又是如何回收这些对象的呢?JVM垃圾回收遵循以下两个特性。
|
||||
|
||||
**自动性:**Java提供了一个系统级的线程来跟踪每一块分配出去的内存空间,当JVM处于空闲循环时,垃圾收集器线程会自动检查每一块分配出去的内存空间,然后自动回收每一块空闲的内存块。
|
||||
|
||||
**不可预期性:**一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为有可能当程序结束后,这个对象仍在内存中。
|
||||
|
||||
垃圾回收线程在JVM中是自动执行的,Java程序无法强制执行。我们唯一能做的就是通过调用System.gc方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期。
|
||||
|
||||
## GC算法
|
||||
|
||||
JVM提供了不同的回收算法来实现这一套回收机制,通常垃圾收集器的回收算法可以分为以下几种:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/b9/3f4316c41d4ffb27e5a36db5f2641db9.jpg" alt="">
|
||||
|
||||
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,JDK1.7 update14 之后Hotspot虚拟机所有的回收器整理如下(以下为服务端垃圾收集器):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/74/2824581e7c94a3a94b2b0abb1d348974.jpg" alt="">
|
||||
|
||||
其实在JVM规范中并没有明确GC的运作方式,各个厂商可以采用不同的方式实现垃圾收集器。我们可以通过JVM工具查询当前JVM使用的垃圾收集器类型,首先通过ps命令查询出进程ID,再通过jmap -heap ID查询出JVM的配置信息,其中就包括垃圾收集器的设置类型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/97/953dc139ff9035b41d06d4a400395e97.png" alt="">
|
||||
|
||||
## GC性能衡量指标
|
||||
|
||||
一个垃圾收集器在不同场景下表现出的性能也不一样,那么如何评价一个垃圾收集器的性能好坏呢?我们可以借助一些指标。
|
||||
|
||||
**吞吐量:**这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算GC的吞吐量:系统总运行时间=应用程序耗时+GC耗时。如果系统运行了100分钟,GC耗时1分钟,则系统吞吐量为99%。GC的吞吐量一般不能低于95%。
|
||||
|
||||
**停顿时间:**指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。
|
||||
|
||||
**垃圾回收频率:**多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
|
||||
|
||||
## 查看&分析GC日志
|
||||
|
||||
已知了性能衡量指标,现在我们需要通过工具查询GC相关日志,统计各项指标的信息。首先,我们需要通过JVM参数预先设置GC日志,通常有以下几种JVM参数设置:
|
||||
|
||||
```
|
||||
-XX:+PrintGC 输出GC日志
|
||||
-XX:+PrintGCDetails 输出GC的详细日志
|
||||
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
|
||||
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
|
||||
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
|
||||
-Xloggc:../logs/gc.log 日志文件的输出路径
|
||||
|
||||
```
|
||||
|
||||
这里使用如下参数来打印日志:
|
||||
|
||||
```
|
||||
-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs
|
||||
|
||||
```
|
||||
|
||||
打印后的日志为:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/58/58d74b6e3e68edf9b595287686b42b58.png" alt="">
|
||||
|
||||
上图是运行很短时间的GC日志,如果是长时间的GC日志,我们很难通过文本形式去查看整体的GC性能。此时,我们可以通过[GCViewer](https://sourceforge.net/projects/gcviewer/)工具打开日志文件,图形化界面查看整体的GC性能,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/79/69db951663299d342aad572d911b0279.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/f9/37/f95d5db87d20068085b6d67cd6822d37.png" alt="">
|
||||
|
||||
通过工具,我们可以看到吞吐量、停顿时间以及GC的频率,从而可以非常直观地了解到GC的性能情况。
|
||||
|
||||
这里我再推荐一个比较好用的GC日志分析工具,[GCeasy](https://www.gceasy.io/index.jsp)是一款非常直观的GC日志分析工具,我们可以将日志文件压缩之后,上传到GCeasy官网即可看到非常清楚的GC日志分析结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/22/ab3119a73f313d20a4aa0cee02e84022.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/ef/27/ef85b02537b9c970d55d3bbd5a3e3427.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/71/95/71e8f8922bc7045a7b52e5a6dff82595.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/83/be/834d779d27afbee8c70219c1628f0bbe.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/83/ba/830069547013fdcbbb74c1e9b75a77ba.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/63/8e/638ede71247b855b50e04a25564f268e.jpeg" alt="">
|
||||
|
||||
## GC调优策略
|
||||
|
||||
找出问题后,就可以进行调优了,下面介绍几种常用的GC调优策略。
|
||||
|
||||
### 1. 降低Minor GC频率
|
||||
|
||||
通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此我们可以通过增大新生代空间来降低Minor GC的频率。
|
||||
|
||||
可能你会有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但不会增加单次Minor GC的时间吗?如果单次Minor GC的时间增加,那也很难达到我们期待的优化效果呀。
|
||||
|
||||
我们知道,单次Minor GC时间是由两部分组成:T1(扫描新生代)和T2(复制存活对象)。假设一个对象在Eden区的存活时间为500ms,Minor GC的时间间隔是300ms,那么正常情况下,Minor GC的时间为 :T1+T2。
|
||||
|
||||
当我们增大新生代空间,Minor GC的时间间隔可能会扩大到600ms,此时一个存活500ms的对象就会在Eden区中被回收掉,此时就不存在复制存活对象了,所以再发生Minor GC的时间为:两次扫描新生代,即2T1。
|
||||
|
||||
可见,扩容后,Minor GC时增加了T1,但省去了T2的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。
|
||||
|
||||
如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加Minor GC的时间。如果堆中的短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。因此,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。
|
||||
|
||||
### 2. 降低Full GC的频率
|
||||
|
||||
通常情况下,由于堆内存空间不足或老年代对象太多,会触发Full GC,频繁的Full GC会带来上下文切换,增加系统的性能开销。我们可以使用哪些方法来降低Full GC的频率呢?
|
||||
|
||||
**减少创建大对象:**在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于web端显示。例如,我之前碰到过一个一次性查询出60个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过Minor GC之后也会进入到老年代。这种大对象很容易产生较多的Full GC。
|
||||
|
||||
我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
|
||||
|
||||
**增大堆内存空间:**在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低Full GC的频率。
|
||||
|
||||
### 选择合适的GC回收器
|
||||
|
||||
假设我们有这样一个需求,要求每次操作的响应时间必须在500ms以内。这个时候我们一般会选择响应速度较快的GC回收器,CMS(Concurrent Mark Sweep)回收器和G1回收器都是不错的选择。
|
||||
|
||||
而当我们的需求对系统吞吐量有要求时,就可以选择Parallel Scavenge回收器来提高系统的吞吐量。
|
||||
|
||||
## 总结
|
||||
|
||||
今天的内容比较多,最后再强调几个重点。
|
||||
|
||||
垃圾收集器的种类很多,我们可以将其分成两种类型,一种是响应速度快,一种是吞吐量高。通常情况下,CMS和G1回收器的响应速度快,Parallel Scavenge回收器的吞吐量高。
|
||||
|
||||
在JDK1.8环境下,默认使用的是Parallel Scavenge(年轻代)+Serial Old(老年代)垃圾收集器,你可以通过文中介绍的查询JVM的GC默认配置方法进行查看。
|
||||
|
||||
通常情况,JVM是默认垃圾回收优化的,在没有性能衡量标准的前提下,尽量避免修改GC的一些性能配置参数。如果一定要改,那就必须基于大量的测试结果或线上的具体性能来进行调整。
|
||||
|
||||
## 思考题
|
||||
|
||||
以上我们讲到了CMS和G1回收器,你知道G1是如何实现更好的GC性能的吗?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
169
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/24 | 如何优化JVM内存分配?.md
Normal file
169
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/24 | 如何优化JVM内存分配?.md
Normal file
@@ -0,0 +1,169 @@
|
||||
<audio id="audio" title="24 | 如何优化JVM内存分配?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/c7/6dec37d734d5d11fb373b069946de5c7.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
JVM调优是一个系统而又复杂的过程,但我们知道,在大多数情况下,我们基本不用去调整JVM内存分配,因为一些初始化的参数已经可以保证应用服务正常稳定地工作了。
|
||||
|
||||
但所有的调优都是有目标性的,JVM内存分配调优也一样。没有性能问题的时候,我们自然不会随意改变JVM内存分配的参数。那有了问题呢?有了什么样的性能问题我们需要对其进行调优呢?又该如何调优呢?这就是我今天要分享的内容。
|
||||
|
||||
## JVM内存分配性能问题
|
||||
|
||||
谈到JVM内存表现出的性能问题时,你可能会想到一些线上的JVM内存溢出事故。但这方面的事故往往是应用程序创建对象导致的内存回收对象难,一般属于代码编程问题。
|
||||
|
||||
但其实很多时候,在应用服务的特定场景下,JVM内存分配不合理带来的性能表现并不会像内存溢出问题这么突出。可以说如果你没有深入到各项性能指标中去,是很难发现其中隐藏的性能损耗。
|
||||
|
||||
JVM内存分配不合理最直接的表现就是频繁的GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的GC,且是正常的对象创建和回收,这个时候就需要考虑调整JVM内存分配了,从而减少GC所带来的性能开销。
|
||||
|
||||
## 对象在堆中的生存周期
|
||||
|
||||
了解了性能问题,那需要做的势必就是调优了。但先别急,在了解JVM内存分配的调优过程之前,我们先来看看一个新创建的对象在堆内存中的生存周期,为后面的学习打下基础。
|
||||
|
||||
在[第20讲](https://time.geekbang.org/column/article/106203)中,我讲过JVM内存模型。我们知道,在JVM内存模型的堆中,堆被划分为新生代和老年代,新生代又被进一步划分为Eden区和Survivor区,最后Survivor由From Survivor和To Survivor组成。
|
||||
|
||||
当我们新建一个对象时,对象会被优先分配到新生代的Eden区中,这时虚拟机会给对象定义一个对象年龄计数器(通过参数-XX:MaxTenuringThreshold设置)。
|
||||
|
||||
同时,也有另外一种情况,当Eden空间不足时,虚拟机将会执行一个新生代的垃圾回收(Minor GC)。这时JVM会把存活的对象转移到Survivor中,并给对象的年龄+1。对象在Survivor中同样也会经历MinorGC,每经过一次MinorGC,对象的年龄将会+1。
|
||||
|
||||
当然了,内存空间也是有设置阈值的,可以通过参数-XX:PetenureSizeThreshold设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的好处就是可以减少新生代的垃圾回收。
|
||||
|
||||
## 查看JVM堆内存分配
|
||||
|
||||
我们知道了一个对象从创建至回收到堆中的过程,接下来我们再来了解下JVM堆内存是如何分配的。在默认不配置JVM堆内存大小的情况下,JVM根据默认值来配置当前内存大小。我们可以通过以下命令来查看堆内存配置的默认值:
|
||||
|
||||
```
|
||||
java -XX:+PrintFlagsFinal -version | grep HeapSize
|
||||
jmap -heap 17284
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/d5/436338cc5251291eeb6dbb57467443d5.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/de/59941c65600fe4a11e5bc6b8304fe0de.png" alt="">
|
||||
|
||||
通过命令,我们可以获得在这台机器上启动的JVM默认最大堆内存为1953MB,初始化大小为124MB。
|
||||
|
||||
在JDK1.7中,默认情况下年轻代和老年代的比例是1:2,我们可以通过–XX:NewRatio重置该配置项。年轻代中的Eden和To Survivor、From Survivor的比例是8:1:1,我们可以通过-XX:SurvivorRatio重置该配置项。
|
||||
|
||||
在JDK1.7中如果开启了-XX:+UseAdaptiveSizePolicy配置项,JVM将会动态调整Java堆中各个区域的大小以及进入老年代的年龄,–XX:NewRatio和-XX:SurvivorRatio将会失效,而JDK1.8是默认开启-XX:+UseAdaptiveSizePolicy配置项的。
|
||||
|
||||
还有,在JDK1.8中,不要随便关闭UseAdaptiveSizePolicy配置项,除非你已经对初始化堆内存/最大堆内存、年轻代/老年代以及Eden区/Survivor区有非常明确的规划了。否则JVM将会分配最小堆内存,年轻代和老年代按照默认比例1:2进行分配,年轻代中的Eden和Survivor则按照默认比例8:2进行分配。这个内存分配未必是应用服务的最佳配置,因此可能会给应用服务带来严重的性能问题。
|
||||
|
||||
## JVM内存分配的调优过程
|
||||
|
||||
我们先使用JVM的默认配置,观察应用服务的运行情况,下面我将结合一个实际案例来讲述。现模拟一个抢购接口,假设需要满足一个5W的并发请求,且每次请求会产生20KB对象,我们可以通过千级并发创建一个1MB对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下:
|
||||
|
||||
```
|
||||
|
||||
@RequestMapping(value = "/test1")
|
||||
public String test1(HttpServletRequest request) {
|
||||
List<Byte[]> temp = new ArrayList<Byte[]>();
|
||||
|
||||
Byte[] b = new Byte[1024*1024];
|
||||
temp.add(b);
|
||||
|
||||
return "success";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### AB压测
|
||||
|
||||
分别对应用服务进行压力测试,以下是请求接口的吞吐量和响应时间在不同并发用户数下的变化情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/26/8b67579af661a666dff89d16ab2e2f26.jpg" alt="">
|
||||
|
||||
可以看到,当并发数量到了一定值时,吞吐量就上不去了,响应时间也迅速增加。那么,在JVM内部运行又是怎样的呢?
|
||||
|
||||
### 分析GC日志
|
||||
|
||||
此时我们可以通过GC日志查看具体的回收日志。我们可以通过设置VM配置参数,将运行期间的GC日志 dump下来,具体配置参数如下:
|
||||
|
||||
```
|
||||
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log
|
||||
|
||||
```
|
||||
|
||||
以下是各个配置项的说明:
|
||||
|
||||
- -XX:PrintGCTimeStamps:打印GC具体时间;
|
||||
- -XX:PrintGCDetails :打印出GC详细日志;
|
||||
- -Xloggc: path:GC日志生成路径。
|
||||
|
||||
收集到GC日志后,我们就可以使用[第22讲](https://time.geekbang.org/column/article/107396)中介绍过的GCViewer工具打开它,进而查看到具体的GC日志如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/d1/bffd496963f6bbd345092c1454524dd1.jpeg" alt="">
|
||||
|
||||
主页面显示FullGC发生了13次,右下角显示年轻代和老年代的内存使用率几乎达到了100%。而FullGC会导致stop-the-world的发生,从而严重影响到应用服务的性能。此时,我们需要调整堆内存的大小来减少FullGC的发生。
|
||||
|
||||
### 参考指标
|
||||
|
||||
我们可以将某些指标的预期值作为参考指标,上面的GC频率就是其中之一,那么还有哪些指标可以为我们提供一些具体的调优方向呢?
|
||||
|
||||
**GC频率:**高频的FullGC会给系统带来非常大的性能消耗,虽然MinorGC相对FullGC来说好了许多,但过多的MinorGC仍会给系统带来压力。
|
||||
|
||||
**内存:**这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加FullGC,严重的将导致CPU持续爆满,影响系统性能。
|
||||
|
||||
**吞吐量:**频繁的FullGC将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。
|
||||
|
||||
**延时:**JVM的GC持续时间也会影响到每次请求的响应时间。
|
||||
|
||||
### 具体调优方法
|
||||
|
||||
**调整堆内存空间减少FullGC:**通过日志分析,堆内存基本被用完了,而且存在大量FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。
|
||||
|
||||
```
|
||||
java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar
|
||||
|
||||
```
|
||||
|
||||
以下是各个配置项的说明:
|
||||
|
||||
- -Xms:堆初始大小;
|
||||
- -Xmx:堆最大值。
|
||||
|
||||
调大堆内存之后,我们再来测试下性能情况,发现吞吐量提高了40%左右,响应时间也降低了将近50%。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/af/5fd7c3f198018cf5e789c25bd4f14caf.png" alt="">
|
||||
|
||||
再查看GC日志,发现FullGC频率降低了,老年代的使用率只有16%了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/2e/b924a13d8cb4e383b94e82d34125002e.jpeg" alt="">
|
||||
|
||||
**调整年轻代减少MinorGC:**通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些MinorGC([第22讲](https://time.geekbang.org/column/article/107396)有通过降低Minor GC频率来提高系统性能的详解)。
|
||||
|
||||
```
|
||||
java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar
|
||||
|
||||
```
|
||||
|
||||
再进行AB压测,发现吞吐量上去了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/04/55de34ab7eccaf9ad83bac846d0cbf04.png" alt="">
|
||||
|
||||
再查看GC日志,发现MinorGC也明显降低了,GC花费的总时间也减少了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/5d/75f84993ba0c52d6d338d19dd4db1a5d.jpeg" alt="">
|
||||
|
||||
**设置Eden、Survivor区比例:**在JVM中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor和 To Survivor区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候SurvivorRatio默认设置的比例会失效。
|
||||
|
||||
在JDK1.8中,默认是开启AdaptiveSizePolicy的,我们可以通过-XX:-UseAdaptiveSizePolicy关闭该项配置,或显示运行-XX:SurvivorRatio=8将Eden、Survivor的比例设置为8:2。大部分新对象都是在Eden区创建的,我们可以固定Eden区的占用比例,来调优JVM的内存分配性能。
|
||||
|
||||
再进行AB性能测试,我们可以看到吞吐量提升了,响应时间降低了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/c2/91eb734ea45dc8a24b7938401eafb7c2.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/fd/cfaef71dbc8ba4f149a6e134482370fd.jpeg" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
JVM内存调优通常和GC调优是互补的,基于以上调优,我们可以继续对年轻代和堆内存的垃圾回收算法进行调优。这里可以结合上一讲的内容,一起完成JVM调优。
|
||||
|
||||
虽然分享了一些JVM内存分配调优的常用方法,但我还是建议你在进行性能压测后如果没有发现突出的性能瓶颈,就继续使用JVM默认参数,起码在大部分的场景下,默认配置已经可以满足我们的需求了。但满足不了也不要慌张,结合今天所学的内容去实践一下,相信你会有新的收获。
|
||||
|
||||
## 思考题
|
||||
|
||||
以上我们都是基于堆内存分配来优化系统性能的,但在NIO的Socket通信中,其实还使用到了堆外内存来减少内存拷贝,实现Socket通信优化。你知道堆外内存是如何创建和回收的吗?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
232
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/25 | 内存持续上升,我该如何排查问题?.md
Normal file
232
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/25 | 内存持续上升,我该如何排查问题?.md
Normal file
@@ -0,0 +1,232 @@
|
||||
<audio id="audio" title="25 | 内存持续上升,我该如何排查问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/fc/300f6947de14c0197145f2122c8ea7fc.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
我想你肯定遇到过内存溢出,或是内存使用率过高的问题。碰到内存持续上升的情况,其实我们很难从业务日志中查看到具体的问题,那么面对多个进程以及大量业务线程,我们该如何精准地找到背后的原因呢?
|
||||
|
||||
## 常用的监控和诊断内存工具
|
||||
|
||||
工欲善其事,必先利其器。平时排查内存性能瓶颈时,我们往往需要用到一些Linux命令行或者JDK工具来辅助我们监测系统或者虚拟机内存的使用情况,下面我就来介绍几种好用且常用的工具。
|
||||
|
||||
### Linux命令行工具之top命令
|
||||
|
||||
top命令是我们在Linux下最常用的命令之一,它可以实时显示正在执行进程的CPU使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/49/3633095ed54d1ef22fc08310497d6b49.jpg" alt="">
|
||||
|
||||
除了简单的top之外,我们还可以通过top -Hp pid查看具体线程使用系统资源情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/47/1e4429a9785ae4e6c0884655ee8b5747.jpg" alt="">
|
||||
|
||||
### Linux命令行工具之vmstat命令
|
||||
|
||||
vmstat是一款指定采样周期和次数的功能性监测工具,我们可以看到,它不仅可以统计内存的使用情况,还可以观测到CPU的使用率、swap的使用情况。但vmstat一般很少用来查看内存的使用情况,而是经常被用来观察进程的上下文切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/62/31a79622cdcadda4e9003b075378dc62.jpg" alt="">
|
||||
|
||||
- r:等待运行的进程数;
|
||||
- b:处于非中断睡眠状态的进程数;
|
||||
- swpd:虚拟内存使用情况;
|
||||
- free:空闲的内存;
|
||||
- buff:用来作为缓冲的内存数;
|
||||
- si:从磁盘交换到内存的交换页数量;
|
||||
- so:从内存交换到磁盘的交换页数量;
|
||||
- bi:发送到块设备的块数;
|
||||
- bo:从块设备接收到的块数;
|
||||
- in:每秒中断数;
|
||||
- cs:每秒上下文切换次数;
|
||||
- us:用户CPU使用时间;
|
||||
- sy:内核CPU系统使用时间;
|
||||
- id:空闲时间;
|
||||
- wa:等待I/O时间;
|
||||
- st:运行虚拟机窃取的时间。
|
||||
|
||||
### Linux命令行工具之pidstat命令
|
||||
|
||||
pidstat是Sysstat中的一个组件,也是一款功能强大的性能监测工具,我们可以通过命令:yum install sysstat安装该监控组件。之前的top和vmstat两个命令都是监测进程的内存、CPU以及I/O使用情况,而pidstat命令则是深入到线程级别。
|
||||
|
||||
通过pidstat -help命令,我们可以查看到有以下几个常用的参数来监测线程的性能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/46/90d26ef49ad94510062ac3f36727a346.jpg" alt="">
|
||||
|
||||
常用参数:
|
||||
|
||||
- -u:默认的参数,显示各个进程的cpu使用情况;
|
||||
- -r:显示各个进程的内存使用情况;
|
||||
- -d:显示各个进程的I/O使用情况;
|
||||
- -w:显示每个进程的上下文切换情况;
|
||||
- -p:指定进程号;
|
||||
- -t:显示进程中线程的统计信息。
|
||||
|
||||
我们可以通过相关命令(例如ps或jps)查询到相关进程ID,再运行以下命令来监测该进程的内存使用情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/61/184df3ee5ab0a920f506b3daa6250a61.jpg" alt="">
|
||||
|
||||
其中pidstat的参数-p用于指定进程ID,-r表示监控内存的使用情况,1表示每秒的意思,3则表示采样次数。
|
||||
|
||||
其中显示的几个关键指标的含义是:
|
||||
|
||||
- Minflt/s:任务每秒发生的次要错误,不需要从磁盘中加载页;
|
||||
- Majflt/s:任务每秒发生的主要错误,需要从磁盘中加载页;
|
||||
- VSZ:虚拟地址大小,虚拟内存使用KB;
|
||||
- RSS:常驻集合大小,非交换区内存使用KB。
|
||||
|
||||
如果我们需要继续查看该进程下的线程内存使用率,则在后面添加-t指令即可:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/72/3c9072c659a91b5f83cbc1a112ddcc72.jpg" alt="">
|
||||
|
||||
我们知道,Java是基于JVM上运行的,大部分内存都是在JVM的用户内存中创建的,所以除了通过以上Linux命令来监控整个服务器内存的使用情况之外,我们更需要知道JVM中的内存使用情况。JDK中就自带了很多命令工具可以监测到JVM的内存分配以及使用情况。
|
||||
|
||||
### JDK工具之jstat命令
|
||||
|
||||
jstat可以监测Java应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息。我们可以运行jstat -help查看一些关键参数信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/e8/42880a93eb63ae6854a7920e73a751e8.jpg" alt="">
|
||||
|
||||
再通过jstat -option查看jstat有哪些操作:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/7d/7af697d9cfd6002a49063ab2464d5f7d.jpg" alt="">
|
||||
|
||||
- -class:显示ClassLoad的相关信息;
|
||||
- -compiler:显示JIT编译的相关信息;
|
||||
- -gc:显示和gc相关的堆信息;
|
||||
- -gccapacity:显示各个代的容量以及使用情况;
|
||||
- -gcmetacapacity:显示Metaspace的大小;
|
||||
- -gcnew:显示新生代信息;
|
||||
- -gcnewcapacity:显示新生代大小和使用情况;
|
||||
- -gcold:显示老年代和永久代的信息;
|
||||
- -gcoldcapacity :显示老年代的大小;
|
||||
- -gcutil:显示垃圾收集信息;
|
||||
- -gccause:显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
|
||||
- -printcompilation:输出JIT编译的方法信息。
|
||||
|
||||
它的功能比较多,在这里我例举一个常用功能,如何使用jstat查看堆内存的使用情况。我们可以用jstat -gc pid查看:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/68/e59188982cf5b75243a8c333bfead068.jpg" alt="">
|
||||
|
||||
- S0C:年轻代中To Survivor的容量(单位KB);
|
||||
- S1C:年轻代中From Survivor的容量(单位KB);
|
||||
- S0U:年轻代中To Survivor目前已使用空间(单位KB);
|
||||
- S1U:年轻代中From Survivor目前已使用空间(单位KB);
|
||||
- EC:年轻代中Eden的容量(单位KB);
|
||||
- EU:年轻代中Eden目前已使用空间(单位KB);
|
||||
- OC:Old代的容量(单位KB);
|
||||
- OU:Old代目前已使用空间(单位KB);
|
||||
- MC:Metaspace的容量(单位KB);
|
||||
- MU:Metaspace目前已使用空间(单位KB);
|
||||
- YGC:从应用程序启动到采样时年轻代中gc次数;
|
||||
- YGCT:从应用程序启动到采样时年轻代中gc所用时间(s);
|
||||
- FGC:从应用程序启动到采样时old代(全gc)gc次数;
|
||||
- FGCT:从应用程序启动到采样时old代(全gc)gc所用时间(s);
|
||||
- GCT:从应用程序启动到采样时gc用的总时间(s)。
|
||||
|
||||
### JDK工具之jstack命令
|
||||
|
||||
这个工具在模块三的[答疑课堂](https://time.geekbang.org/column/article/105234)中介绍过,它是一种线程堆栈分析工具,最常用的功能就是使用 jstack pid 命令查看线程的堆栈信息,通常会结合top -Hp pid 或 pidstat -p pid -t一起查看具体线程的状态,也经常用来排查一些死锁的异常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/88/2869503e8d5460e36b3fd3e1a52a8888.jpg" alt="">
|
||||
|
||||
每个线程堆栈的信息中,都可以查看到线程ID、线程的状态(wait、sleep、running 等状态)以及是否持有锁等。
|
||||
|
||||
### JDK工具之jmap命令
|
||||
|
||||
在[第23讲](https://time.geekbang.org/column/article/108139)中我们使用过jmap查看堆内存初始化配置信息以及堆内存的使用情况。那么除了这个功能,我们其实还可以使用jmap输出堆内存中的对象信息,包括产生了哪些对象,对象数量多少等。
|
||||
|
||||
我们可以用jmap来查看堆内存初始化配置信息以及堆内存的使用情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/3f/808870b42f5f6525d79f70fd287a293f.jpg" alt="">
|
||||
|
||||
我们可以使用jmap -histo[:live] pid查看堆内存中的对象数目、大小统计直方图,如果带上live则只统计活对象:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/7b/74f42fa2b48ceaff869472f6061c1c7b.jpg" alt="">
|
||||
|
||||
我们可以通过jmap命令把堆内存的使用情况dump到文件中:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/17/f3c17fd9bb436599fb48cf151ee7ba17.jpg" alt="">
|
||||
|
||||
我们可以将文件下载下来,使用 [MAT](http://www.eclipse.org/mat/) 工具打开文件进行分析:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/43/3cc14844625cebcc1cdb836e5ccbfc43.jpg" alt="">
|
||||
|
||||
下面我们用一个实战案例来综合使用下刚刚介绍的几种工具,具体操作一下如何分析一个内存泄漏问题。
|
||||
|
||||
## 实战演练
|
||||
|
||||
我们平时遇到的内存溢出问题一般分为两种,一种是由于大峰值下没有限流,瞬间创建大量对象而导致的内存溢出;另一种则是由于内存泄漏而导致的内存溢出。
|
||||
|
||||
使用限流,我们一般就可以解决第一种内存溢出问题,但其实很多时候,内存溢出往往是内存泄漏导致的,这种问题就是程序的BUG,我们需要及时找到问题代码。
|
||||
|
||||
**下面我模拟了一个内存泄漏导致的内存溢出案例,我们来实践一下。**
|
||||
|
||||
我们知道,ThreadLocal的作用是提供线程的私有变量,这种变量可以在一个线程的整个生命周期中传递,可以减少一个线程在多个函数或类中创建公共变量来传递信息,避免了复杂度。但在使用时,如果ThreadLocal使用不恰当,就可能导致内存泄漏。
|
||||
|
||||
这个案例的场景就是ThreadLocal,下面我们模拟对每个线程设置一个本地变量。运行以下代码,系统一会儿就发送了内存溢出异常:
|
||||
|
||||
```
|
||||
@RequestMapping(value = "/test0")
|
||||
public String test0(HttpServletRequest request) {
|
||||
ThreadLocal<Byte[]> localVariable = new ThreadLocal<Byte[]>();
|
||||
localVariable.set(new Byte[4096*1024]);// 为线程添加变量
|
||||
return "success";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在启动应用程序之前,我们可以通过HeapDumpOnOutOfMemoryError和HeapDumpPath这两个参数开启堆内存异常日志,通过以下命令启动应用程序:
|
||||
|
||||
```
|
||||
java -jar -Xms1000m -Xmx4000m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heapTest.log heapTest-0.0.1-SNAPSHOT.jar
|
||||
|
||||
```
|
||||
|
||||
首先,请求test0链接10000次,这个时候我们请求test0的接口报异常了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/dc/60ab8d7847a55a9bcf84d17ecd11ebdc.jpg" alt="">
|
||||
|
||||
通过日志,我们很好分辨这是一个内存溢出异常。我们首先通过Linux系统命令查看进程在整个系统中内存的使用率是多少,最简单就是top命令了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/37/d2ad570e1fff2a64a1924c2852f93e37.jpg" alt="">
|
||||
|
||||
从top命令查看进程的内存使用情况,可以发现在机器只有8G内存且只分配了4G内存给Java进程的情况下,Java进程内存使用率已经达到了55%,再通过top -Hp pid查看具体线程占用系统资源情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/a7/6fdea40b5ff4f2f0744e019c3bef79a7.jpg" alt="">
|
||||
|
||||
再通过jstack pid查看具体线程的堆栈信息,可以发现该线程一直处于 TIMED_WAITING 状态,此时CPU使用率和负载并没有出现异常,我们可以排除死锁或I/O阻塞的异常问题了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/87/4bfb58d626f988260e016a2bdf0e8687.jpg" alt="">
|
||||
|
||||
我们再通过jmap查看堆内存的使用情况,可以发现,老年代的使用率几乎快占满了,而且内存一直得不到释放:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/71/feb358259ea8b3ed2b67e868c101d271.jpg" alt="">
|
||||
|
||||
通过以上堆内存的情况,我们基本可以判断系统发生了内存泄漏。下面我们就需要找到具体是什么对象一直无法回收,什么原因导致了内存泄漏。
|
||||
|
||||
我们需要查看具体的堆内存对象,看看是哪个对象占用了堆内存,可以通过jmap查看存活对象的数量:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/d9/c5b89deb306a2c470e606fa9c49dd0d9.jpg" alt="">
|
||||
|
||||
Byte对象占用内存明显异常,说明代码中Byte对象存在内存泄漏,我们在启动时,已经设置了dump文件,通过MAT打开dump的内存日志文件,我们可以发现MAT已经提示了byte内存异常:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/63/4ceb91714afa77b54d1112a0e1f0c863.jpg" alt="">
|
||||
|
||||
再点击进入到Histogram页面,可以查看到对象数量排序,我们可以看到Byte[]数组排在了第一位,选中对象后右击选择with incomming reference功能,可以查看到具体哪个对象引用了这个对象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/91/5a651a2f52dfed72712543f7680de091.jpg" alt="">
|
||||
|
||||
在这里我们就可以很明显地查看到是ThreadLocal这块的代码出现了问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/a2/2bed3871097249d64ccf4c79d68109a2.jpg" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
在一些比较简单的业务场景下,排查系统性能问题相对来说简单,且容易找到具体原因。但在一些复杂的业务场景下,或是一些开源框架下的源码问题,相对来说就很难排查了,有时候通过工具只能猜测到可能是某些地方出现了问题,而实际排查则要结合源码做具体分析。
|
||||
|
||||
可以说没有捷径,排查线上的性能问题本身就不是一件很简单的事情,除了将今天介绍的这些工具融会贯通,还需要我们不断地去累积经验,真正做到性能调优。
|
||||
|
||||
## 思考题
|
||||
|
||||
除了以上我讲到的那些排查内存性能瓶颈的工具之外,你知道要在代码中对JVM的内存进行监控,常用的方法是什么?
|
||||
|
||||
期待在留言区看到你的分享。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
142
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/26 | 答疑课堂:模块四热点问题解答.md
Normal file
142
极客时间专栏/Java性能调优实战/模块四 · JVM性能监测及调优/26 | 答疑课堂:模块四热点问题解答.md
Normal file
@@ -0,0 +1,142 @@
|
||||
<audio id="audio" title="26 | 答疑课堂:模块四热点问题解答" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/80/0cfb0354c0f56d7debb1b06a39fa2080.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
本周我们结束了“JVM性能监测及调优”的学习,这一期答疑课堂我精选了模块四中 11 位同学的留言,进行集中解答,希望也能对你有所帮助。另外,我想为坚持跟到现在的同学点个赞,期待我们能有更多的技术交流,共同成长。
|
||||
|
||||
## [第20讲](https://time.geekbang.org/column/article/106203)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/3a/31a205290c3b2391f115ee77f511a43a.jpeg" alt="">
|
||||
|
||||
很多同学都问到了类似“黑夜里的猫"问到的问题,所以我来集中回复一下。JVM的内存模型只是一个规范,方法区也是一个规范,一个逻辑分区,并不是一个物理空间,我们这里说的字符串常量放在堆内存空间中,是指实际的物理空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/01/2ac5ee0c9a6fe67ce8f896be75d05f01.jpeg" alt="">
|
||||
|
||||
文灏的问题和上一个类似,一同回复一下。元空间是属于方法区的,方法区只是一个逻辑分区,而元空间是具体实现。所以类的元数据是存放在元空间,逻辑上属于方法区。
|
||||
|
||||
## [第21讲](https://time.geekbang.org/column/article/106953)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/76/f2fa07e388f5a3dbe84bb12bfea5ee76.jpeg" alt="">
|
||||
|
||||
Liam同学,目前Hotspot虚拟机暂时不支持栈上分配对象。W.LI同学的留言值得参考,所以这里一同贴出来了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/f2/20e59cb2df51bd171d41c81e074821f2.jpeg" alt="">
|
||||
|
||||
## [第22讲](https://time.geekbang.org/column/article/107396)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/25/09ada15236e8ceeef2558d6ab7505425.jpeg" alt="">
|
||||
|
||||
非常赞,Region这块,Jxin同学讲解得很到位。这里我再总结下CMS和G1的一些知识点。
|
||||
|
||||
CMS垃圾收集器是基于标记清除算法实现的,目前主要用于老年代垃圾回收。CMS收集器的GC周期主要由7个阶段组成,其中有两个阶段会发生stop-the-world,其它阶段都是并发执行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/aa/500c2f0e112ced378fd49a09c61c5caa.jpg" alt="">
|
||||
|
||||
G1垃圾收集器是基于标记整理算法实现的,是一个分代垃圾收集器,既负责年轻代,也负责老年代的垃圾回收。
|
||||
|
||||
跟之前各个分代使用连续的虚拟内存地址不一样,G1使用了一种 Region 方式对堆内存进行了划分,同样也分年轻代、老年代,但每一代使用的是N个不连续的Region内存块,每个Region占用一块连续的虚拟内存地址。
|
||||
|
||||
在G1中,还有一种叫 Humongous 区域,用于存储特别大的对象。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代的YoungGC中被回收掉。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/be/f832278afd5cdb94decd1f6826056dbe.jpg" alt="">
|
||||
|
||||
G1分为Young GC、Mix GC以及Full GC。
|
||||
|
||||
G1 Young GC主要是在Eden区进行,当Eden区空间不足时,则会触发一次Young GC。将Eden区数据移到Survivor空间时,如果Survivor空间不足,则会直接晋升到老年代。此时Survivor的数据也会晋升到老年代。Young GC的执行是并行的,期间会发生STW。
|
||||
|
||||
当堆空间的占用率达到一定阈值后会触发G1 Mix GC(阈值由命令参数-XX:InitiatingHeapOccupancyPercent设定,默认值45),Mix GC主要包括了四个阶段,其中只有并发标记阶段不会发生STW,其它阶段均会发生STW。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/2f/b8090ff2c7ddf54fb5f6e3c19a36d32f.jpg" alt="">
|
||||
|
||||
G1和CMS主要的区别在于:
|
||||
|
||||
- CMS主要集中在老年代的回收,而G1集中在分代回收,包括了年轻代的Young GC以及老年代的Mix GC;
|
||||
- G1使用了Region方式对堆内存进行了划分,且基于标记整理算法实现,整体减少了垃圾碎片的产生;
|
||||
- 在初始化标记阶段,搜索可达对象使用到的Card Table,其实现方式不一样。
|
||||
|
||||
这里我简单解释下Card Table,在垃圾回收的时候都是从Root开始搜索,这会先经过年轻代再到老年代,也有可能老年代引用到年轻代对象,如果发生Young GC,除了从年轻代扫描根对象之外,还需要再从老年代扫描根对象,确认引用年轻代对象的情况。
|
||||
|
||||
**这种属于跨代处理,非常消耗性能。**为了避免在回收年轻代时跨代扫描整个老年代,CMS和G1都用到了Card Table来记录这些引用关系。只是G1在Card Table的基础上引入了RSet,每个Region初始化时,都会初始化一个RSet,RSet记录了其它Region中的对象引用本Region对象的关系。
|
||||
|
||||
除此之外,CMS和G1在解决并发标记时漏标的方式也不一样,CMS使用的是Incremental Update算法,而G1使用的是SATB算法。
|
||||
|
||||
首先,我们要了解在并发标记中,G1和CMS都是基于三色标记算法来实现的:
|
||||
|
||||
- 黑色:根对象,或者对象和对象中的子对象都被扫描;
|
||||
- 灰色:对象本身被扫描,但还没扫描对象中的子对象;
|
||||
- 白色:不可达对象。
|
||||
|
||||
基于这种标记有一个漏标的问题,也就是说,当一个白色标记对象,在垃圾回收被清理掉时,正好有一个对象引用了该白色标记对象,此时由于被回收掉了,就会出现对象丢失的问题。
|
||||
|
||||
为了避免上述问题,CMS采用了Incremental Update算法,只要在写屏障(write barrier)里发现一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的。而在G1中,采用的是SATB算法,该算法认为开始时所有能遍历到的对象都是需要标记的,即认为都是活的。
|
||||
|
||||
G1具备Pause Prediction Model ,即停顿预测模型。用户可以设定整个GC过程中期望的停顿时间,用参数-XX:MaxGCPauseMillis可以指定一个G1收集过程的目标停顿时间,默认值200ms。
|
||||
|
||||
G1会根据这个模型统计出来的历史数据,来预测一次垃圾回收所需要的Region数量,通过控制Region数来控制目标停顿时间的实现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/b4/915e9793981a278112087f0c880b96b4.jpeg" alt="">
|
||||
|
||||
Liam提出的这两个问题都非常好。
|
||||
|
||||
不管什么GC,都会发送stop-the-world,区别是发生的时间长短。而这个时间跟垃圾收集器又有关系,Serial、PartNew、Parallel Scavenge收集器无论是串行还是并行,都会挂起用户线程,而CMS和G1在并发标记时,是不会挂起用户线程的,但其它时候一样会挂起用户线程,stop the world 的时间相对来说就小很多了。
|
||||
|
||||
Major Gc 在很多参考资料中是等价于 Full GC的,我们也可以发现很多性能监测工具中只有Minor GC 和 Full GC。一般情况下,一次Full GC将会对年轻代、老年代、元空间以及堆外内存进行垃圾回收。触发Full GC的原因有很多:
|
||||
|
||||
- 当年轻代晋升到老年代的对象大小,并比目前老年代剩余的空间大小还要大时,会触发Full GC;
|
||||
- 当老年代的空间使用率超过某阈值时,会触发Full GC;
|
||||
- 当元空间不足时(JDK1.7永久代不足),也会触发Full GC;
|
||||
- 当调用System.gc()也会安排一次Full GC。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/24/a8a506a512922609669b4073d0dbc224.jpeg" alt="">
|
||||
|
||||
接下来解答 ninghtmare 的提问。我们可以通过 jstat -gc pid interval 查看每次GC之后,具体每一个分区的内存使用率变化情况。我们可以通过JVM的设置参数,来查看垃圾收集器的具体设置参数,使用的方式有很多,例如 jcmd pid VM.flags 就可以查看到相关的设置参数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/2e/26d688a3af534fb00fe3b89d261e5c2e.jpg" alt="">
|
||||
|
||||
这里附上第22讲中,我总结的各个设置参数对应的垃圾收集器图表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/56/e29c9ac3e53ffbc8a5648644a87d6256.jpeg" alt="">
|
||||
|
||||
## [第23讲](https://time.geekbang.org/column/article/108139)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/76/bb92ec845c715f9d36a6ce48a0c7d276.jpeg" alt="">
|
||||
|
||||
我又不乱来同学的留言真是没有乱来,细节掌握得很好!
|
||||
|
||||
前提是老年代有足够接受这些对象的空间,才会进行分配担保。如果老年代剩余空间小于每次Minor GC晋升到老年代的平均值,则会发起一次 Full GC。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/20/2838514b87e62d69bf51d7a7f12a0c20.jpeg" alt="">
|
||||
|
||||
看到这里,我发现爱提问的同学始终爱提问,非常鼓励啊,技术是需要交流的,也欢迎你有任何疑问,随时留言给我,我会知无不尽。
|
||||
|
||||
现在回答W.LI同学的问题。这个会根据我们创建对象占用的内存使用率,合理分配内存,并不仅仅考虑对象晋升的问题,还会综合考虑回收停顿时间等因素。针对某些特殊场景,我们可以手动来调优配置。
|
||||
|
||||
## [第24讲](https://time.geekbang.org/column/article/108582)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/24/1080a8574a1a1ded35b736ccbec40524.jpeg" alt="">
|
||||
|
||||
下面解答Geek_75b4cd同学的问题。
|
||||
|
||||
我们知道,ThreadLocal是基于ThreadLocalMap实现的,这个Map的Entry继承了WeakReference,而Entry对象中的key使用了WeakReference封装,也就是说Entry中的key是一个弱引用类型,而弱引用类型只能存活在下次GC之前。
|
||||
|
||||
如果一个线程调用ThreadLocal的set设置变量,当前ThreadLocalMap则会新增一条记录,但由于发生了一次垃圾回收,此时的key值就会被回收,而value值依然存在内存中,由于当前线程一直存在,所以value值将一直被引用。.
|
||||
|
||||
这些被垃圾回收掉的key就会一直存在一条引用链的关系:Thread --> ThreadLocalMap–>Entry–>Value。这条引用链会导致Entry不会被回收,Value也不会被回收,但Entry中的key却已经被回收的情况发生,从而造成内存泄漏。
|
||||
|
||||
我们只需要在使用完该key值之后,将value值通过remove方法remove掉,就可以防止内存泄漏了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/9a/8da35d95d5b31e3f0a582dbd4d47fd9a.jpeg" alt="">
|
||||
|
||||
最后一个问题来自于WL同学。
|
||||
|
||||
内存泄漏是指不再使用的对象无法得到及时的回收,持续占用内存空间,从而造成内存空间的浪费。例如,我在[第03讲](https://time.geekbang.org/column/article/97215)中说到的,Java6中substring方法就可能会导致内存泄漏。
|
||||
|
||||
当调用substring方法时会调用new string构造函数,此时会复用原来字符串的char数组,而如果我们仅仅是用substring获取一小段字符,而在原本string字符串非常大的情况下,substring的对象如果一直被引用,由于substring里的char数组仍然指向原字符串,此时string字符串也无法回收,从而导致内存泄露。
|
||||
|
||||
内存溢出则是发生了OutOfMemoryException,内存溢出的情况有很多,例如堆内存空间不足,栈空间不足,还有方法区空间不足等都会导致内存溢出。
|
||||
|
||||
内存泄漏与内存溢出的关系:内存泄漏很容易导致内存溢出,但内存溢出不一定是内存泄漏导致的。
|
||||
|
||||
今天的答疑就到这里,如果你还有其它问题,请在留言区中提出,我会一一解答。最后欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他加入讨论。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user