JVM
JVM
JDK、JRE、JVM
官方提供的结构图:
JDK
Java Devlopment Kit 是Java开发工具包。它包含了JRE和JVM,以及用于开发、调试、编译Java应用程序的工具,例如javac、java、javadoc等。因此它是针对开发人员的。以下是一些重要的组件:
java
- 运行工具,运行.class的字节码javac
- 编译器,将.java源码文件编译成.class字节码文件javap
- 反编译程序javadoc
- 文档生成器jar
- 打包工具jdb
- 调试工具jstack
- 栈跟踪程序
JDK可以从Oracle官方下载,我这里选择了JDK8
:
在JDK安装目录下有几个文件夹,分别是:
- bin - 上面提到的工具的执行文件就在这个目录下
- include - Java和JVM交互的头文件
- jre - Java的运行环境
- lib - Java类库
JRE
Jave Runtime Environment 是Java运行时环境,提供了运行Java应用程序所需要的软件环境,包含了JVM和常用的Java类库。
JVM
Java Virtual Machine 是Java虚拟机的简称。JVM 是一个虚拟的计算机,用于执行 Java 程序。JVM 对于不同的操作系统平台和处理器架构有不同的实现。它可以把Java程序编译成不同平台的机器指令,让其在各个平台运行。这也是为什么Java是平台无关性的。
JVM编译大概过程:
- Java 源文件
.java
被编译器javac编译成能被 Java 虚拟机执行的字节码文件.class
- 这个字节码文件再交到不同平台上的JVM虚拟机去读取执行,从而实现一次编写,处处运行。
此外,JVM放在jre/bin/server目录下:
Java代码到执行
Java不像C语言可以直接从源代码生成一个可以执行的文件,而是将源代码转化成字节码文件(.class)。这个字节码文件可以放到任何不同架构的计算机上运行,只要安装了JRE运行环境。
在这个章节里,我们从宏观层面了解下java编译到执行的过程,然后在后序的章节中会详细介绍其中的步骤。
编译
Java是一门高级语言,只有人类能理解其逻辑,计算机是无法识别的,所以java代码必须要编译成机器语言才能被计算机执行。
在这个步骤里,java代码会被javac编译器转化成字节码文件然后存储在磁盘中。
类加载
Java程序是运行在JVM虚拟机之上的。执行程序的时候,JVM会先通过类加载器把本地的字节码文件读取到内存中。在这个过程中,类加载器会做三件事:加载、链接、初始化。在初始化之后,字节码文件才转为类,然后才能根据类创建对象。
字节码校验器
当类加载器将新加载的java 平台类的字节码传递给虚拟机时, 这些字节码首先要接受校验器的校验。
执行引擎
这时候
JVM读取到字节码文件后会交给执行引擎中的解释器,解释器会把字节码文件转成计算机可识别的机器码。
运行数据区
编译原理
导读
常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。而Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分:
- 前端编译 - 由javac把源码文件编译成通用的中间形式,也就是字节码文件。
- 后端编译 - 字节码文件被虚拟机加载以后由解释器逐条将字节码解释为机器码来执行。
所以在性能上,Java通常不如C++这类编译型语言。
前端编译
前端编译主要是把源码文件编译成字节码文件,过程包括法分析、语法分析、语义分析与中间代码生成。
1. 词法分析
Java源文件是由一个个字符构成,但是编译器所能识别的是token(标记)。所以在语法分析中,需要读取源文件中的字符流,过滤掉字符流中的空格、注释等,并将其分割为一个个的token,形成一个token流,这样才能用于后续的语法分析。
1 |
|
以上的这个语句会被拆分为7个token:
1 |
|
int是由3个字符构成,但是对于词法分析来说,这三个字符会被解析成一个token。
TIP:词法分析主要由com.sun.tools.javac.parser.Scannaer
类来实现。
2. 语法分析
接下来,根据语言的语法规则来解析这个token流,最终生成一个抽象语法树(AST)。
语法树是一种用来表示程序代码语法结构的表现形式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符等。
TIP:语法分析主要有com.sun.tools.javac.parser.Parser
类来实现。
1 |
|
使用JDTAstView插件查看上述这段代码生成的抽象语法树:
TIP:上述抽象语法树在Java中使用com.sun.tools.javac.tree.JCTree
类来表示,之后所有的操作均建立在抽象语法树之上。
3. 语义分析
语法分析后可以保证形成语法树以后不存在语法错误,但无法保证源程序是符合逻辑,所以需要对源程序上下文进行审查。
语义分析主要包含三个步骤:
- 标注检查:包括变量使用前是否已声明, 变量与赋值之间的数据类型是否匹配
- 数据及控制流分析
- 数据流分析:检查局部变量是否在使用前已经赋值、方法的每条路径都有返回值、所有的受检查异常是否被正确处理,所有的语句是否都要被执行等等。
- 控制流分析:去掉无用的代码,比如永假的if代码块,变量的自动转换,比如自动装箱拆箱等等。
com.sun.tools.javac.comp.Flow
- 解语法糖:把语法糖还原成简单的基础语法结构
- 解语法糖的过程由
desugar()
方法触发,在com.sun.tools.javac.comp.TransTypes
和com.sun.tools.javac.comp.Lower
类中完成。
- 解语法糖的过程由
语义分析完成后,源程序的结构解析完成,所有编译期错误都已被排除,所有使用到的变量名和函数名都绑定到其声明位置(地址),至此编译器可以说是真正理解了源程序,可以开始进行代码生成和代码优化了。
4. 中间代码生成
字节码生成是javac编译的最后一个阶段。字节码生成阶段不仅仅是把各个步骤生成的信息转换成字节码写到磁盘,还进行了代码的添加和转换工作,比如:
- 添加实例构造器方法和类构造器方法到语法树之中。(这里的实例构造器并不是指默认的构造函数,而是指我们自己重载的构造函数。)
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给com.sun.tools.javac.jvm.ClassWriter
类,由这个类的writeClass()
方法输出字节码,生成最终的.class文件。
后端编译
Javac 编译源代码
百闻不如一见,上面啰里啰嗦的说了一大堆不如看javac的源代码理解的会更快,Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler
类,上述的前端编译过程的代码逻辑集中在这个类的compile()
和compile2()
方法中。
Javap 反编译代码
javap
是jvm自带的一个工具,主要用于反编译Java的class文件,比idea自带的反编译更加细致(我没有仔细研究过idea的反编译设置,默认的idea反编译比较简洁,无法查看语法糖实现细节),可以查看一个java类反汇编、常量池、变量表、指令代码行号表等等信息。
javap的用法格式:
1 |
|
在命令行中直接输入javap
或javap -help
可以看到有如下选项:
1 |
|
一般常用的是以下三个选项:
- javap -v 不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。
- javap -l 会输出行号和本地变量表信息。
- javap -c 会对当前class字节码进行反编译生成汇编代码。
如下是一个操作User对象的测试类,源码如下:
1 |
|
我们可以先通过javac TestUser.java
生成字节码文件,然后通过javap -c TestUser
对class文件进行反编译,输出如下:
1 |
|
javap并没有将字节码反编译成java文件,而是生成了一种我们可以看得懂字节码。其实javap生成的文件仍然是字节码,只是程序员可以稍微看得懂一些。
TIP:另外通过jclasslib工具也可以看到上面这些信息,而且是可视化的,效果更好一些。
通过对前面例子代码反汇编中各个指令操作的分析,可以发现,一个方法的执行通常会涉及下面几块内存的操作:
- java栈中:局部变量表、操作数栈。这些操作基本上都值操作。
- java堆。通过对象的地址引用去操作。
- 常量池
- 其他如帧数据区、方法区(jdk1.8之前,常量池也在方法区)等部分,测试中没有显示出来,这里说明一下。
类字节码详解
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码。这个编译后的java代码,就是本文要介绍的java字节码。
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的,java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行。
此外,JVM也不再只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
类的生命周期
上面提到类的加载过程包括了:加载
、验证
、准备
、解析
、初始化
这五个阶段。其中,加载、验证、准备、初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言运行时的动态绑定。此外还包括了类的使用
、卸载
这最后的两阶段,如上七个阶段构成了类的生命周期:
加载
查找并加载类的二进制数据。加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三个事情:
- 通过类的全限定名来获取此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,也称为“类对象”,作为对方法区中这些数据的访问入口。
在Java中,一个类在被加载的时候就会在堆区里生成一个这个类的一个Class类型的“类对象”,每个类都对应着一个这样的类对象,通过这个Class类型的类对象,我们就能够使用“内省与反射”机制,访问一个类的信息,比如:对应类中的方法有哪些,成员域有哪些等等;获取一个类的“类对象”的方法有如下几种:
- 利用
Class.forName("类的全限定名")
方法获取类对象,最常见的应该是应用于JDBC注册驱动的时候用到的。 - 利用
对象.getClass()
方法获取类对象。 - 利用
类名.class
方式获取类对象。
有了“类对象”之后,虚拟机就能根据它来创建多个“实例对象”。实例对象就是类的具体实现,对于java来说,就是通过new方法,开辟一块内存存储new出来的实例对象。所以说,多个实例对象可以共享一个类对象,那么它们之间是如何联系起来的呢?在Java Hotspot 虚拟机中,对象和类是以一种被称为 oop-klass 的模型来表示的,篇幅有限,将在以后详述。
加载时验证
注意这虽然是加载阶段,但其实在加载阶段是夹杂着一些验证工作的,主要有以下验证:
文件格式验证
:验证字节流是否符合Class文件格式的规范;例如: 是否以魔数 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。元数据验证
:对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object
之外。这也就意味着,一旦某个类被加载,那么它的父类,祖先类等等也会被加载(但此时还不会被链接,初始化)
有人可能会困惑,为啥需要做这些校验工作呢,字节码文件难道不安全?字节码文件一般来说是通过正常的 Java 编译器编译而成的,但字节码文件也是可以编辑修改的,也是有可能被篡改注入恶意的字节码的,就会对程序造成不可预知的风险,所以加载阶段的验证是非常有必要的
验证
确认被加载的类的正确性。在验证阶段主要验证以下两种:
字节码验证
:主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法不会在运行时做出危害虚拟机安全的行为。符号引用验证
:确保解析动作能正确执行。这个验证其实是在解析阶段发生,符号引用可以看作是对类自身以外(常用池引用中的各种符合引用)的各类信息进行匹配性的验证,我们知道在字节码方法中如果调用了或者说引用了某个类,那么这个类是在字节码中是以符号引用的形式存在的,所以就要确保真正用到此类的时候能找到此类,如果找不到就会报错,举个简单的例子,假设有以下两个类,显然编译时都能通过,但在编译后如果我把 B.class 删掉,A.class 保留着 B 类的符号引用,如果执行 A 的 main 方法需要加载 B 类,由于 B.class 文件缺失导致无法加载 B 类,就会报错。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用
-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
为静态变量分配内存,并初始化其为默认值。这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这时候进行内存分配的仅包括静态变量(
static
),而不包括成员变量,成员变量会在对象实例化时随着对象一块分配在Java堆中。 - 这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为: public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
这里还需要注意如下几点:
- 局部变量需要显性赋值:对基本数据类型来说,对于静态变量和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- final常量需要显性赋值:对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。 - 引用数据类型默认为null值:对于引用数据类型
reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
。 - 数组中元素默认为null值:如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
- 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中。
解析
解析阶段是虚拟机将类中常量池内的符号引用替换直接引用的过程,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符7类符号引用进行。
- 符号引用:就是一组符号来描述目标,可以是任何字面量。
- 直接引用:能定位到内存方法区中对应类信息的内存中的具体地址,就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
以下面的代码为例:
1 |
|
在编译后,A 类的字节码文件 A.class 包括 B 的符号引用,那么在执行 main 方法后,由于碰到了 new B(),此时就会将 B 的符号引用转为指向 B 的类对象的直接引用,由于 B 未加载,所以,所以此时也会触发 B 的加载生成 B 的类对象,这样符号引用就可以转成直接引用了,这里是以类的解析举例,但实际上,常量,方法,字段等符号引用也都会被解析。
但需要注意的是这一阶段有可能发生在初始化之后,因为只有真正用到了比如需要调用某个类的方法时才需要去解析,如果在初始化时此方法还没有被用到,那解析自然也完全没有必要了。
初始化
在初始化阶段,主要做两件事:
- 初始化静态变量,为其赋予正确的初始值。
- 执行静态代码块内容。
无论是初始化静态变量还是执行静态代码块,javac编译后, 它们都会被一起置于一个被称为<clinit>()
的方法中,并且 JVM 会对其加锁以保证此方法只会被执行一次,只有在初始化完成之后,类才真正成为可执行状态。另外需要注意的,在子类的<clinit>()
完成之前,JVM 会确保父类的<clinit>()
也已经完成了,这从继承的角度也容易理解,子类毕竟继承着父类,只有父类初始化可用了,子类才能放心继承或者说使用父类的方法等。
类初始化步骤
- 假如这个类还没有被加载和链接,则程序先加载并链接该类。
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
- 假如类中有初始化语句,则系统依次执行这些初始化语句。
类初始化时机
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下几种:
- 创建类的实例,也就是new对象。
- 访问某个类或接口的静态变量,或者对该静态变量赋值。
- 调用类的静态方法。
- 反射,比如
Class.forName()
- 初始化某个类的子类,则其父类也会被初始化。
使用
类访问方法区内的数据结构的接口, 对象是堆区的数据。
卸载
Java虚拟机将结束生命周期的几种情况:
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
总结
怎么来更通俗地理解加载,链接,初始化这些阶段呢?
理解技术概念,代入生活中的场景会更容易理解,比如我们要盖房子,你总要图纸吧(字节码文件),按图纸建筑(加载)后成了一座房子(类对象
),但此时的房子还只是毛坯房,还不能住人,如果这个房子盖了没人住,那之后的装修等过程就没必要做了,这就是为什么上文定义了Test[] list = new Test[10]
这样的数组变量只是加载的原因,因为你没有调用 Test 相关的方法等操作,后续的步骤就没有必要做了,但如果房子盖好了之后你要入住,那首先这是个毛坯房,总得找人验下房(验证)吧,不然要是出现一些状况(比如把承重墙敲了成为了危房)这房子根本就不符合验收标准总得拒收吧,好了,验收通过之后那就可以开始装修了,为沙发,电视等预留好空间(准备),此时你只是在相应的地方标记了一下,A 位置留出来给电视,B 位置留出现给沙发,此时就相当只是做了一个符号引用,但你真正要看电视的时候,此时没有,那么你就得去买来装到对应的位置上,这就是(解析),当把房子装修完成之后(即初始化完成),此时的房子才是可用状态(即类处于可用状态),才可以交付给人入住。另外不难看出,解析这一步是可以放到初始化之后的,就就好比,虽然你为电视预留了位置,但你不看不买电视也照样能够入住。
类的加载机制
在上一个章节中提到类加载阶段分为加载、连接、初始化三个阶段,而加载阶段需要通过类的全限定名来获取此类的二进制字节流。Java特意把这一步抽出来用类加载器来实现。把这一步骤抽离出来使得应用程序可以按需自定义类加载器。并且得益于类加载器,OSGI、热部署等领域才得以在Java中得到应用。
类加载器
类加载必须由类加载器(Class Loader)来完成,它所做的就是将字节码文件加载为java.lang.Class
类对象,让每一个类在Java堆中都有一个相应的Class对象。
一个类的唯一性由加载它的类加载器和这个类的本身来决定(类加载器ID + 类全限定名)。比较两个类是否相等(包括Class对象的equals()
、isAssignableFrom()
、isInstance()
以及 instanceof
关键字等),只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个字节码文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不相等。也就是你用你A类加载器加载的com.example.Car
和你B类加载器加载的com.example.Car
它们是不同的。
既然类的唯一性有一方面由类加载器ID来决定,这也说明在JVM中定义了多种类加载器,这也为类的安全性和责任分离提供了环境。
首先说安全性,试想如果只有一个类加载器会出现什么情况,我们可能会定义一个
java.lang.virus
的类,这样的话由于此类与java.lang.String
等核心类处于同一个包名下,那么此类就具有访问这些核心类 package 方法的权限,此外如果用户自定义一个java.lang.String
类,如果类加载器加载了这个类,有可能把原本的 String 类给替换掉,这显然会造成极大的安全隐患。再来说责任分离,像
rt.jar
包下的核心类等没有什么特殊的要求显然可以直接加载来提高加载速度,而且由于是核心类,程序一启动就会被加载。而有些字节码文件由于反编译等原因可能需要加密,此时类加载器就需要在加载字节码文件时对其进行解密,再比如实现热部署也需要类加载器从指定的目录中加载文件,这些功能如果都在一个类加载器里实现,会导致类加载器的功能很复杂,所以解决办法就是定义多个类加载器,各自负责加载指定路径下的字节码文件,从而针对指定路径下的类文件加载做相关的操作,达到责任分离的目的。
类加载器的层次
从实现方式上,类加载器可以分为两种: 一种是启动类加载器,由C++语言实现,是虚拟机自身的一部分;另一种是继承于java.lang.ClassLoader
的类加载器,由Java语言实现,独立于虚拟机之外,包括扩展类加载器、应用程序类加载器以及自定义类加载器,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
启动类加载器(Bootstrap CL)
:负责加载存放在 JRE/lib/ 下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库,如rt.jar,所有的*java.**开头的类均被加载。启动类加载器是无法被Java程序直接引用的。扩展类加载器(Extension CL)
:该加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载 JRE/lib/ext/ 目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如*javax.**开头的类),开发者可以直接使用扩展类加载器。应用程序类加载器(Application CL)
:该类加载器由sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。自定义的类加载器(User CL)
:应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:在执行非置信代码之前,自动验证数字签名。
动态地创建符合用户特定需要的定制化构建类。
从特定的场所取得java class,例如数据库中和网络中。
现在我们知道了有以上几个种类的类加载器,那么这里有三个问题需要回答:
- 怎么指定类由哪个类加载器加载的呢?
- 类加载器是如何保证类的一致性的,由以上可知类加载器+类的全限定名唯一确定一个类,那怎么避免一个类被多个类加载器加载呢,毕竟你无法想象工程中有两个 Object 类,那岂不乱套了
- 类加载器(java.lang.ClassLoader)是用来加载类的,但其本身也是类,那么类加载器又是被谁加载的呢
为了解决上述问题,类加载器采用了双亲委派模型模式来设计类加载器的层次结构
寻找类加载器
1 |
|
结果如下:
1 |
|
从上面的结果可以看出,并没有获取到Ext CL
的父Loader,原因是Bootstrap CL
是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null
。
类的加载
类加载有三种方式:
- 命令行启动应用时候由JVM初始化加载
- 通过Class.forName()方法动态加载
- 通过ClassLoader.loadClass()方法动态加载
1 |
|
类加载特性
全盘负责
,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入缓存机制
,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效双亲委派机制
, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派模式
双亲委派的意思就是如果一个类加载器需要加载类,那么它会把这个类请求委派给父类加载器去完成。当父类加载器无法完成这个请求时,子类才会尝试去加载。这里的双亲其实就指的是父类。
除了启动类加载器除外,其他所有类加载器都需要继承抽象类ClassLoader
,这个抽象类中定义了三个关键方法,理解清楚它们的作用和关系非常重要:
1 |
|
``loadClass `方法的主要职责就是实现双亲委派机制:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载:
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
双亲委派机制的重要性
它确保类只被加载过一次并且核心类只能被Bootstrap CL来加载。就拿java.lang.Object
来说,你加载它经过一层层委托最终是由Bootstrap CL来加载的,也就是最终都是由Bootstrap CL去找JRE\lib
中 rt.jar里面的java.lang.Object
类,并加载到JVM中。 由于包中的核心类在程序已启动就会被加载,此时如果有不法分子自己造了个相同的java.lang.Object
类,根据这个双亲委派机制,是不会被再次加载的,这也使得核心类受到了保护。
如果开发者自己开发开源框架,也可以自定义类加载器,利用双亲委派模型,保护自己框架需要加载的类不被应用程序覆盖。
自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。
如果想自定义类加载器,第一种就需要继承ClassLoader
类,并重写findClass
方法,如果想不遵循双亲委派的类加载顺序,还需要重写loadClass
。第二种是通过线程上下文类加载器的传递性,让父类加载器中调用子类加载器的加载动作。
如下是一个自定义的类加载器TestClassLoader
,并重写了findClass
和loadClass
:
1 |
|
1 |
|
** 需要练习
注意破坏双亲委派的位置,自定义类加载机制先委派给ExtClassLoader加载,ExtClassLoader再委派给BootstrapClassLoader,如果都加载不了,然后自定义类加载器加载,自定义类加载器加载不了才交给AppClassLoader。为什么不能直接让自定义类加载器加载呢?
不能!双亲委派的破坏只能发生在AppClassLoader及其以下的加载委派顺序,ExtClassLoader上面的双亲委派是不能破坏的!
因为任何类都是继承自超类java.lang.Object,而加载一个类时,也会加载继承的类,如果该类中还引用了其他类,则按需加载,且类加载器都是加载当前类的类加载器。
如Demo类只隐式继承了Object,自定义类加载器TestClassLoader加载了Demo,也会加载Object。如果loadClass直接调用TestClassLoader的findClass会报错java.lang.SecurityException: Prohibited package name: java.lang。
为了安全,java是不允许除BootStrapClassLOader以外的类加载器加载官方java.目录下的类库的。在defineClass源码中,最终会调用native方法defineClass1获取Class对象,在这之前会检查类的全限定名name是否是java.开头。(如果想完全绕开java的类加载,需要自己实现defineClass,但是因为个人能力有限,没有深入研究defineClass的重写,并且一般情况也不会破坏ExtClassLoader以上的双亲委派,除非不用java了。
通过自定义类加载器破坏双亲委派的案例在日常开发中非常常见,比如Tomcat为了实现web应用间加载隔离,自定义了类加载器,每个Context代表一个web应用,都有一个webappClassLoader。再如热部署、热加载的实现都是需要自定义类加载器的。破坏的位置都是跳过AppClassLoader。
自定义类加载器可以实现资源jar隔离,代码保护、热加载等。
JVM 内存结构
运行时数据区
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程共享数据区:方法区、堆、堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
- 线程私有数据区:程序计数器、虚拟机栈、本地方法栈
下面我们就来一一解读下这些内存区域,先从最简单的入手
程序计数器
JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
概述
PC 存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
(分析:进入class文件所在目录,执行 javap -v xx.class
反解析(或者通过 IDEA 插件 Jclasslib
直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。)
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有的?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。
总结
- 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
- 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域
虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
特点:
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
栈的存储单位
栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧。
- 栈帧中存方法的局部变量、部分结果,并参与方法的调用和返回。
栈运行原理
- JVM 对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则。
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
IDEA 在 debug 时候,可以在 debug 窗口看到各种方法的压栈和出栈情况:
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking):指向运行时常量池的方法引用
- 方法返回地址(Return Address):方法正常退出或异常退出的地址
- 附加信息
继续深抛栈帧中的五部分~
1. 局部变量表
局部变量表是一组变量值存储空间,主要用于存储方法体的方法参数和局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
1.1 变量槽
局部变量表最基本的存储单元是 Slot(变量槽)
在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
- long 和 double 则占据两个 Slot
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)
在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
2. 操作数栈
- 每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的
max_stack
数据项中 - 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
- 操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
- 栈中的任何一个元素都可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
- 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
2.1 栈顶缓存(Top-of-stack-Cashing)
HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。寄存器是物理 CPU 中的组成部分之一,它同时也是 CPU 中非常重要的高速存储资源。一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU 寄存器数量是不同和不规律的。寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
3. 动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
3.1 JVM是如何执行方法调用的
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。
【这一块内容,除了方法调用,还包括解析、分派(静态分派、动态分派、单分派与多分派),这里先不介绍,后续再挖】
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关:
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。
3.2 虚方法和非虚方法
- 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。
- 其他方法称为虚方法。
3.3 虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
4. 方法返回地址
- 用来存放调用该方法的 PC 寄存器的值。
- 一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
- 当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口。一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
- 在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
- 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
5. 附加信息
- 栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本地方法栈
本地方法接口
简单的讲,一个本地方法就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。
为什么要使用本地方法(Native Method)?
Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了
- 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
- 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
- Sun’s Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类
java.lang.Thread
的setPriority()
的方法是用Java 实现的,但它实现调用的是该类的本地方法setPrioruty()
,该方法是C实现的,并被植入 JVM 内部。
本地方法栈
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈也是线程私有的
允许线程固定或者可动态扩展的内存大小
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个
StackOverflowError
异常如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个
OutofMemoryError
异常
本地方法是使用 C 语言实现的
它的具体做法是
Native Method Stack
中登记 native 方法,在Execution Engine
执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈。
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。
堆内存
栈是运行时的单位,而堆是存储的单位。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。
内存划分
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 年轻代:新对象和没达到一定年龄的对象都在新生代
- 老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx
和 -Xms
控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError
异常。
1. 年轻代(Young Generation)
年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
- Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
- 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代。
2. 老年代(Old Generation)
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝。
3. 元空间(Metaspace)
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
所以元空间放在后边的方法区再说。
设置堆内存大小和OOM
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx
和 -Xms
来设定
-Xms
用来表示堆的起始内存,等价于-XX:InitialHeapSize
-Xmx
用来表示堆的最大内存,等价于-XX:MaxHeapSize
如果堆的内存大小超过 -Xmx
设定的最大内存, 就会抛出 OutOfMemoryError
异常。
我们通常会将 -Xmx
和 -Xms
两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
可以通过代码获取到我们的设置值,当然也可以模拟 OOM:
1 |
|
1. 查看JVM堆内存分配
在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。
默认情况下新生代和老年代的比例是 1:2,可以通过
–XX:NewRatio
来配置- 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过
-XX:SurvivorRatio
来配置
- 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过
若在 JDK 7 中开启了
-XX:+UseAdaptiveSizePolicy
,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄。- 此时
–XX:NewRatio
和-XX:SurvivorRatio
将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy
- 在 JDK 8中,不要随意关闭
-XX:+UseAdaptiveSizePolicy
,除非对堆内存的划分有明确的规划
- 此时
每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小。计算依据是GC过程中统计的GC时间、吞吐量、内存占用量
1 |
|
1 |
|
对象在堆中的生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代。
- 新生代又被进一步划分为 Eden区 和 Survivor区,Survivor 区由 From Survivor 和 To Survivor 组成
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器(
-XX:MaxTenuringThreshold
)
- 此时 JVM 会给对象定义一个对象年轻计数器(
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代
对象的分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
- new 的对象先放在伊甸园区,此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到伊甸园区。
- 然后将伊甸园中的剩余对象移动到幸存者 0 区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区。
- 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
- 什么时候才会去养老区呢? 默认是 15 次回收标记。
- 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理。
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生
OutOfMemoery
异常。
GC 垃圾回收简介
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
- 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾。
TLAB(Thread Local Allocation Buffer)
- 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
- 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
- OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计
为什么要有 TLAB ?
- 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
在程序中,可以通过 -XX:UseTLAB
设置是否开启 TLAB 空间。
默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent
设置 TLAB 空间所占用 Eden 空间的百分比大小。
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
方法区
方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是
String.intern()
方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryErro
r 异常。方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
JVM 关闭后方法区即被释放
疑惑
你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?
- 方法区(method area)\只是 **JVM 规范**中定义的一个*概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)*是 **Hotspot** 虚拟机特有的概念, Java8 的时候又被**元空间**取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
- Java7 中我们通过
-XX:PermSize
和-xx:MaxPermSize
来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
用来设置元空间参数 - 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
- 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出
OutOfMemoryError
- JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
所以对于方法区,Java8 之后的变化:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)
设置方法区内存的大小
JDK8 及以后:
- 元数据区大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数 - 默认值依赖于平台。Windows 下,
-XX:MetaspaceSize
是 21M,-XX:MaxMetaspacaSize
的值是 -1,即没有限制 - 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常
OutOfMemoryError:Metaspace
-XX:MetaspaceSize
:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize
的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize
时,适当提高该值。如果释放空间过多,则适当降低该值- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值。
方法区内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
1. 类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
2. 域(Field)信息
JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
3. 方法(Method)信息
JVM 必须保存所有方法的
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)。
- 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
为什么需要常量池?
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool
常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池
- 在加载类和结构到虚拟机后,就会创建对应的运行时常量池。
- 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
intern()
方法就是这样的。
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。
方法区在 JDK6、7、8中的演进细节
只有 HotSpot 才有永久代的概念
jdk1.6及之前 | 有永久代,静态变量存放在永久代上 |
---|---|
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8及之后 | 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中 |
移除永久代原因:
- 为永久代设置空间大小是很难确定的。
- 在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制
- 对永久代进行调优较困难
方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
栈、堆、方法区的交互关系
- 本文作者:Jacckx
- 本文链接:http://jacckx.me/2023/07/09/JVM/index.html
- 版权声明:本博客所有文章均采用 BY-NC-SA 许可协议,转载请注明出处!