⭐Java代码运行原理
首先需要把java文件编译成class文件,然后把编译后的class文件加载到Java虚拟机中。
加载后的Java类会被存放在方法区中
但是Java字节码是无法直接执行的,所以需要Java虚拟机把字节码翻译成机器码。
Java虚拟机有两种翻译模式:
- 第一种是解释执行:就是翻译一条执行一条。
- 第二种是即时编译:就是把一个方法中包含的所有字节码都翻译成机器码之后再执行。
解释执行的优势在于不需要等待编译,程序的启动速度更快。
即时编译的优势在于编译完成后实际运行速度更快。
**HotSpot默认采用的是混合模式,它会先解释执行字节码,然后把其中反复执行的热点代码以方法为单位再进行即时编译**。
⭐为什么不把Java代码全部编译成机器码?
为了避免重复编译,即时编译后生成的机器码会保存在CodeCache中,会占用额外的内存空间。如果把很多调用次数很少的代码,也用即时编译生成机器码,就会导致编译时间变长,而且也需要更多内存空间保存这些机器码。
CodeCache:是一块堆外内存,经过即时编译后的机器码会存放在这里。
CodeCache的大小是固定的,如果CodeCache满了,JVM就会判断每一个即时编译后的方法的方法调用计数器和循环回边计数器是否低于阈值,如果低于阈值就会清理掉这个方法的机器码。
可以通过JVM参数
-XX:ReservedCodeCacheSize
控制CodeCache的大小。
⭐即时编译
Java虚拟机内置了多个即时编译器:C1、C2。
C1:也叫做Client编译器,面向的是对启动速度有要求的客户端应用,优化手段比较简单,编译时间比较短。
对应的JVM参数是
-client
。C2:也叫做Server编译器,面对的是对执行效率有要求的服务端应用,优化手段相对复杂,编译时间比较长,但是生成的代码执行效率高(用C++实现的)。
对应的JVM参数是
-server
。
从JDK7版本开始,HotSpot默认采用分层编译模式:**热点代码首先会被C1编译,然后热点中的热点会进一步被C2编译**。
即时编译的过程是在额外编译线程中进行的,HotSpot会根据CPU核心数来设置编译线程的数量,并按照1:2的比例分配给C1和C2。
字节码的解释执行和即时编译可以同时进行,即时编译完成后的机器码会在下次调用该方法时替换原本的解释执行。
分层编译会把Java虚拟机的执行状态分为五个层次:C1代码(C1生成的机器码),C2代码(C2生成的机器码)
- 第一层是,解释执行。
- 第二层是,执行不带profiling的C1代码。
- 第三层是,执行只带方法调用次数和循环回边次数profiling的C1代码。
- 第四层是,执行带所有profiling的C1代码。
- 第五层是,执行C2代码。
其中第2层和第5层是终止状态,当一个方法被这两层编译后,如果编译后的机器码没有失效,Java虚拟机不会再对该方法发出编译请求。
通常情况下,热点方法会被第4层的C1编译,然后再被第5层的C2编译,但是如果方法比较简单(比如getter/setter),Java虚拟机就会认为这个方法编译后的C1代码和C2代码执行效率相同。就会直接选择第2层的C1编译。
Java8默认开启了分层编译。不管开启还是关闭分层编译,-client
和-server
都是无效的。关闭分层编译的情况下,默认使用的是C2。
如果希望使用C1,可以在打开分层编译的情况下,使用-XX:TieredStopAtLevel=1
,这样在解释执行之后会直接由2层的C1进行编译。
⭐profiling:收集能够反映程序执行状态的数据的过程。这些数据被称为profile。
比如方法调用次数、循环回边次数、分支跳转次数、类型强制转换指令,类型判断指令(instanceof)。
⭐热点代码:
- 被多次调用的方法
- 方法内部包含循环次数较多的循环体
Java虚拟机为每个方法都准备了两个计数器:方法调用计数器和循环回边计数器。
方法调用计数器:
用来统计方法的调用次数,在C1中默认阈值是1500次,在C2中默认阈值是10000次。
方法调用计数器统计的不是方法被调用的绝对次数,而是执行频率。也就是说在一段时间内,如果方法的调用次数未达到阈值,计数器就会减少为原来的一半,这个过程叫热度衰减,这段时间叫半衰周期。
比如阈值是10000,半衰周期是1个小时,如果在1个小时内,某一个方法被调用了8000次,虚拟机就会认为它不是热点代码,就会把调用次数减少为4000。
可以通过JVM参数调整计数器阈值、热度衰减以及半衰周期。
1 | // 计数器阈值 |
循环回边计数器:
用来统计方法中循环体的循环次数。在C1中默认阈值是13500次,在C2中默认阈值是10700次。
之所以要维护两个计数器,是因为Java虚拟机还存在一种以循环为单位的即时编译,叫做OSR编译,循环回边计数器就是用来触发这种类型的编译的。
⭐OSR编译:是一种可以在程序执行过程中,动态替换方法栈帧的技术,可以让程序在解释执行和即时编译后的代码之间切换。
⭐Java内存模型
⭐Java内存区域
Java虚拟机会在内存中划分出堆、Java虚拟机栈、本地方法栈、程序计数器、方法区。
⭐堆:堆是虚拟机内存中最大的一块空间,是线程共享的,大部分对象都会被分配到堆中。
堆是垃圾收集器管理的区域,Java虚拟机中的垃圾回收器又是基于分代回收的理论设计的,所以堆又被划分为新生代和老年代,新生代又被划分为Eden区和两个Survivor区。
⭐Java虚拟机栈:Java虚拟机栈是线程私有的。
每当调用一个Java方法,Java虚拟机就会在当前线程的Java方法栈中生成一个栈帧,用来存放方法参数和方法内部定义的局部变量。不管方法是正常返回还是异常返回,Java虚拟机都会弹出当前栈帧。
当创建一个线程时,会在Java虚拟机栈中申请一个线程栈,用来保存栈帧。
栈帧的大小是编译时就计算好的
⭐本地方法栈:本地方法栈也是线程私有的,作用跟Java虚拟机栈类似,主要用来管理本地(Native)方法(C++实现的方法)。
⭐程序计数器:主要用来完成分支、循环、跳转、异常处理、线程恢复等功能。
为了保证线程切换后能够恢复到正确的执行位置,每个线程都有一个独立的程序计数器,所以程序计数器是线程私有的。
如果正在执行的是一个Java方法,程序计数器中保存的是字节码指令的地址。
如果正在执行的是一个本地(Native)方法,程序计数器中的内容为空。
⭐方法区:方法区是线程共享的,主要用来存放类信息(字段、方法、接口、父类)、常量,静态变量、运行时常量池等。
如果方法区无法满足新的内存分配需求时,会直接OOM。
运行时常量池:class文件中会保存一份常量池表,用来保存编译器生成的各种字面量和符号引用,这个常量池表最终会被放到运行时常量池中。
⭐happens-before规则
由于即时编译器的优化可能会把原本的代码执行顺序打乱,在多线程环境下就有可能导致程序运行结果无法预测。
所以JDK 5定义了 Java 内存模型。其中最为重要的一个概念便是 happens-before 关系。
happens-before 规则是用来描述两个操作的内存可见性的,如果操作A happens-before 操作B,那么A的结果对B可见。
在同一个线程中,前一行代码 happens-before 后一行代码(前一行代码的执行结果对后一行代码可见)。
前一行代码并不一定在后一行代码之前执行。如果后一行代码不需要依赖于前一行代码,这两行代码就有可能被重排序。
解锁操作 happens-before 之后对这把锁的加锁操作
前一个线程解锁之后,下一个加锁的线程可以看到前一个线程的执行结果,在解锁时,Java虚拟机会强制刷新CPU缓存,让当前线程对内存做的修改对其它线程可见。这就是为什么锁可以解决并发问题。
对volatile字段的写操作 happens-before 之后对同一个字段的读操作。
用volatile来修饰的变量,对这个变量的读写操作都不能使用CPU缓存,必须从内存中读写。
happens-before还具有传递性,就是说对于volatile字段的写操作之前的写操作,也是可见的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14public class T {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 10;
v = true;
}
public void reader() {
if (v == true) {
System.out.print(x); // x的值对v是可见的,所以这里x的值是10
}
}
}线程A执行
writer()
方法,线程B执行reader()
方法。如果线程B看到的v是true,那么线程A设置的x=10,对线程B就是可见的。
因为
x = 10
happens-beforev = true
,所以x = 10
happens-beforev == true
。
父线程启动子线程之前的操作 happens-before 子线程的所有操作。
如果线程A调用线程B的
start()
方法,那么线程B能看到线程A调用start()
方法之前的所有操作。1
2
3
4
5
6
7
8
9
10
11
12
13public class T {
int x = 10;
public static void main(String[] args) {
Thread t = new Thread(() -> {
// 主线程启动子线程之前的所有操作,子线程都能看到。
System.out.print(x); // 这里x的值是11
});
x = 11;
// 启动子线程
b.start();
}
}线程中的所有操作 happens-before
join()
方法的返回。如果线程A调用线程B的
join()
方法,那么线程A可以看到线程B中的所有操作。1
2
3
4
5
6
7
8
9
10
11
12
13public class T {
int x = 10;
public static void main(String[] args) {
Thread t = new Thread(() -> {
x = 11;
});
t.start();
// 子线程的所有操作,在主线程调用join()方法之后可见
t.join();
System.out.print(x); // 这里x的值是11
}
}线程对其他线程的中断操作happens-before被中断线程收到的中断事件。
构造器中的最后一个操作happens-before第一个操作。
happens-before关系还具有传递性。如果操作A happens-before 操作B,而操作B happens-before 操作C,那么操作A happens-before 操作C。
⭐Java内存模型的底层实现
Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。
编译器会根据 happens-before 关系,向正在编译的目标方法中插入内存屏障(字节码指令)。
即时编译器会把内存屏障翻译成CPU指令。关闭即时编译器的话,解释执行也会把内存屏障字节码指令翻译成CPU指令。
对于编译器来说,这些内存屏障会限制即时编译器的重排序。
- 对于volatile字段,编译器会在该字段的读写操作前后插入内存屏障,来禁止重排序。
对于处理器来说,这些内存屏障将会触发CPU缓存的刷新操作。
⭐类加载机制
从class文件到内存中的类,需要经过加载、验证、准备、解析、初始化五个步骤。
⭐加载:
加载是指查找字节流,并创建类的过程。
基础类是Java虚拟机预先定义好的,数组类是由Java虚拟机直接生成的,对于其他类,Java虚拟机需要依赖类加载器完成加载过程。
类加载器有三种:启动类加载器、拓展类加载器、应用类加载器。
- 启动类加载器:是虚拟机的一部分,是由C++实现的,没有对应的Java对象。主要负责加载JDK目录中lib文件夹中的jar包。以及
-Xbootclasspath
参数指定的路径中的jar包。 - 拓展类加载器:拓展类加载器的父加载器是启动类加载器,主要负责加载JDK目录中lib\ext文件夹中的jar包,以及系统变量
java.ext.dirs
指定的类。 - 应用类加载器:应用类加载器的父加载器是拓展类加载器,主要负责加载应用程序路径下的类。
在Java虚拟机中,类的唯一性是由类加载器和类的全名确定的,就算是同一个class文件,由两个不同的类加载器加载,也会得到两个不同的类。为了避免同一个类被重复加载,Java虚拟机用双亲委派机制来解决这个问题。
JDK9版本引入了模块系统,把拓展类加载器改为平台类加载器。只有少数的几个核心模块,比如java.base是由启动类加载器加载的,其它模块都是平台类加载器加载的。
⭐双亲委派机制:
如果一个类加载器收到了加载类的请求,它会先将其委托给父加载器,父加载器继续向上委托,直到启动类加载器。只有父加载器无法加载该类的时候,子加载器才会自己去加载。
类加载器之间并没有继承关系,是通过组合来实现委派的。
双亲委派机制是为了保证同一个类只被加载一次。
假设没有双亲委派机制,如果在代码中创建了Object类的对象,那么应用类加载器加载这个类的时候就会去加载Object类(会在解析阶段触发Object类的加载),但是Object类已经被启动类加载器加载过了,因为类的唯一性是通过类加载器和全类名确定的,这就会导致应用中有两个Object类。
⭐验证
验证阶段的目的是,确保被加载进来的类满足Java虚拟机的约束条件。
通常Java编译器生成的class文件必然满足Java虚拟机的约束条件,
⭐准备:
准备阶段的目的是,为静态变量分配内存并设置初始值,以及创建当前类的方法表。
⭐解析:
解析阶段的目的,是把符号引用解析成实际引用。
如果符号引用指向一个未被加载的类,那么将会触发这个类的加载(不会触发这个类的验证、准备、解析、初始化)。
对于一个方法的符号引用可以分为接口符号引用和非接口符号引用:
对于接口符号引用,Java虚拟机首先会在目标接口中查找方法名和方法描述符都相同的方法。
如果没有找到,就在Object类中找。
如果还是没有找到,就在目标接口的父接口中寻找。
对于非接口符号引用,Java虚拟机首先会在目标类中查找方法名和方法描述符都相同的方法。
如果没有找到,就在父类中寻找,直到Object类。
如果还是没有找到,就在实现的接口中找。
符号引用:
在编译阶段,一个类被加载到Java虚拟机之前,这个类无法知道其他类、方法、字段对应的具体地址。所以需要引用这些对象的时候,Java编译器会生成一个符号引用。符号引用存储在class文件常量池中。
一个方法的符号引用包含:目标方法所在类的名字、目标方法的名字、接收参数类型、返回值类型。
⭐初始化:
初始化阶段会执行<clinit>
方法。
编译器会在编译阶段,把赋值操作、静态代码块中的代码,收集在一起组成<clinit>
方法。
子类初始化时会首先调用父类的
<clinit>
方法,然后再执行子类的<clinit>
方法。JVM会通过加锁的方式保证
<clinit>
方法只会执行一次。
方法调用原理
Java虚拟机会根据类名、方法名、方法描述符来识别方法。
Java虚拟机中有5个调用方法的指令:
- invokestatic:用于调用静态方法。
- invokespecial:用于调用私有方法、构造器、使用super关键字调用父类实例方法或构造器、所实现接口的默认方法。
- invokevirtual:用于调用非私有方法。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于调用动态方法。
对于静态方法和私有方法,Java虚拟机可以直接识别具体的目标方法。
对于非私有方法和接口方法,因为方法有可能被重写,所以Java虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。 如果方法被标记为final,就可以直接确定目标方法。
在编译过程中,Java虚拟机并不知道目标方法的内存地址,所以Java编译器会用符号引用来表示目标方法。
经过解析阶段之后,这些符号引用会被解析成实际引用。
- 对于可以静态绑定的方法来说,实际引用指向的是一个指向目标方法的指针。
- 对于需要动态绑定的方法来说,实际引用指向的是一个方法表的索引。在执行过程中,Java虚拟机会获取调用者的实际类型,并获取该类型中的方法表,然后根据引用指向的索引值获得目标方法。
所以动态绑定会更加耗时。不过为了减少动态绑定额外开销,即时编译有两种优化手段:内联缓存和方法内联。
方法描述符:由方法的参数类型和返回类型组成。
静态绑定:Java虚拟机在解析阶段就能识别目标方法。
动态绑定:Java虚拟机需要在运行过程中根据调用者的动态类型来识别目标方法。
⭐方法表:
方法表本质上是一个数组,每个元素指向当前类或者父类中非私有的方法。这些方法可能是具体的、可执行的方法,也可能是抽象方法。
方法表有两个特性:
- 第一,子类方法表包含父类方法表中所有方法。
- 第二,子类重写的方法,在方法表中的索引值,与父类方法表中的索引值相同。
内联缓存:
内联缓存是一种加快动态绑定的技术,它可以缓存调用虚方法的调用者的动态类型,以及该类型对应的目标方法。
之后在调用方法时,如果发现类型已缓存,就可以直接调用目标方法。如果没有缓存,就使用方法表来动态绑定目标方法。
Lambda表达式
在编译过程中,Java编译器会对Lambda表达式进行解语法糖,生成一个方法来保存Lambda表达式的内容。这个方法的参数列表包含:Lambda表达式的参数和捕获的外部变量。
第一次执行时,Java虚拟机会生成一个适配器类,这个适配器实现了对应的函数式接口。
对于没有捕获外部变量的Lambda表达式,可以认为它与上下文是无关的,只需要创建一次适配器类的实例。
对于捕获了外部变量的Lambda表达式,为了避免变量发生变化,每次执行Lambda表达式时都会创建一个新的适配器实例。
对于没有捕获外部变量的Lambda表达式,第一次调用时会有额外创建适配器类的开销,之后的调用会复用同一个适配器实例,跟普通方法的调用的效率一样。
对于捕获了外部变量的Lambda表达式,如果Lambda表达式中的内容可以被内联,会借助逃逸分析将新建适配器实例这个操作优化为空操作。
所以使用Lambda表达式的时候,尽量不要捕获外部变量。
⭐方法内联
方法内联是指,在编译过程中遇到方法调用时,将目标方法纳入到当前方法体的编译范围中,可以消除方法调用的额外开销。
比如调用get/set方法时,需要保存当前方法的执行位置、创建get/set方法的栈帧、访问字段,弹出栈桢、最后再恢复到原来方法的执行位置。如果内联了get/set方法之后,就只需要访问字段就好了。
在C2编译器中,方法内联是在解析字节码的过程中完成的,每当碰到方法调用字节码时,编译器会判断是否需要内联目标方法,如果需要内联就会开始解析目标方法的字节码,并复制到当前方法中。
通常情况下,方法内联越多,字节码的执行效率就越高,但是对于编译器来说,内联越多,编译时间就越长,生成的机器码就越长。在Java虚拟机中,即时编译后生成的机器码会存在CodeCache中,但是这个区域的大小是有限制的。也就是说,生成的机器码越长,越容易填满CodeCache,如果CodeCache满了就会暂时关闭即时编译。
可以通过JVM参数
-XX:ReservedCodeCacheSize
控制CodeCache的大小。
所以方法内联是有条件的:
JDK内部方法中,带有
@ForceInline
注解的方法会被强制内联,带有@DontInline
注解的方法不会被内联。如果目标方法编译后生成的机器码大小超过2000,则无法内联。
可以通过
-XX:InlineSmallCode
调整递归方法不会被内联。
调用层级超过9层的的方法调用。
如果方法a调用方法b,方法b调用方法c,那么方法b就是方法a的1层调用,方法c就是方法a的2层调用。
可以通过
-XX:MaxInlineLevel
调整。如果目标方法的调用次数小于250次,不会被内联。
可以通过JVM参数
-XX:MinInliningThreshold
调整如果目标方法的字节码小于6,就直接内联。
可以通过JVM参数
-XX:MaxTrivialSize
调整
异常处理原理
在编译生成字节码的时候,编译器会为每个方法添加一个异常表。异常表中的一个元素代表一个异常处理器,异常处理器由from指针、to指针、target指针和捕获的异常类型组成。
其中from指针和to指针代表异常处理器的监控范围,就是try代码块覆盖的范围。target指针指向的是异常处理器的起始位置,就是catch代码块的起始位置。
当程序触发异常时,Java虚拟机会从上到下遍历异常表中所有元素,当触发异常的字节码索引值在某个异常处理器的from指针和to指针范围内,Java虚拟机就会判断抛出的异常与该异常处理器要捕获的异常类型是否匹配。如果匹配,Java虚拟机就会执行target指针指向的字节码。
如果异常表中没有匹配的异常处理器,就会弹出当前方法对应的Java栈帧。然后遍历调用者的异常表,最坏的情况下,Java虚拟机需要遍历当前线程Java栈上所有方法的异常表。
finally代码块的实现原理就是,复制finally代码块的内容分别放在try-catch代码块的出口处。
⭐反射实现原理
Method#invoke
invoke()
方法实际上会委派给MethodAccessor
接口来处理,这个接口有两个实现类DelegatingMethodAccessorImpl
(委派实现)和NativeMethodAccessorImpl
(本地实现)。
我们调用method对象的invoke()
方法时,会先调用委派实现(DelegatingMethodAccessorImpl)的invoke()
方法,然后通过委派实现再调用本地实现(NativeMethodAccessorImpl)的invoke()
方法,最终抵达目标方法。
之所以要采用委派实现作为中间层,是因为还有一种动态生成字节码的实现方式(动态实现)。
动态实现是用Java实现的,性能会比较好,但是初始化时需要比较多的时间。
本地实现是C++实现的,调用时需要经过JNI,所以性能比较差,但是启动速度会比较快。
所以Java虚拟机设置了一个阈值,默认是15,当某个反射调用的调用次数超过15次时,就会开始动态生成字节码(MethodAccessorGenerator),并且把委派实现类中的委派对象切换到动态实现(调用DelegatingMethodAccessorImpl中的setDelegate(MethodAccessorImpl methodAccessor)
方法)。
可以通过-Dsun.reflect.inflationThreshold
参数来调整触发动态实现的次数。
也可以通过-Dsun.reflect.noInflation=true
关闭本地实现,这样一开始就会直接采用动态实现。
反射的开销
Class.forName 需要调用本地方法,Class.getMethod 会遍历该类的公有方法,如果没有匹配到,它还将遍历父类的公有方法。
所以这些方法都会比较耗时,可以通过缓存Class.forName和Class.getMethod的结果来避免反复调用的开销。
⭐垃圾回收
⭐如何判断一个对象是否死亡
Java虚拟机使用的是可达性分析算法,这个算法是将一系列的[GC Roots](#GC Roots)作为初始的存活对象集合(live set),然后从这个集合开始,探索能够被这些对象引用到的对象,并将其加入到该集合。最终没有被探索到的对象就是已经死亡的,是可以回收的。
可达性分析算法可以解决引用计数法的循环引用问题。因为就算A和B相互引用,只要从[GC Roots](#GC Roots)出发无法探索到A和B,可达性分析算法就不会把它们加入到存活的对象集合中(live set)。
引用计数法:是为每个对象添加一个引用计数器,用来统计指向该对象的引用数量。
具体逻辑是这样的:如果一个引用被赋值为某一个对象,那么该对象的引用计数器就会+1,如果这个引用又被赋值为其它对象,那么这个对象的引用计数器就会-1。
引用计数法需要拦截所有引用更新的操作,并且增减目标对象的引用计数器,还需要额外的空间存储计数器。
引用计数器还有一个严重的BUG,就是无法处理循环引用的对象。
假设对象A和B相互引用,除此之外没有其他引用指向A和B,这种情况下A和B实际已经死亡了,但是因为它们的引用计数器都不为0,就会导致这两个对象占据的内存空间不可回收,从而造成内存泄露。
不过可达性分析算法也是有问题的,在多线程环境下,假设对象A没有被赋值给任何引用,所以没有被加入到存活的对象集合(live set)中,然后有一个线程把对象A赋值给某一个引用,这就会导致垃圾回收器会回收仍然被引用的对象。Java虚拟机是通过Stop-the-world来解决这个问题的。
⭐GC Roots:可以理解为堆中对象的引用。
- 栈帧中的方法参数和局部变量
- 引用类型的静态变量
- 运行中的Java线程
⭐Stop-the-world:就是阻塞其它非垃圾回收线程,直到完成垃圾回收。
Java虚拟机中的Stop-the-world是通过是通过安全点(safepoint)机制来实现的,当Java虚拟机收到Stop-the-world请求,它就会等待所有线程都到达安全点之后,再让GC线程工作。
⭐安全点(safepoint):
安全点实际上是一个稳定的状态,在这个状态下,Java虚拟机的堆栈内存不会发生变化,这样GC线程就可以安全地回收垃圾了。
具体的逻辑是这样的:Java虚拟机收到安全点请求的时候会设置一个标志位,Java线程在进入安全点时需要检查这个标志位,如果标志位被设置,当前线程需要停止,如果没有被设置,就继续执行。这样抵达安全点检测的线程就会进入停止状态。
安全点检测有三种情况:
阻塞状态下的线程处于安全点。
对于解释执行来说,每条字节码的结尾都是安全点。所以Java虚拟机收到安全点请求时,每执行一条字节码就会进行一次安全点检测。
对于即时编译来说,即时编译后的机器码直接运行在底层硬件上,所以在生成机器码的时候,即时编译器需要插入安全点检测的指令。Java虚拟机的做法是在方法出口(return指令之前)和循环回边处(执行下一次循环之前)插入安全点检测。
⭐为什么不在每个机器码都插入安全点检测?
执行安全点检测相当于一次内存访问的操作,所以它本身也是有开销的。
如果安全点的数量太少就会导致垃圾回收时间变长,因为Java虚拟机需要等所有线程都进入安全点之后才能进行垃圾回收。
⭐垃圾回收算法
⭐标记-清除:就是把死亡对象占据的内存空间标记为空闲内存,并记录在一个空闲列表中,当需要新建对象时,就把空闲列表中标记的空闲内存分配给新的对象。
这种方式有两个缺点:
一是会造成内存碎片,Java虚拟机堆中的对象必须是连续分布的,就有可能出现总空闲内存足够,但是无法分配的情况。
二是分配效率很低,Java虚拟机需要挨个访问空闲列表中的每一项,找到能够放下新对象的空闲内存。
⭐标记-压缩:就是把存活的对象聚集到内存的起始区域,可以留下一段连续的内存空间,这种做法可以解决内存碎片化的问题,代价是压缩算法的性能开销。
⭐标记-复制:就是把内存分为两份,分别用两个指针from和to来维护,并且只用from指针指向的区域来分配内存,当发生垃圾回收时,就把from区域中存活的对象复制到to指针指向的区域,并且交换from和to指针的内容。
这种回收方式也可以解决内存碎片化的问题,它的缺点是堆空间使用效率非常低,只有一半的内存是可用的。
⭐Java虚拟机的堆划分
Java虚拟机将堆划分为新生代和老年代,其中,新生代又被划分为Eden区和两个大小相同的Survivor区。
新生代用来存储新建的对象,当对象存活时间比较长时,就将其移动到老年代。
Java虚拟机会给新生代和老年代使用不同的回收算法:
对于新生代,大部分的Java对象只存活一小段时间,虚拟机就会采用耗时较短的垃圾回收算法。
对于老年代,大部分垃圾已经在新生代中被回收了,处于老年代中的对象大概率会继续存活,真正触发老年代回收,通常是堆空间已经耗尽了,这时候Java虚拟机就会做一次全堆扫描([Full GC](#Full GC))。
默认情况下,Java虚拟机会根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。
也可以通过JVM参数-XX:SurvivorRatio
来固定Eden区和Survivor的比例。需要注意的是,其中一个Survivor区是空的,Survivor分配的空间越大堆空间使用率就越低。
通常来说,当我们调用new指令时,虚拟机就会在Eden区分配一块储存新对象的空间。由于堆空间是线程共享的,所以划分空间是需要进行线程同步的,不然就有可能出现两个对象占用同一块内存的问题。
如果创建的对象超过了年轻代最大对象阈值,会被直接创建在老年代。
Java虚拟机的解决方案是TLAB。如果新建的对象超过了TLAB剩余的内存空间,当前线程就会向虚拟机申请新的TLAB,如果新的TLAB还是放不下新建的对象,则在Eden区直接创建对象。
如果Eden区的空间耗尽了,Java虚拟机就会触发一次[Minor GC](#Minor GC),来回收新生代中的垃圾。存活下来的对象会被移动到Survivor区。
新生代中的两个Survivor区,分别用from和to指针指向,from指向的区域是空的。发生[Minor GC](#Minor GC)时,Eden区和from指向的Survivor区中存活的对象会被复制到to指针指向的Survivor区,然后交换from和to指针,保证下次[Minor GC](#Minor GC)时,to指向的Survivor还是空的。
Java虚拟机会记录Survivor区中的对象被复制了多少次,如果一个对象被复制的次数超过15次,这个对象就会被移动到老年代。可以通过JVM参数-XX:MaxTenuringThreshould
来调整。
如果单个Survivor区被占用超过50%,其中复制次数较多的对象也会被移动到老年代。
⭐TLAB(Thread Local Allocation Buffer):实际上就是在Eden区中为每个线程分配一块独立的内存空间,这样可以减少线程同步,提高内存分配的效率。每个线程需要维护两个指针,第一个指针指向内存开始位置,第二个指向末尾位置。然后通过指针加法判断是否分配成功。
可以通过JVM参数-XX:UseTLAB
禁用。
⭐指针加法:
1 | if ((起始指针 + 请求的字节数) <= 末尾指针) { |
⭐Minor GC:是新生代GC,只有Eden区耗尽时才会触发,采用的是标记-复制算法。正常情况下,Eden区中的对象大部分都是已经死亡的对象,需要复制的数据很少,所以这种算法的效率很高。
Minor GC有一个问题,就是老年代的对象可能会引用新生代的对象。也就是说,在标记存活对象的时候,需要扫描老年代中的对象,如果老年代中的对象持有新生代对象的引用,这个引用就会作为[GC Roots](#GC Roots)。
这样一来Minor GC和[Full GC](#Full GC)就没有区别了。Java虚拟机的解决方案是卡表(Card Table)。
⭐Full GC:是老年代GC,调用System.gc()方法时可能会触发、老年代空间不足、方法区空间不足都会触发。
⭐卡表:就是把整个堆划分为一张张卡,每张卡的大小是512字节。然后再维护一张卡表,来保存每个卡的标志位。这个标志位代表对应的卡是否可能存在指向新生代对象的引用。如果可能存在引用,就认为这张卡是脏的。
这样在进行[Minor GC](#Minor GC)时,就不用扫描整个老年代,只需要在卡表中寻找脏卡就可以了,然后把脏卡中的对象作为[Minor GC](#Minor GC)的[GC Roots](#GC Roots)进行垃圾回收。扫描完脏卡之后,虚拟机就会将所有脏卡的标志位清零。
Java虚拟机是通过写屏障来维护卡表状态的,虚拟机会拦截所有引用类型变量的写操作,然后更新对应卡表的标志位。这个操作在解释执行中比较容易实现,但是在即时编译生成机器码的时候,需要插入写屏障。
出于性能考虑,写屏障不会判断更新后的引用是否指向新生代中的对象,而是一律当成新生代对象的引用。虽然写屏障会带来一些额外开销,但是可以提高[Minor GC](#Minor GC)的效率,还是值得的。
这里的写屏障实际上就是一条更新卡表标志位的指令。
⭐垃圾回收器
针对新生代的垃圾回收器有三个:Serial、Parallel Scavenge和Paralel New。
这三个采用的都是标记-复制算法。
Serial是单线程的,Parallel New是Serial的多线程版本,Parallel Scavenge和Parallel New类似,但是吞吐率更好,不能和CMS一起用。
针对老年代的垃圾回收器也有三个:Serial Old、Parallel Old和CMS。
Serial Old和Parallel Old采用的是标记-压缩算法。Parallel Old是Serial Old的多线程版本。
CMS采用的是标记-清除算法,它可以在程序运行期间进行垃圾回收。只有少数的几个操作需要Stop-the-world。JDK9版本Java虚拟机使用G1来替代CMS。
G1(Grabage First):是一个横跨新生代和老年代的垃圾回收器,在G1中,直接把堆分成多个区域,每个区域都可以作为Eden区、Survivor区或者老年代。它采用的是标记-压缩算法,而且可以在程序运行期间进行垃圾回收。G1可以单独对某一个区进行垃圾回收,所以它会优先回收死亡对象较多的区域。
⭐synchronized实现原理
synchronized可以用来声明一个代码块,也可以直接标记整个方法。
如果用synchronized声明代码块,编译器会在代码块的开头和结尾加上monitorenter和monitorexit指令。
如果用synchronized标记方法,编译器会在方法的入口和出口加上monitorenter和monitorexit指令。
执行monitorenter指令的时候,如果锁对象的计数器为0,就说明当前锁对象没有被其他线程持有,Java虚拟机就会把锁对象的持有线程设置为当前线程,并把计数器+1。如果目标锁对象的计数器不为0,Java虚拟机还会判断锁对象的持有线程是否是当前线程,如果是,就再把计数器+1,否则就进入阻塞状态。所以synchronized是可重入锁。
执行monitorexit指令的时候,Java虚拟机就会把锁对象的计数器减一,计数器为0就表示锁被释放掉了。编译器还会在异常执行路径(catch代码块)上插入monitorexit指令,确保发生异常时,锁也会被释放。
JDK6对synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度(这个过程是不可逆的)。
⭐偏向锁:从始至终只有一个线程请求某一把锁。
加锁时,如果锁对象支持偏向锁,Java虚拟机会通过CAS操作,把当前线程的地址保存在锁对象的标记字段中,并且把标记字段后三位设置成101。
解锁时,Java虚拟机会判断锁对象的标记字段中:**epoch值是否和锁对象中的epoch值相同、后三位是否为101、是否包含当前线程的地址**。
如果这三个条件都满足,说明当前线程持有该偏向锁。
如果有条件不满足,就需要撤销偏向锁并升级为轻量级锁。
如果某一个类中的锁对象撤销次数达到阈值,默认是20次,Java虚拟机就会让这个类的偏向锁失效。
可以通过
-XX:BiasedLockingBulkRebiasThreshold
参数调整。
⭐轻量级锁:当偏向锁被多个线程访问时,就会升级为轻量级锁。
加锁时,JVM会在当前线程的栈帧中分配一块空间,用来保存锁记录(Lock Record),并且把锁对象的标记字段复制到锁记录中。
然后通过CAS操作,把锁对象的标记字段替换为刚才分配的锁记录的地址。(相当于判断加锁期间是否有其它线程获取锁)
- 如果更新成功,就说明加锁成功。
- 如果更新失败,就说明有其它线程获取当前锁,Java虚拟机就会把这把锁升级为重量级锁,并阻塞当前线程。
解锁时,会通过CAS操作,比较锁对象的标记字段是否是锁记录的地址。
⭐重量级锁:是最基础的锁,这种锁会阻塞所有加锁失败的线程,并在释放锁的时候再唤醒这些线程。线程的阻塞和唤醒需要操作系统来完成,开销会非常大。
为了避免线程的阻塞和唤醒操作,Java虚拟机会在线程进入阻塞状态之前和被唤醒后竞争不到锁的时候,进入自旋状态。在自旋过程中,如果锁被释放了,那么线程就无需进入阻塞状态,可以直接获取锁。
自旋状态的线程仍然处于运行状态,只不过运行的是没有意义的指令。如果自旋的时间很长,就会浪费大量的CPU资源。
Java虚拟机的解决方案是适应性自旋,会根据以前自旋等待时是否能够获得锁,来动态调整自旋时间。如果以前只有很小的概率能通过自旋等待获得锁,那么虚拟机可能直接让线程进入阻塞状态。
自旋机制还会导致不公平的锁机制,处于阻塞状态的线程没办法立刻竞争锁,处于自旋状态的线程就有很大概率优先获得锁。
epoch:
Java虚拟机会在每个类中都维护一个epoch值,这个epoch值可以理解为是偏向锁的版本号。
当某个类的偏向锁失效时,Java虚拟机就会把这个类的epoch值加1,表示之前的偏向锁已经失效了,新设置的偏向锁需要复制新的epoch值。
为了保证已经持有偏向锁的线程不会丢锁,虚拟机还需要通过标记字段中保存的线程地址,找到持有这个偏向锁的线程,并把这些锁对象的标记字段中的epoch值加1。
如果某一个类中的偏向锁失效次数超过另一个阈值,默认是40次,Java虚拟机就会认为这个类不适合偏向锁了。之后的加锁过程直接为该类的锁对象设置成轻量级锁。
可以通过-XX:BiasedLockingBulkRevokeThreshold
参数调整。
如何区分锁等级?
Java虚拟机会根据锁对象的对象头中的标记字段的后两位,来判断对象的锁状态:00代表轻量级锁,01代表无锁或偏向锁,10代表重量级锁。
对象内存布局
在Java虚拟机中,每个对象都有一个对象头(object header),对象头由标记字段(mark word)和类型指针(class pointer)组成。标记字段和类型指针各占64位(8字节),也就是说,每个Java对象在内存中都有16字节的额外开销。
以Integer为例,它内部维护了一个int类型的成员变量,占4个字节,再加上对象头的16个字节,就是20个字节,相当于int类型的5倍,这也是Java引入基本类型的原因之一。
标记字段(Mark Word):用来存储对象的运行时数据,比如HashCode、GC信息、锁信息。类型指针是指向该对象的类信息。
标记字段中的最后两位用来表示该对象的锁状态。
00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。
压缩指针
为了减少对象的内存使用量,Java虚拟机引入了压缩指针的概念(JVM参数 -XX:+UseCompressedOops
,默认开启),将对象头的大小从16字节降低至12字节。
⭐逃逸分析
逃逸分析是一种可以减少内存分配和回收压力的技术。Java虚拟机可以通过逃逸分析,判断对象是否发生逃逸。
如果对象被存入堆中,或者对象被多处代码引用,就说明对象是逃逸的。
1
2
3 public Object test() {
return new Object(); // 将对象return出去,会发生逃逸
}
1
2
3
4 private Object obj;
public Object test() {
obj = new Object(); // 将对象赋值为成员属性,会发生逃逸
}
1
2
3
4 public Object test() {
Object obj = new Object();
test2(obj); // 将对象作为参数传递给其它方法,会发生逃逸
}
即时编译可以根据逃逸分析的结果进行锁消除、锁粗化、标量替换的优化。
⭐锁消除:
如果锁对象不逃逸,那么对于该对象的加锁和解锁是没有意义的,因为其它线程并不能获取该锁对象,也不可能对其进行加锁和解锁操作。这种情况下,即时编译就会消除对该对象的加锁和解锁操作。
1 synchronized (new Object()) {}这样的代码会被完全优化掉,因为其它线程无法获取到该锁对象。
⭐锁粗化:
如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作。
比如对于一段连续调用
StringBuffer.append()
方法的代码,只需要在第一次append方法时加锁,最后一次append方法结束后解锁:
1
2
3
4
5
6
7
8 public class StringBufferTest {
StringBuffer sb = new StringBuffer();
public void append(){
sb.append("a");
sb.append("b");
sb.append("c");
}
}
⭐标量替换:
标量替换可以理解为把原本对对象字段的访问,替换为一个个局部变量的访问。
实际上就是把要访问的目标对象内部的字段提取到当前方法的局部变量中,这样就可以减少对象的分配次数,从而提高垃圾回收的效率。
1
2
3
4
5 public void repeat() {
User user = new User(); // 经过标量替换后该分配无意义,可以被优化掉
user.username = "xxx";
user.password = "xxx";
}
1
2
3
4
5 public void repeat() {
String username = "xxx"; // 标量替换
String password = "xxx"; // 标量替换
}
栈上分配:
Java虚拟机中的对象是在堆上分配的,但是堆空间是线程共享的,Java虚拟机需要定期对堆空间进行垃圾回收。
其实对于不会发生逃逸的对象,Java虚拟机可以直接分配到栈上,这样就可以通过弹出当前方法栈帧时自动回收该对象占据的空间,就可以减少垃圾回收器需要回收的对象数量,可以提高垃圾回收的效率。
泛型擦除
Java程序中的泛型在虚拟机中会被擦除。
没有限定继承类的泛型参数,经过泛型擦除后会变成Object类。
限定了继承类的泛型参数,经过泛型擦除后,泛型参数会变成限定的继承类。
1
2
3
4
5 public class GenericTest<T extends Number> {
public T foo(T t) {
return t;
}
}经过泛型擦除后:
1
2
3
4
5
6
7
8 T foo(T);
descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
flags: (0x0000)
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: areturn
Signature: (TT;)TT;
循环优化
循环无关代码外提:
在循环中,对于一些值不变的表达式,在能够不改变程序语义的情况下,把这些代码提出循环外,就可以避免重复执行这些代码,从而达到性能提升的效果。
比如我们在遍历一个集合的时候,通常会把size()
方法放到循环体里面,但实际上size()
方法是不会变化的,经过循环外提后,size()
方法会作为局部变量保存起来。
1
2
3
4
5
6
7 public int foo(int x, int y, int[] array) {
int sum = 0;
for (int i = 0; i < array.length; i++) {
sum += x + y + array[i];
}
return sum;
}上面这段代码中,循环体中的
x + y
以及array.length属于恒定不变的代码。经过循环无关代码外提后,会变成这样:
1
2
3
4
5
6
7
8
9 public int foo(int x, int y, int[] array) {
int sum = 0;
int n = x + y;
int len = array.length;
for (int i = 0; i < len; i++) {
sum += n + array[i];
}
return sum;
}
循环展开:
循环展开可以减少循环执行次数。
1
2
3
4
5 public void foo() {
for (int i = 0; i < 200, i++) {
delete(i);
}
}上面的代码需要循环200次,通过循环展开可以得到下面这段代码:
1
2
3
4
5
6
7
8
9 public void foo() {
for (int i = 0; i < 200, i+=5) {
delete(i);
delete(i+1);
delete(i+2);
delete(i+3);
delete(i+4);
}
}