作为一位Java开发工作者,在关心业务代码开发的同时,我们也需要了解java底层时如何运作的,了解为什么说java是跨平台的语言,所以这一篇对JVM(java虚拟机)进行剖析和详解,首先让我们来看一张JDK的概念图( 下文都是围绕HotSpot虚拟机展开 ):
从上图可以看出,概念图中最上层的是工具包和工具,往下是部署包和组件,再往下是基础库,而最下层的就是JVM虚拟机,所以说,整个JDK都架于JVM之上,由此可见 了解和深入JVM对我们以后工作和学习的深度和广度都有很大的影响。
如果这样说不够官方,那么下面是从官网翻译过来的内容,告诉你JVM的特性:
1.Java虚拟机是Java平台的基石,其负责其硬件和操作系统的独立性,其编译的代码很小以及保护用户免受恶意程序攻击的能力。
2.Java虚拟机是一种抽象计算机,像真正的计算机一样,它有一个指令集并在运行时操作各种内存区域。
3.Java虚拟机不承担任何特定的实现技术、主机硬件或主机操作系统,它本身并没有被解释。
4.Java虚拟机不知道Java编程语言,只知道特定的二进制格式,即 class 文件格式, class 文件包含Java虚拟机指令(或字节码)和符号表,以及其他辅助信息。
5.出于安全考虑,Java虚拟机对 class 文件中的代码施加了强大的语法和结构约束,但是,任何具有可以用有效 class 文件表示的功能的语言都可以由Java虚拟机托管,由通用的、与机器无关的平台吸引,其他语言的实现者可以将Java虚拟机作为其语言的交付工具。
这里先放一张大致概念的过程图,然后就这个过程展开分析:
java语言都是基于 .java 文件开发的,但是从java文件最后如何进入到虚拟机 并且最终被机器执行的,最后又如何被回收的,这个过程可能很多人都有了解过,但是却没有深入研究。首先,我们知道一个.java文件一定是先编译的,一个java文件被编译成class文件之后,才会被加载到JVM中,而这个class文件中 保存了哪些信息,让我们一步步分析:
定义一个Demo.java文件:
public class Demo {
private Integer id;
private String name;
private Object obj;
public static final String fin = "demo";
public static void testMethod(){
System.out.println(Demo.fin);
}
}
然后经过javac编译,得到它对应的class文件:
cafe babe 0000 0034 003b 0a00 0f00 2909
000e 002a 0900 0e00 2b09 000e 002c 0700
2d0a 0005 0029 0800 2e0a 0005 002f 0a00
0500 3008 0031 0a00 0500 3208 0033 0a00
0500 3407 0035 0700 3601 0002 6964 0100
134c 6a61 7661 2f6c 616e 672f 496e 7465
....
可以看出来这是一个字节码文件,里面这些就是java类编译成class文件之后的内容,这么看我们也许看不出来里边到底表示什么意思,下面是经过 javap -verbose 命令解析这个class字节码文件:
Classfile /E:/lecco-rpc/src/main/java/com/leeco/rpc/framework/jvm/classloader/Demo.class
Last modified 2020-12-14; size 571 bytes
MD5 checksum ef5fc10da4027c7059d1767bc02155a9
Compiled from "Demo.java"
public class com.leeco.rpc.framework.jvm.classloader.Demo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#22 // java/lang/Object."":()V
#2 = Fieldref #23.#24 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #25 // com/leeco/rpc/framework/jvm/classloader/Demo
#4 = String #26 // demo
#5 = Methodref #27.#28 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = Class #29 // java/lang/Object
#7 = Utf8 id
#8 = Utf8 Ljava/lang/Integer;
#9 = Utf8 name
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 obj
#12 = Utf8 Ljava/lang/Object;
#13 = Utf8 fin
#14 = Utf8 ConstantValue
#15 = Utf8
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 testMethod
#20 = Utf8 SourceFile
#21 = Utf8 Demo.java
#22 = NameAndType #15:#16 // "":()V
#23 = Class #30 // java/lang/System
#24 = NameAndType #31:#32 // out:Ljava/io/PrintStream;
#25 = Utf8 com/leeco/rpc/framework/jvm/classloader/Demo
#26 = Utf8 demo
#27 = Class #33 // java/io/PrintStream
#28 = NameAndType #34:#35 // println:(Ljava/lang/String;)V
#29 = Utf8 java/lang/Object
#30 = Utf8 java/lang/System
#31 = Utf8 out
#32 = Utf8 Ljava/io/PrintStream;
#33 = Utf8 java/io/PrintStream
#34 = Utf8 println
#35 = Utf8 (Ljava/lang/String;)V
{
public static final java.lang.String fin;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: String demo
public com.leeco.rpc.framework.jvm.classloader.Demo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 8: 0
public static void testMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String demo
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 19: 0
line 20: 8
}
SourceFile: "Demo.java"
这些内容分别代表什么意思呢,我们大致说明一下:
a.类和接口的全限定名
b.字段的名称和描述符(字段表)
c.方法的名称和描述符(方法表)
解释: java类在编译的时候,会对java的语法和词法进行解析,生成语法树,进行校验。
java代码在进行javac的时候,并不像C和C++那样有"连接"这一步骤,而是在虚拟机加载class文件的时候进行动态链接,也就是说,在class文件中并不会保存各个方法和字段最终的内存布局信息,因此这些字段和方法的符号引用不经过运行时转换的话无法得到真正的内存入口地址,也就无法被虚拟机直接使用,当虚拟机运行时,需要从常量池获取对应的符合引用,再在类创建时或运行时解析并翻译到具体的内存地址之中,在下面的类加载过程会详细介绍。
一个java文件经过编译之后,得到了对应的class字节码文件,这个文件中包含了一些描述信息,而这些信息自然是需要被加载到JVM中之后,才可以被访问和使用,那么就进入到了类加载阶段。类的生命周期会经过几个阶段:加载,连接(验证,准备,解析),初始化,使用,卸载。
加载是类加载的一个过程,大家不要把这两个概念混淆了,在加载阶段需要完成三件事:
①. 通过一个类的全限定名来获取定义此类的二进制文件流。
②. 将这个字节流文件所代表的静态存储结构转化为方法区的运行时数据数据结构。
③. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
这里需要知道的是,我们获取一个字节流文件,并不意味着必须是一个java文件编译而来的,也可以是从网络中读取的,动态生成的,从数据库中获取的等等方式。
加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,然后在内存中实例化一个java.lang.Class类的对象,这个对象并没有明确必须存放堆中,而对于HotSpot虚拟机而言,这个对象是存放在方法区中的。
注意:加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,并不是说必须要等到加载结束之后,才开始验证阶段。
验证时连接阶段的第一步,这一步骤时为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求。可能有的人就会问了,一个java文件在编译的时候,不是已经对语法和词法进行解析了吗,为什么这里还需要校验,前面说到了,加载字节流文件到JVM中的时候,并不一定是经过编译了的class文件,也有可能是从其他途径获取到的二进制文件,所以这里为了保证JVM的安全,会做以下几种校验:
①. 文件格式验证:验证是否符合Class文件格式的规范,并且能被当前虚拟机处理。
②. 元数据验证:对字节码的描述信息进行语义分析,保证符合Java的语法。
③. 字节码验证:主要目的是通过数据流和控制流分析,确定程序语义是合法的 且 符合逻辑的。
④. 符号引用校验:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段(解析阶段)中发生。校验内容:
a. 符号引用中通过字符串描述的全限定名是否能找到对应的类。
b. 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
c. 符号引用中的类,字段,方法的访问性(private,protected,public,default) 是否可以被当前类访问。
准备阶段是正式为 类变量分配内存 并 设置变量初始值 的阶段,这些变量所使用的内存,将在方法区中进行分配。需要注意的是:这个阶段中分配内存的变量仅仅包括类变量( 被static修饰的变量 ),而不包括实例变量, 实例变量将会在对象实例化的时候随着对象一起分配在Java堆 中。
然后为这些类变量赋初始值,并不是赋予我们在代码中定义的值,这里只是赋予该变量对应类型的初始值,例如int类型,则赋初始0值。
特殊:在准备阶段,为类变量赋初始值,这一点没有问题,但是会有一个特殊情况,就是 当一个变量同时被static和final所修饰的时候,在准备阶段就会为这个变量赋予指定的值 ,例如 public static final String value = “ConstantValue”; 会在该阶段就初始化完成,而不会等到初始化阶段再去赋实际值。
解析阶段是将常量池中的符号引用替换为直接引用的过程。
符号引用:符号引用是任何形式的字面量,与虚拟机的内存布局无关,它明确的定义在Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针,相对量或者能间接定位到目标的句柄,它与虚拟机的内存布局相关。
①. 类或者接口的解析。
②. 字段解析。
③. 类方法解析。
④. 接口方法解析。
初始化时类加载过程的最后一步,前面的类加载过程中, 除了在加载阶段用户可以通过自定义类加载器参与之外,其余动作完全由虚拟机控制 。而到了初始化阶段,才是真正开始执行类定义的Java代码。
在准备阶段的时候,类变量已经赋了初始值,而在初始化阶段,则会根据代码内容,对这些类变量赋真正指定的值。换句话说该阶段会 执行类构造器() ,具体的工作就是:
①. 为类变量赋值和赋资源。
②. 执行静态代码块( static{} ),它会先去加载父类的静态代码块,然后才是子类。这里需要注意的就是,静态代码块只能访问到在它之前定义的类变量,因为它是线性执行的。
注:
类构造器并不等同于类的构造函数。
JVM会保证一个类在执行类构造器的时候,同一时间只会与一个线程对类进行初始化,换句话说就是会对当前操作进行上锁,防止出现多次初始化的问题。
这里需要说明的是,每一个类并不一定执行完解析阶段就开始初始化阶段,也就是说,一个类如果没有被触发进行初始化,那么它可能会一直处于解析阶段而不被初始化和使用,那么什么时候会触发一个类的初始化呢,JVM规定了5种场景:
①. 遇到new,getstatic,putstatic或invokestatic这四条指令时,最常见的就是new一个对象的时候,读取和设置一个类的静态变量的时候(被static+final的字段除外),以及调用一个类的静态方法的时候。
②. 使用java.lang.reflect进行反射调用的时候。
③. 其父类没有被初始化的时候,会先触发父类的初始化。
④. 用户指定的main方法的执行类。
⑤. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有被初始化,会触发初始化。
虚拟机设计团队将类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到JVM外部去实现,以便让应用程序可以自己决定如何获取所需要的类, 实现这个动作的代码模块称为类加载器 。
对于任意一个类,都需要由加载它的类加载器和类本身一同确认其在JVM中的唯一性,每一个类加载器,都拥有独立的类名称空间。通俗来说,比较两个类是否相等,只有这两个类在同一个类加载器下才有意义,不然肯定不没有比较意义的,因为即使是同一个类,不同的类加载器加载出来之后,他们也是不相等的。
public class ClassLoaderTest {
public static void main(String[] args) throws Exception{
ClassLoader myClassLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try{
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name,b,0,b.length);
}catch (Exception e){
e.printStackTrace();
}
return null;
}
};
Object instance = myClassLoader.loadClass("com.leeco.framework.jvm.classloader.Demo").newInstance();
System.out.println("输出:");
System.out.println(instance.getClass());
System.out.println(instance instanceof com.leeco.rpc.framework.jvm.classloader.Demo);
}
}
输出:
class com.leeco.framework.jvm.classloader.Demo
flase
上述是自定义一个类加载器,并且加载Demo类,然后对它实例化,最后做类型检查,发现结果是false,这是因为存在了两个Demo类,一个是系统加载的,一个是我们自定义类加载器加载的。
类加载器分为四种:
①. 启动类加载器(BootStrap ClassLoader) ,这个类是C++语言实现的,是虚拟机的一部分,它负责加载放在
②. 扩展类加载器(Extension ClassLoader) ,这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载
③. 应用程序类加载器(Application ClassLoader) ,这个类加载器由sun.misc.Launcher$AppClassLoader实现,由于这个类加载器是ClassLoader种的getSystemClassLoader()方法的返回值,所以也成为系统类加载器,她负责加载用户类路径 (ClassPath) 上所有的类库,开发者可以直接使用这个类加载器,这也是默认的类加载器。
④. 自定义类加载器(User ClassLoader) ,用户可以在上面三个系统加载器下面,添加自定义的类加载器,而且类加载器之间不是继承关系,而是组合关系。
上图展示的类加载器之间的层次关系,称为类加载器的 双亲委派模型 ,双亲委派模型要求 除了顶层的启动类加载器之外,其余的类加载器都应该有自己的父类加载器 。这里虽然是父子关系,但是 并不是继承实现,而是通过组合实现的 。它并不是一个强约束性的模型,而是推荐开发者的实现方式。
双亲委派模型的思想:如果一个类加载器收到了类加载的请求,它首先不会自己去加载,而是把所有的加载请求向上传递给父类加载器完成,层层上推,只有当父类加载器反馈自己无法完成这个加载请求(没有在搜索范围内找到所需的类),子加载器才会尝试自己去加载。
双亲委派最大的好处就是:带有层级关系,且不会重复的去加载一个加载过的类,这样会变得很混乱。
我们看一下ClassLoader的部分源码:
// 首先 检查请求的类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//如果父加载器不为空 则转给父加载器去加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 否则交给顶层的BootStrap类加载器去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 这里抛出异常 说明父加载器无法加载
}
if (c == null) {
// 调用本身的findClass去加载
long t1 = System.nanoTime();
c = findClass(name);
}
}
虽然双亲委派模型是Java设计者推荐给开发者的一种设计实现,但是并不是强约束性的,在一些特殊原因,为了实现需求和功能,也会出现不得已破坏双亲委派模型的情况。
①. SPI机制,例如大家常用的JDBC,JNDI等等SPI机制的实现,都需要破坏双亲委派模型,因为他们的场景是需要在上层的类中加载下层的类,而双亲委派并不允许这么做,所以出现了一种不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader),它通过Thread类的setContextClassLoader()方法进行设置,而且它默认的就是应用程序类加载器,这样,子类加载器就可以获取到父类加载器去加载它所需要的资源了。
②. 代码热部署,比如OSGI,它不再是双亲委派中的树结构,而是一种网状结构。
当一个二进制流文件加载到JVM中之后,内存是如何分配的,以及内存都划分了哪些区域,这就涉及到了JVM的内存分配,有一点需要注意的是,JVM内存分配不等同于内存模型 ,内存模型在下面会介绍,这里是针对内存分配的说明,JVM内存分为两大区,一个是线程共享区域,一个是线程私有区域;线程共享区域分为方法区和堆;而线程私有的内存区域分为虚拟机栈、本地方法栈和程序计数器。
上面说到,在java文件编译之后的二进制文件中,会有一段常量池数据,这个常量池就和JVM方法区中的运行时常量池有很大的关系,在JDK1.8之前, 线程共享 区域还有另外一块区域叫永久代,1.8之后就将永久代合并到方法区中了。方法区中包含了 类信息,常量,静态变量,即时编译器编译后的代码等数据 ,还有 运行时常量池和class常量池等 ,一个类的静态变量就会在类加载阶段将信息存储在常量池中,一个类加载后的访问入口就被创建为class对象存储在class常量池中。
堆是我们常常听到的,也是我们存储对象信息的地方,这块区域也是 线程共享 的。当我们创建一个对象的时候,例如Object obj = new Object(),这个时候就会在堆中创建一个实例对象,然后将obj的引用指向堆中的这个实例变量。但是需要注意一点的是,针对JVM的 逃逸分析 ,当没有发生逃逸分析,JVM可以在栈中分配内存,并在栈销毁时回收该内存,快速而且减少了内存压力。从上图我们能看到, 一个对象不仅仅包含了所有的变量信息,还包含了分代年龄,锁标志,指向类元信息的地址等 。实际上为了保证 并发创建对象并分配内存的情况,采用的是CAS+重试机制保证其原子性 。众所周知,Java对象都是朝生夕死的,所以JVM的设计者考虑到这个特性,就将堆内存分为两块区域: 新生代和老年代 ,新生代主要存储新创建的对象实例,而老年代则存储那些长久存活下来的实例对象;其中 新生代又分为:Eden区,From区和To区 ,可能有的人就有疑问了,为什么要划分新老年代,而且新生代为什么又再分为三个区,别着急,等下面说到GC垃圾回收的时候,再详细说明。
虚拟机栈也是我们常常说到的,它是 线程私有的 ,也就是说,每一个线程都会独立创建一个虚拟机栈执行代码逻辑,既然他是线程私有的,而且是真正执行我们代码逻辑的内存区域,那它肯定会有更细粒度的划分,是的,它的最小粒度是 栈帧 ,一个栈帧就是代码中的一次方法调用,所以他是栈结构,保证了 先进后出 的逻辑。
从上图就可以看出来,每一个虚拟机栈都会有对应的栈帧,而且每一个栈帧内部会再次划分几块区域: 局部变量表,操作数栈,动态链接和方法返回 :
1. 局部变量表:局部变量表是方法的入参和方法内部的参数变量;
2. 操作数栈:操作数栈是针对局部变量的操作指令,它是采用入栈出栈的流程;
3. 动态链接:动态链接是将符号引用改为直接引用;
4. 方法返回:方法返回是记录当前的栈帧是从哪里被调用,记录当前栈帧执行完之后应该返回到的位置。
其中1,2,4大家都能理解,但是针对动态链接,可能很多人就会嘀咕了,这里为什么还需要将符号引用转换为直接引用,明明在类加载阶段中的解析阶段就将常量池中的符号引用转为直接引用了,这里何必又要做一次呢。其实在类加载阶段中,一部分符号引用转为直接引用称为: 静态解析 ,而其余部分的转换就是栈帧中的 动态链接 ,那么问题来了,什么情况走静态解析,什么情况走动态链接?其中走静态解析的前提是:“方法在程序真正运行之前就有一个可以确定的调用版本,并且这个方法的调用版本在运行期不会发生改变”,一句话来说就是: 编译器可知,运行期不可变。主要包括:静态方法和私有方法两大类。 解析调用一定是静态的过程,但是分派则不一定了,分派调用可能是静态的(静态分派),也可能是动态的(动态分派),也就是针对我们常说的 重写和重载 ,大致来说,方法的重载是在静态解析阶段进行转换的,而重写则是在动态链接中进行转换的。
/**
* 重载(静态分派)
*/
public class StaticDispatch {
static abstract class Human{
}
static class Man extends Human{
}
static class Women extends Human{
}
public void sayHello(Human human){
System.out.println("hello human");
}
public void sayHello(Man man){
System.out.println("hello man");
}
public void sayHello(Women women){
System.out.println("hello women");
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
StaticDispatch dispatch = new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(women);
}
}
结果:
hello human
hello human
从上面的代码和结果可以看出来,Human是静态类型,在编译时期,静态类型是可知的,但是实际类型(Man,Women)在运行期才可以确定的,因此在编译时期选择了Human作为调用目标,并写入指令中。
/**
* 重写(动态分派)
*/
public class StaticDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override
protected void sayHello() {
System.out.println("hello Man");
}
}
static class Women extends Human{
@Override
protected void sayHello() {
System.out.println("hello Women");
}
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
man.sayHello();
women.sayHello();
man = new Women();
man.sayHello();
}
}
结果:
hello Man
hello Women
hello Women
从上面的代码和结果可以看出来,这里没有显式的静态类型(Human),所以只能采用动态类型(Man,Women)来确定调用的目标,所以这个过程的直接引用转换就是在栈帧中的动态链接发生的。
我们知道,java中有很多类库,其中就有一个native本地方法库,而本地方法栈就是在调用本地方法的时候,压入的内存区域,它是线程私有的。
说到程序计数器,就要说到操作系统和硬件了,我们现在的硬件基本上都是多核多线程的,CPU的执行多数是基于时间片轮转的,所以每一个虚拟机栈在执行的过程中,往往都会和其他线程抢夺CPU,并且根据时间片轮转,那就需要记录当前线程到底执行到哪里了,这样,当该线程下次抢夺到CPU后,还能从之前中断的位置继续执行。
前面介绍了类加载和JVM的内存分配,那么当对象不再使用,JVM是如何对已经不在使用的对象进行回收的,JVM是如何确定哪些对象是可以回收的,下面着重说明GC相关的内容:
如何确定对象已死?一般会有两种方式来确定对象是否还在被引用: 引用计数算法 和 可达性分析 ,引用计数算法:顾名思义,就是当前对象实例被其他对象引用的计数,只有当前对象没有再被其他引用的时候,就表示这个对象可以被回收了,但是这里有一个问题,假如 两个对象互相引用 ,但是他们没有被其他更多的对象引用,就会出现这两个对象一直占用内存,如果这种情况多了,很可能会带来 内存泄露导致的内存溢出。 因此引用计数算法并不是主流,真正主流的还是:可达性分析。
可达性分析是选取一些GC Root对象的起始点,从这些起始点开始向下搜索,假如一个对象到GC Root没有任何引用关系,则表示该对象是GC Root不可达对象,则会被判定为可回收的对象。
从上图我们明白了,一个对象是否存活,主要看它是否能从GC Root可达,但是我们这里一般说的是强引用关系,针对与其他的引用类型,会有一些不同:
聊一聊引用:
即使在可达性分析中不可达的对象,也不是非死不可的,这个时候它们暂时处于"缓刑"阶段,要真正宣告一个对象的死亡,至少要经历两次标记过程,所以我们可以重写对象的 finalize() 方法,进行 抢救一次 ,在这个方法中,可以对快要被垃圾回收的对象,进行强关联操作,从而抢救这个对象不被回收,但是要记住的是,这个 方法只能被抢救一次 ,如果下一次回收,这个对象还是不可达的,那么finalize()方法则不会再被调用。
上面我们了解了可达性分析是为了找到哪些对象可以被回收,但是我们会思考一个问题,哪些对象才能作为GC Root根节点呢,还有就是难道每一次要进行垃圾回收都要扫描整个内存,去找到这些Root节点吗?
首先, 哪些节点能作为GC Root ,这些节点一定是合理的:
然后就是我们如何在垃圾回收标记的时候,去找到这些Root,为了提高效率,在HopSpot的实现中,采用了一组称为 枚举根节点(OopMap) 的数据结构来达到这个目的,在类加载完成后,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中也是一样。这样,在GC扫描时就可以直接得知这些信息了。
在OopMap的协助下,可以准确的进行GC Root枚举,但是它并不在每一个指令都生成OopMap,而是在到达 安全点(SafePoint) 时才能暂停,然后更新OopMap,进行GC标记,例如:
这里还有最后一个问题,就是当我们的线程已经处于休眠状态该怎么办呢?这个时候它无法响应JVM的中断请求,对于这种情况,JVM采用了 安全区域(SafeRegion) 的模型,它指在一段代码中,引用关系不会发生改变,在这个区域中任意地方开始GC都是安全的,当线程执行到Safe Region中的代码时,标记自己进入了安全区域,假设在这段时间发生了GC,就不用管已经标识自己是SafeRegion状态的线程,当线程要离开安全区域时,她自己会检查是否已经GC完成了,如果没有完成,则会等待GC结束。
GC算法时非常复杂和繁琐的,这里只介绍一下几种经典的算法和思想:
标记-清除算法 :同他的名字一样,算法经历了两个阶段:标记和清除,首先标记处所有需要回收的对象,然后在标记完成之后进行统一回收。它的不足主要有两个:一个是效率问题,标记和清除两个过程的效率都不高;第二个是空间问题,在进行清除之后,会留下来很多内存碎片,导致之后在给较大对象分配空间时,找不到合适的内存区域,从而不得不触发又一次垃圾回收,影响程序性能。
复制算法 :它将可用内存分为大小相等的两块区域,每次只使用一块。当这一块的内存使用完了,就将还存活的对象复制到另外一块内存上面,然后对已经使用过的内存进行一次性清理。这样得到每次对边的内存空间都是连续的了,不会出现内存碎片,内存分配简单,回收方便。但是这个缺点就是很浪费内存,每一次只能使用一般的内存空间。但是HotSpot对这个算法进行了改进,它将上面我们说到的 堆中的新生代 分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden和Survivor其中的一块。当回收时,清理掉刚才的Eden区域和使用过的一块Survivor区域,将它们中存活的对象复制到另外一块没有被使用的Survivor区域,默认比例是 8:1:1 ,当然了,这是普通场景,当遇到回收时另外一块空闲的Survivor内存空间不够存储存活对象的时候,会依赖老年代进行 分配担保 。
标记-整理算法 :复制算法在对象存活率较高的时候就要进行较多的复制操作,效率会变低。更关键的是,如果不想浪费一半的内存空间,就需要额外的空间进行分配担保,以应为被使用的内存中所有对象都存活的极端情况,所以一般老年代不采用复制算法,采用的是标记-整理算法。根据老年代的特点,有人提出了 先标记,再整理,最后清除 ,也就是在上面的标记-清除算法上,进行了改进,在GC时,让所有存活的对象都往一个方向移动,然后直接清除掉边界以外的内存。
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。不同的厂商会提供不同的垃圾收集器,当然采用的垃圾回收算法也不一样。
从上图我们能看出到,7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它可以搭配使用,不然是不能搭配使用的,上面表示的是新生代可用的收集器,下面表示的是老年代可用的收集器,不过G1收集器比较特殊,它不再物流上区分新老年代,详细的说明请往下看。在这里我们要明确一点:没有绝对完美的垃圾收集器,只有合适的垃圾收集器,而如何选择合适的收集器,也请各位客官往下看。
Serial/Serial Old收集器 :Serial收集器是比较历史悠久的收集器了,这个收集器是单线程的,也就是说当它进行垃圾标记和回收的时候,要暂停所有应用线程,这也就是 Stop The World 的由来。像这类收集器,目前为止已经很少会有公司使用了,当然了,如果你是一个单线程的项目,或者一个很小型项目,例如 嵌入式 ,这种垃圾收集器反而可以很好的发挥作用。
ParNew收集器 :ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的控制参数(例如: -XX:SurvivorRatio , -XX:PretenureSizeThreshold , -XX:HandlePromotionFailure 等),收集算法,Stop The World,对象分配规则,回收策略都与Serial收集器一样。
Parallel Scavenge/Parallel Old收集器 :Parallel Scavenge是一个新生代的收集器,它也是 复制算法 ,而且是并行的多线程收集器,它与ParNew不同的是,它关注的是达到一个可控的吞吐量,对于吞吐量来说,高的吞吐量能高效率的利用CPU时间,尽快的完成任务。对于停顿时间来说,停顿时间越短,则用户的体验感就越好。所以Paraleel Scavenge收集器提供了两个参数用于精准控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。Parallel Old收集器是Parallel Scavenge对应的老年代收集器,使用多线程和 标记-整理 算法。而且这个老年代收集器一般配合Parallel Scavenge使用。目前我们常用的JDK1.8版本默认就是使用这两种垃圾收集器的组合。
CMS收集器 :CMS垃圾收集器是一种以获得最短回收停顿时间为目标的收集器。这一类收集器是为了更好提高吞吐量,提升用户体验。CMS收集器是基于 标记-清除 算法的,它的运作过程较为复杂,不是简单的标记清除,它的整个过程分为5步:
1. 初始标记:Stop The World,仅仅只标记一下GC Root能直接关联到的对象,所以速度很快。
2. 并发标记:与用户线程并发,进行GC Roots Tracing的过程。
3. 重新标记:Stop The World,是为了修正并发标记期间因为用户程序运作而导致标记产生变化的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记长一些,但远比并发标记的时间短。
4. 并发清除:并发,清除前面标记的垃圾。
5. 重置线程:与用户线程并行,是重新恢复GC的数据结构,为下一次的GC做准备。
整个过程中,耗时最长的并发标记和标记清除过程收集器和用户线程是并发执行的,所以CMS也是一种 并发类型 的垃圾收集器。有一点我们需要注意的是,CMS无法处理 浮动垃圾 ,浮动垃圾就是由于CMS在并发清除阶段用户线程还在运行,运行期间也会产生垃圾,而CMS并没有对他们进行标记,所以只能等到下一次GC时对他们进行回收,因此CMS不能等到老年代的内存快要满的时候,才开始进行回收,要预留一部分空间,防止内存不够。在JDK1.5之前,内存阈值默认是68%的时候进行CMS回收,但是在JDK1.6之后,CMS默认就是 92% 了,假如在CMS回收期间,预留的空间不够了,JVM会 临时替换为Serial Old 进行重新回收,这样停顿的时间就很长了,所以可以设置参数 -XX:CMSInitiatingOccupancyFraction 表示触发 CMS GC 的老年代使用阈值,一般设置为 70~80(百分比)。
G1收集器 :Garbage-First收集器是当前比较新的成果之一,它与CMS一样都是为了追求高吞吐量,低停顿时间而出现。但是它还有另外的几个特点:
在G1收集器中,Region之间的引用关系,虚拟机是使用 Remembered Set 来避免全堆扫描的。G1中每一个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围加入Remembered Set就可以保证不对全堆扫描,而且也不会有遗漏了。
G1的操作大致分为4个步骤:
1. 初始标记:Stop The World,仅仅标记一下GC Root能直接关联到的对象,并且修改TAMS的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,时间很短。
2. 并发标记:与用户线程并发,从GC Root开始可达性分析,找到存活对象,耗时较长。
3. 最终标记:Stop The World并行,为了修正并发标记阶段期间因用户线程运作而导致的标记变化的那一部分记录,虚拟机将这个变化记录在Remembered Set Logs中,然后在最终标记阶段合并到Remembered Set中。
4. 筛选回收:首先对各个Region中的回收价值和成本进行排序,然后根据用户期望的GC停顿时间定制回收计划。
G1相关参数:
- -XX: +UseG1GC 开启G1垃圾收集器
- -XX: G1HeapReginSize 设置每个Region的大小,是2的幂次,1MB-32MB之间
- -XX:MaxGCPauseMillis 最大停顿时间
- -XX:ParallelGCThread 并行GC工作的线程数
- -XX:ConcGCThreads 并发标记的线程数
- -XX:InitiatingHeapOcccupancyPercent 默认45%,代表GC堆占用达到多少的时候开始垃圾收集
从上面介绍的各个GC收集器来看,如果我们的项目是比较小的,比如消耗内存很小,或者说服务器的硬件性能很一般,例如1C2G这种,那么就可以使用Serial垃圾回收器。如果我们的硬件性能较高,而且又对吞吐量和停顿时间有较高的要求,推荐使用CMS,对于G1收集器,因为没有大量生产环境的验证和性能统计数据,所以还是要慎用。而Parallel一类的垃圾收集器,已经可以满足大多数项目的使用场景,在配合上自定义的JVM参数,正常情况下不需要更换其他的垃圾收集器的。
GC相关参数设定(G1相关的参数已在上面列出,下面只展示其余GC收集器相关的参数)
- UseSerialGC:打开此开关后,使用Serial + Serial Old组合的垃圾收集器。
- UseParNewGC:打开此开关后,使用ParNew + Serial Old组合的垃圾收集器。
- UseConcMarkSweepGC:打开此开关后,使用ParNew + CMS + Serial Old组合的垃圾收集器,Serial Old作为当CMS出现Concurrent Mode Failure失败后的后备收集器使用。
- UseParallelGC:打开此开关后,使用Parallel Scavenge + Serial Old组合的垃圾收集器。
- UseParallelOldGC:打开此开关后,使用Parallel Scavenge + Parallel Old组合的垃圾收集器。
- SurvivorRatio:新生代中Eden区域与Surivivor区域容量的比值,默认为8,表示Eden:Survivor = 8:1。
- PretenureSizeThreshold:直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象会直接分配到老年代中。
- MaxTenuringThreshold:晋升到老年代的对象年龄。每个对象在坚持一次Minor GC之后,年龄就会加1,当超过这个年龄,就会进入到老年代。
- UseAdaptiveSizePolicy:动态调整Java堆中各个区域的大小以及进入老年代的年龄。
- HandlePromotionFailure:是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden区域和Survivor区域的所有对象都存活的极端情况。
- ParallelGCThreads:设置并行GC时内存回收的线程数。
- GCTimeRatio:GC时间的总占比,默认值为99%,即允许GC消耗1%的时间,仅在使用Parallel Scavenge收集器的时候有效。
- MaxGCPauseMillis:设置GC的最大停顿时间,仅在使用Parallel Scavenge收集器时有效。
- CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被分配使用多少后触发垃圾收集,JDK1.8的默认是92%,不同的JDK版本该值不同,它仅在使用CMS收集器时生效。
- UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾回收之后,是否要进行一次内存碎片整理,仅在CMS垃圾收集器中生效。
- CMSFullGCsBeforeCompaction:设置CMS收集器在进行若干次垃圾收集之后,再启动一次碎片整理,仅在CMS垃圾收集器中生效。
说到内存模型,就要先说一下计算机硬件的相关知识,大家都知道,根据 摩尔定律 ,CPU和内存的处理速率是存在较大差异的,所以CPU为了中和这种差异,会在CPU中引入 高速缓存 的概念,但是也带来一个问题,由于计算机内存是所有CPU共享的,而高速缓存是CPU独享的,这也就引出了 缓存一致性 的问题,这个问题就是当多个CPU处理同一块主内存区域的时候,可能会带来 数据一致性 的问题。所以就衍生除了一些列的协议来避免这个问题。
同样的JVM的内存模型和上述的模型类似,JVM的内存模型主要目标是定义程序中各个变量的访问规则。JVM内存模型中规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保留了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在主内存中进行,不能直接操作主内存中的变量,不同线程之间也不能操作对方的工作内存的变量,线程之间的变量值需要通过主内存来完成,如下图:
当我们处理多线程竞争的时候,通常使用synchronized进行同步,但是会很影响性能,所以了解和正确使用volatile关键字有很大的意义。当一个变量定义为volatile关键字之后,它将具备两种特性,第一是保证变量对所有线程的 可见性 ,也就是说当一个线程修改了主内存的值,其余线程都会立即得知。但是需要注意的是,保证了可见性 并不是保证了volatile变量在并发下是安全的 ,因为java中的运算并非都是原子性的,所以是会存在并发下不安全的问题,请看下面的代码:
public class VolatileTest {
public static volatile int race = 0;
public static void increase(){
race ++;
}
private static final int THREADS_COUNT = 5;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++){
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0; i < 1000; i++){
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount() > 1){
Thread.yield();
}
System.out.println(race);
}
}
如果能够正常并发,结果应该是5000,但是实际输出4960:
4960
从上面的例子能够看出来,自增(race ++)计算并不是原子的,所以它导致了,虽然volatile实现了内存可见性,但还是出现了并发一致性的问题。
由于volatile变量只能保证可见性,在不符合以下两条规则的场景中,我们还是需要通过加锁(synchronized或者juc原子类)来保证原子性:
1. 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2. 变量不需要与其他状态变量共同参与不变约束。
volatile第二个特性就是禁止 指令重排序 。指令重排序我们都知道,在JVM编译器和解释器发生的时候,会进行指令重排序,计算机会根据自身的特性,对一连串指令在保证结果不变的形况下进行指令重排序,这也是为了提高性能,但是重排序在并发环境下是会带来一致性问题的。而volatile关键字就采用 内存屏障 的方式禁止了指令重排序。
如果java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,那么有一些操作就会变得很烦琐,但是我们在编写代码的时候并没有感觉到这一点,这是因为java中有一个“ 先行发生 ”的原则。这个原则很重要,它是判断数据是否存在竞争,线程是否安全的主要依据。
先行发生是java内存模型中定义的两项操作之间的偏序关系,例如操作A线性发生与操作B,其实就是说发生操作B之前,操作A产生的影响会被B观察到。所以下面列出的就是天然的先行发生关系,这个关系是默认存在的规则,可以直接使用,否则就可能会发生指令重排序的情况:
1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生与后面的操作。
2. 管程锁定规则:一个unlock操作先行发生与后面(时间上)的对同一个锁的lock操作。
3. volatile变量规则:对一个volatile变量的写操作先行发生与后面(时间上)对这个变量的读操作。
4. 线程启动规则:Thread对象start()方法先行发生于此线程的每一个动作。
5. 线程终止规则:线程中所有的操作都先行发生于终止检测,例如:join(),isAlive()方法等。
6. 线程中断规则:对于线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生:Thread.interruputed()。
7. 对象终结规则:一个对象的初始化完成先行发生于它的finaliez()方法的开始。
8. 传递性规则:如果操作A先行发生于操作B,操作B先行发生于操作C,那么能得到操作A一定先行发生于操作C的结论。
高效并发是JDK1.5到1.6的重要改进,HotSpot开发团队实现了这种锁优化技术,如自适应自旋,锁消除,锁粗话,轻量级锁,偏向锁等,为了更好的解决竞争问题。所以在使用类似synchronized悲观锁的时候,并不是一定要挂起和阻塞线程,JVM会对这些锁操作进行一系列的优化。
在大多数情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,所以只需要线程执行一个自旋来等待锁,这就是自旋锁。自旋等待不能代理阻塞,自旋是为了避免线程切换的开销,但是自旋等待会占用CPU的时间很长,所以如果所被占用的时间很短,自旋等待的效果就会非常好,反之则会带来很大的浪费,因此自旋等待必须要有一定的限度,如果超过了自旋次数仍然没有获得锁,就应该转为传统的方式去挂起线程了,默认的自旋次数是10次,但是可以根据 -XX:PreBlockSpin 参数更改。
在JDK1.6中引入了自适应自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚获取到锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能成功,进而它将允许自旋等待持续更长的时间,比如100次循环。反支,则可能就会舍弃掉自旋的过程了。
锁消除是指JVM的即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在数据竞争的锁进行消除。锁消除的主要判断是依据 逃逸分析 的数据。逃逸分析就是:决定某些实例或者变量是否要在堆中进行分配,如果开启了逃逸分析,即可将这些变量直接在栈上进行分配,而非堆上进行分配。这些变量的指针可以被全局所引用,或者其其它线程所引用。举个例子,我们在栈帧中实例化了一个对象,这个对象只有这个栈帧中使用,而且用完就立刻销毁,那么就没有必要在堆上开辟空间给这个实例对象了,省时又省力。
实际上,我们在编写代码的时候,总是推荐锁的作用范围限制尽可能的小一些,即只在共享数据的实际域上进行同步,这样是为了使得需要同步的操作数量尽可能的变小。但是在某些某些特殊情况下,反复对一个对象加解锁,甚至在循环体中操作,那么即使没有竞争,也会带来很大的性能损耗。在这种情况下,JVM锁优化会把加锁同步的范围进行粗话,甚至是整个操作序列外部。
轻量级锁是针对于重量级锁的概念来说的。重量级锁一般牵扯到线程的挂起和阻塞,以节上下文切换,但是轻量级锁的意义是在没有太多的竞争的前提下,减少重量级锁带来的性能损耗。要理解轻量级锁,这就又说到了堆中一个对象所持有的信息了,上面在内存分配中说到了,一个堆中的对象不仅仅包含了所持有的变量数据,还持有头信息,其中就包括了分代信息和锁信息:
在代码进入代码块的时候,如果此对象没有被锁定(锁标志位为 01),虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录 的空间,用于存储锁对象目前的Mark Word的拷贝,然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针,如果这个动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位就转变为 00,表示此对象处于轻量级锁状态。如果这个操作成败了,虚拟机首先查看当前对象的Mark Word是否指向当前线程的栈帧,如果是,则直接进入同步块中执行,否则则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争夺同一把锁,那么轻量级锁就不再有效,要膨胀为重量级锁,锁标志位的状态就要变为 10,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。
偏向锁也是JDK1.6引入的,他的目的是消除数据在无竞争情况下的同步原语,进一步提高性能。如果说轻量级锁是在无竞争情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都清除掉,连CAS操作都没有了。
假设当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机会将对象头中的标志位设为 01,即偏向模式,同时使用CAS操作获取锁,如果获取成功,则持有偏向锁的线程每次进入同步代码块的时候,虚拟机将不再做任何操作。但是当有另外的线程尝试获取该锁,则宣告偏向模式结束,根据锁对象当前是否处于被锁定的状态,撤销偏向后恢复到未锁定(01)或者轻量级锁定(00)的状态,后续的同步操作就如上面说到的轻量级锁那样执行。
上面介绍了接种锁优化的思想和原理,总的来说,就是针对无竞争情况下或者很小竞争情况下,尽可能的防止出现同步下的互斥量的出现,尽可能的提高性能和减小CPU和线程的开销,流程图如下:
上面说了这么多的概念的思想,当我们在生产环境或者开发环境中,如何对JVM进行监控和查看是一个很重要的问题,下面介绍几种工具和命令,对JVM中的参数和数据进行查看和监控:
jps是java提供的一个显示当前所有java进程pid的命令,适合在linux/unix平台上简单察看当前java进程的一些简单情况。它的作用是显示当前系统的java进程情况及进程id。我们可以通过它来查看我们到底启动了几个java进程(因为每一个java程序都会独占一个java虚拟机实例),并可通过opt来查看这些进程的详细启动参数。
jinfo 是 JDK 自带的命令,可以用来查看正在运行的 java 应用程序的扩展参数,包括Java System属性和JVM命令行参数;也可以动态的修改正在运行的 JVM 一些参数。当系统崩溃时,jinfo可以从core文件里面知道崩溃的Java应用程序的配置信息。
Jstat是JDK自带的一个轻量级小工具。全称“Java Virtual Machine statistics monitoring tool”,它位于java的bin目录下,主要利用JVM内建的指令对Java应用程序的资源和性能进行实时的命令行的监控,包括了对Heap size和垃圾回收状况的监控。可见,Jstat是轻量级的、专门针对JVM的工具,非常适用。
如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。另外,jstack工具还可以附属到正在运行的java程序中,看到当时运行的java程序的java stack和native stack的信息, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。
命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。打印出某个java进程(使用pid)内存内的,所有‘对象’的情况(如:产生那些对象,及其数量)。或与jhat (Java Heap Analysis Tool)一起使用,能够以图像的形式直观的展示当前内存是否有问题。
Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。
Arthas 是Alibaba开源的Java诊断工具,采用命令行交互模式,是排查jvm相关问题的利器。有兴趣的可以去看一看官方文档手册:arthas官方手册
前面的内容介绍了:
JVM里面的知识点和技术点实现是很复杂的,这里只是大致的介绍一下思想和流程,每一个小结都可能会扩展出来更多知识面和技术难点,所以感兴趣的小伙伴可以深入阅读以下JVM的官方文档,或者阅读一些经典的论文。最后奉上一张JVM优化思路,希望对大家有帮助: