从字节码到GC那些你应该知道的Java虚拟机

开发 前端
JVM,即Java Virtual Machine。Java虚拟机(JVM)是可运行Java代码的假想计算机。只要根据JVM规格描述将解释器移植到特定的计算机上,就能保证经过编译的任何Java代码能够在该系统上运行。

1、引言

JVM,即Java Virtual Machine。Java虚拟机(JVM)是可运行Java代码的假想计算机。只要根据JVM规格描述将解释器移植到特定的计算机上,就能保证经过编译的任何Java代码能够在该系统上运行。

一个完整的JVM包含的知识体系是很庞大的,例如下面的每一个章节包含的知识点完全可以写成一本厚厚的书籍。本文抽取JVM中的字节码、即时编译器、运行时数据区、对象内存布局、垃圾收集、常用参数等几个方面进行编写。基于篇幅有限,其他的例如:内存模型、类加载、多线程、反射、Javaagent、JVM性能监控等本文就不再赘述了,有兴趣的可以自行搜索相关资料。

2、字节码

2.1 概述

Java是一门面向对象的高级语言,它从C++之上发展而来,在代码的编写风格上就很相似。但是不同于C++,Java编译后的产物不能被cpu直接运行,它是生成一种名为“字节码”的中间产物,然后再由不同机器上的JVM识别,翻译成本地cpu指令。

图片

Java之父詹姆斯·高斯林在设计Java的时候,便有一个雄心勃勃的计划“一次编写,到处运行”。为了解决这个问题,于是高斯林提出了字节码的概念,并且借助于和平台无关的“字节码”。实际上,通过字节码不仅能做到跨平台,还能做到跨语言相互调用(而Graal VM这个高科技虚拟机,在跨语言上,更进一步了,限于篇幅这里不多加赘述,有兴趣的可以自行搜索)。

2.2 栈指令集架构? 寄存器指令集架构?

虚拟机常见的实现方式有2种:基于栈和基于寄存器,典型的基于的栈虚拟机有oracle的HotSpot以及微软的.net CLR,而基于寄存器的虚拟机有LuaVM以及谷歌的DalvikVM,JVM采用的是基于栈的指令集架构。实际上2者各有自己的优缺点:

  • 基于栈的指令集移植性更好,指令更短,实现简单。但是不能随机访问堆栈中的元素,完成相同功能往往比基于寄存器的架构要多,要频繁的执行入栈出栈操作,不利于代码优化
  • 基于寄存器的指令集速度快,可以充分利用寄存器,有利于程序运行优化,但是操作数需要显示指定,指令长

2.3 字节码指令集a.字节码指令

一条字节码指令包含一个操作码(opcode)以及随后跟随的0至多个操作数(operand)。虚拟机中的大部分指令并不包含操作数,只有一个操作码。操作码的大小固定为1个字节,这也限制了字节码的种类无法超过256个。通过限制操作码大小为1个字节,这样能尽可能的获得短小精悍的编译代码。

JVM解释器在解析字节码的时候工作流程类似如下这个样子:

do{
自动计算程序寄存器以及从程序寄存器中取出操作码;

if(存在操作数)
取出操作数;

执行操作码所定义的操作;
} while(处理下一次循环);

b.指令分类

绝大部分的字节码操作是和类型相关的,例如ireturn用于返回一个int类型数据,freturn用于返回一个float类型的数据。根据字节码的用途,这里大概分为这么些种类:

  • 加载和存储,如iload将一个int类型数据从局部变量表中加载到操作数栈
  • 控制转移,如ifeq跳转指令
  • 对象操作,如new创建对象
  • 方法调用,目前有5条命令支持方法调用,均为invoke*,如invokevirtual用于调用虚方法
  • 执行运算,如iadd执行int类型的加法
  • 线程同步,如monitorenter以及monotorexit用于支持Java中的关键字synchronized
  • 异常处理,如athrow用于抛出异常

如果想“正确”实现一个的虚拟机其实并没有大家所想的那么高深和困难,只需要正确的去实现class文件的每一条字节码指令,并且能正确的执行这些指令所蕴含的操作即可。

2.4 字节码的解释器执行过程

Java的虚拟机的字节码执行系统是以栈为基础执行的,这里的栈即:操作数栈。当一个方法被调用的时候,需要在线程栈中创建一个名为栈帧的数据结构,一个方法栈帧包含 局部变量表、操作数栈、异常表、常量池引用等。

这里举一个很简单的例子,只有一行代码的方法,麻雀虽小,五脏俱全。我们可以通过JVM解释器的执行过程,来窥探从底层理解代码到底是如何运行的。

其对应的代码和字节码如下:

public void localVar(int x) {    
x = x + 10;
}

这里讲述这段代码是如何执行的,也就下编号为0~4的4行字节码执行过程(假设x=110)。

图片

a.开始阶段:

图片

b.执行编号为0的指令,iload_1

把局部变量表1号位置的值压栈,也就是变量x的值压入操作数栈。字节码中有很多类似于xxx_0,xxx_1这种指令,这种指令本身就携带操作数,等价于xxx 1,也就是说_1后面的1就是它的操作数。JVM这么干的目的就是减少类文件的体积,保证短小精悍!

图片

c.执行bipush 10把常量10压入栈:

图片

d.执行iadd指令,将操作数栈里面栈顶的2个元素累加,并且出栈,将计算结果压栈:

图片

e.执行istore_1指令,将操作数栈内栈顶元素弹出,并且存入编号为1的局部变量表:

图片

就此x=x+10代码便执行完毕,x的值此时从110->120;

2.5 语法糖

实际上,为了方便我们更好的使用Java语言,Java会对我们的代码进行加工,而这种加工手段就被称之为语法糖。专业点的说法是:语法糖指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。

其中Java的语法糖包括:字符串拼接、Enum、switch-on-string、自动装箱拆箱、常量折叠、条件编译、assert、try-with-resourse、foreach、var声明变量、Lambda等等。篇幅有限,笔者下面挑选有代表意义的Lambda表达式,并且从字节码角度进行原理讲解,希望对大家有所帮助!

Java8的时候,引入了函数式接口和Lambda表达式,这一举动可以说是极大的提高我们的开发效率,从此以后,Java也是一门支持函数式编程的语言。

我们看下下面这段代码:

public static void func1() {
int localInt = 0;
localInt += 1;

Action0 add10 = () -> {
System.out.println(localInt + 10);
};
}

实际上这段代码是无法通过编译的,因为localInt变量没有定义为final。同时在这里,关于在Lambda表达式中引用外部方法的局部变量的这种写法,也引申了2个问题,读者可以试着思考并解答下:

  • Lambda内部使用的局部变量localInt和外部方法func1的localInt是同一个变量吗?
  • 为什么localInt变量必须声明为final?

想要回答这个问题,我们需要知道这么几个知识点:线程栈、栈帧(方法帧)、局部变量表

  • 每条线程即为一个线程栈
  • 每次调用一个方法的时候,会在当前线程创建一个方法栈帧,方法栈帧是可以嵌套的,例如a方法调用b方法,再调用c方法的时候,会创建3个方法栈帧,3个方法栈帧都是存活的,但是a和b方法都是处于未激活状态,只有正在运行的c方法的方法栈帧是激活的。当一个方法调用结束的时候,方法栈帧会被销毁,此方法栈帧无法再继续使用。当c方法调用完毕之后,c方法栈帧销毁,此刻,b方法栈帧处于激活状态。

同一个线程的同一时间只有一个方法栈帧可以处于激活状态

