参考
《深入理解JVM虚拟机》第三版,在此感谢宋红康老师的JVM教程
走进Java
- c/c++编译生成的可执行文件跟操作系统和指令集架构都有关系。满足相同
的操作系统和指令集架构这两个条件才可以执行这个可执行文件。指令集不
同 很好理解,如果指令集相同操作系统不同也是不能执行的。原因如下,
参考https://www.zhihu.com/question/22672994
- 一个可执行文件除了机器指令还包括各种数据和运行时资源,并且文件的格
式也不同 - 可执行文件执行前操作系统要有一些准备工作,不同的操作系统准备工作
不同 - 一个可执行文件所执行的绝大多数操作(比如:文件操作、输入输出、内存
申请释放、任务调度等等)都需要与操作系统交互才能完成,而不同的操作
系统使用这些操作的方法完全不同。Java的一大特点就是摆脱了硬件平台的
束缚,一次编写到处运行,这跟c/c++有很大的区别
- java虚拟机的功能比Java要强大,实际上是跨语言的平台,不同的编程
语言编写的程序也可以编译成字节码文件,如果符合虚拟机规范也可以在虚
拟机上运行,实际上能在jvm平台执行的字节码格式都是一样的,统称为
jvm字节码 查看自己的Java环境 - JDK是用于支持Java程序开发的最小环境,JRE是支持Java程序运行的标准环境
- Java程序设计语言,Java虚拟机,JavaAPI类库组成JDK
- Java虚拟机,Java SE API组成JRE
- 编译过程 编译器(javac)将Java代码翻译成字节码,也叫做前端编译器,
因为操作系统并不能识别字节码,所以Java虚拟机中的执行引擎中的JIT编译
器要将字节码翻译成机器指令被CPU执行,所以JIT被称为后端编译器 - 运行过程 Java虚拟机能够将字节码解释成具体平台的机器码
- 虚拟机 是一台虚拟的计算机,也就是一个软件,用来执行一系列虚拟计算机指令
- 系统虚拟机 VisualBox VMware 完全对物理计算机的仿真,提供一个
可运行完整操作系统的软件平台,实际上模拟的是上图硬件 - 程序虚拟机 Java虚拟机 专门为执行单个计算机程序而设计,Java虚
拟机中执行的指令称为Java字节码指令,Java字节码是可以运行在任何
支持Java虚拟机的硬件平台和操作系统上的二进制文件,字节码的执行
实际上是被翻译成机器代码而执行的过程。实际上模拟的是上图JVM
JVM架构
- HotSpot虚拟机
- JVM的整体结构
- Java代码执行流程
- 指令集架构分为两种,Java编译器输入的指令流基本上是一种基于栈的指令集架构
反编译1
2
3//x86
mov $2, %eax
add $3, %eax
JVM的生命周期
- 虚拟机的启动 通过引导类加载器创建一个初始类来完成,这个类是由虚拟机
的具体实现指定 - 虚拟机的执行 执行Java程序,程序开始执行时执行,程序结束时结束。实际
上执行的是一个进程 进程执行结束后 - jps JVM Process Status 可以列出正在运行的虚拟机进程,并显示虚拟机
执行主类名称以及这些进程的本地虚拟机唯一ID - 虚拟机的退出
- 程序正常结束
- 程序遇到异常或错误
- 操作系统错误
- Runtime类或System类的exit方法
类文件结构
之前已经介绍过,Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只
与Class文件这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机
指令集、符号表以及若干其他辅助信息
Class类文件的结构
任何一个Class文件都对应着唯一的一个类或接口的定义信息。Class文件是一组
以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文
件之中,中间没有添加任何分隔符,如果遇到占用8个字节以上空间的数据项时
会按照高位在前的方式分割成若干个8个字节存储,与x86正好相反
数据类型
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构
只有两种数据类型
- 无符号数 u1表示1个字节的无符号数,还有u2 u4 u8等类推。可以用来描述
数字、索引引用、数量值或者按照UTF-8构成字符串值 - 表 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表
都以_info结尾,用于描述有层次关系的复合结构的数据
魔数与Class文件的版本
魔数0xCAFEBABE
每个Class文件的前4个字节被称为魔数,它的唯一作用是确定这个文件是否为
一个能被虚拟机接受的Class文件,使用魔数而不是扩展名来进行识别主要是
基于安全考虑,文件的扩展名可以被随意改动
版本号
紧接着魔数的4个字节存储的是Class文件的版本号,分别是次版本号和主版本
号。Java的版本号是从45开始,JDK1.1之后的每个JDK大版本发布主版本号向
上加1,高版本的JDK能够向下兼容以前版本的Class文件,但不能兼容以后版
本的Class文件
常量池
版本号之后就是常量池入口,常量池可以比喻为Class文件里的资源仓库。由于
常量池中常量的数量时不固定的,所以在常量池入口需要放置一项u2类型的数
据,代表常量池容量计数值。这个容量计数是从1开始而不是0开始,例如
0x0016表示十进制22,代表常量池有21项常量,索引值范围在1~21,如果
某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量
池项目的含义,可以把索引值设置为0来表示。常量池中主要存放两大类常量
- 字面量 文本字符串、被声明为final的常量值等
- 符号引用 当虚拟机加载时,将会从常量池中获得对应的符号引用,在类
创建时或运行时解析、翻译到具体的内存地址之中
- 被模块导出或者开放的包
- 类和接口的全限定名
- 字段的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
常量类型
常量池中每一项常量都是一个表,表结构起始的第一位是个u1类型的标志位
(tag),代表当前常量属于哪种常量类型,截止JDK13常量表中分别有17
种不同类型的常量
- CONSTANT_CLASS_info 代表类或接口的符号引用
1
2
3
4
5
6类型 名称 数量
u1 tag 1
u2 name_index 1
//tag是标志位,用于区分常量类型
//name_index是常量池的索引值,它指向常量池中一个CONSTANT_Utf-8_info类型常量
//此常量代表这个类的全限定名 - CONSTANT_Utf-8_info
1
2
3
4
5
6u1 tag 1
u2 length 1
u1 bytes length
//Class文件中方法、字段都需要引用CONSTANT_Utf-8_info型常量来描述名称
//length说明字符串长度是多少字节,最大是65535,如果方法、字段名超过64KB则无法编译
//length值后边紧跟的是使用UTF-8缩略编码的字符串
常量池17中数据类型结构总表
访问标志
常量池之后紧接着是2个字节代表访问标志,这个标志用于识别一些类或者接口的
访问信息,包括这个Class是类还是接口,是否定义为public类型,是否定义为
abstract类型,如果是类是否声明为final等等,符合多个标志位例如A和B的
话标志值为 A|B,比如一个普通的public类 0x001 | 0x0020 = 0x0021
类索引、父类索引与接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据
的集合,Class文件由这三项数据来确定该类型的继承关系。类索引用于确定这个
类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引集合就是
这个类实现了哪些接口。类索引、父类索引和接口索引集合都按顺序排列在访问
标志之后,类索引和父类索引用两个u2类型表示,各自指向类型为CONSTANT_
Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索
引值找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串
对于接口索引,入口的第一项u2类型的数据是接口计数器,表示索引表的容量,
如果该类没有实现任何接口则计数器值为0,后面接口的索引表不再占用任何
字节
字段表集合
首先是一个容量计数器表示字段的数量,然后是字段表
字段表用于描述接口或者类中声明的变量,Java语言中的字段包括类级变量和
实例级变量,但不包括在方法内部声明的局部变量。字段可以包括的修饰符有
字段的作用域(public/private/protected)、实例变量还是类变量
(static)、可变性(final)、并发可见性(volatile,是否强制从主内存
读写)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)
、字段名称等等。上述这些信息各个修饰符都是布尔值,要么有某个修饰符要
么没有,很适合用标志位表示,而字段的名字和数据类型只能引用常量池
中的常量来描述
name_index和description_index都是对常量池项的引用,分别代表字段的
简单名称以及字段和方法的描述符
- 全限定名 org/fenixsoft/clazz/TestClass就是一个全限定名,仅仅把
类全名中的.换成了/而已 - 简单名称 没有类型和参数修饰的方法或字段名称,比如inc()方法和m字段
的简单名称就是inc和m
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型
以及顺序)和返回值,对象类型是字符L加对象的全限定名来表示
对于数组类型,每一维度将使用一个前置的[字符来描述,如定义一个为
java.lang.String[][]类型的二维数组将被记录成[[Ljava/lang/String
一个整型数组int[]将被记录成[I
用描述符描述方法时按照先参数列表后返回值的顺序描述,参数列表按照参数
的严格顺序放在一组小括号()之内,比如方法void inc()描述符为()V,方法
java.lang.String.toString()描述为()Ljava/lang/String,方法
int indexOf(char[] source,int sourceOffset,int sourceCount,
char[] target,int targetOffset,int targetCount,int fromIndex)
的描述符为([CII][III)I
在descriptor_index之后跟随一个属性表集合,用于存储一些额外的信息,
这部分在属性表再介绍。字段表集合中不会列出从父类或者父接口中继承而
来的字段,另外在Java语言中字段是无法重载的,必须使用不一样的名称
方法表集合
Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,
方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性
表集合
因为volatile和transient关键字不能修饰方法,而synchronized native
strictfp abstract可以修饰方法
方法里面的代码经过Javac编译器编译成字节码指令后存放在方法属性表集合中
一个名为Code的属性里面
- 与字段表集合类似,如果父类方法在子类中没有被重写那么就不会出现父类
方法的信息,但同样会出现编译器自动添加的方法,比如clinit和init - 在Java语言中要重载一个方法,除了要与原方法具有相同的简单名称之外,
还必须要求拥有一个与原方法不同的特征签名。特征签名是指一个方法中各
个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特
征签名中,所以不能仅依靠返回值的不同对一个方法进行重载,Java代码的
方法特征签名只包括方法名称、参数顺序和参数类型,而字节码的特征签名
还包括方法返回值以及受查异常表
属性表集合
Class文件、字段表、方法表都可以携带自己的属性表(attribute_info)集合
属性表集合的限制更为宽松,不再要求各个属性表具有严格顺序
对于每一个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型
的常量来表示,而属性值的结构完全自定义的,一个符合规则的属性表应该满足
的结构如下
1 | 类型 名称 数量 |
Code属性
Java程序方法体里面的代码经过编译后变为字节码指令存放在Code属性内,
Code属性存在于方法表的属性集合之中,接口或抽象类中的方法不存在Code
属性
attribute_name_index是指向CONSTANT_Utf8_info型常量的索引,此常量
固定为Code,它代表了该属性的属性名称,attribute_length指示了属性值
的长度,属性值的长度为整个属性表长度减去6个字节(属性名称索引和属性
长度),max_stack代表操作数栈深度的最大值,max_locals代表局部变量
所需的存储空间,单位是slot,方法参数(包括实例方法的this)、显示异
常处理程序的参数(try-catch中catch块中所定义的异常)、方法体中定义
的局部变量都需要依赖局部变量表来存放,code_length和code用来存储Java
源程序编译后生成的字节码指令,code_length代表字节码长度,code是用
于存储字节码指令的一系列字节流。每条指令就是一个u1类型的单字节,当
虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的
是什么指令,并且可以根据这条指令后面是否需要跟随参数以及参数如何
解析。虽然code_length是4个字节,但是限制一个方法不允许超过65535
条指令,实际只能使用2个字节
异常表对于Code属性来说并不是必须存在的Exceptions属性
与刚刚讲解的异常表不是一回事,列举出方法中可能抛出的受查异常,也就
是方法描述在throws关键字后面列举的异常,Exceptions属性结构如下1
2
3
4
5类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_exceptions 1
u2 exception_index_table number_of_exceptions此属性的number_of_exceptions项表示方法可能抛出number_of_exceptions
中受查异常,每一种受查异常使用一个exception_index_table项表示,
exception_index_table指向常量池中CONSTANT_Class_info型常量的索引,
代表该受查异常的类型LineNumberTable属性
用来描述Java源码行号和字节码行号之间的对应关系,line_number_info包含
两个数据项,start_pc是字节码行号,line_number是Java源码行号1
2
3
4u2 attribute_name_index 1
u4 attribute_length 1
u2 line_number_table_length 1
line_number_info line_number_table line_number_table_lengthLocalVariableTable及LocalVariableTypeTable属性
LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义
的变量之间的关系
Local_variable_info代表一个栈帧与源码中的局部变量的关联
start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移
量及其作用域范围覆盖的长度,index是这个局部变量在栈帧的局部变量表中
变量槽的位置,LocalVariableTypeTable属性可以描述泛型。为什么局部变
量也在常量池中有名称和描述符?我查了一下资料,发现在一篇博客中有提
到这样一句话:而对于基本类型数据(甚至是方法中的局部变量),常量池中
只保留了他的的字段描述符I和字段的名称value,他们的字面量不会存在于
常量池 https://www.cnblogs.com/qingfengyiran-top1/p/11300654.htmlSourceFile及SourceDebugExtension
用于记录生成这个Class文件的源码文件名称1
2
3u2 attribute_name_index 1
u4 attribute_length 1
u2 sourcefile_index 1ConstantValue属性
通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量才可以使
用这项属性1
2
3u2 attribute_name_index 1
u4 attribute_length 1
u2 constantvalue_index 1InnerClasses属性
用于记录内部类与宿主类之间的关系
字节码指令简介
Java虚拟机的指令是由一个字节长度的、代表着某种特定操作含义的数字(称为
操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作
数,Operand)构成
1 | 如果不考虑异常处理,那Java虚拟机的解释器可以用以下伪代码表示 |
字节码与数据类型
在Java虚拟机指令集中大多数指定都包含其操作所对应的数据类型信息,例如
iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令
加载float类型的数据
对于大部分与数据类型相关的字节码指令,它们的操作码助记符都有特殊的字符
表明专门为哪种数据类型服务,i代表对int类型的数据操作,以此类推l s b
c f d a,a代表reference,arraylength指令操作数是一个数组类型的对象
编译器在编译期或运行期将byte和short类型的数据符号扩展为int类型,将
boolean和char类型数据零位扩展为int类型,所以相应也会用int类型的字
节码指令来处理
加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间传输
- 将一个局部变量加载到操作数栈 iload iload<\n>
- 将一个数值从操作数栈存储到局部变量表 istore istore<\n>
- 将一个常量加载到操作数栈 bipush ldc iconst_ml
运算指令
对操作数栈上的两个值进行某种特定运算,并把结果重新存入操作数栈顶
- 加法指令 iadd ladd fadd dadd
- 减法指令 isub lsub fsub dsub
- 乘法指令 imul lmul fmul dmul
- 除法指令 idiv ldiv fdiv ddiv
- 求余指令 irem lrem frem drem
- 取反指令 ineg lneg fneg dneg
- 位移指令 ishl ishr iushr lshl lshr lushr
- 按位或指令 ior lor
- 按位与指令 iand land
- 按位异或指令 ixor lxor
- 局部变量自增指令 iinc
- 比较指令 dcmpg dcmpl fcmpg fcmpl lcmp
类型转换指令
将两种不同的数值类型相互转换,小范围向大范围转换无需显示的转换指令,例
如int到long float double,long到float double,float到double,处理
窄化类型转换用到的指令有i2b i2c i2s l2i f2i f2l d2i d2l d2f
对象创建与访问指令
Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
- 创建类实例的指令 new
- 创建数组的指令 newarray anewarray multianewarray
- 访问类字段的指令 getfield putfield getstatic putstatic
- 把一个数组元素加载到操作数栈的指令 baload caload saload aaload
- 将一个操作数栈的值存储到数组元素中的指令 bastore castore aastore
- 取数组长度的指令 arraylength
- 检查类实例类型的指令 instanceof checkcast
操作数栈管理指令
直接操作操作数栈
- 将操作数栈的栈顶一个或两个元素出栈 pop pop2
- 复制栈顶一个或两个数并将复制值或双份的复制值重新压入栈 dup dup2
- 将栈最顶端两个数值交换 swap
控制转移指令
有条件或无条件跳转指定位置
- 条件分支 ifeq iflt ifle ifne ifgt
- 复合条件分支 tableswitch lookupswitch
- 无条件分支 goto goto_w
方法调用和返回指令
方法调用指令与数据类型无关,方法返回指令与数据类型有关,例如ireturn
(boolean byte char short int)lreturn freturn dreturn areturn
如果返回为void则是return
- invokevirtual指令 用于调用对象的实例方法
- invokeinterface指令 用于调用接口方法
- invokespecial指令 用于调用一些需要特殊处理的实例方法
- invokestatic指令 用于调用类静态方法
- invokedynamic指令 用于运行时动态解析处调用点限定符所引用的方法
异常处理指令
Java程序中显示抛出异常(throw)都是由athrow指令实现,处理异常不是由
字节码指令实现而是由异常表完成
同步指令
Java虚拟机支持方法级的同步和方法内部一段指令序列的同步
类加载器与类加载过程
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解
析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为虚拟机的
类加载机制。类加载过程都是在程序运行期间完成的,为Java提供了极高的扩展性
和灵活性,比如编写一个面向接口的程序,等到运行时指定实际的实现类,本地程
序运行时从网络或其他地方加载二进制流作为程序代码的一部分,比如JSP等技术。
加载
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据区
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各
种数据的访问入口
对于数组类而言,数组类本身并不通过类加载器创建,它是由Java虚拟机直接在
内存中动态构造出来,当数组类的元素类型最终还是通过类加载器来完成加载,
一个数组类创建过程遵循如下原则
- 如果数组的组件类型(去掉一个维度)是引用类型,那么递归采用加载过
程去加载这个组件类型,数组将被表示在加载该组件类型的类加载器的类名
称空间上 - 如果数组的组件类型不是引用类型(例如int[]),Java虚拟机将会把数组
标计为引导类加载器关联 - 数组类的可访问性与它的组件的可访问性一致,如果组件类型不是引用类
型,它的数组类的可访问性将默认为public,可被所有的类和接口访问
加载结束后,Java虚拟机外部二进制流就按照虚拟机设定的格式存储在方法区中,
然后会在堆中实例化一个java.lang.Class类的对象,这个对象将会作为程序访
问方法区中的类型数据的外部接口
链接
验证
- 验证是确保Class文件的字节流中包含的信息符合Java虚拟机规范中的全部
约束要求,保证这些信息不回被当做代码运行后危害虚拟机自身的安全 - Java是相对安全的编程语言,无法访问数组边界以外的数据、将一个对象转
型为并未实现的类型、跳转到不存在的代码行之类的情况,可以编译但在运行时会报错 - 文件格式验证 只有通过了这一阶段的验证才被允许进入Java虚拟机内存中
的方法区中进行存储,之后的三个验证阶段都是基于方法区中的内容
- 是否以魔数0xCAFEBABE开头
- 主、次版本号是否在当前Java虚拟机接受范围
- 元数据验证 该阶段是对字节码描述的信息进行语义分析,保证符合Java
语言规范的要求
- 这个类是否有父类(除了java.lang.Object,所有的类都要有父类)
- 这个类是否继承了不被允许的类(被final修饰的类)
- 如果这个字段不是抽象类是否实现了父类或接口中要求实现的所有方法
- 类中的字段、方法是否与父类参数矛盾(比如覆盖了父类的final方法,或
者出现不符合规则的方法重载,例如方法参数一样类型不一样)
- 字节码验证 通过数据流分析和控制流分析确定语义是合法的、符合逻辑的
在第二阶段对元数据信息中的数据类型校验完毕后这一阶段对类的方法体进
行校验分析
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会
出现在操作数栈中放置了一个int类型的数据,使用时却按long类型来加载入
本地变量表中 - 保证任何跳转指令都不会调转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的,例如吧一个子类对象赋给父类数据类
型是安全的,但是父类对象赋值给子类数据类型甚至对象赋值给毫无继承关
系的数据类型是不符合要求的
- 符号引用验证 这一过程发生在虚拟机将符号引用转化为直接引用的时候,
这个转化过程将在连接的第三个阶段-解析阶段发生,可以看做是对类自身
以外的各类信息进行匹配性校验,该类是否缺少或者被禁止访问它依赖的某
些外部类、方法、字段等资源
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性是否可被当前类访问
准备
准备阶段是正式为类中定义的变量(即静态变量)分配内存并设置类变量初始
值的阶段。JDK7以前HotSpot使用永久代实现方法区时变量所使用的内存分配
在方法区,JDK8及以后类变量会随着Class对象一起存放在堆内存中,这时
类变量在方法区就是一种逻辑上的表述,进行内存分配的也只有类变量,
实例变量会在对象实例化时随对象分配在堆中。准备阶段a赋值为0,初
始化过程赋值为1
1 | public class HelloApp{ |
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
- 符号引用 符号引用十一组符号来描述所引用的目标,可以是任何形式的字
面量,只要使用时能无歧义地定位到目标即可 - 直接引用 直接引用是直接指向目标的指针、相对偏移量或者一个能间接定
位到目标的句柄,引用的目标必定已经在虚拟机的内存中存在
初始化
什么情况开始加载过程并没有强制约束,这点可以由虚拟机自由把握。
初始化阶段有且只有六种情况必须对类进行初始化(而加载、验证、准备等自然
已经完成),这六种场景称为对一个类型进行主动引用,其余引用方式称为被动
引用,接口也有初始化过程,但是接口中不能用static{}语句块,但是编译器仍
然会为接口生成clinit类构造器,用于初始化接口中所定义的成员变量,接口与
类的区别在于第三种场景,接口初始化时并不要求其所有父接口都完成初始化,
只有真正使用到父接口的时候才会初始化,也就是只有父接口中定义的变量被使用
时,才会被初始化。接口的实现类在初始化时也一样不会执行接口的clinit方法
- 遇到new getstatic putstatic invokestatic这4条指令代码时如果类
型没有进行初始化则会出发初始化阶段,能够生成这4条指令的场景如下
- 使用new关键字实例化对象的时候
- 读取或设置一个类型的静态字段的时候(被final修饰已经在编译期就确定的
字段除外) - 调用一个类型的静态方法的时候
- 使用java.lang.reflect包的方法对类型进行反射调用的时候如果类型没有
初始化就先触发初始化 - 初始化类时,如果父类没有初始化就先触发父类的初始化
- 虚拟机启动时,用户需要指定一个要执行的主类(包含main方法),虚拟机
会先初始化这个类 - 使用JDK7新加入的动态语言支持时,如果java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic REF_putStatic REF_invokeStatic
RED_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有
过初始化会触发其初始化 - 当一个接口中定义了JDK8中新加入的默认方法(被default关键字修饰的接口
方法)时,如果这个接口的实现了发生初始化那么接口要先初始化1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40public class SuperClass{
static{
System.out.println("super init");
}
public static int value=23;
}
public class SubClass extends SuperClass{
static{
System.out.println("sub init");
}
public static final String hello="Hello World";
}
public class Test1{
public static void main(Sting[] args)
{
System.out.println(SubClass.value);
}
}
//运行上述代码只会输出 super init而不会输出 sub init
//对于静态字段,只有直接定义这个字段的类才会被初始化
//sub类引用super类的静态字段所以super会被初始化,但是加载和验证阶段并不确定
//对于HotSpot虚拟机可以通过-XX:+TraceClassLoading参数观察是否被加载
public class Test2{
public static void main(String[] args)
{
SuperClass[] array=new SuperClass[10];
}
}
//没有输出 super init,说明没有触发super类的初始化
//通过数组定义类引用类,不会触发类的初始化
public class Test3{
public static void main(String[] args)
{
System.out.println(SubClass.hello);
}
}
//没有输出 sub init
//编译阶段通过常量传播优化已经将常量的值"Hello World"存储在Test3类的常量池中
//以后Test3对于常量SubClass.hello的引用实际上被转化为Test3类对自身常量池的引用
//Test3的Class文件中并不存在对SubClass类的符号引用入口,这两个类在编译后就没有关系了 - 以下代码是允许的,链接时将num和number都赋值为0,初始化时将赋值动
作和静态代码块按顺序执行,最终number的值为1,但是不能有打印语句,
在number声明之前可以赋值但是不能前向引用 - 类变量必须要加static,否则就是实例变量
- 如果不存在类变量的赋值(不光定义类变量)和静态代码块那么就不会有clinit方法
1
2
3
4
5
6
7
8
9
10public class Init{
public static int num=1;
static {
num=2;
number=10;
//System.out.println(number);
}
public static int number=1;
//最终number是1
}
类加载器分类
每一个类加载器都有一个独立的类名称空间,也就是说比较两个类是否相等
的两个条件是来自同一个Class文件并且由同一个类加载器加载
- 启动类加载器
- 自定义类加载器,Java虚拟机规范定义所有继承抽象类java.lang.ClassLoader
的类加载器都划分为自定义类加载器
- 类加载器的复制关系一般不是以继承的关系来实现的,而是通常使用组合
关系来复用父加载器的代码 - 用户自定义类加载器
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
- ClassLoader 是一个抽象类,所有类加载器都继承自ClassLoader
(除了引导类加载器)
双亲委派机制
自己定义了一个java.lang.String类,系统类加载器加载时首先往上委托,
发现启动类加载器可以加载java.lang.String,所以自己定义的java.lang.String
不会被加载
1 | public class String{ |
优势是避免类的重复加载,保护程序安全,防止核心API被随意篡改
沙箱安全机制
补充
- JVM必须知道一个类型是由哪个类加载器加载的(是启动还是用户)。如
果是用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型
信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用时,JVM
需要保证这两个类型的类加载器是相同的
运行时数据区内部结构
一个进程对应一个红色区域,一个线程对应一个灰色区域
内存
线程
程序计数器介绍
java虚拟机中存在PC寄存器,也可以叫做程序计数器
作用
- 是一块很小的内存空间,也是运行速度最快的存储区域
- 每个线程都有自己的程序计数器,生命周期与线程的生命周期一致
- 任何时间一个线程都只有一个方法在执行,也就是当前方法,程序计数器
会存储当前线程正在执行的Java方法的虚拟机字节码指令地址,如果执行
native方法则未指定值。是唯一不存在OOM的区域
例子
虚拟机执行字节码指令实际是通过生成机器指令执行的
两个常见问题
CPU时间片
- 串行 线程排成队依次被CPU执行
- 并行 多个线程同时被执行
- 并发 一个CPU快速切换线程,看起来像是并行
虚拟机栈
虚拟机栈的概述
java虚拟机栈早期也叫Java栈,每个线程创建时都会创建一个虚拟机栈
,其内部保存一个个栈帧,对应一次次的Java方法调用,生命周期和线程
一致,主管Java程序的运行,保存方法的局部变量、部分结果,并参与方法
的调用和返回。局部变量包括8种基本数据类型和对象的引用地址
栈帧的粗略描述
栈的优点
栈中可能出现的异常
HotSpot虚拟机的栈容量是不可以动态扩展的,所以不会出现虚拟机栈
无法扩展而导致的OOM异常,只要申请栈空间成功了就不会OOM,如果申
请时就失败会出现OOM
设置栈内存大小
使用参数-Xss 设置线程的最大栈空间 1m=1024k
栈的存储单位
栈运行原理
栈帧的内部结构
局部变量表
解析字节码文件
- main方法所在的栈帧中 locals=3 表示局部变量表的长度
字节码中方法内部结构剖析
在IDEA插件中下载jclasslib,在view中show bytecode
- public static + void + main,参数是String型数组
Slot
方法运行期间不会改变局部变量表的大小,这里的大小指的是变量槽的数量,
虚拟机自行决定是用多大的空间表示一个变量槽(32bit or 64bit)
静态方法的局部变量表中没有this,所以不能在静态方法中引用this
slot的重复利用
局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么
在其作用域之后申明的新的局部变量就很有可能过期局部变量的槽位,达到节
省资源的目的
变量c使用之前已经销毁的变量b占据的slot的位置
静态变量与局部变量的对比
- 按数据类型分类 基本数据类型和引用数据类型
- 按照在类中声明的位置 成员变量和局部变量,成员变量又可分为是否
用static修饰。如果static修饰就是类变量否则就是实例变量 - 成员变量在使用前都被默认初始化,在类加载过程的链接中的准备阶段给类
变量默认赋值,在initial阶段给类变量显式赋值以及静态代码块赋值。实例变
量随着对象的创建会在堆空间中分配实例变量空间并进行默认赋值 - 局部变量在使用前必须进行显式赋值
- 如果局部变量表中没有指向堆的一个引用那么就会进行垃圾回收
操作数栈
代码追踪
- 一开始指令地址指向0,局部变量表中只有this,将15(int)放入
操作数栈,然后指令地址指向2,执行出栈操作,同时局部变量表中加
入15 - 从局部变量表中依次取出索引为1和2的数据放入操作数栈中
- iadd指令被执行引擎翻译成机器指令被CPU执行,将操作数栈中的两
个数出栈然后进行加法运算将结果放入操作数栈中 - 对于有返回值的情况,获取上一个栈帧返回的结果并保存在操
作数栈中
栈顶缓存技术
动态连接
- 帧数据区:动态链接,一些附加信息,方法返回地址
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属法规办法的引用,
持有这个引用是为了支持调用过程中的动态链接 - 所有的变量和方法引用都作为符号引用保存在常量池中
- 字节码中的方法调用指令就以常量池中里指向方法的符号引用作为参数
- 静态解析 一部分符号引用会在类加载阶段或者第一次使用的时候被转化
为直接引用 - 动态解析 一部分符号引用会在每一次运行期间都转化为直接引用,例如多态
方法的调用
1 | public class Main { |
虚方法和非虚方法
1 | public class Father |
invokedynamic指令
方法的重写
例如Son没有重写toString方法,如果没有虚方法表那么就会往上进行查找Father
然后到Object,有了虚方法表后直接到Object
方法返回地址
存放调用该方法的pc寄存器的值
附加信息
例如对程序调试、性能收集相关的信息,这部分信息由具体虚拟机实现
栈的相关面试题
- 可以通过-Xss设置栈空间大小溢出时StackOverFlowError,可以动态扩展时如
果内存空间不足无法动态扩充栈空间时会出现OOM - 不能保证,如果发生无限递归的情况一定会栈溢出
- 并不是,会挤占其它内存空间
- 不会
- 有可能存在不安全问题
本地方法栈
HotSpot虚拟机不区分虚拟机栈和本地方法栈,因此-Xoss参数(设置本地方法栈)
虽然存在但是没有任何效果,栈容量只能由-Xss设定
堆
一个进程就对应一个JVM实例,一个JVM实例就有一个运行时数据区,一个运行时
数据区就有一个堆,一个进程的多个线程要共享一个堆空间
Java堆即可实现成固定大小也可使是可扩展的,目前主流的虚拟机都是可以扩展
的,HotSpot虚拟机的栈容量是不可扩展的,通过参数-Xms -Xmx 动态扩展堆空间
将最大值与最小值设置为一样就可避免自动扩展
VisualVM是功能最强大的运行监视和故障处理程序之一
Visual GC 插件可以在工具中下载,可能要多次重试下载
12m+8+1m+7m=20m
1 | public class Main { //Main2代码跟Main一样,不过一个堆空间是10M一个是20M |
堆的核心概述
堆内存细分
设置堆空间的大小时实际只包括新生代和老年代,并不包括元空间
-XX:PrintGCDetails 打印垃圾回收的细节
设置堆内存大小与OOM
开发中建议将初始堆内存和最大堆内存设置一样大,否则GC之后会重新调整堆大小
给系统造成额外压力
默认情况下实际可用的空间不足8G
jstat -gc 监视Java堆运行情况
OutOfMemory
年轻代与老年代
-XX:NewRatio=1
jinfo的作用是实时查看和调整虚拟机各项参数
对象分配过程
红色的部分已经是垃圾,绿色的部分还要使用放到幸存者区
对象分配的特殊情况
每次垃圾回收必须保证Eden区清空如果幸存者区放不下就放到老年区,YGC时
幸存者区也会进行垃圾回收
GC
堆空间分代思想
内存分配策略
TLAB
默认开启
堆空间的参数设置小结
- -XX:SurvivorRatio 设置新生代中Eden和s0/s1的占比,如果Eden过大那么
对象会更可能存放到老年代,使Minor GC失去意义,如果Eden过小那么
Minor GC的频率就会变高,会影响用户进程
通过逃逸分析看堆空间的对象分配策略
快速判断是否发生逃逸分析就看new的对象实体是否有可能在方法外调用,如果对
象声明是static仍然会发生逃逸
总结就是在开发中应该多使用局部变量少在方法外定义
代码优化
代码优化之栈上分配
代码优化之同步省略
代码优化之标量替换
堆的小结
所以之前讲解的栈上分配实际上用的是标量替换,对象依然是在堆中
方法区
栈、堆、方法区的交互关系
方法区的基本理解
实际加载的类数量远多于直接能识别的类
Hotspot中方法区的演进
设置方法区大小和OOM
-XX:MetaspaceSize=100m
Metaspace举例
方法区的内部结构
类型信息
域信息
方法信息
non-final的类变量
全局常量
static+final 每个全局常量在编译的时候就被分配了
class文件中的常量池
常量池就可以看做是一张表,虚拟机指令根据这张表找到要执行的类名、方法
名、参数类型、字面量类型
运行时常量池
方法区的演进
- jdk1.6以前 有永久代,静态变量存放在永久代上
- jdk1.7 有永久代但是逐步去永久代,字符串常量池、静态变量移除保留在堆中
- jdk1.8及以后 无永久代,类型信息、字段、方法、常量保存在本地内存的元
空间中,当字符串常量池、静态变量仍在堆中 - 为永久代设置空间大小是很难确定的,在某些场景下如果动态加载类过多
容易产生Perm区的OOM,比如某个实际的web工程中因为功能比较多在运
行过程中要不断加载很多类,元空间使用本地内存受本地内存大小的影响动态改变
StringTable的调整
jdk7中将StringTable放到了堆空间中,因为永久代的回收效率很低,在full gc
的时候才会触发,而full gc是老年代的空间不足、永久代空间不足时才触发,开发
中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆中能够及
时回收
静态变量的存储位置
静态引用对应的对象实体始终存放在堆空间,与jdk版本无关,jdk7以后静态变量
本身也是放在堆空间中的
方法区的垃圾回收
Java虚拟机规范中并没有强制要求对方法区进行垃圾回收,方法区的垃圾回收主要
是常量池中废弃的常量和不再使用的类型。方法区常量池中主要存放的两大常量是
字面量和符号引用,字面量比较接近Java语言层次的常量概念,如文本字符串,
被声明为final的常量值等。符号引用则属于编译原理方面的概念,包括以下三类
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
只要常量池中的常量没有被任何地方引用就可以被回收
本地方法接口
1 | //native和abstract不可以共存,native方法是有方法体的 |
对象
对象的创建
当Java虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中
定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解
析和初始化过,如果没有先执行相应类加载过程。对象所需的内存大小在类加载
完成后便可确定,内存分配完成后将分配到的空间(不包括对象头)都初始化
为零值,这步操作保证对象的实例字段在Java代码中不赋初始值就可以使用。
对象头中的内容如下
- 这个对象是哪个类的实例
- 如何找到类的元数据信息
- 对象的哈希码(实际等到真正调用时才计算)
- 对象的GC分代年龄
对象的实例化
调用完构造器对象才算创建完成,new之后是默认赋值,调用构造器是显式赋值
,例如捏面人,如果new是捏了一个形状那么调用构造器就是上色。是否执行构
找函数是由new指令后面是否跟随invokespecial指令决定,Java编译器会在
遇到new关键字的地方同时生成这两条字节码指令
对象的内存布局
对象访问定位
栈帧中的对象引用访问到内部对象实例有两种方式
- 句柄访问 好处是reference中存储的是稳定句柄地址,在对象被移动时只
会句柄中的实例数据指针,而reference本身不需要修改 - 直接指针 好处是速度快,节省一次指针定位的时间开销,HotSpot采用这种
方式
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java是虚拟机规范中定
义的内存区域,但是这部分内存也频繁使用甚至出现OOM,JDK1.4加入了NIO类,
引入了一种基于通道与缓冲区的IO方式,使用Native函数库直接分配堆外内存,
然后通过堆中的DirectByteBuffer对象作为这块内存的引用进行操作
1 | //直接分配本地内存空间 |
通常访问直接内存的速度优于访问Java堆,即读写性能更高
- 处于性能考虑,读写频繁的场合可能考虑使用本地内存
- Java的NIO库允许Java程序使用直接内存用于数据缓冲区
直接内存的OOM
直接内存在Java堆外,它的大小不会受限于-Xmx指定的最大堆大小,但是系统
内存有限,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存,
直接内存大小可以通过MaxDirectMemorySize设置,如果不指定默认与堆的
最大值-Xmx参数值一致,缺点如下
- 回收成本高
- 不受JVM内存回收管理
执行引擎
执行引擎的工作过程
所有Java虚拟机的输入输出形式都是一致的,输入的是字节码二进制流,处理
过程是字节码解析执行的等效过程,输出的是执行结果
Java代码编译和执行过程
HotSpot虚拟机既有解释器也有JIT编译器,所以说Java是半编译半解释型
语言
- 解释器 当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的
方式执行,每条字节码文件中的内容翻译为对应平台的本地机器指令 - JIT编译器 将字节码直接编译成与本地机器平台相关的机器语言,但不执行
解释器
将字节码中的内容翻译成对应平台的机器指令,一开始的解释器一条条执行效率
低下,目前JVM平台支持一种即时编译的技术,目的是避免函数被解释执行,而是
将整个函数体编译成机器码,每次函数执行时只执行编译后的机器码即可
JIT编译器
HotSpot虚拟机采用解释器与即时编译器并存的架构,在虚拟机运行时能够相互
协作,各自取长补短。当程序启动时解释器可以马上逐条执行不必等即时编译器
全部编译完成后再执行,随着程序的运行,根据热点探测功能编译器将有价值
的字节码编译为本地机器指令,以换取更高的执行效率。JRockit VM内部不包
含解释器,字节码全部依靠即时编译器编译后执行,所以执行性能高但是编译
需要一定时间所以启动时需要花费更长的时间编译
热点代码探测
是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令需要根据
代码被调用执行的频率而定,那些需要被编译为本地代码的字节码也称为热点
代码,JIT编译器在运行时会针对那些频繁被调用的热点代码做出深度优化,将
其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能
回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到
控制流后跳转的指令称为回边,显然建立回边计数器统计的目的就是为了触发
OSR编译
HotSpot设置模式
- -xint 完全采用解释器模式执行程序
- -Xcomp 完全采用即时编译器执行程序,如果即时编译器出问题解释器会执行
- -Xmixed 采用解释器+即时编译器混合执行
在HotSpot中内嵌两个JIT编译器,分别是Client Compiler和Server Compiler
大多数情况下称为C1和C2编译器,可以通过如下命令指定 - -client C1编译器会对字节码进行简单和可靠的优化,耗时短,可以更快编译
- -server C2耗时较长的时间优化以及激进优化,但优化的代码执行效率更高
StringTable
JDK7以前,字符串常量池放在永久代,JDK7以后字符串常量池放在堆中
String的基本特性
- String:字符串,使用”fsd”这种字面量的定义方式,或者new String(“fsd”)
- String声明为final,不可被继承
- String实现了Serializable接口,表示字符串支持序列化,实现了Comparable
接口表示String可以比较大小 - String在jdk8及以前内部定义final char[] value用于存储字符串数据,jdk9
改为byte[] - String的不可变性
- 当对字符串重新赋值时需要重写指定内存区域赋值,不能使用原有的value
- 当对先有的字符串连接操作也需要重写指定内存区域赋值
- replace()方法修改字符串也需要从写指定区域赋值jdk8开始可设置的最小值是1009
1
2
3
4
5
6
7
8
9
10String s1="fsd";
String s2="fsd";
//字符串常量池中不能有重复的字符串,指向同一个位置
//s1==s2
String s2="vxfs";
//字符串常量池中新创建一个字符串,s2指向另一个位置
String s2+="vsdfa";
//同上,总之就是字符串不可在原有基础修改
String s2=s1.replace("f","m");
//s1并未变化只是创建了一个新的字符串赋给s2
String的内存分配
8中基本数据类型和String类型都提供了一种常量池的概念,目的是使它们运行
过程中速度更快更节省内存。String常量池的使用方式有两种
- 直接使用双引号什么出来的String对象会直接存储在常量池中
- 如果不使用双引号也可以使用String提供的intern()方法
String的基本操作
Java语言规范要求完全相同的字符串字面量应该包含相同的字符序列,必须指向
同一个String实例
1 | class Memory |
字符串拼接操作
- 常量与常量的拼接结果放在常量池中,原理是编译期优化
- 常量池中不会存在相同内容的常量
- 只要有一个是变量,结果就在堆中(非常量池),原理是StringBuilder
- 如果拼接的结果是调用intern()方法,则主动将常量池中还没有的字符串
对象放入池中,并返回对象地址1
2
3String s1="a"+"b"+"c"; //编译期就直接优化为"abc"
String s2="abc";
//s1==s21
2
3
4
5
6
7
8
9
10String s1="javaEE";
String s2="hadhoop";
String s3="javaEEhadhoop";
String s4="javaEE"+"hadhoop";
//以下拼接过程出现变量,则相当于在堆空间中new String("javaEEhadhoop")
String s5=s1+"hadhoop";
String s6="javaEE"+s2;
String s7=s1+s2;
//判断字符串常量池中是否有javaEEhadhoop,如果存在返回地址否则在常量池加载并返回
String s8=s6.intern();
字符串拼接操作的底层原理
1 | String s1="a"; |
拼接操作与append对比
通过StringBuilder的append()方式添加字符串的效率要远高于使用String
的字符串拼接方式,前一种方式自始至终只创建一个StringBuilder对象,
后一种方式创建多个StringBuilder对象和多个String对象,而且由于创建
较多对象内存占用大,进行GC时花费时间更多。前一种方式的一种改进方式
是在创建对象时就指定一个容量减少扩容次数
intern()的使用
new String()
1 | String str=new String("ab"); |
1 | String str=new String("a")+new String("b"); |
1 | //注意以下的堆以及之前所说的堆在谈到与常量池的关系时都是指除去常量池的堆 |
垃圾回收器的String去重
垃圾回收算法
垃圾回收概述
- 垃圾 垃圾就是指在运行程序中没有任何指针指向的对象,这个对象就是需
要被回收的垃圾,如果不及时对内存中的垃圾进行清理,那么这些垃圾对象
所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象
使用 - 早期的c/c++时代,垃圾回收基本是手工进行,可以使用new进行内存申请,
并使用delete进行内存释放,但是给开发人员带来频繁申请和释放内存的管理
负担,万一有一块内存忘记delete了那么这块内存就永远没有被清除 - 除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收
Java自动内存管理介绍
自动内存管理,开发人员无需手动参与内存的分配和回收,降低内存泄漏和内存
溢出的风险,更加专注于业务的开发。但是也弱化了Java开发人员在程序出现内
存溢出时定位问题和解决问题的能力,所以了解JVM的自动内存分配和内存回收
原理就尤为重要
垃圾回收相关算法
在堆中存放几乎所有的Java对象实例,在GC执行垃圾回收之前首先需要区分内存
中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象才会
被回收。判断对象是否存活有两种方式,引用计数算法和可达性分析算法
引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况,如果
对象的引用计数器的值为0那么这个对象就不再使用可以被回收。
- 优点是实现简单,垃圾对象容易辨识,判定效率高,回收没有延迟性
- 缺点是需要单独的字段存储计数器,这样增加存储空间的开销,每次赋值都
需要更新计数器,伴随加法和减法操作增加时间开销,尤其是无法处理循环
引用的情况,导致Java的垃圾回收期中没有使用这种算法
可达性分析算法
可达性分析算法不仅同样具备实现简单和执行高效等特点,而且有效解决在引用
计数器中循环利用的问题防止内存泄漏的发生,这种类型的垃圾收集也叫追踪性
垃圾收集,基本思路如下
- 以根对象集合为起始点,按照从上至下的方式搜索被跟对象集合所连接的目
标对象是否可达,GC Roots根集合就是一组必须活跃的引用 - 使用可达性分析算法以后,内存中的存活对象都会被根对象集合直接或间接
连接着,搜索所走过的路径称为引用链 - 如果目标对象没有任何引用链相连,则是不可达的,就意味着对象已经死
亡,可以标记为垃圾对象 - 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是
存活对象 - 如果要使用可达性分析算法来判断内存是否可以回收,那么分析工作必须
在一个能保障一致性的快照中进行,这点不满足分析结果的正确性就无法保
证,这点也是导致GC时必须”stop the world”的一个重要原因,及时在CMS
收集器中枚举根节点也是必须要停顿的
跨代引用假说
将堆内存分为新生代和老年代分别回收存在一个问题,对象不是孤立的,对象
之间存在跨代引用,例如现在进行一次新生代的收集,但是新生代的对象可能
被老年代的对象引用,所以为了找到存活对象必须遍历老年代,反过来也是一
样,实际上跨代引用相对同代引用仅占少数,只需要在新生代中建立一个全局
的数据结构(成为记忆集,Remembered Set),这个结构把老年代划分为若
干个小块,标识老年代哪一块内存会存在跨代引用
对象的finalization机制
- Java语言提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义
处理逻辑 - 当垃圾回收器发现没有一个引用指向一个对象,即垃圾回收此对象之前总会
先调用这个对象的finalize()方法 - finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释
放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套
接字和数据库连接等 - 永远不要主动调用finalize()方法
- 在finalize()时可能导致对象复活
- finalize()方法的执行时间没有保障,完全由GC线程决定,极端情况如果
不发生GC,则finalize()方法没有执行机会 - 一个糟糕的finalize()会严重影响GC的性能
- 由于finalize()方法的存在对象存在三种状态
- 具体过程
- 可复活的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public class Main {
public static Main obj;
protected void finalize() throws Exception{
System.out.println("调用重写的finalize");
obj=this;
}
public static void main(String[] args) {
try{
obj=new Main();
obj=null;
System.gc();
System.out.println("第一次gc");
Thread.sleep(2000);
if(obj==null)
System.out.println("obj is dead");
else
System.out.println("obj is still alive");
System.out.println("第二次gc");
obj=null;
System.gc();
Thread.sleep(2000);
if(obj==null)
System.out.println("obj is dead");
else
System.out.println("obj is still alive");
}catch(Exception e)
{
e.printStackTrace();
}
}
}
使用JProfiler查看GC Roots
JProfiler能够在IDEA的plugins中下载
1 | public class Main { |
垃圾清除算法
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,
释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内
存。目前JVM中常见的三种垃圾收集算法是标记-清除算法、复制算法、标记-压缩
算法
标记-清除算法(Mark-Sweep)
当堆中的有效空间被耗尽的时候,就会停止整个程序,然后进行两项操作,一项
是标记,第二项是清除
- 标记 Collector从引用跟节点开始遍历,标记所有被引用的对象,一般是在
对象的Header中记录可达对象 - 清除 Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其
Header中没有标记为可达对象,则将其回收 - 缺点 效率并不高,需要停止整个应用程序,导致用户体验差,这种方式清理
出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表 - 清除 这里所谓的清除并不是真的清除,而是需要清除的对象地址保存在空
闲的地址列表里面,下次有新对象需要加载时,判断垃圾的位置空间是否够
,如果够就存放
复制算法
将活着的内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正使用
的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块
中的所有对象,交换两个内存的角色,最后完成垃圾回收
- 优点 没有标记和清除过程,实现简单,运行高效,复制过去以后保证空间
的连续性,不会出现碎片问题 - 缺点 需要两倍的内存空间,对于G1这种分拆成为大量region的GC,复制
而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用
或者时间开销也不小 - 如果系统中垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,
或者说非常低比较好
标记-压缩算法(Mark-Compact)
老年代大部分对象是存活的对象,此时复制算法效果并不好,标记-压缩算法
可以应用在老年代中
标记-压缩算法的最终效果等同于标记-清除算法执行完成后再进行一次内存
碎片整理,因此也可以称为标记-清除-压缩算法,二者的本质区别是前者
是一种移动式的回收算法,后者是非移动式算法,是否移动回收后的存活
对象是一项优缺点并存的风险决策,可以看到标记的存活对象会被整理,
按照内存地址一次排列,而未被标记的内存会被清理,如此一来当需要给
新对象分配内存时JVM只需要持有一个内存的起始地址即可,这比维护一个
空闲列表显然少许多开销
- 优点 消除了标记-清除算法中内存区域分散的缺点,我们需要给新对象
分配内存时,JVM只需要持有一个内存的起始地址即可,消除了复制算法
中内存减半的高额代价 - 缺点 从效率来说标记-整理算法要低于复制算法,移动对象的过程中如果
对象被其他对象引用,则还需要调整引用的地址,移动过程中需要全程暂停
用户应用程序即STW
对比三种算法
分代收集算法
具体问题具体分析,基于分代的概念,GC所使用的内存回收算法必须结合年轻代
和老年代各自的特点
- 年轻代 区域相对较小,对象生命周期短,这种情况复制算法最快,复制
算法的效率只与存活对象有关,很适用于年轻代的回收,而复制算法内存利
用率不高的问题通过HotSpot中的两个Survivor可以缓解 - 老年代 区域相对较大,对象生命周期长,一般有标记-清除或者是标记-清除
与标记-整理的混合实现
增量收集算法
缺点是由于在垃圾回收过程中间断性执行应用程序代码,能够减少系统的停顿
时间,但是线程切换和上下文准换的消耗会使得垃圾回收的总成本上升,造成
系统吞吐量下降
分区算法
指针碰撞
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,
彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配
内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位
置上,这种分配方式就叫指针碰撞
垃圾回收相关概念
System.gc
在默认情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用,
会显示触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃的对
象占用的内存,然而调用附带一个免责声明,就是无法保证对垃圾收集器的调
用,JVM实现者可以通过System.gc()调用来决定JVM的GC行为,而一般情况下,
垃圾回收应该是自动进行的,无需手动触发,在一些特殊情况下,比如正在编
写一个性能基准,我们可以在运行之间调用System.gc()
1 | //buffer始终占据局部变量表索引为1的位置,所以从新生代放入老年代后没有被回收 |
内存溢出与内存泄漏
- 内存溢出 没有空闲内存,并且垃圾收集器也无法提供更多内存。没有空闲
内存有两种情况,一是Java虚拟机的堆内存设置不够,二是代码中创建了大
量大对象,并且长时间不能被垃圾收集器收集 - 内存泄漏 只有对象不会再被程序用到,但是GC又不能回收的情况才叫内
存泄漏,尽管内存泄漏并不会理科引起程序崩溃,但是一旦发生内存泄漏,
程序中的可用内存就会逐步被蚕食直至耗尽所有内存,最终出现OOM
- 单例模式 单例的生命周期的应用程序一样长,所以单例程序中,如果持
有对外部对象的引用的话那么这个对象是不能被回收的,则会导致内存泄漏
的产生 - 一些提供close的资源未关闭导致内存泄漏,数据库连接、网络连接和io
连接必须手动close,否则是不能被回收的
Stop The World
简称为STW,指的是GC事件发生过程中,会产生应用程序的停顿,停顿产生
时整个应用程序线程都会被暂停,没有任何响应。可达性分析算法中枚举所
有根节点会导致所有Java线程停顿
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指整个分析器件整个执行系统看起来像被冻结在某个时间点上
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确
性无法保证STW事件和采用哪款GC无关,所有的GC都有这个事件,哪怕
是G1也不能完全避免STW,只能说垃圾回收器越来越优秀,回收效率越
来越高,尽可能缩短了暂停时间。STW是JVM在后台自动发起和自动完成
的,在用户不可见的情况下把用户的正常工作线程全部停掉
垃圾回收的并行与并发
- 并发 在操作系统中,指一个时间段中有几个程序都处于已启动运行到
运行完毕之间,且这几个程序都是在同一个处理器上,并发并不是真正意
义上的同时进行,用户线程和垃圾收集线程同时执行 - 并行 当系统有一个以上的CPU时,一个CPU执行一个进程,多个进程同
时执行,多条垃圾收集线程可以并行工作,此时用户线程仍处于等待状态,
比如ParNew Parallel Scavenge Parallel Old - 如果用户线程和收集器并发工作,收集器在对象图上标记颜色(三色
标记),同时用户线程在修改引用关系,会产生两种后。一种是把原本
消亡的对象错误标记为存活,这个影响不大,另一种就是把原本存活
的对象错误标记为消亡。产生对象小时的情况如下 - 赋值器插入一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决方案是增量更新和原始快照。增量更新是当黑色对象插入新的白色对象
时将这个新插入的引用记录下来,等并发扫描结束再将这些记录过的引用
关系中的黑色对象为根,重新扫描一次。原始快照实际上不会理会删除,
还是按照第一次扫描进行搜索
安全点与安全区域
GC发生时,检查所有线程都跑到最近的安全点停顿有两种方式
- 抢先式中断 首先中断所有线程,如果还有线程不在安全点就恢复线程,
让线程跑到安全点,目前没有虚拟机采用 - 主动式中断 设置一个中断标志,各个线程运行到中断点的时候主动轮
询这个标志,如果中断标志为真,则将自己进行中断挂起,轮询标志的
地方和安全点是重合的 - 安全点的选取基本上是以是否具有让程序长时间执行的特征,例如方法
调用、循环跳转、异常跳转等都属于指令序列复用,所以具有这些功能的
指令才会产生安全点
记忆集与卡表
为解决对象跨代引用所带来的问题,垃圾收集器在新生代建立了名为记忆
集的数据结构,用以避免老年代加进GC Roots扫描范围
写屏障
写屏障可以看成在虚拟机层面对引用类型字段赋值这个动作的AOP切面
Java中几种不同引用
jdk1.2之后Java对引用的概念进行扩充,将引用分为强引用、软引用、弱
引用和虚引用,这4种引用强度依次逐渐减弱
强引用
强引用可以直接访问目标对象,强引用所指向的对象在任何时候都不会被
系统回收,虚拟机宁愿抛出OOM异常也不会回收强引用指向的对象
软引用
弱引用
虚引用
终结器引用
它用以实现对象的finalize()方法,也可以称为终结器引用,在GC时,
终结器引用入队,由Finalizer线程通过终结器引用找到被引用的对象
并调用它的finalize()方法,第二次GC时才能回收被引用对象