【订阅专栏合集,作者所有付费文章都能看】
本专栏系统地讲解JVM面试、JVM调优相关知识。包括但不限于JVM的结构、垃圾回收机制及垃圾回收器、JVM调优实战技术和工具使用。
本专栏以极其精炼、通俗的语言梳理了Java虚拟机(JVM)的相关知识。此乃居家旅行、跳槽面试必备之物,望笑纳。
Java虚拟机(JVM)可以看成是一台执行Java字节码的虚拟计算机。其本质上就是一个软件程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。虚拟机能够屏蔽操作系统所带来的差异,这就是所谓的“一次编译,到处运行”。
现如今JVM可以运行各种编程语言编译而成的字节码文件,只要其符合JVM规范。本质上JVM就是提供了一个字节码的运行环境,其装载class文件,并解释/编译为对应平台上的机器指令执行。
虚拟机的启动是通过引导类加载器(bootstrap class loader)创建的一个初始类(initial class)来完成的,这个类由具体的虚拟机指定。
虚拟机的退出有下列几种情况:
1)程序正常执行完毕
2)程序执行过程中异常终止
3)由于操作系统的错误导致Java虚拟机进程终止
4)程序中调用Runtime类或System类的exit方法或者Runtime类的halt方法(Java安全管理器允许的情况下)
5)JNI规范描述的使用JNI Invocation API来加载/卸载Java虚拟机的情况
自从1995年Sun公司正式发布Java相关产品,Java语言正式诞生,JVM的发展也经历了很多年。
2000年随着jdk1.3的发布,Hotspot virtual machine正式发布,成为JDK默认的虚拟机。
2008年Oracle收购BEA,囊括了后者拥有的JRockit虚拟机。该虚拟机号称是世界上最快的JVM。
2010年Oracle收购Sun公司,获得了Java商标和Hotspot虚拟机。
自此两大优秀的虚拟机走上了融合发展的道路。
业界流行的Hotspot虚拟机的架构图如下,本文就是围绕这个核心架构展开介绍。
推荐【JVM面试调优教程】https://blog.csdn.net/hellozpc/category_10959501.html
推荐【Kafka】https://bigbird.blog.csdn.net/article/details/108770504
推荐【rabbitmq】https://bigbird.blog.csdn.net/article/details/81436980
推荐【Flink】https://blog.csdn.net/hellozpc/article/details/109413465
推荐【SpringBoot】https://blog.csdn.net/hellozpc/article/details/107095951
推荐【SpringCloud】https://blog.csdn.net/hellozpc/article/details/83692496
推荐【Mybatis】https://blog.csdn.net/hellozpc/article/details/80878563
推荐【SnowFlake】https://blog.csdn.net/hellozpc/article/details/108248227
推荐【并发限流】https://blog.csdn.net/hellozpc/article/details/107582771
类的生命周期:加载(Loading)、链接(Linking)、初始化(Initialization)、使用、卸载
加载
类的加载过程如下:
1)通过类的全限定名来获取该类的二进制字节流
2)将上述字节流所代表的静态存储结构转化为运行时数据结构,存储在方法区中
3)在内存中生成代表这个类的Java.lang.Class类对象,作为方法区这个类的数据访问的入口
链接
链接阶段又分为验证(Verification)、准备(Preparation)、解析(Resolution)三个过程。
验证:根据Java 虚拟机规范来验证加载进来的.class文件的内容是否符合JVM规范。包括文件格式验证、元数据验证、字节码验证、符号引用验证。比如每个字节码文件开头都包含4个字节的魔法数字(magic number):0xCAFEBABE,对应的文本为Cafe babe。如果被篡改了JVM将验证不通过。
准备:为类变量(static修饰的变量)分配内存并且设置该类变量默认的初始值;这里分配内存不包含使用final修饰的static变量,这类变量在编译阶段就确定了。也不会为实例变量分配内存初始化。类变量是分配在方法区中的,而实例变量会随着对象的创建分配到Java堆内存中。
解析:将常量池中的符号引用转为直接引用。字节码文件中使用一组符号(放在常量池中)来描述所引用的目标,这是一种字面量的形式;直接引用就是运行时直接指向字面量所引用的真实目标的指针、相对偏移量或者间接指向目标的句柄。
初始化
前面讲了在链接阶段的准备阶段就会给类变量一个默认的初始值。那么在初始化阶段就会显式执行类的初始化。此时静态代码块、类变量的赋值语句会被执行。
本质上类的初始化阶段就是执行类的构造方法
的过程,此方法不是我们自己定义的,而是在编译阶段由javac编译器收集类中所有的类变量赋值动作和静态代码块中的语句合并而成。而我们自己写的类的构造器会被编译成
方法。和普通方法一样,这2个方法都在字节码文件中有体现,使用jclasslib等工具反编译java源文件都能看到。比如在下面这个类反编译的字节码中,除了看到了我们定义的main和method1方法,还有两个初始化方法,分别对应类的初始化和构造器初始化:
public class Test04 {
static int count = 1;
static {
System.out.println("Initialize class");
}
public static void main(String[] args) {
}
public void method1() {
}
}
JVM在执行我们编写好的代码过程中,什么时候会去加载、初始化一个类呢?
简单地讲,就是在代码中首次使用这个类的时候。Java程序对类的使用方式又分为主动使用和被动使用。被动使用不会导致类的初始化,但是有可能会加载类。
主动使用的情况:
1)创建类的实例,比如new 一个对象时
2)访问类或者接口的静态变量、对该静态变量赋值、调用类的静态方法
3)Jvm启动时被标明为启动类的类,比如main方法、Java Test 所在的类
4)初始化一个类的子类。比如初始化一个类时,发现其父类还没有还没初始化,则先加载其父类,并初始化
5)反射。比如使用Class.forName(“com.xx.Y”)
6)JDK7开始提供的动态语言支持(较少使用)
除此之外的方式都可以认为是被动使用,不会导致Java类的初始化。
被动使用的情况:
1)通过子类引用父类的静态字段,为子类的被动使用,不会导致子类初始化
public class Test01 {
public static void main(String[] args) {
int count = Son.count;
}
}
class Father {
static int count = 1;
static {
System.out.println("Initialize class Father");
}
}
class Son extends Father {
static {
System.out.println("Initialize class Son");
}
}
上述例子中,虽然是以Son.count形式调用的,但是由于count是Father的静态成员变量,因此只会初始化父类Father类,而不会初始化Son类。
2)通过数组定义类引用类
class C {
static {
System.out.println("Initialize class C");
}
}
public class Test02 {
public static void main(String[] args) {
C[] array = new C[20];
//array[0] = new C();
}
}
上述例子中在定义数组引用时,不会触发C类的初始化,在创建具体的实例时才会。
3)使用定义了常量的类不会导致定义常量的类的初始化
public class Test03 {
public static void main(String[] args) {
int x = A.count;
}
}
class A {
static final int count = 1;
//static int count = 1;
static {
System.out.println("Initialize class A");
}
}
final修饰的变量在编译期间确定了值,存在常量池中,因此这里没有直接引用到定义常量的类,不会初始化。
加载.class文件的方式有哪些呢?
1)本地直接加载,平时我们自己写代码,运行就是这种方式
2)通过网络加载,比如Web Applet
3)从压缩包中加载,比如zip包、jar包、war包;日常打包发布就是这种形式
4)由其它文件生成,比如JSP应用
5)运行时动态生成,比如动态代理
类加载器和双亲委派
上面介绍了类的加载过程,要实现这一过程,则依赖类加载器(ClassLoader)。
类加载器的分类
1)引导类加载器(Bootstrap ClassLoader)
2)用户自定义类加载器(User-defined ClassLoader)
一般我们说的自定义加载器是指由开发人员自行定义开发的加载器。但是按照JVM规范,所有继承自抽象类ClassLoader的类加载器都被归类为用户自定义类加载器。
实际开发中常见的类加载器有3个:Bootstrap ClassLoader,Extension ClassLoader,Application ClassLoader。
1)Bootstrap ClassLoader,启动类加载器。也叫引导类加载器,使用C/C++实现,内嵌在JVM中。用于加载Java的核心类库。比如加载jdk安装目录JAVA_HOME/jre/lib下的jar包。该加载器没有继承抽象的ClassLoader类,没有父加载器。处于安全考虑,Bootstrap 加载器只加载包名为java、javax、sun开头的类。除此之外,Bootstrap 加载器还会加载Extension ClassLoader,Application ClassLoader,并作为它们的父加载器。
2)Extension ClassLoader,扩展类加载器。由Java语言编写,其实现类为sun.misc.Launcher.ExtClassLoader,继承自抽象的ClassLoader类。该加载器的父加载器为Bootstrap ClassLoader。其负责加载java.ext.dirs系统属性所指定的目录下的类或从jdk安装目录jre/lib/ext路径下加载类。
3)Application ClassLoader,应用程序类加载器。因为可以使用java.lang.ClassLoader#getSystemClassLoader获得,因此也叫系统类加载器。该加载器实现类为sun.misc.Launcher.AppClassLoader,派生自抽象的ClassLoader类。其负责加载环境变量classpath下或者java.class.path系统属性指定路径下的类。它是Java程序中默认的类加载器,我们编写的Java代码就是由该加载器加载,其父加载器为Extension ClassLoader。
除了上述3种类加载器外,我们也可以自定义类加载器,根据实际需求定制类的加载方式。
注意,在jdk9中有了新的变化。。。
双亲委派机制
JVM的类加载器遵循一个层级结构,即最上层是引导类加载器,其次是扩展类加载器,再者是应用程序类加载器。当一个类加载器收到了类加载请求,自己不会先去加载,而是把这个类加载请求委托给父加载器去执行。倘若父加载器还存在上一级父加载器,则继续向上委托。最终到达Bootstrap ClassLoader。如果父加载器能够完成类的加载就成功返回,否则才是子加载器自己去尝试加载。上述过程就是所谓的"双亲"委派机制。
举个例子,比如下面这个Java程序,JVM在加载Test04.class时,应用程序类加载器先会问自己的父加载器(扩展类加载器)能否加载。扩展类加载器直接问自己的父加载器(引导类加载器)能否加载。由于引导类加载器在自己负责的加载路径下没有找到该类,直接回退到扩展类加载器加载,扩展类加载器在自己的加载范围内也没有找到这个类,因此又回退到应用程序类加载器加载。应用程序类加载器发现自己能够加载该类,因此完成加载。
public class Test04 {
public static void main(String[] args) {
ClassLoader classLoader1 = Test04.class.getClassLoader();
ClassLoader classLoader2 = ClassLoader.getSystemClassLoader();
ClassLoader classLoader1Parent = classLoader1.getParent();
ClassLoader classLoader1ParentParent = classLoader1Parent.getParent();
System.out.println(classLoader1);
System.out.println(classLoader2);
System.out.println(classLoader1Parent);
System.out.println(classLoader1ParentParent);
}
}
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@17f052a3
null
双亲委派机制的好处
1)避免类的重复加载,一个类只由一个类加载器加载
2)优先加载核心类库,防止jdk自带的核心类被篡改
从前文我们知道JVM在运行我们写好的代码时,需要将字节码文件加载到内存。然后根据我们的代码流程,使得程序运行起来。代码中有类、字段、方法等组成元素,方法中又有局部变量,变量可能指向一个对象。这些信息都需要存放到JVM管理的内存中。实际上JVM会将操作系统分配给它的物理内存空间划分成不同的逻辑区域,来存放不同的运行时数据。下图的绿色方框圈出的,就是本节要讲解的运行时数据区。运行时数据区包含:方法区、程序计数器、Java虚拟机栈、本地方法栈、堆。
JVM内存布局规定了Java程序运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。实际上不同的JVM实现对于内存的划分和管理存在部分差异,上图展示的是经典的JVM内存布局。
JVM定义的若干程序运行期间会使用到的运行时数据区,其中一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁。一些是与线程一一对应的,即这些数据区会随着线程的开始而创建,线程的结束而销毁。图中红色部分(方法区、堆区)即为多线程共享的,浅黄色部分(PC计数器、虚拟机栈、本地栈)是线程独有的,每个线程一份。
方法区是所有线程共享的,随着JVM的启动而创建,随着JVM的关闭而释放。方法区存储每个从.class文件加载进来的类的结构信息,比如类的运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括在类、接口初始化和对象实例初始化中使用的特殊方法。《Java虚拟机规范》中明确说明:“尽管方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择对方法区进行垃圾收集或者进行压缩”。因此对于Hotspot而言,方法区还有一个别名,叫做非堆(Non-Heap),目的就是为了将其与Java堆区分开。
方法区占用的实际物理内存可以是不连续的。其大小可以通过参数设置。因为方法区是存放类相关信息的,因此方法区的大小决定了系统可以保存多少个类。如果系统加载了太多的类,比如加载大量的第三方jar包、Tomcat部署过多的工程、大量使用反射动态生成类都有可能导致方法区内存溢出。即抛出下列异常信息:
jdk7-: java.lang.OutOfMemoryError:PermGen space
jdk8+:java.lang.OutOfMemoryError:Metaspace
可以通过下列方式设置方法区大小
jdk7及之前
使用-XX:PermSize设置永久代初始大小,默认是20.75MB。使用-XX:MaxPermSize设置永久代最大可分配大小。32位机器默认是64MB,64位机器默认是82MB。如果JVM加载的类信息的大小超过了这个值将报OOM错误。
jdk8及之后
使用-XX:MetaspaceSize和-XX:MaxMetaspaceSize替换上面的2个参数来指定。默认值依赖于不同的OS平台。不同于永久代,如果不设置大小,默认情况下会耗尽所有可用的系统内存。
在jdk7及之前,通常将方法区称为"永久代"。从jdk8开始,类的元数据存储到本地堆(native heap)中,方法区的称谓变成“元空间”。因此,可以将永久代(PermGen Space)和元空间(Metaspace)看成是Java 虚拟机规范中方法区的2种不同实现。两者的区别是:元空间不在Java虚拟机管理的内存空间中,而是使用本地内存(native memory)。Java8及之后类的类型信息、字段、方法、常量保存在本地内存的元空间,而字符串常量池、静态变量仍保存在堆中。
方法区都存储啥?主要是存储类相关的信息,比如类型信息、类变量、类的方法、字段,以及运行时常量池、JIT代码缓存。
对于JVM加载的每个类型,都需要在方法区中存储下列信息。
这里指的是非final的static变量。静态变量随着类的加载而加载,是类数据的一部分。
这个区域是用来存储即时编译后的代码。编译后的代码就是本地代码(硬件相关的),它是由JIT(Just In Time)编译器生成的。
JVM在方法区保存所有的方法信息,包括方法名称、返回类型、方法的参数数量和类型、方法的修饰符、方法的字节码、操作数栈的大小,局部变量表及其大小、异常表。
JVM在方法区保存类的所有域(field)相关信息以及域的声明顺序。包括field的名称、类型、修饰符。
在介绍运行时常量池之前先说说几个关于常量池的概念。通常有3种常量池的叫法。
String pool也称为String literal pool,是用来存放String类型的字符串常量。在jdk6及之前,字符串常量池存放在永久代。从jdk7开始字符串常量池的位置调整到Java堆中,因为永久代的垃圾回收效率很低,只在full GC的时候才会触发。如果应用程序很大,代码中有大量的字符串被创建,且回收效率低,则会导致永久代内存不足,甚至导致OOM。放到堆空间能够及时回收。jdk8开始方法区的实现变成元空间(Metaspace),字符串常量池还是在堆中存储。字符串常量池是全局唯一的,即每个Java虚拟机中只有一份。
是class文件的一部分。class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool)。用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References),如下图所示:
反编译Test04.java,得到的字节码文件解析如下,其中Constant pool就是字节码文件常量池,可以看到其中包含很多引用。
完整的解析结果如下:
D:workProjectsmyappdemo argetclassescomexampledemo>javap -verbose Test04
警告: 二进制文件Test04包含com.example.demo.Test04
Classfile /D:/workProjects/myapp/demo/target/classes/com/example/demo/Test04.class
Last modified 2021-3-10; size 940 bytes
MD5 checksum cbb3d7a077fe13ea4bf8dd339c9f273c
Compiled from "Test04.java"
public class com.example.demo.Test04
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#28 // java/lang/Object."":()V
#2 = Class #29 // com/example/demo/Test04
#3 = Methodref #30.#31 // java/lang/Class.getClassLoader:()Ljava/lang/ClassLoader;
#4 = Methodref #32.#33 // java/lang/ClassLoader.getSystemClassLoader:()Ljava/lang/ClassLoader;
#5 = Methodref #32.#34 // java/lang/ClassLoader.getParent:()Ljava/lang/ClassLoader;
#6 = Fieldref #35.#36 // java/lang/System.out:Ljava/io/PrintStream;
#7 = Methodref #37.#38 // java/io/PrintStream.println:(Ljava/lang/Object;)V
#8 = Class #39 // java/lang/Object
#9 = Utf8
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/example/demo/Test04;
#16 = Utf8 main
#17 = Utf8 ([Ljava/lang/String;)V
#18 = Utf8 args
#19 = Utf8 [Ljava/lang/String;
#20 = Utf8 classLoader1
#21 = Utf8 Ljava/lang/ClassLoader;
#22 = Utf8 classLoader2
#23 = Utf8 classLoader1Parent
#24 = Utf8 classLoader1ParentParent
#25 = Utf8 MethodParameters
#26 = Utf8 SourceFile
#27 = Utf8 Test04.java
#28 = NameAndType #9:#10 // "":()V
#29 = Utf8 com/example/demo/Test04
#30 = Class #40 // java/lang/Class
#31 = NameAndType #41:#42 // getClassLoader:()Ljava/lang/ClassLoader;
#32 = Class #43 // java/lang/ClassLoader
#33 = NameAndType #44:#42 // getSystemClassLoader:()Ljava/lang/ClassLoader;
#34 = NameAndType #45:#42 // getParent:()Ljava/lang/ClassLoader;
#35 = Class #46 // java/lang/System
#36 = NameAndType #47:#48 // out:Ljava/io/PrintStream;
#37 = Class #49 // java/io/PrintStream
#38 = NameAndType #50:#51 // println:(Ljava/lang/Object;)V
#39 = Utf8 java/lang/Object
#40 = Utf8 java/lang/Class
#41 = Utf8 getClassLoader
#42 = Utf8 ()Ljava/lang/ClassLoader;
#43 = Utf8 java/lang/ClassLoader
#44 = Utf8 getSystemClassLoader
#45 = Utf8 getParent
#46 = Utf8 java/lang/System
#47 = Utf8 out
#48 = Utf8 Ljava/io/PrintStream;
#49 = Utf8 java/io/PrintStream
#50 = Utf8 println
#51 = Utf8 (Ljava/lang/Object;)V
{
public com.example.demo.Test04();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/demo/Test04;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // class com/example/demo/Test04
2: invokevirtual #3 // Method java/lang/Class.getClassLoader:()Ljava/lang/ClassLoader;
5: astore_1
6: invokestatic #4 // Method java/lang/ClassLoader.getSystemClassLoader:()Ljava/lang/ClassLoader;
9: astore_2
10: aload_1
11: invokevirtual #5 // Method java/lang/ClassLoader.getParent:()Ljava/lang/ClassLoader;
14: astore_3
15: aload_3
16: invokevirtual #5 // Method java/lang/ClassLoader.getParent:()Ljava/lang/ClassLoader;
19: astore 4
21: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
24: aload_1
25: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
28: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
31: aload_2
32: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
35: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
38: aload_3
39: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
42: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
45: aload 4
47: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
50: return
LineNumberTable:
line 5: 0
line 6: 6
line 7: 10
line 8: 15
line 9: 21
line 10: 28
line 11: 35
line 12: 42
line 13: 50
LocalVariableTable:
Start Length Slot Name Signature
0 51 0 args [Ljava/lang/String;
6 45 1 classLoader1 Ljava/lang/ClassLoader;
10 41 2 classLoader2 Ljava/lang/ClassLoader;
15 36 3 classLoader1Parent Ljava/lang/ClassLoader;
21 30 4 classLoader1ParentParent Ljava/lang/ClassLoader;
MethodParameters:
Name Flags
args
}
SourceFile: "Test04.java"
可见,虽然Test04.java的代码很少,但是编译后的字节码却包含很多信息。一小段Java代码简间接或直接引用了PrintStream、System、String、Object、Class等结构。如果Java程序代码更多,可以想象,将引用更多的结构,通常这种数据会很多,如果都直接放到字节码指令里面,则产生大量重复结构,换种方式可以存到常量池。用到的地方直接去常量池引用(ref)即可。比如上面字节码中的#号,就直接指向了常量池中的内容。在类加载的动态链接阶段,符号引用会转换为直接引用。
class文件常量池可以看成是一张表,执行虚拟机指令时从这张常量表中找到要执行的类名、方法名、参数类型、字面量等信息。
Java源文件被编译成class文件之后,就会生成class常量池。那么运行时常量池又是在什么时候创建的呢?
运行时常量池是方法区的一部分。字节码加载后,即类加载到内存后,JVM就会将class文件常量池中的内容放到运行时常量池中。因此运行时常量池也是每个类都维护一个。在类加载过程中的resolve阶段class文件常量池中的符号引用替换为直接引用。解析过程中会查询全局字符串池,保证运行时常量池中的字符串与全局字符串池中的一致。
运行时常量池包含多种不同的常量,包括编译期间就确定的数值字面量,也包括运行期间才解析得到的方法或字段引用。运行时常量池相对于class文件常量池的差别在于其具备动态性。Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。比如可以使用String.intern()直接强制入池。
上面罗列了一大堆细节,总结一下就是一句话:JVM维护一个全局的字符串常量池存放字符串数据,jdk7之后字符串常量池放到了堆中;class文件常量池是class文件的一部分,存放了编译器生成的各种字面量和符号引用,是一个静态的概念;运行时常量池是在类加载到虚拟机后创建的,除存放class文件常量池里的内容外,还能动态添加内容,是一个动态的概念。
和计算机组成原理中的程序计数器(PC)的概念类似,JVM中的程序计数器也是用来存储指向下一条指令的地址。前文阐述了Java代码会被翻译成字节码,对应各种字节码指令。有了程序计数器,字节码执行引擎就可以一条一条读取指令并执行。
JVM中的程序计数器实际上是一块很小的存储空间,小到几乎可以忽略不计。但是它也是运行最快的存储区域。这和CPU里的寄存器容量小、速度快道理类似。每个线程都有自己的程序计数器,其生命周期和线程的生命周期一致。任何时刻,线程都只能有一个方法在执行,即"当前方法",程序计数器会存储当前线程正在执行的Java方法的JVM指令地址。执行native方法时,则是未指定值(undefined)。
流程控制,比如分支、循环、跳转,异常处理、线程恢复等都依赖程序计数器。字节码解释器在工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令。
程序计数器是JVM规范中唯一一个没有规定任何OutOfMemoryError的区域。
Java Virtual Machine Stack早期也叫Java栈。每个线程在创建时都会创建一个Java虚拟机栈,其内部保存一个个的栈帧,每个栈帧对应一次方法调用。Java虚拟机栈保存方法的局部变量、部分结果,并参与方法的调用和返回。栈是一种快速有效的内存分配方式,其访问速度仅次于程序计数器。
每个方法(method)的执行都伴随着入栈(方法开始执行)、出栈(方法执行结束)操作。对于栈来说不存在垃圾回收问题,方法执行完,就出栈了。如果方法调用很多,线程请求分配的栈容量超过Java虚拟机允许的最大容量,则JVM会抛出StackOverflowError。可以使用参数-Xss 设置线程的最大栈空间大小。栈空间的大小决定了函数调用可以达到的最大深度。
栈由一个个栈帧组成,我们来看看栈帧的内部结构。每个栈帧内部存储下列信息:
局部变量表(Local Variables)
用于存储方法参数、定义在方法体里的局部变量。由于局部变量表是建立在线程独有的虚拟机栈上,因此是线程私有数据,不存在线程安全问题。局部变量表中的变量只在当前方法中有效,方法结束后,随着方法栈帧的销毁,局部变量也随之销毁。
方法能够嵌套调用的次数由栈的大小决定。通常认为栈空间越大,方法能够嵌套调用的次数越多。对于一个方法而言,其参数和局部变量越多,就会使得局部变量表越大,栈帧就会越大,占用的栈空间就会越多,导致其能够嵌套调用的次数减少。
局部变量表中的变量也是垃圾回收时的重要根节点(GC Roots),即只要是被局部变量表中的变量直接或间接引用的对象不会被垃圾回收掉。
操作数栈(Operand Stack)
也称为表达式栈(Expression Stack),在方法执行过程中根据字节码指令往栈中写入数据或者读取数据,即入栈(push)和出栈(pop)操作。说白了代码执行过程中的计算逻辑在计算过程中会产生一些中间结果,这些中间结果就存在操作数栈中,同时也作为计算过程中变量的临时存储空间。我们说Java虚拟机的解释器引擎是基于栈的执行引擎,这里的栈就是指的操作数栈。
动态链接(Dynamic Linking)
每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。有了这个引用,当前方法的代码就能实现动态链接。之前我们提到,java源文件编译为字节码文件时,所有的变量及方法引用都是以符号引用的方式存在class文件常量池中。比如从Test04.java反编译的字节码文件中,我们可以看到描述一个方法调用了其它的方法时,就是通过常量池中指向方法的符号引用(#数字)表示的。动态链接的作用就是将所有的符号引用转换为调用方法的直接引用。
方法返回地址(Return Address)
存放调用该方法的程序计数器的值。方法执行完毕退出后都应该返回到该方法被调用的位置。因此正常退出时,应该将调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。异常退出时,返回地址需要根据异常表来确定。
一些附加信息
栈帧中还允许携带与JVM实现相关的一些附加信息,比如对程序调试提供支持的信息。
下图清晰地展示了一个运行中程序的Java虚拟机栈空间分配。
本地方法就是一个Java方法调用非Java代码实现的接口。其初衷是为了融合C/C++程序,实现Java与底层系统的交互,能够使用一些Java语言本身没有封装提供的操作系统的特性。
本地方法栈就是用来管理本地方法调用的,就像Java虚拟机栈用于管理Java方法调用。当线程调用本地方法时,它就进入了一个不受Java虚拟机限制的世界,它甚至和JVM有着同样的权限。比如可以通过本地方法接口来访问JVM运行时数据区、可以使用本地内存。
堆可以说是运行时数据区最重要的区域,通常也是JVM 所管理的占用内存最大的一块区域。Java堆可以位于物理上不连续的空间中,但是逻辑上应视为连续的。堆空间在JVM启动时就被创建并且空间大小也随之确定。堆内存的大小可以通过JVM参数设置。堆空间是被所有线程共享的,但是其内部还是可以划分一小部分作为线程私有的缓冲区(Thread Local Allocation Buffer,TLAB),每个线程一份。
那么堆区存放什么数据呢?
按照JVM规范中的描述,所有的类的实例(对象)以及数组都会在运行时分配到堆上。在一个Java方法(函数)结束时,堆中的对象不会立即被移除,而是在执行垃圾回收时才会被清除(栈上分配的对象除外)。
说白了堆就是存储Java对象的。下面两张图展示了一行Java代码所涉及的几个运行时数据区的交互关系。
Book类的类型信息存储在方法区;book局部变量存储在虚拟机栈中,保存一个引用,指向对象在堆中的位置;Book对象存储在堆中,同时保存对象类型数据的指针并指向方法区。
堆是JVM内存管理的核心区域,也是执行垃圾回收(GC)的重点区域。本节将详细阐述堆区相关的原理、细节,为后面介绍JVM垃圾回收作铺垫。
我们知道,垃圾回收器主要回收的就是堆空间的对象。现代垃圾收集器普遍基于分代的思想。因此堆空间在逻辑上会划分为不同的“分代”。
Java7及之前堆空间逻辑上分为新生代、老年代、永久代;新生代又被划分为伊甸园区、幸存者1区和幸存者2区。
Java8及之后堆空间逻辑上分为新生代、老年代、元空间。即永久代->元空间。
对于不同的区域有着多种叫法,比如下面这些:
从下面堆空间内部结构图可以看出,Java7和Java8在方法区的实现上不同,Java7方法区的实现是永久代,而Java8变成了元空间。
JDK7:
JDK8:
为什么要在堆空间进行分代呢?
实际生产实践中,人们发现不同的Java对象生命周期往往不同。有的对象生命周期比较短,这类对象的创建和消亡都非常迅速。有的对象生命周期很长,甚至在某些情况下还能与JVM的生命周期保持一致。因此针对不同特点的对象采取不同的内存分配方式和垃圾回收策略是必要的。通常生命周期比较短的、那些朝生夕死的对象会存在年轻代;而经历过多次垃圾回收仍然存活的对象则会晋级到老年代。
如果没有分代,所有对象杂糅在一起,这样垃圾回收时就需要对堆的所有区域扫描以找到那些没有用的对象。如果大多数对象都是朝生夕死的话,那么通过分代,把这些具有共同特征的对象放到一起,这样GC时的效率就会提高,能够一下子腾出大量空间。
为新的对象分配内存是一件极其复杂而又严谨的事儿。既要考虑内存的合理分配,还要考虑之后的内存回收。不同的垃圾收集器下可能存在差别。本文按照经典的流程来讲述。
1)首先,new出来的对象先会放到新生代的伊甸园区,此区有大小限制,后面会讲如何设置。
2)当伊甸园区满,再创建对象时,JVM垃圾收集器将对伊甸园区进行垃圾回收(Minor GC),将没有引用指向的的对象清除之后再放入新的对象到伊甸园区。
3)接着将伊甸园区中剩余的未被回收的对象移到幸存者0区。
4)下次再触发垃圾回收时,上次回收幸存下来放到Survivor 0区的,如果没有被回收,将会复制到幸存者1区。
5)如果再次经历垃圾回收,没有被回收的对象又会重新复制回幸存者0区。就这样一直往复。
6)如果来回往复达到设定的次数(默认的年龄计数为15次),则进入老年代。这个参数是:-XX:MaxTenuringThreshold=15
7)在老年代的对象,垃圾回收的频率远小于年轻代。当老年代内存不足时,再次触发垃圾回收(Major GC),清理垃圾对象。
8)如果在老年代已经执行了垃圾回收的前提下,空间依然不足以存放对象,则产生OOM异常。
java.lang.OutOfMemoryError:Java heap space
垃圾回收的原则:频繁收集新生代、较少收集老年代、几乎不动永久代/元空间。下面这张图展示了堆中对象分配的流程。
对照上图,再次阐述一下内存分配的策略。
新的对象都先在Eden区分配,如果经过第一次年轻代垃圾回收(MinorGC)后仍然存活,且能被Survivor放得下的话就被移到Survivor空间,并且将对象年龄设置为1。之后对象在Survivor空间中每经历一次MinorGC,年龄就增加1岁。当年龄达到一定的阈值(默认是15)就会被晋升到老年代。
不同年龄段的对象分配原则如下:
1)优先分配到Eden区
2)大对象直接分配到老年代
3)长期存活的对象分配到老年代
4)如果 Survivor区中所有相同年龄的对象大小之和大于Survivor空间的一半,则年龄大于或等于该年龄的对象直接进入老年代,无需等到-XX:MaxTenuringThreshold参数设置的阈值。
TLAB全称为Thread Local Allocation Buffer,即线程本地分配缓存区,是线程私有的分配区。我们知道堆区是所有线程共享的,如果对象的创建非常频繁,在并发环境下从堆区划分内存空间将会产生线程安全问题。为了避免这个问题就需要引入加锁等机制,这会影响内存分配速度。因此从Eden区划分出一个很小的区域作为线程私有的缓存区域(TLAB)。
如果设置了虚拟机参数 -XX:UseTLAB,则每当线程初始化时都会申请一块指定大小的内存,只给当前线程使用。这样在多线程同时分配内存时,各个线程都在自己的空间上分配,不存在竞争,能提高内存分配的吞吐量。TLAB的内存占用非常小,默认只有整个Eden空间的1%,可以使用-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比。
由于TLAB的空间很小,所以不适合存储大对象。一旦对象在TLAB上分配失败,JVM就会使用加锁机制保证操作的原子性,从而直接在Eden区分配内存。尽管不是所有对象都能够在TLAB上分配成功,但是JVM确实是将TLAB作为对象内存分配的首选。
JVM参数设置请参考官方文档,本文列举几个常用的。
-Xms:初始堆空间内存大小,默认为物理内存的1/64,比如设置初始堆大小为100M,-Xms100m
-Xmx:最大堆内存大小,默认为物理内存的1/4,比如设置最大Java堆内存大小为1G,-Xmx1024m
-Xmn:设置新生代的大小,等价于 -XX:NewSize ,比如-XX:NewSize=256m和-Xmn256m都是设置新生代大小为256兆
-XX:NewRatio:设置新生代和老年代的堆空间大小比例,默认值是2,就是新生代和老年代比例是1:2
比如设置比如新生代老年代的比例是1:4,就是-XX:NewRatio=4
-XX:SurvivorRatio:设置新生代中Eden区和S0、S1区的比例。默认值是8,就是8:1:1。
比如设置Eden区和S0、S1区的比例为:4:1:1就是-XX:SurvivorRatio=4
-XX:MaxTenuringThreshold:设置新生代的年龄阈值,默认为15,比如设置为20,-XX:MaxTenuringThreshold=20
-XX:+PrintGCDetails:输出详细的GC日志,默认是关闭的
经过上面的介绍,可能给很多读者造成了思维定势,认为对象一定是在堆空间分配的。但是随着JIT技术的发展和逃逸分析技术的日渐成熟,对象总是在堆上分配也不是那么绝对了。如果经过逃逸分析发现一个对象并没有逃逸出所在方法的话,那么可能就会被优化成栈上分配。即无需在堆上分配,也无需垃圾回收。
所谓逃逸分析就是指Java编译器能够分析出一个对象的引用的使用范围从而决定是否将该对象分配到栈上或是堆上。如果一个对象在方法中定义之后仅在方法内部使用,则认为没有发生逃逸;如果这个对象被外部方法所引用,则认为发生逃逸。例如作为返回参数返回到调用的地方,或者作为参数传递到其它地方。没有发生逃逸的对象可以分配到栈上,随着方法执行的结束,栈空间就释放。
比如下列几种情况都是没有发生逃逸的,对象的作用域都只在方法内部。
public void method1(){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("test");
System.out.println(stringBuffer.toString());
}
public String method2(){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("test");
return stringBuffer.toString();
}
逃逸的例子比如下面这些。对象被返回给方法之外的调用者、为成员属性赋值。
public StringBuffer method3(){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("test");
return stringBuffer;
}
public void setValue(){
this.stringBuffer = new StringBuffer();
}
从JDK6u23版本之后,Hotspot中默认开启逃逸分析。可以使用-XX:+DoEscapeAnalysis显式开启逃逸分析。
可以通过-XX:-DoEscapeAnalysis关闭。此参数只对Hotspot有效。
逃逸分析有其优点,但是也不能忽视其缺陷。逃逸分析自身也需进行一系列的复杂分析,也是一个耗时的过程。如果经过逃逸分析后发现没有一个对象不是逃逸的,那这个逃逸分析的过程就浪费掉了。
我们把上面讲述的概念结合实际的Java代码,捋一遍整体的流程,让读者们加深理解。
比如对照下面的Java代码阐述一下整个流程。
运行程序时,首先JVM会加载Book.class到内存中,然后启动main线程执行main方法。main线程会关联一个程序计数器,记录其执行到了哪一条指令。main线程在执行main方法时会在main线程对应的Java虚拟机栈中压入一个main方法的栈帧。接着执行main方法时发现需要创建一个Book类的对象,就会看看Book类有没有加载,之前加载过了就不会重复加载。然后创建一个Book对象分配在堆内存中,并在main方法的栈帧里面的局部变量表定义一个book变量,保存刚才创建的Book对象在Java堆内存中的地址。接着main线程会执行Book对象的setName方法,main线程依次将自己执行到的方法对应的栈帧压入自己的虚拟机栈。执行完成后把方法对应的栈帧从Java虚拟机栈中出栈。调用System类的方法时依然如此。字符串字面量会存储到字符串常量池中(Java8及以后字符串常量池放在堆空间)。
public class Book {
private String name;
public static void main(String[] args) {
Book book = new Book();
book.setName("《循序渐进学Netty》");
System.out.println(book.getName());
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
本小节更加深入地讲述一下对象,包括对象的创建、内部结构以及内存布局。
1)直接new 出来的
2)调用Class的newInstance()方法
3)调用对象的clone()方法
4)使用反序列化方式
5)使用第三方库比如objenesis
1)判断对象所属类是否已经加载。若没有则先加载类、链接、初始化。
虚拟机遇到一条new的指令时,会根据这个指令的参数去MetaSpace的常量池中找到这个类的符号引用,并且检查这个符号引用表示的类是否已经加载。如果还没加载则基于双亲委派机制,使用当前的类加载器以ClassLoader+包名+类名为key查找对应的class文件,如果找到则生成对应的Class类对象,否则抛出java.lang.ClassNotFoundException
2)为对象分配内存。如果内存规整,则使用指针碰撞法(Bump The Pointer);如果内存不规整,则使用空闲列表法(Free List)。
首先会计算对象占用空间的大小,接着在堆空间分配一块内存给新的对象。所谓指针碰撞法就是使用过的内存放在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器。分配内存时仅仅是把指针向空闲的那一边移动与对象大小相等的距离。一般带有内存整理(compact)的垃圾收集器会使用指针碰撞法。
若果内存不是规整的,用过的内存和没用过的内存相互交错,则会采用空闲列表法为对象分配内存。即虚拟机维护了一个列表,记载了哪些内存块是可用的,分配内存时从中找到一块够大的分配给对象,并更新列表。
3)处理内存分配过程中的并发问题。
这里可以采用CAS机制、区域加锁等措施;也可以使用TLAB方式。
4)初始化分配好的空间
这就是所谓的默认初始化。给对象所有的属性设置默认值,保证对象的实例字段在没有赋值时也可以使用。
5)设置对象头
将对象的hashcode、GC信息、锁信息以及对象所属类的元数据信息存到对象头中。
6)执行init方法进行初始化
这就是所谓的显式初始化。包括类的属性(成员变量)的显式初始化、代码块中的初始化、构造器中的初始化。编译器会将这几个初始化过程编译到字节码文件中的init方法中。
对象在内存中主要存储对象头、实例数据、对齐填充。
对象头
1)运行时元数据(Mark Word),包括对象的哈希值、GC年龄计数、锁状态标记、线程持有的锁、偏向时间戳、偏向线程ID
2)类型指针(Instance Data),指向了方法区(元空间)中对象所属的数据类型。
3)如果创建的是数组,还需要保存数组的长度
实例数据
这是真正存放对象有效信息的,包括程序中定义的各种类型的字段(包括从父类继承的)。
下图完整地展示了一段Java代码中对象的内存布局
代码:
public class Order {
String orderNo;
Item item;
public Order(String orderNo, Item item) {
this.orderNo = orderNo;
this.item = item;
}
public static void main(String[] args) {
Order order = new Order("O10001", new Item("物品"));
}
}
class Item {
String name;
public Item(String name) {
this.name = name;
}
}
布局:
直接内存(Direct Memory)不是Java虚拟机运行时数据区的组成部分,在Java虚拟机规范中也没有定义此内存区域。直接内存是指在Java堆外的、直接向操作系统申请的内存区域。比如在NIO中,通过DirectByteBuffer就可以操作Native内存。
直接内存的好处是可以避免数据在内核空间缓冲区和用户空间缓冲区之间拷贝,因此读写性能优于Java堆内存。频繁读写的场景下可以优先使用直接内存。Java NIO对此做了支持。
虽然直接内存的大小不受Java堆空间大小(-Xmx指定)制约,但是系统的物理内存是有限的。Java堆和直接内存的总大小依然受限于操作系统可用的最大内存。由于不受JVM内存管理,因此回收成本比较高。直接内存的大小可以通过参数MaxDirectMemorySize设置,默认与堆内存最大值参数-Xmx指定的一致。
可以简单的认为Java进程的内存=Java堆内存+本地内存(native memory)
前文已经讲述了JVM负责加载字节码文件到其内部,但是字节码本身并不能够直接运行在操作系统上,其内部只是一些能够被JVM所识别的字节码指令、符号表,以及其它辅助信息。
要让Java程序运行起来,必须依赖执行引擎,执行引擎的作用就是将字节码指令解释、编译为可以执行的本地机器指令。
解释器和编译器
解释器(Interpreter):根据Java虚拟机规范对字节码进行逐行解释执行,即将每条字节码指令转换为平台对应的本地机器指令执行。
编译器:在Java中指的是JIT编译器,即即时编译器(Just in time compiler),将源代码直接编译为平台对应的本地机器码。
作为业界流行的高性能虚拟机,Hotspot VM 采用解释器与即时编译器并存的架构。即在Java虚拟机运行时,解释器和即时编译器相互协作,取长补短。权衡编译代码的耗时和直接解释执行的耗时,最终做出最优的选择。这也是Java程序性能可以和C/C++一较高下的原因之一。
Hotspot VM的执行方式就像它的名字所代表的的含义一样。即当虚拟机启动时,解释器首先发挥作用,而不必等待即时编译器全部编译完成再执行。这样可以节省许多不必要的编译时间。随着程序的运行,即时编译器逐渐发挥作用,它会根据热点代码探测的结果,将那些"热点"代码编译为本地机器指令,提高程序的执行效率。热点代码可以认为是那些频繁被调用的代码。
默认情况下Hotspot VM采用解释器和编译器并存的方式。可以通过参数设置。
-Xint:完全采用解释器模式执行
-Xcomp:完全采用即时编译器模式执行,如果即时编译器出现问题解释器会介入
-Xmixed:采用解释器+即时编译器的模式
正因为引入了JIT,Java程序也存在"预热"一说。Java程序刚跑起来时,大部分都是解释执行的,一段时间后热点代码被编译,存到JIT代码缓存中,后续这部分程序的执行就是直接运行编译好的机器码,因此程序的整体运行性能逐步提升。
未完待续…