每个方法执行的时候,只能访问自己方法栈帧的数据(局部变量表、操作数栈等)

  • 每个方法栈帧包含局部变量表、操作数栈等数据结构,我们定义的局部变量和方法参数、this引用等就存放在方法栈帧的局部变量表中

这里我们就可以回答上面的第一个问题了,localInt其实是func1方法的局部变量,也就意味着localInt的生命周期和调用func1方法所创建栈帧是保存一致的。而当add10被执行的时候(调用add10.call()),其实是在调用一个新的方法,也就是说会有一个新的方法栈帧,很明显,add10和func1不在同一个方法栈帧中执行,在add10运行的时候,func1的方法栈帧甚至都可能已经被销毁了。也就是说func1的localInt和add10的localInt处于不同的局部变量表中,言外之意,就是他两其实本质上并不是同一个变量(虽然2个名字是一样的^-^)!

Java语言强制要求localInt变量必须声明为final,原因的话,我觉得应该是为了保持数据的一致,因为如果不定义为final,这个字段被修改了,在其他方法是无法体现的;如add10中的localInt被修改了,在func1中的localInt根本不会有任何影响,因为这2个localInt压根就不是同一个变量,压根就没共享内存。其实localInt必须声明为final只是Java语言要求的,其他语言,例如C#就没这种要求。

到这里我们解答了上面的2个问题,又引入了一个新的问题,那就是既然func1的localInt和add10的localInt不是同一个变量,那么这个变量是怎么传值过去的呢?想回答这个问题,还是得借助 “字节码”。

我们改写下代码,以便通过编译:

public static void func1(int localInt) {
Action0 add10 = () -> {
System.out.println(localInt + 10);
};
}

使用命令javap查看字节码:


public static void func1(int);
Code:
0: iload_0
1: invokedynamic #3, 0 // InvokeDynamic #0:call:(I)Lrx/functions/Action0;
6: astore_1
7: return

private static void lambda$func1$0(int);
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iload_0
4: bipush 10
6: iadd
7: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
10: return

这段字节码表达的意义如下:其中方法LambdaMetafactory.metafactory是和invokedynamic指令有关的一个特殊方法。我们看到编译器自动生成了一个名为lambda$func1$0的方法,这个方法其实就是我们在func1里面定义的Lambda表达式,并把localInt变量按照参数传递给lambda$func1$0方法。因为Java方法参数仅支持按值传递,所以其实相当于把localInt复制了一份然后传递给了lambda$func1$0方法。上面字节码字节反编译的结果如下:

final class Main$$Lambda$1 implements Action0 {
private final int arg$1;

private Main$$Lambda$1(int var1) {
this.arg$1 = var1;
}

private static Action0 get$Lambda(int var0) {
return new Main$$Lambda$1(var0);
}

@Hidden
public void call() {
Main.lambda$func1$0(this.arg$1);
}
}

但是,上述反编译代码其实是不能正常运行的。真实过程其实是JVM运行时会生成一个新的内部类,而这个内部类本质上是由InnerClassLambdaMetafactory使用ASM字节码技术动态生成。如果我们添加启动参数:

-Djdk.internal.lambda.dumpProxyClasses

再运行,运行时动态生成的类会出现在项目中的目录下,找到这个名为

Main$$Lambda$1的class文件,然后再并反编译,结果如下:


final class Main$$Lambda$1 implements Action0 {
private final int arg$1;

private Main$$Lambda$1(int var1) {
this.arg$1 = var1;
}

private static Action0 get$Lambda(int var0) {
return new Main$$Lambda$1(var0);
}

@Hidden
public void call() {
Main.lambda$func1$0(this.arg$1);
}
}

而下面这行代码:

Action0 add10 = () -> {
System.out.println(localInt + 10);
};

被替换成了

Action0 add10 = Main$$Lambda$1.get$Lambda(localInt);

为了方便展示结果,下面把相关代码写在一起,原始方法相当于被重写成了这个样子:

// 这个是原始方法
public static void func1(int localInt) {
Action0 add10 = () -> {
System.out.println(localInt + 10);
};
}

// ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️
// 下面的全部代码相当于上面的 原始方法
public static void func1(int localInt) {
Action0 add10 = Main$$Lambda$1.get$Lambda(localInt);
}

// lambda表达式里面的代码被转换为了下面这个方法
private static /* synthetic */ void lambda$func1$0(int localInt) {
System.out.println(localInt + 10);
}

// 运行时利用ASM技术创建的内部类
final class Main$$Lambda$1 implements Action0 {
private final int arg$1;

private Main$$Lambda$1(int var1) {
this.arg$1 = var1;
}

private static Action0 get$Lambda(int var0) {
return new Main$$Lambda$1(var0);
}

@LambdaForm.Hidden
public void call() {
Main.lambda$func1$0(this.arg$1);
}
}

可以看到,其实Lambda表达式在运行期间,被转换为了一个“内部类”(注意和编译期间的内部类区分开来)。当然不同的虚拟机可以按照其想用的方式实现Lambda表达式,因为Java虚拟机规范并没有强制说一定只能用内部类来实现。如果想替换实现方式,只需要修改LambdaMetafactory.metafactory里面的逻辑即可,这种方式把Lambda的翻译策略从编译期推迟到运行时,并且未来的JDK版本如何实现Lambda方式可能还会有变化。

如果对字节码足够熟悉和理解,Java的各种语法的面纱将被撕开,变得不再神秘!

3、即时编译器

3.1 Java语言真的运行很慢吗?

相对于那些直接编译成本地cpu的C、C++的语言而言,Java语言首先要将代码编译成字节码,然后再由虚拟机托管,翻译成cpu命令再运行,这种以二次编译(准确的来说,第二次编译的过程其实包含解释+编译)方式运行,总体而言的确是要比这种本地编译成cpu语言慢的。

在1996年1月23号的时候,sun公司推出了JDK的第一个版本JDK1.0。早些的时候,JVM还是采用纯解释的方式运行字节码,那个时候的Java运行慢的出奇,和同时期的C、C++等语言简直就是没法比。而到98年JDK1.2的时候才引入一个组件,并且这个组件在尽自己的努力优化代码,这个组件就是JIT(即时编译器)。有JIT的加持,使得Java的运行速度和C、C++等差距越来越小了。

特别的,对于某些代码,Java跑起来竟然要比C、C++要快:

  • JIT能在运行时刻收集机器的的信息,将某些特殊代码直接翻译成当前cpu支持的特殊指令。相反,C、C++代码是提前编译好的,编译器对环境的认识肯定没有JIT深刻。
  • JIT能够评估代码的运行情况,将已经编译好的代码推翻,并重新将字节码翻译成cpu命令。重新编译的代码可以重新组织,减少不正确的分支判断。

3.2 代码样例

先举一个样例,来见识见识JIT的威力吧!

我们来看下下面这段荒诞的代码:


public static void main(String[] args) {
long now = System.currentTimeMillis();
loop(Integer.MAX_VALUE);
System.out.println("用时:" + (System.currentTimeMillis() - now) + "ms");
}

public static void loop(int count) {
for (int i = 0; i < count; i++) {
doNothing(i);
}
}

private static void doNothing(int x) {
x++;
x++;
}

我们在main方法内调用了loop方法,loop方法内部是一个循环,循环体调用doNothing方法,循环的次数是2147483647次,现在我们执行这段代码,查看结果:

用时:3ms

仅需要3ms就执行21亿次多循环运算?不可思议!倒不是因为笔者电脑多强大,能执行这么快,得归功于JIT的优化手段。JIT在运行期间发现doNothing里面的代码x变量有赋值,但是未被使用,于是代码直接删除。接下来JIT对代码体积比较小的doNothing内联,空方法内联,直接删除调用,接下来,发现这个循环毫无作用,继续优化,这个循环被直接删掉。也就是说上面这段代码被优化之后loop方法实际上是一个空操作,啥都不做!

3.3 分层编译

JIT中执行代码的时候,共有三层编译手段,分别是:

第0层:解释执行

第1层:使用client(c1)模式编译执行

第2层:使用server(c2)模式编译运行

其中第1层还可以继续细分成三层,总的而言如下图所示

(https://www.infoq.cn/article/java-10-jit-compiler-graal):

图片

第0层:解释执行

第1层:使用c1模式编译执行,不附带任何profiling(性能监控)

第2层:使用c1模式编译运行,附带调用次&循环回边执行次数profiling

第3层:使用c1模式编译运行,附带所有profiling

第4层:使用c2模式编译运行,采用比较激进优化策略,附带profiling

其中C2代码执行效率要比C1高出30%以上,而C1模式下,按照执行的效率是1>2>3。5个层次中,第1层和第4层是终态。当一个方法被终止态编译之后,如果此代码没有失效,jvm是不会再次发出该方法的编译请求的。

一开始,JIT采用解释的方式,如果JIT发现某个方法是热点代码(这也是HotSpot虚拟机名字的由来),便会触发即时编译,生成本地cpu执行。JIT采用计数的方式统计代码执行次数,在C1模式下默认1500次触发,而在C2模式下,是10000次。

-Xint参数是用于指定让程序以解释的方式运行,而-client是直接指定使用C1模式运行,-server是采用C2模式运行。C2模式运行效率最高,优化过程也比较激进,缺点就是编译耗时会久一点,程序使用内存会更大(代码缓存)(控制台输入java -X能看到服务的的工作模式参数一览)

3.4 方法内联

所有的优化策略里面,首当其冲的便是“方法内联”,方法内联不仅可以给体积比较小的方法节省不小的额外开销,而且还是其他优化措施的先行条件。

笔者第一次接触这个名词还是在学习C++的时候,C++语法中,需要显式的使用inline关键字声明方法告诉编译器,这个方法代码体比较简单,需要编译器帮助做内联操作。现在到了Java,不存在什么inline关键字,内联由JIT触发,JIT会自动的将一些代码体积比较小的方法直接内联。那什么是方法内联呢?

先看这段代码,这是一种非常常见的写法,利用get、set方法封装字段:

class People {
private String name;
private int 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;
}
}

但是,执行每个方法都有一些额外的开销,包括查找虚方法表、压栈、弹栈等操作,那么相对于只有一条很简单的访问字段语句的方法而言,这些额外的开销不能忽视。所以,JIT会将这种代码体积比较小直接替换代码调用处,以省去这些额外开销,也就是说:

public void call(People people){
System.out.println(people.getName());
}

经过方法内联后,这行代码会被变成这个样子,直接访问字段,跳过的调用方法的额外开销:

public void call(People people){
System.out.println(people.name);
}

但是,想法很美好,Java实现内联还是有点问题的,那就是虚方法的内联,首先,解释下什么是虚方法:

无论是C++、C#还是Java,都是面向对象并且支持多态的,基类定义的方法可以被子类重写,而这些可以被子类重写的方法就是虚方法。

对于C++、C#语言而言,一个普通的成员方法不是虚方法,当我们显式使用virtual关键字声明的方法才会被当作虚方法,而Java是相反的,默认就是虚方法,当我们加上private或者final等访问限制后,才会变成非虚方法。

那么在Java里面有哪几种类型的方法是虚方法呢:

  • 使用invokeInterface字节码调用的方法
  • 使用invokevirtual字节码调用的方法,但是没有附加final修饰符

假设People有这么2个派生类:


class Chinese extends People {
@Override
public int getName() {
return "汉字名字";
}
}

class English extends People {
@Override
public int getName() {
return "ABC";
}
}

call方法的参数people的getName方法有可能是调用People、Chinese、English的。JIT是不能直接判断出来到底用哪个类的方法进行内联,那么JIT就采取一种名为CHA(类型继承分析)的手段,JIT会分析现在已经加载好的所有类,查看People是否有派生类,派生类是否重写了getName方法,如果没有,说明People的getName方法仅只有一个,于是可以放心的做内联。但是这又有一个新的问题,因为Java是支持运行时动态加载类的,那万一运行时,有新加载的类重写了这个方法呢?JIT的结局方案是会在运行时监控类加载,如果发现People.getName方法被子类重写了,就会推翻当前编译过的代码,重新回到第0层,也就是解释执行。这个过程也叫逆优化。逆优化也是JVM相对而言一个比较强大的功能。

即使使用了CHA,但是还是有些问题,好比一个类有多个实现,好比我上面举的例子,JIT会使用一种名为内联缓存(Inline Cache)的优化措施。开始的时候缓存为空,那么第一次调用后,会缓存方法接受者的信息。并且每次调用都先比较此信息,看缓存是否命中,以减少查找虚方法表的性能损耗。

3.5 逃逸分析

逃逸分析也是是JIT一个非常重要的优化手段,和CHA一样,并不是直接优化代码,而是为其他优化策略提供分析技术。

逃逸分析的基本原理是:分析在方法体内部创建的对象,是否被当作字段或者数组中的元素传入堆对象中,或者被当作参数传入其他方法,如果没有的话,称之为不逃逸。逃逸力度从不逃逸、方法逃逸、线程逃逸依次递增。

3.5.1.栈上分配

在Java里面,绝大多数的对象都是创建在堆中,而堆中的对象,是能被各个线程共享,但是如果能判断一个对象是非逃逸的,那么就可以直接在栈上给这个分配这个内存,当方法结束后,该对象自动销毁,这样能给堆减少一定的GC压力。不过由于比较复制等原因,这个优化措施在oracle的HotSpot虚拟机中暂时未实现。

栈上分配,这个可以参考C++;在C++中,如果直接创建一个对象(不使用new运算符)的话,这个对象也是分配于栈中的,当变量作用范围结束后,会自动调用析构方法并释放该对象内存

3.5.2.标量替换

这个优化措施达到的结果和栈上分配类似,内存都是在栈中申请的,能减轻GC的工作量。那什么是标量呢?指的是一个数据已经无法再分解称更小的数据类型,例如Java的基本数据类型(int,long,reference等类型)。假设逃逸分析能证明一个对象不会被外部方法所访问,那么这个对象就可以被分解成一个一个的局部变量,也就是说这些字段的内存直接在栈上分配。就好比说:


private static void call(int x, int y) {
Point point = new Point(x, y);
return point.getX() + 2;
}

经过标量替换再配合方法内联,会变成这个样子:

private static void call(int x, int y) {
return x + 2;
}

C#中使用struct定义的类型,被称做为“值类型”,例如,C#所有原生的基本类型(除了String),都是使用struct声明的值类型,值类型相对于使用class声明的“类”而言,是一种非常轻量级别的类型,其内存分配于栈,也就直接等价于JIT的标量替换

3.5.3 同步消除

线程之间使用同步本身就是一个比较耗时的过程,那么如果JIT发现锁对象不会逃逸出当前线程,也就不会被其他线程访问,那么JIT就可以放心的直接删除这个无效的锁。

3.6 补充

本文仅介绍JIT的部分优化策略,其实JIT包含很多很丰富的优化策略。好比:公共子表达式消除、数组边界检查消除、无效代码消除、循环展开、循环条件外提等

介于篇幅优先,不做介绍。有兴趣的可以查阅:​​https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex​

4、运行时数据区

Java虚拟机规范里面定义的运行时数据区有:pc寄存器、Jvm栈、本地方法栈、Java堆、方法区、运行时常量池。我们常用的HotSpot虚拟机的内存除了上述之外,还有直接内存、元空间(JDK8之后的叫法)、压缩类空间、代码缓存等。

图片

其中VM栈、本地方法栈以及程序计数器是线程私有的,也就是说每一条线程就有一份,并且不同线程的这些内存是隔离的,不能相互访问,但是堆和方法区是共享的,不同的线程都可以访问这些内存。

4.1 堆

堆在JVM启动的时候便已经创建好了,堆中的数据被各个线程共享。Java中创建的对象,均存放在堆中,包括使用关键字new、反序列化、Object.clone、Unsafe.allocateInstance、反射等创建的对象。(当然,此处不考虑JIT的一些优化策略,因为如果考虑栈上分配、标量替换等优化策略,创建出来的对象就不一定是在堆中分配内存了)。堆中的对象,由JVM进行管理,JVM的GC组件负责回收不再使用的对象。

4.2 方法区

和堆一样,方法区也是被各个线程所共享的区域。关于方法区,还有另外一种叫法no-heap(非堆)。方法区和传统语言中的编译存储区或者操作系统进程的正文段很相似,它存储了每一个类的结构信息,例如:运行常量池、字段、方法数据、构造方法以及普通方法的字节码等。

4.3 VM栈

在Java中,线程使用的是操作系统的线程,也就是说一条Java线程会和一条操作系统的线程做一一映射关系,一条Java线程大小可以通过命令java -XX:+PrintFlagsFinal -version | grep "ThreadStackSize"查看。栈中方法调用一层套一层,每调用一层方法,便会创建一个栈帧,栈帧的大小在编译之后就确定了,这个栈帧的大小直接写入了类文件中,在运行时无需从新计算。在线程执行的某个时间点上,只有目前正在执行的那个方法的栈帧是活动的,这个栈帧也别称为当前栈帧,这个栈帧下的方法也被称为当前方法。对局部变量表和操作数栈的操作,都是针对于当前栈帧而言的。那么一个栈帧又具体包含那些数据呢?

a.局部变量表

局部变量表中包含方法参数以及声明的局部变量。关于局部变量表,我们就可以对比数组,把变量放在一个名为局部变量表的数组中。在方法执行的开始阶段,会执行“序幕”,所谓的序幕其实就是做一些准备工作,给局部变量表赋值。那么好比如下代码:

public void localVar(int x, int y) {
int s = 0;
byte d = 123;
Object o1 = new int[10];
}

局部变量有s、d、o1,还有方法参数x、y,其实这里还漏了一个局部变量,那就是this,并且this总是位于局部变量表的0号位置,在每次发起方法调用的时候,this也需要当作参数传入方法,也就是说我们可以理解为:this引用其实相当于类的实例方法的隐藏的第一个方法参数。这里,我们执行javap -c -p -l class文件路径。

查看字节码:

图片

其中

  • Start:局部变量的作用域启始位置
  • Length:其作用域范围
  • Slot:变量在局部变量表中的索引
  • Signature是变量类型的类描述符,L开头的表明是引用类型,I是int类型,B是byte类型

局部变量表中,boolean、byte、char、short、int占用1个Slot,也就是说,长度低于int(4字节)的变量也和int类型变量一样,占用相同的内存大小,换句话说,就是boolean、byte、char、short在局部变量表中也是4个字节(它们不同于放在堆中内存大小,可不是1个字节或者2个字节哦~)。并且Java操作码中,并没有对应这些类型专门用于计算的操作码,他们会被当作int类型一样,使用和int一样的操作码。double和long占用2个Slot,但是在访问这些变量的时候,使用的是他们第一个Slot的索引位置。引用类型有点特殊,因为在32位虚拟机和64位虚拟机中(启用压缩指针)的时候,占用1Slot,而在64位虚拟机,不开启压缩指针的时候,占用2个Slot(以下所有图例为了方便,引用类型按照1Slot处理)。

这里有个示意图,用来表明这些变量是怎么存储的,值是多少:

图片

b.操作数栈

前面也提到了字节码是基于栈的,而这个栈指的就是操作数栈。

在执行代码的时候,变量不能凭空计算(i++这种代码除外),必须借助另一个数据结构,这就是“操作数栈”。操作数栈,如其名,是一个FILO的“栈”。执行字节码之前,会将操作数执行压栈操作,计算之后,会弹出栈,把执行结果再压入栈。

操作数栈的大小也是提前就计算好的,在编译成字节码的时候,大小就已经写入了类文件。

c.异常表

public static void main(String[] args) {
try {
int y = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
e.printStackTrace();
} catch (Exception e) {
System.out.println(e);
}
}

图片

  • from:try的开始位置
  • to:try的结束位置
  • target:catch块字节码的启始位置
  • type:捕捉的异常类型

我们可以看到每个catch块的第一个字节码都是astore_1。这是因为如果触发异常,这个异常会压入栈顶,通过astore_1字节码,取栈顶的异常,放入局部变量表索引为1的位置。

d.常量池引用

观察字节码,不难发现,在操作数里面有类似于#1、#2这种标记,实际上通过记录当前方法所在类的常量池引用,便可以将这些标记转换为一个实际的符号引用。

4.4 本地栈

在Java语言中需要调用一些native的本地方法,这些方法都不是用Java语言编写,这个时候就会创建“本地栈”。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(比如:Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

4.5 程序计数器

程序计数器本身是一个记录着当前线程所执行的字节码的行号指示器。

JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。

5、对象内存布局

5.1 内存布局

对象的内存布局在Java虚拟机规范(Java虚拟机规范SE8)里面并没有做强制要求,不同的虚拟机可以以自己的方式给对象分配内存,在oracle的HotSpot虚拟机中,一个对象的内存被划分为了如下三块:

  • 对象头
  • 对象字段
  • 填充空白

5.2 对象头

HotSpot是一种准确式内存管理虚拟机,也就是说虚拟机能在运行时候,准确的知道某个对象的类型,那么虚拟机是如何做到的呢?答案就在对象头里面。

每个对象除了自己基本的字段需要占用内存之外,还有一些额外的内存,那就是对象头和填充空白,其中对象头又被划分为三部分,类型指针、标记字(mark word)、数组长度(仅数组对象有)。

a:类型指针

类型指针和普通的引用类型一样,在32位和64位虚拟机(未开启压缩指针)中,分别占有4个和8个字节大小,而在64位虚拟机中,如果开启压缩指针(默认开启)的话,占有4个字节。而正是因为对象头里面的类型指针的存在,使得jvm能在运行时刻准确的知道一个对象具体是什么类型。

b:标记字

标记字是用于存储一个对象运行时相关的数据,包括对象哈希值、GC分代年龄、锁状态、线程持有的锁、偏向线程Id、偏向时间戳等。其中内存的话,在32位和64位虚拟机中,分别占有4个和8个字节。

事实上,对象在运行时需要存储的空间很多,已经超过4个或者8个字节的范围,于是,标记字可以通过一个标志位来区分其他空间的作用,下面的是mark word的存储内容示意图(想知道详细点可以查看OpenJdk源码):

想知道详细点可以查看OpenJdk源码

存储内容

标志位

状态

对象哈希吗、对象分代年龄

01

未锁定

指向锁记录的指针

00

轻量级锁定

指向重量级锁的指针

10

重量级锁定

空、不需要记录信息

11

GC标记

偏向线程Id、偏向时间戳、对象分代年龄

01

可偏向

c:数组长度

如果一个对象是数组的话,对象头里面还有一个额外的内存,是用于存储数组大小的,数组创建后,可以通过这个值获取数组长度,占4个字节。

5.3 字段

a.字段大小

字段占有的内存大小和字段的类型密切相关,不同的类型大小不一样(基本数据类型大小和虚拟机位数没关系)

  • byte 1个字节
  • char、short 2个字节
  • int、float 4个字节
  • long、double 8个字节

引用类型(oop-Ordinary object pointer) 32位虚拟机4个字节;64位虚拟机,未开启指针压缩8个字节,开启4个字节

b.字段排列

字段排列有三种方式(参数:-XX:FieldsAllocationStyle,默认是1)

  • FieldsAllocatinotallow=0,oop在前面,基本类型在后
  • FieldsAllocatinotallow=1,基本类型在前,oop在后
  • FieldsAllocatinotallow=2,子类和父类oop放一起排列(可以方便gc查找gc root)

无论是上述哪一种方式排列,都会遵循如下两个规则

  • 设一个字段为C个字节,那么这个字段的偏移量是N*C,例如,int类型字段的偏移量为4的整数倍
  • 子类继承父类的偏移量

c.伪共享

伪共享指的是2个不同的volatile字段被2个不同的线程访问,然后恰好这2个字段在同一个cpu缓存行内,就会无意之间影响彼此之间的性能。

使用jdk的@Contended注解标记字段,并使用-XX:-RestrictContended启动参数,就会在字段的前后填充一定的空白字符,这样就能让两个不同的字段位于不同的缓存行,从而达到提升性能的作用。

static class BaseClass {
private int intFiled1;
@Contended
private Object oopField2;
}

static class CoreClass extends BaseClass {
private int intField1;
private Object oopField2;
}

再执行,观察结果,发现在BaseClass.oopField2前后补上了128个空白字节

|对象头(12个字节)|BaseClass.intFiled1(4个字节)|空白填充(128个字节)|BaseClass.oopField2(4个字节)|空白填充(128个字节)|CoreClass.intField1(4个字节)|CoreClass.oopField2(4个字节)|空白填充4个字节共288个字节
br

为了直观的表示一个对象内存排列方式,可以参考下面的图解,括号里面的是占用的字节数(64位虚拟机,开启指针压缩,默认的字段布局方式,启动参数:-XX:-RestrictContended)

图片

5.4 填充空白

从上面的代码输出结果就可以看出来,虚拟机会在对象之间或者对象末尾插入一些空白字节,目的是使对象的总字节数达到8*N,其中一个原因是考虑到了cpu缓存行,因为这样的话就能使得更多的将一个对象的字段恰好能放到一个缓存行内。

6、垃圾收集器

6.1 概述

无论是什么语言编写的应用程序,都一定需要申请内存资源,用于存储计算的结果。而当申请的内存不再被使用的时候,又需要释放以便腾出这段内存给其他功能使用。这种简单的模式却又是导致编程错误的“元凶”之一。想想看,又有多少程序员忘记释放不再使用的内存,又有多少机器因为内存泄漏而频繁的宕机。

进行非托管编程的时候,这种bug往往比其他bug都要更严重,因为一般无法预测他们的后果或者发生时间。

当需要释放不使用的内存,往往需要先解决下面这3个问题:

  • 什么对象是不再使用的
  • 什么时机回收内存
  • 如何回收内存

上述这些操作,如果是C、C++等程序需要程序员手动进行判断,而往往这个工作比较繁琐和枯燥。而对于Java、C#等托管语言而言,这里就变得非常轻松,这些动作交给虚拟机即可。程序员只需要按照自己的需求手动创建对象即可;因为JVM有个专门的组件,GC(garbage collection)来完成上述的3个过程。

有意思的是,虽然不需要自己管理内存,但是往往Java工程师们往往对GC工作原理很感兴趣。而C、C++工程师却因为缺乏一套自动的垃圾回收机制,而导致在释放内存这里程序往往会出现严重错误。正如周志明在《深入理解Java虚拟机》里面说的一句话:

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

6.2 对象已死

a.可达性分析算法

目前主流语言内存管理系统里面,判断对象是否存活的方式,都是通过“可达性分析算法”。如假设ABCDEFG均为Java中创建的对象:

图片

如果对象之间的引用满足如上图所示,那么里面标记黑色的那些对象(ABD)都是存活对象。一个对象是否存活,决定于从GC-Root开始,到对象之间是否存在一条完整的可达路径,如果是,那么就说明对象还在被使用,是不能被释放的。一般而言、不同的垃圾收集器在不同的阶段这个GC-Root都不一样,但是总体而言,有但不完全限定于下面这些会被当作根处理:

  • 局部变量表中的对象
  • 类的静态字段
  • 本地JNI引用的对象
  • 一些Java根,如类加载器
  • Java虚拟机内部引用的对象,

如:Universe,JNIHandles,ObjectSynchronizer,FlatProfiler,Management,JvmtiExport,SystemDictionary,StringTable等

某些垃圾收集器的某些阶段,会有一些临时加入的对象,如:G1的YGC或者mixed-GC的时候,Rset里面引用的对象也会被加入根

b.三色标记算法

试着思考一个问题,Java里面的对象之间时可以相互引用,那么如果把这些对象当作一个整体来看,是什么数据结构呢?是的,答案是有向图。那么如果我们对“图”进行遍历,有哪2种方式呢?答案是BFS(广度优先遍历)和DFS(深度优先遍历)。类似CMS、G1、ZGC等垃圾收集器遍历对象采用的就是BFS/DFS,无论是DFS还是BFS进行遍历的时候,都会对对象着色:

  • 白色:对象的初始颜色是白色,未被遍历的节点是白色。GC里面白色代表垃圾,也就是最终会被回收的对象
  • 灰色:表示的是处于中间状态,表示的是当前阶段已经被遍历,但是子节点未被遍历。标记结束之后,不存在灰色阶段对象。
  • 黑色:表面的是当前节点已经被遍历,而且子节点全部被遍历了。GC里面的黑色代表可达对象,也就是存活的对象

例如下面就是G1的遍历算法(采用的是BFS),用一个专门的列表存储灰色的节点;最终H和J会被当作垃圾回收掉。

图片

c.并发标记的难点

类似于CMS和G1等并发垃圾收集器,在并发标记的时候 ,允许用户线程同时不受影响的执行,也就是说一边在做并发标记,一遍在更新引用的关系。这就好比你妈妈在清理房间的垃圾的时候,你一边还在往房间丢垃圾。并发标记过程中,存在2中情况,一种是本来是垃圾的标记成了存活对象,当作非垃圾处理了,这种没关系,本次GC没回收掉,等下次可以再回收掉,这种也被称之为“浮动垃圾”。而另外一种就不能忽视了,就是指本来不是垃圾,但是却被错误的标记成了垃圾处理,那么就会出现明明这个对象是可达的,还在被使用,但是已经被GC给回收掉了,这个就比较致命了。产生这种错误必须同时满足下面2个条件:

  • 往一个黑色节点插入一个白色节点
  • 删除所有到上述白色节点的所有灰色节点

例如:一开始对象引用关系如下图所示:

图片

然后这个时候,往B节点下插入一个白色的没有遍历过的E节点

图片

然后再F节点遍历之前,删除F到E节点的引用关系。因为每批遍历的时候,都是从灰色节点开始遍历,但是F->E的引用关系被删除,导致E节点无法再继续遍历。然而从GC-Root->B->E这条路径又是可达的,也就是说E节点本身是存活的,但是颜色却又是白色,白色节点都是需要被GC回收的对象,这也就意味着GC会回收一个非垃圾对象,这是一个非常致命的问题。

图片

而解决这个问题的方案,就是破坏上述的2个必要条件之一即可。

  • CMS采用的是破坏第一个条件,如果有白色节点被插入的时候,将这个白色节点记录下来。等到并非标记结束的时候,在重新标记这步的时候,以白色节点为根节点,再遍历一次。当然,为了避免再出现错标的现象,重新标记这一步,不再是并发执行的了,用户线程必须被挂起来。这种方式也被称之为“增量更新”
  • G1采用的是破坏第二个条件,当删除灰色节点到白色节点的引用关系的时候,再记录这个灰色节点的信息。等到并发标记结束的时候,重新标记过程中,再将这些记录过对象再标记一次。这种方式也被称之为“原始快照”

6.3 对象的内存分配

对象分配直接关系到内存的使用效率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响Muataor的运行。

为Java对象分配内存等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中的内存是绝对工整的,所有使用过的内存放在一边,没有使用过的放在另外一边,中间放着一个指针作为分界点的指示器,那么新对象分配的内存仅仅只是把指针往空闲空间方向挪动一段和对象大小相等的距离,这种方式又被称之为“指针加法-Bump The Pointer。

  • 《深入理解Java虚拟机》里面把这个叫做指针碰撞,但是《深入拆解 Java 虚拟机》作者郑雨迪却建议翻译成指针加法,理由是 在英语中我们通常省略了 bump up the pointer 中的 up。在这个上下文中 bump 的含义应为“提高”。另外一个例子是当我们发布软件的新版本时,也会说 bump the version number。
  • 本文采用郑雨迪的译法

那如果堆里面的内存不是规整的,已使用的和未使用的内存交错在一起(如:C语言等)那就没有办法使用指针加法了,内部就必须维护一个列表,记录那些内存块是可用的,在分配内存的时候,需要遍历列表,查找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种方式又被称之为“空闲列表-Free List”。

对象从内堆中分配,但是堆却又是被所有线程共享的资源,所以申请内存这块需要进行加锁处理。但是加锁又会降低应用的并发度。这里JVM引入一个名为TLAB(Thread Local Allocation Buffer,线程本地分配缓存区)进行优化。具体做法是在堆里面为每条线程划分一块独立的内存,并且在线程里面保存TLAB的首尾地址,如果当前线程需要分配内存,优先从TLAB中分配,因为被划成线程私有的了,所以这里在分配的时候不需要锁定整个堆。虽然在TLAB内分配对象的时候,是无锁的,但是TLAB本身内存在申请的时候,还需要锁堆的。

不同的垃圾收集器因为本身算法不一致,在内存分配的时候还是有差异的,这里拿G1的做样例子,列举的对象分配全景流程图:

图片

如上图所示,对于G1而言,大体分为基于TLAB的快速分配和慢速分配;当不能成功分配对象的时候就会触发垃圾回收。

6.4 垃圾收集器一览

GC系统往往都很复杂,而且很多术语都是从英文中翻译过来的,讲垃圾收集器之前,为了方便理解,本文先对这些术语进行一些解释。

  • 并行(parallelism):指的是多个GC线程一起运行,并且这里强调的是仅仅只有GC线程再运行,Java的应用线程都暂停执行,这个阶段也一定发生了STW
  • 并发(concurrentcy):同样是多个GC一起运行,但是和并行不同的是,这里并不在阻止Java的应用线程执行,并发期间没有STW
  • STW(stop-the-world):直译就是停止一切,在JVM里面指的是暂停所有Java应用线程
  • 引用计数算法:在 判断一个对象是否是垃圾的时候,通过一个计数器来判断。每个对象上面挂一个计数器,被引用一次的话次数+1,不再引用次数-1。当计数器值等于0的时候,判断为垃圾。引用计数算法在循环引用这个场景下会有误判。Redis、微软的COM技术等采用的就是这种算法。
  • 标记算法:前面“可达性分析算法”来对象进行标记着色判断对象是否是存活的算法。Java、C#等主流的语言等都是采用这种算法。

标记-清除:先对对象 进行标记,标记完成之后,同一回收未被标记的对象。此算法缺点就是会有大量的内存碎片

标记-复制:在标记-清除的基础上,再加一步,挪动存活对象到新位置,并且让存活的对象紧密靠在一起

标记-整理(压缩):如果存活对象多的话,标记-复制的效率就会比较低,所以,标记-整理在标记完毕之后,会移动存活对象往内存空间的一端移动,然后直接清除边界以外的内存

为了方便大家理解,笔者把目前HotSpot包含的的GC和特点整理一份列成一个表格:

序号

垃圾收集器

多线程方式

标记算法

管理内存区间

特点

1

Serial

单线程

标记-复制

新生代

比较老的GC

2

Serial-Old

单线程

标记-整理

老年代

比较老的GC

3

ParNew

并行

标记-复制


新生代

1.Serial的多线程版本

2.多线程充分利用多核cpu(⬇️后续的GC都支持这个)

4

Parallel-Scavenge

并行

标记-复制

新生代

1.高吞吐量优先

2.支持自适应调优(⬇️后续的GC都支持这个)

5

Parallel-Old

并行

标记-整理

老年代

高吞吐量优先

6

CMS

并行+并发

标记-清除

标记-整理

老年代

1.首次引入“并发”的概念,目的是减少老年回收时候的卡顿时间

2.不能管理太大的内存,要不然卡顿时间还是会很久

7

G1

并行+并发

标记-复制

全部

1.停顿时间可控

2.可管理更大的内存

3.高吞吐量

8

ZGC、Shenandoah

(少量)并行+(大量)并发

标记-复制

全部

1.每次回收都是全量回收(次次都是FGC,恐怖吧!)

2.超低延迟,低于10ms

3.可管理超大内存(TB级别)

4.高吞吐量

6.5 G1

虽然G1综合方面不如目前比较新的ZGC、Shenandoah。但是因为在目前市面上毕竟使用率比较高的还是JDK8,G1并不是JDK8中默认的垃圾收集器,但是想必基于G1的优点,绝大部分公司还是会选择G1(例如、本公司"得物")。

当然介于文章篇幅有限,本文不会全方面讲述G1垃圾收集器,笔者会尽量从“精简”的角度来讲述G1。

a.分区

分区(Heap Region,HR)或称堆分区,是G1堆和操作系统交互的最小管理单元。

图片

  • 自由分区(Free Heap Region,FHR),图中白色部分
  • 新生代分区(Young Hreap Region,YHR),图中蓝色和绿色部分
  • 大对象分区(Humongous Heap Region,HHR),图中灰色部分
  • 老年代分区(Old Heap Region,OHR),图中橙色部分

G1中分区大小固定整是1MB,2MB,4MB,8MB,16MB,32MB这几个选项之间。默认情况会自动分成2048个区,当然分区大小和数据均可自由指定。可以看到里面还包含一种特殊的区,就是大对象区,大对象区可以一次连续分配多个Region。什么是大对象呢?超过Region一半的对象即为大对象,直接放入大对象区。

b.卡表和RemeberSet(RSet)

试着思考这么一个问题,例如在YGC或者混合GC的时候,G1并不会回收全部的堆,但是会出现“非收集区对象”指向“收集区对象”这么一种现象,而非收集器因为不参与垃圾收集,所以总是存活对象,也就是说“相当于前面提到的GC-Root”。那么如果把所有的非收集区对象加入根,然后进行对象标记,显然,效率非常第下。所以需要一套机制来降低需要标记对象的数目。例如,在YGC的时候,全部的Y区都是收集区,但是所有的老年代和大对象区(因为大对象区也划分为了老年代,所以一下暂称统为老年代区)都不参与垃圾回收,那么在Y区的对象就会出现这么集中情况:

  • Y区对象被Y区对象引用
  • Y区对象被老年代引用
  • 老年代或者大对象区对象被Y区对象引用

这里因为3里面的老年代不参与GC,所以这种情况不需要考虑。然后因为回收的是Y区,所以Y区指向Y区的对象也会被回收,也不需要考虑。所以这里就2有问题,如果有老年代指向的Y区间的话,这些对象就不能被回收,因为老年代对象不参与GC,老年代对象是存活的,所以导致老年代对象引用的对象也是存活的。关于如何找出有哪些Y区对象被老年代引用的对象,有两种算法:

遍历所有的老年代分区,并且每个区都按照单个字节维度进行遍历识别出来哪些对象,并且当作根,看有没有引用Y区(当然,因为JVM对象是对齐的,所以实际不需要一个一个字节的挪动)。但是这种方式非常消耗内存,效率很低下。

遍历Y区间中记录过的那些老年代区(也就是一个Y区被老年代引用过,就把这个老年代区的信息记录到这个Y区的数据结果中),并且为了防止一个一个字节的移动识别对象,用一个位为位来标识老年代中一大块内存中是否存在引用过Y区,如果位的值表明有引用的话,因为不确定着一大块内存具体是哪个对象引用了Y区,所以这个时候进需要堆这一大块内存进行一个一个字节的移动识别对象,并且判断这些对象是否有引向Y区间即可(这种做法优点类似咱们做核酸混管,10个人一个管,如果发现一个管中有阳,那么在对这10个管单个单个的校验具体哪几个是阳,这样下来,使用的费用降低了,因为被消耗的管的数目大幅度降低了,同样G1一个位标识一大块内存是否有引用关系的目的也是为了减少这些记录的内存消耗)。

G1采用的的方式是上面的b方案,这里用的数据结构也被称作为“记忆表(Remember Set),简称RSet”,RSet是一种抽象的数据结果,在G1里面记忆表的具体实现方式又被称之为“卡表”(2者的关系相当于HashMap和Map),卡表等同于一个数组,每一位记录老年代区512个字节是否有对象引用Y区间(或者其他老年代区),如果可能引用的存在,同时我们也就认为这张卡是脏的。在YGC的时候,如果发现有脏卡,也就意味着这512个字节的内存里有指向收集区的对象。但是不确定是512个字节中的哪部分,这个时候,只能按照一个对象一个对象(当然,内存对其关系,实际一次会挪动多个字节判断是否是对象)的扫描,看否有指向收集区,如果发现对象符合要求,那么此对象会被加入YGC的“根”。

RSet有2中方式进行记录,一种名为Point-In,一种名为Point-Out。例如有代码(假设objectA在A区,objectB在B区):

objA.field1=objeB;

  • Point-In:在B区维护一个数据结构,记下来,A区间有引用B本身
  • Point-Out: 在A区维护一个数据结构,记下来,A区间自己引用了其他的区间,例如B

很想,标记算法的关键是自己有没有被其他对象引用。所以,如果想实现局部回收,RSet使用Point-In的方式效率更高。

c.YGC过程

需要分配内存的时候,如果发现剩余空间不能满足要分配的对象的时候,就会优先触发YGC(young GC)。由于大部分引用的对象都是朝生暮死的,所以绝大部分的新对象都是“垃圾”,存活对象很少,再加上“标记算法”仅标记存活对象,这也意味着一般而言,YGC可回收的内存可以更多,消耗的时间也会更少,例如:下面是一台机器的YGC日志

图片

花了40ms就清空了7136M的Y区空间,YGC都是非常给力的!特别是对于“互联网”应用,大部分对象在服务启动的时候都创建的差不多了,都是来了一个请求,然后临时创建一些对象,并且这些对象随着请求的结束而结束,所以大部分对象都无法存活到老年代。

虽然G1又被称之为并行GC,但是G1的大部分时候都是在发生YGC,而YGC却又是“并发”执行的,这也就意味着YGC过程中,是有STW的。

YGC大致流程如下:

  • 挂起所有用户线程,应用程序暂时不能向外提供响应(STW)
  • 计算需要收集的区间(CSet),YGC期间,仅回收Y区,所以所有的Y区就是CSet
  • 进行根处理,包含JVM根和Java根。为了扫描全堆,这里只需要把RSet记录的对象加入根即可
  • JIT代码扫描等,根据栈中的对象进行递归遍历复制对象
  • 因为对象已经有挪动,更新JIT代码中指针存储的对象地址
  • 引用处理,一些软、弱、虚、析构(FinalReference)引用等的处理
  • 字符串去重等处理
  • 清理卡表,也就是把全局卡表中已经处理过的分区的卡表清空
  • 进行Redirty操作,也就是重构RSet。释放CSet区

尝试回收大对象,如果某个大对象所在的分区没有RSet引用,说明这个大对象已经死亡,可以直接回收

尝试扩展内存,参数有GCTimeRatio和GExpandByPercentOfAvailable来决定是否需要扩展,前者在G1中默认值是9,代表的意思是GC占用的时间必须小于1/(1+9),也就是10%,这个值在之前的垃圾收集器默认值是99。如果吞吐量不达标,就会尝试扩展内存,大小由GExpandByPercentOfAvailable决定,默认20,也就是未提交内存的20%

如果满足条件的,触发并发标记周期;判断方式是YGC之后,老年代内存占总内存超过一定阈值(参数-XX:InitiatingHeapOccupancyPercent决定,默认45%)触发。

d.混合回收

Y区间的大部分都是垃圾,都存活不了几次GC,但是老年代对象就不一样了,既然存活到能到“老年代区”,说明对象肯定也不一般,是垃圾的概率也大大降低了;这也意味着对老年代的GC往往不如YGC那么给力,即是是回收同样大小的内存,花费的时间也远远要超过YGC。

例如,下面是一次并发标记所消耗的时间和处理过程:

图片

  • 根扫描,初始标记:并发标记周期之前一定有一次YGC,所以初始标记会把这次YGC的存活对象也加入根,还有其他根对象,如:栈对象、全局静态对象、JNI对象等等
  • 并发标记(上图中concurrent-mark-start~end之间):如图中可以看到光标记这些对象(4G左右)就花了616.6892ms(注意和7G的Y区内存全过程40ms做对比),因为存活对象会比较多,所以这块耗时会比较久,CMS、G1等的这部肯定都是“并发执行的”,要不过长的STW时间,会一定程度上的导致服务运行。并且并发标记过程是涉及所有区的,所有老年代区的对象都参与标记。在标记的过程中也会计算每个区的存活对象数目和字节数。
  • 再标记:再标记过程是有STW,图中花了58.6391ms。再标记的目的是处理哪些在并发标记过程中有引用关系变更的对象,见“2.c并发标记的难点”。
  • 清理:清理阶段会统计存活对象,并且按照结果对区维度进行排序,以便后续mixed-gc的CSet的选择。同时和会重置RSet,因为老年代的标记已经完成了,这个时候需要删除原来RSet里面保存的引用关系。

这里要注意的是,很多人容易在这一步发生误解,认为这里的清理会清理所有的老年代垃圾。在清理阶段正在清理的其实是“空闲的区”,也就是指的是那种全部都是垃圾的区;而其他区的那些垃圾并不会被清理,也不会拷贝任何存活的对象,因为这些清理动作被后置到了后续的mixed-gc里面了。

图片

在一次并发标记周期之后,会根据实际情况触发0~8(G1MixedGCCountTarget)次mixed-GC。一次mixed-gc也需要一次YGC作为前提,并且把存活的YGC对象加入根进行标记和回收。而决定一个老年代区是否能加入mixed-gc的前提条件是由G1MixedGCLiveThresholdPercent(默认85,表示85%)控制的,如果存活对象低于85%,就会加入mixed-gc的CSet。还有一个比较重要的值是G1HeapWastePercent,默认值是5,表示5%。也就是当前CSet可以回收的空间占总空间的比例大于5%才会开始混合GC。

7、常用参数&默认值

如果想查看本地虚拟机支持的命令,可以使用下面的命令进行查看:

java -XX:+PrintFlagsFinal -version

JVM的大部分参数虽然对于当前机器并不是一个最佳的值,但是往往是一边比较合理的理想值。大多数情况下并不需要进行参数修改调优,但是如果线上出现问题或者想彻底优化JVM的时候,还是需要知道有哪些常用参数&参数默认值&参数作用的。所以:这里主要列举一些笔者比较熟悉和常用的JVM参数和默认值和作用(涉及GC方面的这里列举常用的G1的参数):

序号

参数

默认值

作用

建议和说明

1

Xms

-

最小内存,初始内存

建议设置

2

Xmx

-

最大内存

建议设置

3

Xmn

-

新生代大小

G1中千万别设置此值,会导致G1的自适应调优失效,无法主动设置新生代大小,以至无法达到预期停顿时间

4

UseG1GC

JDK9默认GC


建议开启

5

PrintGCDetails

false

打印日志明细

建议开启

6

PrintGCTimeStamps

false

打印日志时间

建议开启

7

G1HeapRegionSize

-

Region大小

-

8

GCTimeRatio

9(G1),99(其他)

吞吐量,GC时间=1/(1+ GCTimeRatio)

可以考虑设置此值,如19

9

G1ReservePercent

10

保留内存,百分比

-

10

G1NewSizePercent

5

Y区最小内存比例

实验参数,需要先打开

UnlockExperimentalVMOptions

G1有自动适应调优,会自动调整Y区大小在G1NewSizePercent~ G1NewMaxSizePercent之间。

11

G1NewMaxSizePercent

60

Y区最大内存比例

实验参数,需要先打开

UnlockExperimentalVMOptions

-

12

MaxGCPauseMillis

200ms

期望的停顿时间

不建议将此值设置过大,特别是互联网应用,需要实时向外提供稳定的服务。

13

Xss

-

线程大小

-

14

UseTLAB

true

是否开启TLAB

-

15

ResizeTLAB

true

动态调整TLAB大小

-

16

ParallelGCThreads

-

默认计算公式如下:

if cpus<=8 ParallelGCThreads = cpus

Elseif cpus>8 ParallelGCThreads = 8 +(cpus-8)5/8 (或者少数处理器情况下:8 +(cpus-8)5/16)

-

17

SurvivorRatio

8

G1会根据实际情况自动修改此值大小

-

18

InitiatingHeapOccupancyPercent

45

YGC之后,启动并发标记阈值

过高会导致并发标记时间过久

过低会导致频繁触发并发标记周期

可以设置为比平均内存高一些

19

ConcGCThreads

-

(ParallelGCThreads+2)/4。最小1

-

20

UseDynamicNumberOfGCTGhreads

false

动态调整线程数

-

21

G1MixedGCLiveThreadoldPercent

85

用于判断分区是否能被加入CSet

-

22

G1HeapWastePercent

5

CSet中可回收内存占总内存比例大于G1HeapWastePercent才会开始mixed-gc

-

23

G1MixeGCCountTarget

8

mixed-gc最大次数


24

G1OldCSetRegionThresholdPercent

10

一次最多收集10%的分区

-

25

ClassUnloadingWithConcurrentMark

ture

并发标记周期期间可以卸载已加载的类

-

26

PrintReferenceGC

fals

输出和引用相关的日志

如果引用存在问题,可以考虑

-

27

ParallelRefProcEnable

false

多线程方式加速处理引用关系

-

28

UseStringDeduplication

false

字符串去重

注意G1的去冲逻辑和String.intern逻辑有区别

建议打开

应用的整体调优,没有一个通用的法制,而且大部分参数值在不同机器上最佳值也往往不一样。为了满足最大的吞吐量和最小的延迟,需要根据应用程序设置不同的参数。可以考虑从优化内存大小、引用处理、并发标记、YGC、回收频率、回收大小、停顿时间、TLAB、线程数、等各个方面优化。

一般而言,调优有三个比较关键的指标:

  • 内存,建议调大内存,以便获取一个比较好的吞吐量和延迟(G1和ZGC在大内存上管理比较好,优先考虑)
  • 吞吐量,应用占用的cpu时间越高越好,GC时间越占用的cpu时间越少越好
  • 延迟,绝大多数种类应用中3个指标最重要的一个指标,越低的延迟,应用能持续向外部提供服务的时间比例就越大,服务卡顿的时间就越短

当然,除了在极端少的情况下,是很难同时满足如上3个指标的,3个指标只能选择2个进行调优,舍去其中1个指标。所以一般这里都是舍去内存(多给应用划一些内存,以便换取其他2个指标).

最后,全文都是自己手打的不容易,帮忙点个赞。

8、参考

[1]:《深入理解Java虚拟机》 作者:周志明

[2]:《深入拆解Java虚拟机》 作者:郑雨迪

[3]:《JVM G1源码分析和调优》 作者:彭成寒

[4]:《Java虚拟机规范SE8版》 作者:Tim Lindholm、Frank Yellin等

[5]:《深入理解JVM 字节码》 作者:张亚

[6]:《Java 性能优化权威指南》 作者:Charlie Hunt、Binu John

[7]:《新一代垃圾回收器 ZGC设计与实现》 作者:彭成寒

责任编辑:武晓燕 来源: 得物技术
相关推荐

2018-04-04 15:05:17

虚拟机字节码引擎

2024-02-19 07:44:52

虚拟机Java平台

2015-08-20 11:01:22

Java虚拟机GC算法种类

2010-08-09 13:20:36

Flex

2017-11-03 13:43:24

云计算Saas信息化

2018-02-24 12:54:51

Java虚拟机面试

2013-07-15 10:32:32

Windows虚拟机红帽

2021-01-16 23:27:32

云计算容器工具

2015-11-05 18:03:15

虚拟化云计算资源池

2014-02-21 11:20:34

KVMXen虚拟机

2020-07-29 07:37:20

Git 修复项目

2010-06-18 10:13:17

虚拟机消失

2009-03-19 18:36:49

虚拟化Vmwareesx

2020-01-06 10:58:18

JvmGC机制虚拟机

2018-07-25 14:41:29

Java虚拟机Android

2018-01-17 22:11:54

数字化转型人工智能互联网

2013-04-22 09:15:20

2013-03-20 15:21:56

vSphere Rep

2013-07-10 15:17:20

程序员创业

2010-09-25 15:13:40

JVMJava虚拟机
点赞
收藏

51CTO技术栈公众号