JVM理论
Java 虚拟机概论
[ 第一部分 ]
内存与垃圾回收
一、概览
- 类加载子系统
Loadding
加载:由加载器将字节码文件载入JVM
,核心加载器有:
Bootstrap ClassLoader
(引导类加载器)Extension ClassLoader
(扩展类加载器)Application ClassLoader
(应用/系统类加载器)Linking
链接:检查合法性Initialization
初始化:主要针对静态变量的显式初始化
- 运行时数据区
Stack Area
:线程私有
LV(Local Variables)
- 局部变量表OS(Operand Stack)
- 操作数栈DL(Dynamic Linking)
- 动态链接RA(Return Address)
- 返回地址PC Registers
:程序计数器,线程私有Method Area
:存放类的信息、常量、域信息、方法信息Heap Area
:对象的存储位置Native Method Stack
:主要管辖本地方法接口
- 执行引擎
Interpreter
:解释器JIT Compiler
:及时编译器GC
:垃圾回收器
二、类加载子系统
2.1、简述
- 类加载子系统负责从文件系统或者网络中加载
Class
文件,Class
文件在文件打头有特定的文件标识ClassLoader
只负责Class
文件的加载,至于它是否可以运行,则由执行引擎决定- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池的信息,可能还包括字符串字面量和数字常量
2.2、类的加载过程
2.3、Loading
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
2.3.2、加载方式
- 从本地系统中直接加载
- 通过网络获取
- 从
zip
压缩包中读取,成为日后jar
,war
格式的基础- 运行时计算生成,使用最多的是动态代理技术
- 由其他文件生成,如
JSP
- 从专有数据库中提取
- 从加密文件中获取
2.4、Linking
2.4.1、Verify
- 目的在于确保
Class
文件中的字节流包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全- 主要包括四种验证:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
2.4.2、Prepare
- 为类变量分配内存并设置该类变量的默认初始值
- 这里不包含用
final
修饰的static
,因为final
在编译的时候就会分配,准备阶段会显式初始化- 这里不会为实例变量分配初始化,类变量会分配在方法区中,二实例变量会随着对象一起分配到
Java
堆中
2.4.3、Resolve
- 将常量池内的符号引用转换为直接引用的过程
- 解析操作往往会伴随着
JVM
在执行完初始化之后再执行- 符号引用就是一组符号用来描述所引用的目标。符号引用的字面量形式明确定义在《
Java
虚拟机规范》的Class
文件格式中,直接引用就是直接指向目标的指针、相对偏移量或一个间接位到达目标的句柄- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等,对应常量池中的
CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
等
2.5、Initialization
- 初始化阶段就是执行类构造器
<clinit>()
的过程- 此方法不需要定义,是
javac
编译器自动手机类中的所有类变量的赋值动作和静态代码块中的语句合并而来- 构造器方法中指令按语句在源文件中出现的顺序执行
<clinit>()
不同于类的构造器- 虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁
2.6、ClassLoader
Class
文件存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM
当中来根据这个文件实例化出n
个一模一样的实例Class
文件加载到JVM
中,被称为DNA
元数据模块,放在方法区- 在
Class
文件 -》JVM
-》最终成为元数据模块,此过程就需要 ClassLoader扮演一个快递员的角色
2.6.1、分类
JVM
支持两种类型的类加载器,分别为 引导类加载器(Bootstrap ClassLoader) 和 自定义类加载器(User-Defined ClassLoader)- 系统类加载器(Application ClassLoader) 和 扩展类加载器(Extension ClassLoader) 属于 自定义类加载器(User-Defined ClassLoader)
ClassLoader
继承树
2.6.2、引导类加载器
- 这个类加载器使用
C/C++
语言实现,嵌套在JVM
内部- 用来加载
Java
的核心类库(JAVA_HOME/jre/lib/rt.jar
),用于提供JVM
自身需要的类- 并不继承自
java.lang.ClassLoader
,没有父加载器- 加载 扩展类加载器 和 系统类加载器,并指定为他们的父类
- 处于安全考虑,引导类加载器只加载包为
java、javax、sun
等开头的类
2.6.3、自定义类加载器
- 作用
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
- 实现步骤
- 继承抽象类
java.lang.ClassLoader
类- 在
JDK1.2
之前,在自定义类加载器时,总会去继承ClassLoader
类并重写loadClass()
方法,从而实现自定义的类加载类,在JDK1.2
之后已不再建议用户自己去覆盖loadClass()
中的方法,而是建议把自定义的类加载逻辑写在findClass()
方法中- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承
URLClassLoader
类,这样就可以避免自己去编写findClass()
方法以及获取器字节码流的方式,使自定义类加载器编写更加简洁
2.7、双亲委派机制
Java
虚拟机对Class
文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的Class
文件加载到内存生成Class
对象。而且加载某个类的Class
文件时,Java
虚拟机采用的是 双亲委派机制,即把请求交由父类处理
- 工作原理
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
- 如果父类加载器可以完成类加载任务,就成功返回,否则交由子加载器进行处理
- 优势
- 避免类的重复加载
- 保护程序安全,防止核心
API
被随意篡改
2.8、类的主动使用与被动使用
2.8.1、主动使用
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量复制
- 调用类的静态方法
- 反射
- 初始化一个类的子类
Java
虚拟机启动时被标明为启动类的类JDK 7
开始提供的动态语言支持
2.8.2、被动使用
除主动使用之外的所有情况
三、运行时数据区
阿里的运行时数据区:
3.1、PC Register
PC
寄存器用来存储指向先弄个一条指令的地址代码。由执行引擎读取下一条指令- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域
- 在
JVM
规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的
Java
方法的JVM
指令地址;或者,如果是在执行native
方法,则是未指定值(undefined
)- 是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 是唯一一个在
Java
虚拟机规范中没有规定任何OutofMemoryError
情况的区域
为什么要使用 PC
寄存器记录当前线程的执行地址
因为
CPU
需要不停的切换各个线程,这时候切换回来以后,就得知道从哪里开始继续执行。。JVM
的字节码解释器就需要通过改变PC
寄存器的值来明确下一条应该执行什么样的字节指令
PC
寄存器为什么会被设定为线程私有
由于
CPU
时间片轮转限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程的一条指令。这样必然导致经常终端或恢复,而每个线程在创建后产生自己的程序计数器和栈帧,这样程序计数器在各个线程之间就能互不影响了
3.2、JVM Stack Area
Java
虚拟机栈,早期也叫Java
栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java
方法调用,是线程私有
- 生命周期:和线程保持一致
- 作用:主管
Java
程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回- 背景:由于跨平台性的设计,
Java
的指令都是根据栈来设计的。不同平台CPU
架构不同,所以不能设计为基于寄存器的- 优点:
- 跨平台
- 指令集小
- 编译器容易实现
- 缺点:
- 性能下降
- 实现同样的功能需要更多的指令
- 特点:
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
JVM
直接对Java
栈的操作只有入栈和出栈- 对于栈来说不存在垃圾回收的问题
- 设置栈的大小:
-Xss size
- 单位:
KB
、g/G
、m/M
3.2.1、栈的存储单位
- 每个线程都有自己的栈,栈中的数据都是以栈帧(
Stack Frame
)的格式存在- 在这个线程上正在执行的每个方法都各自对应一个栈帧(
Stack Frame
)- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
3.2.1.1、运行原理
JVM
直接对Java
栈的操作只有两个,就是对栈帧的压栈和出栈,遵循 ”先进后出“ 或者 ”后进先出“ 的原则- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被成为当前栈帧,与当前栈帧对应的方法就是当前方法,定义这个方法的类就是当前类
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧
- 不同的线程中所包含的栈帧是不允许存在互相引用的,即不可能在一个栈帧之中引用另一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
Java
方法有两种返回的方式,不管哪种,都会导致栈帧被弹出
- 正常的返回,使用
return
- 抛出异常
3.2.2、栈帧的内部结构
- 局部变量表
- 操作数栈(表达式栈)
- 动态链接(指向运行时常量池的方法引用)
- 方法返回地址(方法正常退出或者异常退出的定义)
- 一些附加信息
3.2.2.1、Local Variables
- 定义
- 也称为 局部变量数组 或 本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用,以及
returnAddress
类型- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译器确定下来的,并保存在方法的
Code
属性的maximum local variables
数据项中。在方法运行期间是不会改变局部变量表大小的- 方法嵌套调用的次数由栈的大小决定,一般来说,展越大,方法嵌套调用次数越多。对一个方法而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求,进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
- Slot
- 参数值的存放总是在局部变量数组的
index0
开始,到数组长度 -1 索引结束- 局部变量表,最基本的存储单元是
Slot
(变量槽)- 局部变量表中存放编译期可知的各种基本数据类型,引用类型和
returnAddress
类型的变量- 在局部变量表里,32位以内的类型只占一个 Slot(包括
returnAddress
类型),64位的(long
和double
)占用两个 Slotbyte
、short
、char
在存储前被转换位int
boolean
也被转换为int
,0 表示false
,非 0 表示true
long
和double
则占据两个Slot
JVM
会为局部变量表中的每一个Slot
都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值- 当一个实例方法被调用,它的方法参数和方法体内部定义的局部变量将会 按照顺序 复制到局部变量表的每一个
Slot
上- 如果需要访问局部变量表中一个
64bit
的局部变量,只需使用起始索引- 如果当前帧是由构造方法或者实例方法创建的,那么 该对象引用
this
将会存放在 index 为 0 的Slot
处,其余的参数按照参数表顺序继续排列- 栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会服用过期局部变量的槽位,从而达到 节省资源 的目的
- 调优
- 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是垃圾回收根节点,只要被局部变量中中直接或间接引用的对象都不会被回收
3.2.2.2、Operand Stack
- 定义
- 在方法执行过程中,根据字节码指令,往栈里写入数据或提取数据,即入栈/出栈
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数去出栈。使用后再把结果压入栈
- 比如
[8, 15] -> iadd -> [23]
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是
JVM
执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的
Code
属性中,为max_stack
的值- 栈中的任何一个元素都可以是任意的
Java
数据类型
32bit
的类型占用一个栈单位深度64bit
的类型占用两个栈单位深度- 操作数栈 并非 采用访问索引的方式来进行数据访问,只能通过标准的入栈、出栈来完成一次数据访问
- 如果被调用的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈,并更新
PC
寄存器中下一条需要执行的字节码指令- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
- 我们说
Java
虚拟机的 解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
- 栈顶缓存技术
- 由于操作数是存在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,就有了
ToS, Top-of-Stack Cashing
,将栈顶元素全部缓存到物理CPU
的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
举例1
Java
文件:
1
2
3
4
5
6
7 public class OperandStackTest {
public void m1() {
byte a = 10;
int b = 20;
int c = a + b;
}
}方法
m1()
字节码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // 将 10 入栈
0 bipush 10
// 存入局部变量表,1 号槽,并出栈
2 istore_1
// 将 20 入栈
3 bipush 20
// 存入局部变量表,2 号槽,并出栈
5 istore_2
// 取出局部变量表的 1 号槽内容,入栈
6 iload_1
// 取出局部变量表的 2 号槽内容,入栈
7 iload_2
// 取出栈里的数据,在两者相加后将结果入栈
8 iadd
// 存入局部变量表,3 号槽,并出栈
9 istore_3
// 返回
10 return
/****************************************************
** 关于 push: **
** bipush 范围:(-128 ~ 127) **
** sipush 范围:(-32768 ~ 32767) **
** ldc 范围:(-2147483648 ~ 2147483647) **
****************************************************/
3.2.2.3、Dynamic Linking
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前的代码能够实现动态链接,比如
invokeddynamic
- 在
Java
源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在Class
文件的常量池里。- 如描述一个方法调用了另外的其他方法时,就是通过常量池中的指向方法的符号来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
3.2.3.4、Return Address
- 定义:
- 存放调用该方法的
PC
寄存器的值- 一个方法的结束,有 遇到任意一个方法返回的字节码指令
return
(正常执行完成) 和 出现未处理的异常,(非正常退出)- 无论通过那种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的
PC
寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息
- 正常完成出口:一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需根据方法返回值的实际数据类型而定
- 在字节码指令中,返回指令包含
ireturn
(当返回值是boolean
、byte
、char
、short
和int
时)、lreturn
、freturn
、dreturn
以及areturn
,另外还有一个return
指令供声明为void
的方法、实例初始化方法、类和接口的初始化方法使用
- 异常完成出口:在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表里没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
- 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
- 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置
PC
寄存器值等,让调用者方法继续执行下去。- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者返回任何返回值
3.2.3.5、方法的调用
- 定义:
JVM
中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关,绑定机制是 一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次
- 静态链接:当一个字节码文件被装载进
JVM
内部时,如果 被调用的目标方法在编译期可知,且运行期保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接- 早期绑定:被调用的目标方法在编译期可知,且运行期保持不变,即可将这个方法与所属的类型进行绑定,这样依赖,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用
- 动态链接:被调用的目标方法在编译期无法确定下来,只能够在程序运行期将其调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称为动态链接
- 晚期绑定:被调用的对象在编译器无法确定下来,只能够在程序运行期根据实际的类型绑定相关方法,这种绑定方式也就被称之为晚期绑定
- 虚方法与绑定机制:随着高级语言的横空出世,类似于
Java
一样的就面向对象的编程语言如今越来越多,尽管这类编程语言在语法上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然 这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种方式,Java
中任何一个普通方法其实都具备虚函数的特征,它们相当于C++
语言中的虚函数。如果在Java
程序中不希望某个方法拥有虚函数的特征,则可以使用关键字final
来标记这个方法
- 如果方法在编译期间就确定了具体的调用版本,这个版本在运行时是不变的,这样的方法称为 非虚方法,静态方法、私有方法、
final
方法、实例构造器、父类方法都是非虚方法,其他方法称为 虚方法
调用指令:前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而
invokedynamic
指令则支持由用户确定方法版本,其中invokestatic
指令和invokespecial
指令调用的方法称为非虚方法,其余的(final
修饰的除外)称为虚方法
普通调用
invokestatic
:调用静态方法,解析阶段确定唯一方法版本invokespecial
:调用<init>
方法、私有及父类方法,解析阶段确定唯一方法版本invokevirtual
:调用所有虚方法(final
修饰的除外)invokeinterface
:调用接口方法动态调用指令
invokedynamic
:动态解析出需要调用的方法,然后执行
Java7
才新增该指令,目的是为了实现Java
的 “动态类型语言” 支持而做的改进
- 动态类型语言:判断变量值的类型信息,变量没有类型信息,变量值才有类型信息
- 静态类型语言:判断变量自身的类型信息
- 区别:在于对类型的检查是在编译期还是在运行期
- 但是
Java7
中并没有提供直接生成invokedynamic
指令的方法,需要借助ASM
这种底层字节码工具来产生invokedynamic
指令,直到Java8
中Lambda
表达式的出现,invokedynamic
指令的生成,在Java
中才有了直接的生成方式Java7
中增加的动态语言类型支持的本质是对Java
虚拟机规范的修改,而不是对Java
语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java
平台的动态语言的编译器
3.2.3.6、方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作
T
- 如果在类型
T
中找到与常量中描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回Java.lang.IllegalAccessError
异常- 否则按照继承关系从下往上依次对
T
的各个父类进行第 2 步的搜索和验证过程- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常
IllegalAccessError
:程序试图访问或修改一个属性或调用一个方法,然而程序没有这个方法或属性的访问权限,一般这个会引起编译器异常,这个错误如果发生在运行时,就说明一个类发生了不兼容的改变- 虚方法表:在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就很可能影响到执行效率,因此,为了提高性能,
JVM
采用在类的方法区建立一个虚方法表,使用索引表来代替查找
- 每个类都有一个虚方法表,表中存放着各个方法的实际入口
- 虚方法表会在类加载的链接阶段(解析)被创建并开始初始化,类的变量初始值准备完成之后,
JVM
会把该类的方法表也初始化完毕
3.2.3、面试题
举例栈溢出的情况
(StackOverflowError)
,通过-Xss
设置栈的大小,如无限递归
调整栈的大小,就能保证不出现溢出吗
不能
分配的栈内存越大越好吗
可能会使得线程变少,挤占其他东西的资源
垃圾回收是否涉及到虚拟机栈
不会,栈只包含入栈和出栈,没有
GC
可发挥的余地
方法中定义的局部变量是否线程安全
具体问题具体分析
- 内部产生,内部消亡则安全
- 非内部产生或非内部消亡则不安全
3.3、Native Method Stack
- 定义:
Java
虚拟机栈用于管理Java
方法的调用,而本地方法栈用于管理本地方法的调用- 本地方法栈,也是线程私有的
- 允许被实现成固定或者是可动态扩展的内存大小(在内存移除方面是相同的)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,
Java
虚拟机将会抛出StackOverflowError
异常- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存区创建对应的本地方法栈,那么
Java
虚拟机将会抛出一个OutOfMemoryError
的异常- 本地方法是使用
C
语言实现的- 它的具体做法是
Native Method Stack
中登记native
方法,在Execution Engine
执行时加载本地方法库- 当某个线程调用一个本地方法时,它就进入了一个全新的并且不在受虚拟机限制的世界。它和虚拟机拥有同样的权限
- 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
- 并不是所有的
JVM
都支持本地方法,因为Java
虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM
产品不打算支持native
方法,也可以无需实现本地方法栈- 在
Hotspot JVM
中,直接将本地方法栈和虚拟机栈合二为一
3.4、Heap
3.4.1、堆的核心概述
- 一个
JVM
实例只存在一个堆内存,堆也是Java
内存管理的核心区域Java
堆区在JVM
启动的时候即被创建,其空间大小也就确定了。是JVM
管理的最大一块内存空间
- 堆内存的大小是可以调节的
- 《
Java
虚拟机规范》规定,堆可以处于 物理上不连续 的内存空间中,但在逻辑上它应该被视为连续的- 所有的线程共享
Java
堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)
- 《
Java
虚拟机规范》中对Java
堆的描述是:所有对象实例以及数组都应当在运行时分配在堆上- 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 堆,是
GC(Garbage Collection, 垃圾回收器)
执行垃圾回收的重点区域
3.4.1.1、内存细分
- 现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java 7
之前,堆内存逻辑上分为:
- 新生区:
Young Generation Space
Eden
Survivor
- 养老区:
Tenure Generation Space
- 永久代:
Permanent Space
Java 8
之后,堆内存逻辑上分为:
- 新生区:
Young Generation Space
Eden
Survivor
- 养老区:
Tenure Generation Space
- 元空间:
Meta Space
3.4.2、设置堆内存大小
Java
堆区用于存储Java
对象实例,堆的大小在JVM
启动 的时候就已经设定好了,可以通过-Xms
和-Xmx
来进行设置
-Xms(X Memory Start)
用于表示堆区的起始内存,等价于-XX: InitialHeadSize
-Xmx(X Memory Max)
用于表示堆区的最大内存,等价于-XX: MaxHeapSize
- 一旦堆区中的内存大小超过
-Xmx
所指定的最大内存时,则会抛出OutOfMemoryError
异常- 通常会将
-Xms
和-Xmx
两个参数配置相同的值,其目的是 为了能够在Java
垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能- 默认情况下,初始内存大小:物理电脑内存大小 / 64,最大内存大小:物理电脑内存大小 / 4
3.4.3、年轻代与老年代
存储在
JVM
中的Java
对象可以被划分为两类:
- 生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 生命周期很长,在某些极端的情况下还能与
JVM
的生命周期保持移至
Java
堆区进一步细分,可分为年轻代和老年代其中年轻代又可划分成
Eden
空间、S0(from)
和S1(to)
配置新生代与老年代在堆结构的占比
- 默认:
-XX:NewRatio=2
,代表新生代占1
,老年代占2
,新生代占整个堆的1/3
配置
Eden
和两个Survivor
的比例
- 默认:
-XX:SurvivorRatio=8
,代表Eden
占8
,两个Survivor
各占1
几乎所有的
Java
对象都是在Eden
区被new
出来的绝大部分
Java
对象的销毁都在新生代中进行
IBM
公司专门研究表明,新生代中80%
的对象都是 “朝生夕死”可使用
-Xmn
设置新生代最大内存大小
3.4.4、对象分配过程
为新对象分配内存是一件非常严谨和复杂的任务,
JVM
的设计者们不进需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC
执行完内存回收后是否会在内存空间中产生内存碎片
new
的对象先放在Eden
区,此区有大小限制- 当
Eden
区的空间填满,程序又需要创建新的对象,JVM
的垃圾回收器对Eden
区进行垃圾回收(Minor GC)
,将Eden
区中不再被其他对象所引用的对象进行销毁。再加载新的对象放到Eden
区- 然后将
Eden
中剩余的对象移动到Survivor 0
区- 如果再次出发垃圾回收,此时上次幸存下来的放到
Survivor 0
区的如果没有回收,就会放到Survivor 1
区- 如果再次经历垃圾回收,此时会重新放回
Survivor 0
区,如此反复- 每次放入
Survivor
区,就使对象的年龄计数增加1
,当年龄达到15
,下一次如果仍然没有被回收,就会被放入Old
区
- 修改晋升年龄:
-XX:MaxTenuringThreshold
常用调优工具
JDK
命令行Jconsole
JVisualVM
JProfiler
Java Filght Recorder
GCViewer
GC Easy
3.4.5、Minor GC、Major GC、Full GC
JVM
在进行GC
的时候并非每次都对上面三个内存区域一起回收,大部分时候回收的都是指新生代,针对HotSpot VM
的实现,它里面的GC
按照回收区域又分为两大种类型:一种是部分回收Partial GC
,一种是整堆回收Full GC
- 部分回收:不是完整收集整个
Java
堆的垃圾收集
- 新生代收集
(Minor GC/Young GC)
:只是新生代的垃圾收集- 老年代收集
(Major GC/Old GC1)
:只是老年代的垃圾收集
- 目前,只有
CMS GC
会有单独收集老年代的行为- 很多时候
Major GC
会和Full GC
混淆使用,需要具体分辨是老年代回收还是整堆回收- 混合收集
Mixed GC
:收集整个新生代以及部分老年代的垃圾收集,目前只有G1 GC
会有这种行为
- 整堆收集
Full GC
:收集整个Java
堆和方法区的垃圾收集
3.4.5.1、年轻代 GC 触发机制
- 当年轻代空间不足时,就会触发
Minor GC
,这里的年轻代满指的是Eden
区满,且Survivor
区满时不会触发GC
- 因为
Java
对象大多都是具备朝生夕死的特性,所以Minor GC
非常频繁,一般回收速度也比较快Minor GC
会触发STW
,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
3.4.5.2、老年代 GC 触发机制
- 指发生在老年代的
GC
,对象从老年代消失时,我们说Major GC
或Full GC
发生了- 出现了
Major GC
,经常会伴随至少一次的Minor GC
,但非绝对,在Parallel Scavenge
收集器的收集策略里就有直接进行Major GC
的策略选择过程
- 也就是在老年代空间不足时,会先尝试触发
Minor GC
,如果之后空间还不足,则触发Major GC
Major GC
的速度一般会比Minor GC
慢10
倍以上,STW
时间更长- 如果
Major GC
后,内存还不足,就会报OOM
3.4.5.3、整堆回收 GC 触发机制
- 调用
System.gc()
,系统建议执行Full GC
但是不必然执行- 老年代空间不足
- 方法区空间不足
- 通过
Minor GC
后进入老年代的平均大小大于老年代的可用内存- 由
Eden
区、From
区向to
区复制时,对象大小大于to
区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象的大小Full GC
是开发或调优中尽量要避免的,这样暂停时间会短一些
3.4.6、堆空间分代思想
其实不分代也是完全可以的,分代的唯一理由就是 优化
GC
的性能。如果没有分代,那所有的对象都在一块,GC
的时候要找到哪些没用的对象,就需要对整个堆进行扫描。
3.4.7、内存分配策略
- 如果对象在
Eden
区出生并经历过一次Minor GC
后仍然存活,并且能够被Survivor
容纳,将被移动到Survivor
中,并将对象年龄设置为1
,对象在Survivor
区中每熬过一次Minor GC
,年龄就增加1
岁,当年龄达到一定程度时,就会被晋升到老年代
- 官方规则:
- 优先分配到
Eden
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果
Survivor
区中相同年龄的所有对象大小的总和大于Survivor
空间的一半,年龄大于或该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold
的要求年龄- 空间分配担保
-XX:HandlePromotionFailure
3.4.8、为对象分配内存:TLAB
3.4.8.1、为什么要有 TLAB
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在
JVM
中非常频繁,因此在并发的环境下从堆区中划分内存空间是线程不安全的- 为避免多个线程操作同一地址,需要使用枷锁等机制,进而影响分配速度
3.4.8.2、什么是 TLAB
- 从内存模型而不是垃圾收集的角度,对
Eden
区进行划分,**JVM
为每个线程分配一个私有缓存区域**,它包含在Eden
空间内- 多线程同时分配内存时,使用
TLAB
可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为 快速分配策略- 由
OpenJDK
衍生出来的JVM
都提供了TLAB
设计- 尽管不是所有的对象实例都能够在
TLAB
中成功分配内存,但JVM
确实是将TLAB
作为内存分配的首选- 在程序中,开发人员可以通过
-XX:UseTLAB
设置是否开启TALB
空间- 默认情况下,
TLAB
空间的内存非常小,**仅占整个Eden
区的 1%**,-XX:TLABWasteTargetPercent
可以设置其百分比大小- 一旦对象在
TLAB
空间分配内存失败,JVM
就会尝试着通过使用 加锁机制 确保数据操作的原子性,从而直接在Eden
空间中分配内存
3.4.8.3、对象分配过程
3.4.9、堆空间参数设置
-XX:+PrintFlagsInitial
:查看所有参数的默认初始值-XX:+PrintFlagsFinal
:查看所有参数的最终值-Xms
:初始化堆空间大小,**default = 1/64
**-Xmx
:最大堆空间大小,**default = 1/4
**-Xmn
:设置新生代的大小-XX:NewRatio
:设置新生代与老年代在对结构的占比,**default = 1:2
**-XX:SurvivorRatio
:设置新生代中Eden
和S0
、S1
的空间比例,**default = 8:1:1
**-XX:MaxTenuringThreshold
:设置新生代晋升的年龄-XX:+PrintGCDetails
:输出详细的GC
日志
-XX:+PrintGC
、-verbose:gc
:打印简要GC
信息-XX:HandlePromotionFailure
:是否设置空间分配担保
3.4.10、逃逸分析
- 随着
JIT
编译器的发展与 逃逸分析 技术逐渐成熟,栈上分配、标量替换优化技术 将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了- 在
Java
虚拟机中,对象是在Java
堆中分配内存的,这是一个常识。但是,有一种特殊情况,那就是 如果经过逃逸分析(Escape Analysis)
后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了- 此外,基于
OpenJDK
森都定制的TaoBaoVM
,其中创新的GCIH(GC invisible heap)
技术实现off-heap
,将生命周期较长的Java
对象从heap
中移至heap
外,并且GC
不能管理GCIH
内部的Java
对象,依次达到降低GC
回收频率和提升GC
回收效率的目的
3.4.10.1、逃逸分析
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段
- 这是一种可以有效减少
Java
程序中同步负载和内存堆分配压力的跨函数全局数据量分析算法- 通过逃逸分析,
Java HotSpot
编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上- 逃逸分析的基本行为就是分析对象动态作用域
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸
- 代码优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针要永远不会逃逸,对象可能使栈分配的候选,而不是堆分配
JIT
编译器在编译期间根据逃逸分析的结果,发现一个对象并没有逃逸出方法,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需进行垃圾回收- 同步省略:如果一个对象被发现只能从一个线程访问到,那么对于这个对象的操作可以不考虑同步
- 在动态编译同步块的时候,
JIT
编译器可以借助逃逸分析来 判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程,如果没有,那么JIT
编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫 锁消除- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在
CPU
寄存器
- 标量
Scalar
是指一个无法再分解成更小的数据类型的数据。Java
中的原始数据类型就是标量- 相对的,哪些还可以分解的数据叫做 **聚合量
(Aggregate)
**,Java
中的对象就是聚合量,因为他可以分解成其他聚合量和标量- 在
JIT
阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT
优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替代,这个过程就叫做 标量替换- 开启标量替换:
-XX:+EliminateAllocations
3.4.10.2、参数设置
-server
:逃逸分析只有在Server
模式下才可以启用-XX:+DoEscapeAnalysis
:启用逃逸分析-X:+EliminateAllocations
:开启了表链那个替换,允许将对象打散分配到栈上,比如对象拥有id
和name
两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配
3.4.10.3、逃逸分析并不成熟
- 关于逃逸分析的论文在
1999
年就已经发表,直到JDK 1.6
才有实现,而这项技术到如今也并不是十分成熟- 其根本原因就是 无法保证逃逸分析的性能小号一定高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、锁消除。但是逃逸分析自身也是需要进行一些列复杂的分析,这其实也是一个相对耗时的过程
- 虽然这项技术并不十分成熟,但是他也是 即时编译器优化技术中一个十分重要的手段
- 有些观点认为通过逃逸分析,
JVM
会在栈上分配哪些不会逃逸的对象,这在理论上是可行的,但是取决于JVM
设计者的选择,Oracle Hotspot JVM
并未这么做- 米钱很多书籍还是基于
JDK 7
以前的版本,JDK
已经发生了很大的变化,intern
字符串的缓存和静态变量曾经都被分配在永久代,而永久代已经被元数据区所取代。但是intern
字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面的结论:对象实例都是分配在堆上的
3.5、Method Area
3.5.1、堆、栈、方法区的交互关系
1
2 // 方法区 栈 堆
// Person person = new Person();
方法区在哪里:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩,但对于
HotSpot JVM
而言,方法区还有一个别名叫做Non-Heap
,目的就是要和堆分开。所以,方法区看作是一块独立于Java
堆的内存空间方法区和堆一样,是各个线程共享的内存区域
方法区在
JVM
启动的时候被创建,并且它实际的物理内存空间中和Java
堆区一样都可以是不连续的方法区的大小,跟堆空间的一样,可以选择固定大小或者可扩展
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存移除的错误,
java.lang.OutOfMemoryError: PermGenspace
或java.lang.OutOfMemoryError: Metaspace
关闭虚拟机就会释放这个内存的区域
3.5.1.1、Hopspot JVM 中方法区的演进
- 在
JDK7
以前,习惯上把方法区称为永久代,从JDK8
开始,使用原空间取代了永久代- 本质上,方法区和永久代并不等价,仅对
Hotspot
而言的,对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9
中不存在永久代的概念
- 现在看来,当年使用永久代,其实并不是一个好主意,导致
Java
程序更容易OOM
,即:容易超过-XX:MaxPermSize
上限- 到了
JDK8
,终于完全废弃了永久代的概念,改用JRocket
、J9
一样在本地内存中实现的元空间来代替- 元空间的本质和永久代类似,都是对
JVM
规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存- 永久代、元空间二者并不只是名字变了,内部结构也调整了
- 根据《
Java
虚拟机规范》规定,如果方法区无法满足新的内存分配需求,将抛出OOM
异常
3.5.2、设置方法区内存的大小
- 方法区的大小不必是固定的,
JVM
可以根据应用的需要动态调整
JDK7
及以前
- 通过
-XX:PermSize
来设置永久代初始分配空间。默认20.75M
- 通过
-XX:MaxPermSize
来设置永久代最大可分配空间。32
位机器默认是64M
,64
位机器默认82M
- 当
JVM
加载的类信息大小超过这个值,就会抛出异常OutOfMemoryError:PermGen Space
JDK8
及以后
- 元数据区大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数- 默认值依赖于平台,**
Windows
下,-XX:MetaspaceSize
默认值是21M
,-XX:MaxMetaspaceSize
的值是-1
即没有限制**- 与永久代不同,如果不指定大小,默认情况下 虚拟机会耗尽所有可用的系统内存,如果元数据区发生溢出,虚拟机一样会抛出异常,即
OutOfMemoryError: Metaspace
-XX:MetaspaceSize
:设置初始的元空间大小。对于一个 64 位的服务端JVM
来说,其默认值为21M
,这就是初始的高水位线,一旦触及这个水位线,Full GC
将被触发并卸载没用的类,然后这个高水位线将会被重置。新的高水位线取决于GC
释放了多少原空间,如果释放空间过多,则适当降低该值- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收的日志可以观察到
Full GC
多次调用,为了频繁地进行垃圾回收,建议将-XX:MetaspaceSize
设置为一个相对较高的值
- 如何解决
OOM
- 要解决
OOM
异常或者heap space
异常,一般的手段是首先通过内存映像分析工具对dump
出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是先清楚是出现内存泄漏Memory Leak
还是内存溢出Memory Overflow
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到
GC Roots
的用于链。于是就能找到泄漏对象是通过怎样的路径与GC Roots
相关联并导致垃圾收集器无法自动回收他们的。掌握了泄漏对象的类型信息,以及GC Roots
引用链的信息,就可以比较明确地定位出泄漏代码的位置- 如果不存在内存泄漏,换句话说就是内存中的对象确实还必须存活者,那就应当检查虚拟机的对参数
(-Xms 与 -Xmx)
,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长的情况,尝试减少程序运行期的内存消耗
3.5.3、方法区的内部结构
- 用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等
- 类型信息:对每个加载的类型
(类 class、接口 interface、枚举 enum、注解 annotation)
,JVM
必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(包名.类名)
- 这个类型直接父类的完整有效名称(对于
interface
和java.lang.Object
都没有父类)- 这个类型的修饰符
(public, abstract, final 的某个子集)
- 这个类型直接接口的一个有序列表
- 域:
JVM
必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
- 域的相关信息包括:域名称、域类型、域修饰符
(public, private, protected, static, final, volatile, transient 的某个子集)
- 方法:
JVM
必须保存所有方法的以下信息,同域信息一样包括声明顺序
- 方法名
- 返回类型
- 方法参数的数量和类型
- 方法的修饰符
(public, private, protected, static, final, synchronized, native, abstract 的某个子集)
- 方法的字节码,操作数栈,局部变量表及大小
(abstract 和 native 方法除外)
- 异常表
(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
3.5.3.1、Non-final 的类变量
- 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享,即使没有类实例时同样可以访问
3.5.3.2、Static-final 的类变量
- 被声明为
final
的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了
3.5.3.3、常量池与运行时常量长
- 方法区内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- 要弄清楚方法区,需要理解清楚
Class
文件,因为加载类的信息都在方法区- 要弄清楚方法区的运行时常量池,需要理解清楚
Class
文件的常量池- 一个有效的字节码文件中出了包含类的版本信息、字段、方法以及接口等描述信息以外,还包含一项信息那就是常量池表
(Constant Pool Table)
,包括各种字面量和类型、域和方法的符号引用
- 为什么要有常量池
- 一个
Java
源文件中的类、接口,编译后产生一个字节码文件。而Java
中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里 ,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池
- 常量池有什么
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
- 运行时常量池
- 运行时常量池
(Runtime Constant Pool)
是方法区的一部分- 常量池表
(Constant Pool Table)
是class
文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池- 运行时常量池,在加载类和接口到虚拟机以后,就会创建对应的运行时常量池
JVM
为每个已加载的类型都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址
- 运行时常量池,相对于
class
文件常量池的另一重要特征是:具备动态性- 运行时常量池类似于传统编程语言中的符号表,但是它所包含的数据却比符号表要更加丰富一些
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区能提供的最大值,则
JVM
会抛出OutOfMemoryError
异常
3.5.4、方法区使用举例
Java
代码
1
2
3
4
5
6
7
8
9
10
11 public class MethodAreaDemo {
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}
}
class
字节码
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82 Classfile /C:/Users/Cying/Desktop/MethodAreaDemo.class
Last modified 2020-7-12; size 432 bytes
MD5 checksum b502befbb8c73360f9559bae53ee2e31
Compiled from "MethodAreaDemo.java"
public class MethodAreaDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #15.#16 //
java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // MethodAreaDemo
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 MethodAreaDemo.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 MethodAreaDemo
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
{
public MethodAreaDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=5, args_size=1
0: sipush 500
3: istore_1
4: bipush 100
6: istore_2
7: iload_1
8: iload_2
9: idiv
10: istore_3
11: bipush 50
13: istore 4
15: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_3
19: iload 4
21: iadd
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: return
LineNumberTable:
line 4: 0
line 5: 4
line 6: 7
line 7: 11
line 8: 15
line 9: 25
}
SourceFile: "MethodAreaDemo.java"
Init
Step1
Step2
Step3
Step4
Step5
Step6
Step7
Step8
Step9
Step10
Step11
Step12
Step13
Step14
Step15
3.5.5、方法演进的细节
首先明确:只有
HotSpot
才有永久代,BEA JRockit
、IBM J9
事不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java
虚拟机规范》约束,并不要求统一
HotSpot
中方法区的变化
时段 描述 JDK 1.7
以前有永久代 (Permanent Generation)
,
静态变量存放在永久代JDK 1.7
有永久代,但已经逐步 “去永久代”,字符串常量池、
静态变量溢出,保存在堆中JDK 1.7
以后无永久代,类型信息、字段、方法、常量保存在本地的
元空间,但字符串常量池、静态变量仍在堆
3.5.5.1、为什么要用元空间替换永久代
- 随着
Java 8
的到来,HotSpot VM
中再也见不到永久代了,但是这并不意味着类的元数据信息也消失。这些数据被迁移到一个 与堆不相连的本地内存区域,这个区域叫做元空间- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间
- 这项改动是很有必要的
- 为永久代设置空间大小是很难确定的,在某些场景下,如果动态加载类过多,容易产生
Perm
区的OOM
。比如某个实际Web
工程中,因为功能点比较多,在运行过程中,要不断的动态加载很多类,经常出现致命错误:Exception in thread 'dubbo client *.* connector' java.lang.OutOfMemoryError: PermGen space
,而元空间与永久代之间最大的区别在于 元空间并不在虚拟机中,而是使用本地内存,因此默认情况下,元空间的大小仅受本地内存限制- 对永久代进行调优是很困难的
3.5.5.2、为什么要调整 String Table
JDK 7
中将StringTable
放到了堆空间中,因为永久代的回收效率很低,在Full GC
的时候才会触发。而Full GC
是老年代或永久代空间不足时才会触发,这就导致String Table
的回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
3.5.6、方法区的垃圾回收
- 有些人认为方法区,如
HotSpot
虚拟机中的元空间或者永久代,是没有垃圾回收行为的,其实不然,《Java
虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾回收,事实上也确实有为实现或未能完整实现方法区类型卸载的收集器存在- 一般来说 这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时 又是必须的。以前
Sun
公司的Bug
列表中,曾出现过若干的严重的Bug
就是由于低版本的HotSpot
虚拟机对此区域未完全回收而导致内存泄漏- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
3.5.6.1、常量回收
- 方法区常量池主要存放的两大类常量:字面量 和 符号引用。字面量比较接近
Java
语言层次的常量概念,如文本字符串,被声明为final
的常量值等。而符号引用则属于编译原理方面的概念,包括下面三种常量
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot
虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收- 回收废弃常量与回收
Java
堆中的对象非常类似
3.5.6.2、类信息的回收
- 判定一个常量是否 “废弃” 还是相对简单,而要判定一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了,需要同时满足下面三个条件
- 该类所有的实例都已经被回收,也就是
Java
堆中不存在该类及其任何派生子类的实例- 加载该类的加载器已被回收,这个条件除非是精心设计的可替换类加载器场景,如
OSGI
、JSP
的重加载等,否则通常是很难达成的- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法Java
虚拟机被允许对满足上述三个条件的无用类进行回收,这里要说的仅仅是 “被允许”,而并不是和对象一样,没有了引用就必然会回收。关于是否要对类型进行回收,HotSpot
虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX:+TraceClass-Loading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息- 在大量使用反射、动态代理、
CGLib
等字节码框架,动态生成JSP
以及OSGI
这类频繁自定义类加载器的场景中,通常都需要Java
虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
3.6、小结
四、本地方法接口
- 定义:简单地讲,一个
Native Method
就是一个Java
调用非Java
代码的接口,一个Native Method
是这样的一个Java
方法:该方法的实现由非Java
语言实现,比如C
,这个特征并非Java
特有,很多其他的编程语言都有这一机制,比如在C++
中,就可以用extern “C”
告知C++
编译器去调用一个C
函数
Native method is a Java method whose implementation is provided by non-java code
- 在定义一个
Native method
时,并不提供实现体(有些像定义一个Java Interface
,因为其实现体是由非Java
语言在外面实现的- 本地接口的作用时融合不同的编程语言为
Java
所用,初衷是融合C/C++
- 为什么要使用
Native Method
:Java
使用起来非常方便,然而有些层次的任务用Java
实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
- 有时
Java
应用需要与Java
外面环境交互,这是本地方法存在的主要原因,本地方法为我们提供了一个非常简洁的接口,而且我们无需去了解Java
应用之外的繁琐细节JVM
支持着Java
语言本身的运行时库,它是Java
程序赖以生存的平台,它由一个解释器和一些连接到本地代码的库组成。然而不管怎样,它不经不是一个完整的系统,他经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java
实现了JRE
的底层系统的交互,甚至JVM
的一部分就是用C
写的。还有,如果我们要使用一些Java
语言本身没有提供封装的操作系统特性时,我们也需要使用本地方法。Sun
的解释器是用C
实现的,这使得它能像一些普通的C
一样与外部交互。JRE
大部分是用Java
实现的,它也通过一些本地方法与外界交互。例如:类Java.lang.Thread
的setPriority()
方法使用Java
实现,但是它实现调用的是该类里的本地方法setPriority0()
。这个本地方法是用C
实现的,并被植入JVM
内部,在Windows95
的平台上,这个本地方法最终将调用Win32 SetPriority() API
。这是一个本地方法的具体实现由JVM
直接提供,更多的情况是本地方法由外部的动态链接库(External Dynamic Link Library)
提供,然后被JVM
调用
- 现状:目前该方法的使用越来越少了,除非是与硬件有关的应用,比如通过
Java
程序驱动打印机或者Java
系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket
通信,也可以使用Web Service
等等
五、对象的实例化、内存布局、访问定位
5.1、对象的实例化
5.1.1、创建对象的方式
new
:最常见的方式
Xxx
的静态方法XxxBuilder/XxxFactory
的静态方法Class
的newInstance()
:反射的方式,只能调用空参的构造器,权限必须是public
Constructor
的newInstance(Xxx)
:反射的方式,可以调用空参、带参的构造器,权限没有要求- 使用
clone()
:不调用任何构造器,当前类需要实现Cloneable
接口,实现clone()
- 使用反序列化:从文件、网络中获取一个对象的二进制流
- 第三方库
Objenesis
5.1.2、创建对象的步骤
- 判断对象对应的类是否加载、链接、初始化 [ 虚拟机遇到一条
new
指令,首先去检查这个指令的参数能否在Metaspace
的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派机制下,使用当前类加载器以ClassLoader + 包名 + 类名
为关键字进行查找对应的class
文件,如果没有找到文件,则抛出ClassNotFoundException
异常,如果找到则进行类加载,并生成对应的Class
对象 ]- 为对象分配内存 [ 首先计算对象占用空间大小,接着在堆中划分一块内存分配给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即
4
个字节大小 ]
- 如果内存规整:
- 指针碰撞 [ 如果内存是规整的,那么虚拟机将采用指针碰撞算法来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另一边,中间存放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空间那边挪动一段与对象大小相等的距离罢了,如果垃圾收集器选择的是
Serial
、PerNew
这种基于压缩算法的,虚拟机采用这种分配方式,一般使用带有Compact
过程的收集器时,使用指针碰撞 ]- 如果内存不规整:
- 虚拟机需要维护一个列表
- 空闲列表分配 [ 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存,意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式称为 “空闲列表” ]
- 处理并发安全问题:
- 采用
CAS
失败重试、区域加锁保证更新的原子性- 每个线程预先分配一块
TLAB
(通过-XX:+/-UseTLAB
参数来设定)- 初始化分配到的空间:所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
- 设置对象的对象头 [ 将对象的所属类(即类的元数据信息)、对象的
HashCode
和对象的GC
信息、锁信息等数据存储在对象的对象头中,这个过程的具体设置方式取决于JVM
的实现 ]- 执行
init
方法进行初始化 [ 在Java
程序的视角来看,初始化才正式开始,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋给引用变量。因此一般来说(由字节码中是否跟随有invokespecial
指令所决定),new
指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来 ]
5.2、内存布局
5.2.1、对象头 Header
- 包含:
- 运行时元数据
- 哈希值
GC
分代年龄- 锁状态标志
- 线程持有的锁
- 偏向线程
ID
- 偏向时间戳
- 类型指针:指向类元数据
InstanceKlass
,确定该对象所属的类型
- 说明:如果是数组,还有记录数组的长度
5.2.2、实例数据 Instance Data
- 规则
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在子类之前
- 如果
CompactFields
参数为true
**(默认为true
)**:子类的窄变量可能插入到父类变量的空隙
- 说明:它是对象真正存储的有效信息, 包括程序代码中顶一个各种类型的字段(包括从父类继承下来的和本身拥有的字段)
5.2.3、对其填充 Padding
不是必须的,也没有特别含义,仅仅起到占位符的作用
5.2.4、图示
5.3、对象访问定位
创建对象的目的是为了使用它
JVM
通过定位,即栈上reference
访问来访问到其内部的对象实例对象访问的两种主要方式
句柄访问
直接指针
六、直接内存
- 不是虚拟机运行时数据区的一部分,也不是《
Java
虚拟机规范》中定义的内存区域- 直接内存是在
Java
堆外的、直接向系统申请的内存区间- 来源于
NIO
,通过存在堆中的DirectByteBuffer
操作Native
内存- 通常,访问直接内存的速度会优于
Java
堆,即读写性能高
- 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java
的NIO
库允许Java
程序使用直接内存,用于数据缓冲区
6.1、非直接缓冲区
- 读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如图所示,使用
I/O
,需要两份内存存储重复数据,效率低
6.2、直接缓冲区
- 使用
NIO
时,如图,操作系统划出的直接缓存区可以被Java
代码直接访问,只有一份。NIO
适合对大文件的读写操作
6.3、直接内存概述
- 也可能导致
OutOfMemoryError
异常- 优于直接内存在
Java
堆外,因此它的大小不会直接受限于-Xmx
指定的最大堆大小,但是系统内存是优先的,Java
堆和直接内存的总和依然受限于操作系统能给出的最大内存- 缺点
- 分配回收成本高
- 不受
JVM
内存回收管理- 直接内存大小可以通过
MaxDirectMemorySize
来设置- 如果不指定,默认与堆的最大值
-Xmx
参数保持一致
七、执行引擎
7.1、执行引擎概述
JVM
的主要任务是负责 装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅是一些能够被JVM
所识别的字节码指令、符号表,以及其他辅助信息- 那么,如果想要让一个
Java
程序运行起来,执行引擎的任务就是 将字节码指令解释/编译为对应平台上的本地机器指令才可以,简单来说,JVM
中的执行引擎充当了将高级语言翻译为机器语言的译者
7.1.2、执行引擎工作过程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于
PC
寄存器- 每当执行完一项指令后,
PC
寄存器就会更新下一条需要被执行的指令地址- 当然,方法在执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在
Java
堆区中的对象实例信息以及通过对象头中的元数据指针定位到目标对象的类型信息- 从外观上来看,所有的
Java
虚拟机的执行引擎输入、输出都是一致的,输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
7.2、Java 代码编译和执行的过程
- 大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
- 绿色:解释的过程
- 蓝色:编译的过程

Javac
前端编译器过程
- 后端编译器过程
- 解释器:当
Java
虚拟机启动时会根据预定的规范 对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行JIT(Just In Time Compiler)
编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
- 为什么说
Java
是半编译半解释型语言
JDK1.0
时代,将Java
语言定位为 “解释执行” 还是比较准确的。再后来Java
也发展出可以直接生成本地代码的编译器- 现在
JVM
在执行Java
代码的时候,通常都会将解释执行与编译执行二者结合起来进行
7.3、机器码、指令、汇编语言
7.3.1、机器码
- 各种用二进制彪马方式表示的指令,叫做机器指令码。开始,人们就采用它编写程序,这就是机器语言
- 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错
- 用它编写的程序一经输入计算机,
CPU
直接读取运行,因此和其他语言编的程序相比,执行速度最快- 机器指令与
CPU
紧密相关,所以不同种类的CPU
所对应的机器指令也就不同
7.3.2、指令
- 由于机器码是由
0
和1
组成的二进制序列,可读性实在太差,于是人们发明了指令- 指令就是把机器码中特定的
0
和1
序列简化成对应的指令(一般为英文简写,如MOV
、INC
),可读性稍好- 由于不同的硬件平台执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令,对应的机器码也可能不同
7.3.3、指令集
- 不同的硬件平台,各自支持的指令是有差别的,因此每个平台所支持的指令,称之为对应平台的指令集
7.3.4、汇编语言
- 由于指令的可读性还是太差,于是人们又发明了汇编语言
- 在汇编语言中,用 助记符
(Mnemonics)
代替 机器指令的操作码,用 地址符号(Symbol)
或标号(Label)
代替 指令或操作数的地址- 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令,由于计算机只认识指令码,所以用 汇编语言编写的程序还需翻译成机器指令码, 计算机才能识别和执行
7.3.5、高级语言
- 为了使编程更加容易,就出现了各种高级计算机语言,高级语言比起机器指令、汇编语言 更接近人的语言
- 当计算机执行高级语言编写的程序时,仍需把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序
7.3.6、字节码
- 字节码是一种中间状态的二进制代码,它比机器码更抽象,需要直译器转译后才能成为机器码
- 字节码主要是为了实现特定软件运行和软件环境,与硬件环境无关
- 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令
- 字节码的典型应用为
Java Bytecode
7.4、解释器
JVM
设计者们的初中仅仅是单纯地 为了满足Java
程序实现快平台特性,因此避免采用静态编译地方式直接生成机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序地想法- 解释器真正意义上所担任的角色就是一个运行时的翻译者,将字节码文件中的内容翻译为对应平台的本地机器指令执行
- 当一条字节码指令被解释执行完成后,接着再根据
PC
寄存器中记录的下一条需要被执行的字节码指令执行解释操作
7.4.1、解释器的分类
在
Java
的发展历史中,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的 模板解释器
- 字节码解释器在执行时通过 纯软件代码 模拟字节码的执行,效率非常地下
- 而面板解释器将 每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能
- 在
HotSpot
中,解释器主要由Interpreter
模块和Code
模块构成
Interpreter
模块:实现了解释器的核心公共Code
模块:用于管理HotSpot
在运行时生成的本地机器指令- 由于解释器在设计和是线上非常简单,因此除了
Java
语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python
、Perl
、Ruby
等,但今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++
程序员所调侃- 为了解决这个问题,
JVM
平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将 整个函数体编译为机器码,每函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升- 不过无论无何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献
7.5、JIT 编译器
HotSpot
虚拟机是目前市面上高性能虚拟机的代表作之一。采用解释器与即时编译器并存的架构。在Java
虚拟机运行时,解释器和即时编译器能够互相协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间
- 既然
HotSpot
虚拟机已经内置了JIT
编译器,为什么还需要使用解释器来 “拖累” 程序的执行型嫩
- 首先明确:解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率很高
- 所以:尽管
JRocket
虚拟机程序的执行性能非常高效,但程序在启动时必然需要花费更长的时间来进行编译,对于服务端应用来说,启动时间并非是关注重点,但对于哪些看重启动时间的应用场景而言,或许需要采用编译器与即时编译去共存的架构来换取一个平衡点。在此模式下,当Java
虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率- 同时,解释执行再编译器进行激进优化不成立的时候,作为编译器的 “逃生门”
- 当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以 省去许多不必要的编译时间,并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率
- 概念解释:
Java
语言的 “编译期” 其实是一段 “不确定” 的操作过程,因为它可能是指一个编译器的前端把.java
文件转换为.class
文件的过程- 也可能是只虚拟机的后端运行期编译器
(JIT, Just In Time)
,把字节码转变为机器码的过程- 还可能是指使用静态提前编译器
(AOT, Ahead Of Time)
,直接把.java
文件编译成本地机器码的过程
7.5.1、Java 代码的执行分类
- 将源代码编译成字节码文件,然后再运行时通过解释器将字节码文件转为机器码执行
- 编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率会使用即时编译技术,将方法编译成机器码后再执行
7.5.2、热点代码和探测
- 是否需要启动
JIT
编译器将字节码直接编译为对应平台的本地机器指令,则需要根据 代码被调用的频率 来顶。关于那些需要被编译为本地代码的字节码,也被称之为 热点代码,JIT
编译器在运行时会针对那些频繁被调用的 “热点代码” 做出 深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java
程序的执行性能
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为 “热点代码”,因此都可以通过
JIT
编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR(On Stack Replacement)
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以到达这个标准,必须明确一个阈值,
JIT
编译器才会将这些 “热点代码” 编译为本地机器指令执行。这里主要依靠 热点探测功能目前
HotSpot
虚拟机所采用的热点探测方式是基于计数器的热点探测采用基于计数器的热点探测,
HotSpot
虚拟机将会为每一个方法都建立2
个不同类型的计数器,分别为方法调用计数器(Invocation Counter)
和回边计数器(Back Edge Counter)
方法调用计数器用于统计方法的调用次数
- 这个计数器就用于统计方法被调用的次数,默认阈值在
Client
模式下是1500
次,在Server
模式下是10000
次。超过这个阈值,就会触发JIT
编译- 这个阈值可以通过
-XX:CompileThreshold
来设置- 当一个方法被调用时,会先检查该方法是否存在被
JIT
编译过的版本,如果存在则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1
,然后判断 方法调用计数器与回边计数器之和 是否超过方法调用计数器的阈值。如果已经超过,那么将会向即时编译器提交一个该方法的代码编译请求- 热度衰减:
- 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即 一段时间之内方法被调用的次数。当超过 一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被 减少一半,这个过程称为方法调用计数器的热度衰减
(Counter Decay)
,而这段时间就称为此方法的半衰周期(Counter Half Life Time)
- 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数
-XX:-UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分的代码都会被编译成本地代码- 另外,可以使用
-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒
回边计数器则用于统计循环体执行的循环次数
统计一个方法中 循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为 “回边”,显然建立回边的目的就是为了触发
OSR
编译
7.5.3、模式设置
- 缺省情况下
HotSpot
虚拟机采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java
虚拟机指定在运行时到底是 完全采用解释器 执行,还是 完全采用即时编译器 执行
-Xint
:完全采用解释器模式-Xcomp
:完全采用即时编译器模式,如果即时编译出现问题,解释器会介入执行-Xmixed
:采用 解释器 + 即时编译器 地混合模式共同执行程序
JIT
分类
- 在
HotSpot
虚拟机中内嵌了两个JIT
编译器,分别为Client Compiler
和Server Compiler
,但大多数情况下我们简称为C1
编译器、C2
编译器
-client
:指定Java
虚拟机运行在Client
模式下,并使用C1
编译器。C1
编译器回对字节码进行 简单和可靠的优化,耗时短。以达到更快的编译速度-server
:指定Java
虚拟机运行在Server
模式下,并使用C2
编译器。C2
编译器进行 耗时较长,以及激进优化。但优化的代码执行效率更高
C1
和C2
编译器不同的优化策略:
C1
- 方法内联:将引用的函数代码编译到引用点处,可减少栈帧的生成,减少参数传递以及跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间把一些不会执行的代码折叠掉
C2
的优化主要是在全局层面,逃逸分析是优化的基础,基于逃逸分析在C2
上有如下几种优化
- 标量替换:用标量值代替聚合对象的属性值
- 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
- 同步消除:清除同步操作,通常指
Synchronized
7.6、其他编译器
Graal
:JDK10
引入,编译效果短短几年就追平了C2
编译器,目前还处于实验阶段,用以下两条指令进行激活才可使用
-XX:UnlockExperimentalVMOptions
-XX:UseJVMCICompiler
AOT(Ahead Of Time)
:JDK9
引入了实验性AOT
编译工具jaotc
。它借助了Graal
编译器,将所输入的Java
类文件转换为机器码,并存放至生成的动态共享库之中,是与即时编译相对立的一个概念。
- 我们知道即时编译指的是在 程序运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署到托管环境中的过程。而
AOT
编译指的是在 程序运行之前,便将字节码转换为机器码的过程- 好处:
Java
虚拟机加载已经预编译的二进制库,可以直接执行。不必等待即时编译器的预热,减少Java
应用给人带来 “第一次运行慢” 的不良体验- 缺点:
- 破坏了
Java
“一次编译,处处运行”,必须为每个不同硬件、OS
编译对应的发行包- 降低了
Java
链接过程的动态性,加载的代码在编译期就必须全部已知- 还需要继续优化中,最初只支持
Linux x64 Java Base
八、String Table
8.1、String 的基本特性
String
使用一对 “” 来表示
String
声明为final
,不可被继承
String
实现了Serializable
接口(表示字符串是支持序列化的),实现了Comparable
接口(表示字符串是可以比较大小的)
String
在JDK8
及以前内部定义了private final char[] value;
,用于存储字符串数据,JDK9
时改为private final byte[] value;
- 修改的动机:
String
类的当前(JDK8
)实现将字符存储在char
数组中,每个字符使用两个字节(16位)。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含Latin-1
字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char
数组中有一半的空间将不会使用。
- 修改的描述:
- 我们建议将字符串类的内部表示形式从
UTF-16
字符数组改为字节数组加上编码标记字段。新的String
类将根据字符串的内容存储编码为ISO-8859-1/Latin-1
(每个字符一个字节)或UTF-16
(每个字符两个字节)的字符。编码标志将指示使用哪种编码。- 与字符串相关的类,如
AbstractStringBuilder
、StringBuilder
和StringBuffer
,将被更新以使用相同的表示,HotSpot VM
的固有字符串操作也将如此。- 这纯粹是实现更改,对现有的公共接口没有更改。目前还没有添加任何新的公共
api
或其他接口的计划。- 到目前为止所做的原型工作证实了预期的内存占用减少、
GC
活动的大量减少以及在某些极端情况下的少量性能退化。
String
代表不可变的字符序列,简称 不可变性
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的
value
进行赋值- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的
value
进行赋值- 当调用
String
的replace()
方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value
进行赋值通过字面量的方式给一个字符串赋值,此时的字符串值生命在字符串常量池中
字符串常量池中是不会存储相同内容的字符串的
String
的String Pool
是一个固定大小的HashTable
,默认值长度是1009
。如果放进String Pool
的String
非常多,就会造成Hash
冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern()
时性能会大幅下降- 使用
-XX:StringTableSize
可设置StringTable
的长度- 在
JDK6
中StringTable
是固定的,就是1009
的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize
设置没有要求- 在
JDK7
中,StringTable
的长度默认值是60013
,- 在
JDK8
中,1009
是可设置的最小值
8.2、String 的内存分配
- 在
Java
语言中有 8 种基本数据类型和一种比较特殊的类型String
。这些类型为了使他们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念- 常量池就类似一个
Java
系统级别提供的缓存,8
种基本数据类型的常量池都是系统协调的,**String
的常量池比较特殊。它的主要使用方法有两种**
- 直接使用双引号声明出来的
String
对象会直接存储在常量池中- 如果不是用双引号声明的
String
对象,可以是使用String
提供的intern()
方法JDK7
以前,字符串常量池存放在永久代JDK7
时,Oracle
的工程师对字符串池的逻辑做了很大的改变,即:将字符串常量池的位置调整到Java
堆中
- 所有的字符串都保存在堆中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了
- 字符串常量池概念原本使用的比较多,但这个改动使得我们有足够的理由让我们重新考虑 在
Java 7
中使用Sting.intern()
JDK7
以后,元空间取代永久代,字符串常量池仍放在堆中
StringTable
为什么要调整
PermSize
默认比较小- 永久代垃圾回收频率低
8.3、字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
1
2
3
4
5
6 public void test() {
String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2); // true
System.out.println(s1.equals(s2)); // true
}常量池中不会存在相同内容的变量
只要其中有一个是变量,结果就在堆中。变量拼接的原理是
StringBuilder
String s1 = "a"; String s2 = "b"; s1 + s2
的过程:
StringBuilder s = new StringBuilder();
s.append("a");
s.append("b");
s.toString();
约等于new String("ab");
- 字符串拼接操作不一定是
StringBuilder
,如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化- 针对于
final
修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final
的时候建议使用上如果拼接的结果调用
intern()
方法,则主动将常量池中还没有字符串对象放入池中,并返回此对象地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 public void test() {
String javaEE = "javaEE";
String hadoop = "hadoop";
String s1 = "javaEEhadoop";
String s2 = "javaEE" + "hadoop";
String s3 = javaEE + "hadoop";
String s4 = "javaEE" + hadoop;
String s5 = javaEE + hadoop;
String s6 = s5.intern();
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // false
System.out.println(s1 == s5); // false
System.out.println(s1 == s6); // true
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // false
System.out.println(s4 == s5); // false
}
8.4、intern() 的使用
- 如果不是用双引号声明的
String
对象,可以使用String
提供的intern
方法。intern
方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中- 也就是说,如果在任意字符串上调用
String.intern
方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true
("a" + "b" + "c").intern() == "abc"
- 通俗来说,
Interned String
就是确保字符串在内存里只有一份数据,这样也可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池String Intern Pool
JDK 1.7
之前:
- 如果字符串常量池中有,则并不会放入,返回已有的字符串常量池中的对象的地址
- 如果没有,会把 此对象复制一份,放入字符串常量池并返回字符串常量池中的对象地址
JDK 1.7
及之后:
- 如果字符串常量池中有,则并不会放入,返回已有的字符串常量池中的对象的地址
- 如果没有,则会把 对象的引用地址复制一份,放入字符串常量池,并返回字符串常量池中的引用地址
8.4.1、new String(“ab”) 会创建几个对象
对象 ①
Line 0
:String
对象对象 ②
Line 3
:字符串常量池"ab"
1
2
3
4 0 new #2 <java/lang/String>
3 dup
4 ldc #3 <ab>
6 invokespecial #4 <java/lang/String.<init>>
8.4.2、new String(“a”) + new String(“b”) 会创建几个对象
对象 ①
Line 0
:StringBuilder
对象对象 ②
Line 7
:String
对象对象 ③
Line 11
:字符串常量池"a"
对象 ④
Line 19
:String
对象对象 ⑤
Line 23
:字符串常量池"b"
1
2
3
4
5
6
7
8
9
10
11
12
13
14 0 new #2 <java/lang/StringBuilder>
3 dup
4 invokespecial #3 <java/lang/StringBuilder.<init>>
7 new #4 <java/lang/String>
10 dup
11 ldc #5 <a>
13 invokespecial #6 <java/lang/String.<init>>
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 new #4 <java/lang/String>
22 dup
23 ldc #8 <b>
25 invokespecial #6 <java/lang/String.<init>>
28 invokevirtual #7 <java/lang/StringBuilder.append>
31 invokevirtual #9 <java/lang/StringBuilder.toString>对象 ⑥
Line 0
:String
对象 // 此对象直接放在堆空间,并未放入字符串常量池
1
2
3
4
5
6
7
8
9 0 new #80 <java/lang/String>
3 dup
4 aload_0
5 getfield #234 <java/lang/StringBuilder.value>
8 iconst_0
9 aload_0
10 getfield #233 <java/lang/StringBuilder.count>
13 invokespecial #291 <java/lang/String.<init>>
16 areturn
8.4.3、重点面试题
public void test() { /* ** - 字符串常量池生成字符串 "1" ** - 并创建 String 对象 ** 两者地址并不相同 */ String s1 = new String("1"); /* ** 在这之前,字符串已有 "1" ** 故不再做操作 */ s1.intern(); /* ** 从字符串常量池取出 "1",存入变量 s2 ** 此时 s2 的地址就是字符串常量池中 "1" 的地址 */ String s2 = "1"; /* ** 相当于将 s1 中 String 对象的地址与字符串常量池中 "1" 的地址比较 ** JDK 1.7 之前: false ** JDK 1.7 及之后: false */ System.out.println(s1 == s2); }
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
40
41
42
- ```java
public void test() {
/*
** - 右侧表达式的结果相当于 new String("11")
** - 这里是通过 StringBuilder 进行拼接
** - "11" 并不在字符串常量池中
** 所以 s1 就是 String 对象的地址
*/
String s1 = new String("1") + new String("1");
/*
** 在此之前字符串常量池并没有 "11"
** 在字符串常量池生成字符串 "11"
** - JDK 1.7 之前:
** 直接生成一个新的对象,所以地址也是新的
** - JDK 1.7 及之后:
** 创建一个对象,存放已有的 String 对象引用地址,没有新地址生成
*/
s1.intern();
/*
** JDK 1.7 之前:
** 此时 s2 存放的就是字符串常量池中 "11" 的地址
** JDK 1.7 及之后:
** 此时字符串常量池中存放的是 "11" 的 String 对象的引用地址
** 在查找过程中,根据字符串常量找到了存放 "11" 的 String 对象
** 将该 String 对象的地址返回
** 故 s2 亦指向 s1 所指向的 String 对象
*/
String s2 = "11";
/*
** JDK 1.7 之前:false
** - 相当于字符串常量池中 "11" 的地址与 String 对象的地址进行比较
** JDK 1.7 及以后:true
** - 两个不同变量,指向的却是同一个 String 对象
*/
System.out.println(s3 == s4);
}public void test() { /* ** - 右侧表达式的结果相当于 new String("11") ** - 这里是通过 StringBuilder 进行拼接 ** - "11" 并不在字符串常量池中 ** 所以 s1 就是 String 对象的地址 */ String s1 = new String("1") + new String("1"); // 字符串常量池生成字符串 "11" String s2 = "11"; /* ** 在这之前,字符串已有 "11" ** 直接将字符串常量池 "11" 的地址返回给 s3 */ String s3 = s1.intern(); /* ** 相当于将 s1 中 String 对象的地址与字符串常量池中 "11" 的地址比较 ** JDK 1.7 之前: false ** JDK 1.7 及之后: false */ System.out.println(s1 == s2); /* ** 两个变量都指向字符串常量池中 "11" 的地址 ** JDK 1.7 之前: true ** JDK 1.7 及之后:true */ System.out.println(s2 == s3) }
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
### 8.5、StringTable 的垃圾回收
- `-XX:+PrintStringTableStatisics`:打印 `StringTable` 的信息
### 8.6、G1 的 String 去重操作
- 许多 `Java` 应用做的测试得出以下结果
- 堆存活数据集合里 `String` 对象占了 `25%`
- 堆存活数据集合里洪福的 `String` 对象有 `13.5%`
- `String` 对象的平均长度是 `45`
- 许多大规模的 `Java` 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,**`Java` 堆中存活的数据集合差不多 `25%` 是 `String` 对象**,更进一步,这里面差不多一半的 `String` 对象是重复的,即 `str1.equals(str2) == true`,**堆上存在重复的 `String` 对象必然是一种内存的浪费**,这个项目将在 `G1` 垃圾收集器中实现自动持续对重复的 `String` 对象去重,这样就能避免浪费内存
---
- 实现步骤
- 当垃圾收集器工作的时候,会访问堆上存活的对象,**对每一个访问的对象都会检查是否是候选的要去重的 `String` 对象**
- 如果是,把这个对象的一个引用插入到队列中等待后续处理,一个去重的线程在后台执行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 `String` 对象
- 使用一个 `HashTable` 来记录所有的被 `String` 对象使用的不重复的 `char` 数组。当去重的时候,会查这个 `HashTable`,来看堆上是否已经存在一个一模一样的 `char` 数组
- 如果存在,`String` 对象会被调整引用那个数组,释放对原来数组的引用,最终会被垃圾收集器回收掉
- 如果查找失败,`char` 数组会被插入 `HashTable`,这样以后就可以共享这个数组了
---
- 命令行选项
- `-XX:UseStringDedupliation`:开启 `String` 去重,**默认是不开启的,需要手动开启**
- `-XX:PrintStringDeduplicaitonStatistics`:打印详细的去重统计信息
- `-XX:DeduplicationAgeThreshold`:达到这个年龄的 `String` 对象被认为是去重的候选对象
<div style="text-align: center;
font-size: 20px;
margin-top: 30px;
font-weight: bolder;"
<span style="border-top: 1px solid black;
border-bottom: 1px solid black;
letter-spacing:5px"
>>FINISH<<
</span>
</div>
## 九、垃圾回收概述
- 垃圾收集并不是 `Java` 语言的伴生产物。早在 `1960` 年,第一门开始使用内存动态分配和垃圾收集技术的 `Lisp` 语言诞生
- 关于垃圾收集有三个经典的问题
- 哪些内存需要回收
- 什么时候回收
- 如何回收
- 垃圾收集机制是 `Java` 的招牌能力,**极大的提高了开发效率**,如今垃圾收集几乎称为现代语言的标配,即使经过如此长时间的发展,`Java` 的垃圾收集机制仍在不断地演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是 **面试的热点**
---
常见面试题:
- 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下 `CMS` 和 `G1`,包括原理、流程、优缺点、垃圾回收算法的实现原理
- `JVM GC` 算法有哪些,目前的 `JDK` 版本采用什么回收算法
- `G1` 回收期,讲下回收过程
- `GC` 是什么,为什么要有 `GC`
- `GC` 的两种判定方法?`CMS` 收集器与 `G1` 收集器的特点
- 说一下 `GC` 算法,分代回收说下
- 垃圾收集策略和算法
- `JVM GC` 原理,`JVM` 怎么回收内存
- `CMS` 特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
- `Java` 的垃圾回收器都有哪些,说下 `G1` 的应用场景,平时你是如何搭配使用垃圾回收器的
- 讲一讲垃圾回收算法
- 什么情况下触发垃圾回收
- 如何选择合适的垃圾收集算法
- `JVM` 有哪三种垃圾回收器
- 常见的垃圾回收器算法有哪些,各有什么优劣
- `System.gc()` 和 `runtime.gc()` 会做什么事情
- `Java GC` 机制?`GC Roots` 有哪些
- `Java` 对象的回收方式,回收算法
- `CMS` 和 `G1` 了解吗,`CMS` 解决了什么问题,说一下回收的过程
- `CMS` 回收停顿了几次,为什么要停顿两次
### 9.1、什么是垃圾
- 指 **运行程序中没有任何指针指向的对象**,这个对象就是需要被回收的垃圾
- 如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能 **导致内存溢出**
### 9.2、为什么需要 GC
- 对于高级语言来说,一个基本的认知是如果不进行垃圾回收,**内存迟早要被消耗光**,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样
- 除了释放没用的对象,垃圾回收也可以清楚内存里的记录碎片。碎片整理将所占用的内存移动到堆的一段,以便 **`JVM` 将整理出的内存分配给新的对象**
- 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,**没有 `GC` 就不能保证应用程序的正常进行**。而经常造成 `STW` 的 `GC` 又跟不上实际的需求,所以才会不断地尝试对 `GC` 进行优化
### 9.3、早期垃圾回收
- 在早期的 `C/C++` 时代,垃圾回收基本上是手工进行的,开发人员可以使用 `new` 关键字进行内存申请,并使用 `delete` 关键字进行内存释放
```C++
MibBridge *p = new MibBridge();
if (p->Register(kDestory) != NO_ERROR) {
delete p;
}这种方式可以灵活控制内存释放的时间,但是会给开发人员带来 频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生 内存泄漏,垃圾对象永远无法被消除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成 应用程序崩溃。现在,除了
Java
以外,C#
、Python
、Ruby
等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式,已经称为了现代开发语言必备的标准。当有了垃圾回收机制,上述代码块极有可能变成如下情况
1
2 MibBridge *p = new MibBridge();
p->Register(kDestory);
9.4、Java 垃圾回收机制
- 自动内存管理,无需开发人员手动参与内存的分配与回收,这样 降低内存泄漏和内存溢出的风险
- 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以 更专注于业务开发
- 对于
Java
程序员而言,自动管理就像一个黑匣子,如果过度依赖于自动,那么这将会是一场灾难,最严重的是 会弱化Java
开发成员在出现内存溢出时定位问题和解决问题的能力- 此时,了解
JVM
的自动内存分配和内存回收原理就显得非常的重要,只有在真正了解JVM
是如何管理内存后,我们才能够在遇见OutOfMemoryError
时,快速地根据错误异常日志定位问题和解决问题- 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,我们就必须对这些自动化的技术 实施必要的监控和调节
十、垃圾回收相关算法
10.1、标记阶段
- 在堆里存放着几乎所有的
Java
对象实例,在GC
执行垃圾回收之前,首先 需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC
才会在执行垃圾回收时释放其所占用的内存空间,因此这个过程我们可以称为 垃圾标记阶段- 那么在
JVM
中究竟是如何标记一个死亡对象的?简单来说,当一个对象不再被任何的存活对象继续引用时,就可以宣判为已经死亡
10.1.1 引用计数算法
Reference Counting
,对每个对象保存一个整型的 引用计数器属性,用于记录对象被引用的情况- 对于一个对象
A
,只要有任何一个对象引用了A
,则A
的引用计数器就加一,当引用失效时,引用计数器就减一,只要对象A
的引用计数器为零,即表示对象A
不可能再被使用,可进行回收
- 优点:
- 实现简单,垃圾对象便于辨识
- 判定效率高,回收没有延迟性
缺点:
需要单独的字段存储计数器,这样的作法增加了 存储空间的开销
每次复制都需要更新计数器,伴随着加法和减法操作,增加了 时间开销
无法处理循环引用的情况,可能出现 内存泄漏
- 小结
- 引用计数算法是很多语言的资源回收选择,例如
Python
,同时支持引用计数和垃圾回收机制
Python
使用手动解除(在合适的时机,解除引用关系)和弱引用(Weakref
,是Python
提供的标准库,旨在解决循环引用)- 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制以提高吞吐量的尝试
Java
没有使用引用计数算法,是因为不能处理循环引用
10.1.2、可达性分析算法
- 又称 根搜索算法、追踪性垃圾收集
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地 解决在引用技术算法中循环引用的问题,防止内存泄漏发生
- 相较于引用计数算法,这里的可达性分析就是
Java
、C#
选择的
基本思路
可达性分析算法是以根对象集合
(GC Roots)
为起始点,按照从上到下的方式 搜索被根对象集合所连接的目标对象是否可达
- 所谓
GC Roots
根集合就是一组必须活跃的引用使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径为 引用链
(Reference Chain)
如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
GC Roots
包括以下几类元素:
- 虚拟机栈中的引用的对象
- 如:各个线程被调用的方法中使用到的参数、局部变量等
- 本地方法栈内
JNI
引用的对象- 方法区中静态属性引用的对象
- 如:
Java
类的引用类型静态变量- 方法区中常量引用的对象
- 如:字符串常量池
String Table
里的引用- 所有被同步锁
Synchronized
所持有的对象Java
虚拟机内部的引用
- 如:基本数据类型对象的
Class
对象- 如:一些常驻的异常对象(
NullPointerException
、OutOfMemory
)- 如:系统类加载器
- 反射
Java
虚拟机内部情况的JMXBean
、JVMTI
中注册的回调、本地代码缓存等- 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整
GC Roots
集合。比如:分代收集和局部回收Partial GC
- 如果只针对
Java
堆中的某一块区域进行垃圾回收,必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots
集合中去考虑,才能保证可达性分析的准确性
- 注意
- 如果使用可达性分析算法来判断内存是否可回收,那么分析工作就必须在一个能保障一致性的快照中进行,这点不满足的话分析结果的准确性就无法保证
- 这点也是导致
GC
进行时必须Stop The World
的一个重要原因,即使是号称(几乎)不会发生停顿的CMS
收集其中,枚举根节点时也是必须要停顿的
10.2、对象的 finalization 机制
Java
语言提供了对象终止机制来允许开发人员提供 对象被销毁之前的自定义处理逻辑- 当垃圾回收器发现一个没有引用指向的对象,会先电泳这个对象的
finalize()
方法finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理工作,比如关闭文件、套接字和数据库连接等
- 永远不要主动调用某个对象的
finalize()
方法,应该交给垃圾回收机制调用
- 在
finalize()
时可能会导致对象复活finalize()
方法的执行时间是没有保障的,完全由GC
线程决定,极端情况下,若不发生GC
,则finalize()
方法将没有执行的机会- 一个糟糕的
finalize()
会严重影响GC
的性能- 从功能上来说,
finalize()
方法与C++
中的析构函数比较相似,但是Java
采用的是基于垃圾回收器的自动内存管理机制,所以finalize()
方法在本质上不同于C++
中的析构函数
- 由于
finalize()
方法的存在,虚拟机中的对象一般处于三种可能的状态- 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了,一般来说,此对象需要被回收。但事实上,也并非是 “非死不可”,这时候它们暂时处于 “缓刑” 阶段。一个无法触及的对象有可能在某一条件下 “复活” 自己,如果是这样,那么对它的回收就是不合理的
- 可触及的:从根节点开始,可以到达这个对象
- 可复活的:对象的所有引用都被释放,但是对象有可能在
finalize()
中复活- 不可触及的:对象的
finalize()
被调用,并没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()
只会被调用一次
- 具体过程
- 如果对象
ObjA
到GC Roots
没有引用链,则进行第一次标记- 如果筛选,判断此对象是否有必要执行
finalize()
方法
- 如果对象
ObjA
没有重写finalize()
方法,或者finalize()
方法已经被虚拟机调用过,则虚拟机视为没有必要执行,ObjA
被判定为不可触及- 如果对象
ObjA
重写了finalize()
方法,且还未被执行过,那么ObjA
会被插入到F-Queue
队列中,有一个虚拟机自动创建的、低优先级的Finalizer
线程触发其finalize()
方法执行finalize()
方法是对象逃脱死亡的最后机会,稍后GC
会对F-Queue
队列中的对象进行二次标记,如果ObjA
在finalize()
方法中与引用链上的任一对象建立了联系,那么在第二次标记时,ObjA
就会被移除F-Queue
。之后,对象会在此出现没有引用存在的情况,在这个情况下,finalize()
方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()
方法只会被调用一次
10.3、清除阶段
- 当成功区分出内存中存活对象和死亡对象后,
GC
接下来的任务就是执行垃圾回收,释放掉无用的对象所占用的内存空间,以便有足够的可用内存空间作为新对象分配内存- 目前在
JVM
中比较常见的三种垃圾收集算法是标记清除算法(Mark-Sweep)
,复制算法(Copying)
,标记压缩算法(Mark-Compact)
10.3.1、标记清除算法
- 背景:是一种非常基础和常见的垃圾收集算法,该算法被
J·McCarthy
等人在1960
年提出并应用于Lisp
语言
执行过程:当堆中的有效内存
(Available Memory)
被耗尽的时候,就会停止整个程序(Stop The World)
,然后进行两项工作,即 标记 和 清除
- 标记:
Collector
从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header
中记录为可达对象- 清除:
Collector
对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header
中没有标记为可达对象,则将其回收(并非置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否足够,如果够,就存放)
- 缺点:
- 效率不算高
- 在进行
GC
的时候,需要停止整个应用程序,导致用户体验差- 在这种方式清理出来的空闲区是不连续的,产生内存碎片,需要维护一个空闲列表
10.3.2、复制算法
- 背景:为了解决标记清除算法在垃圾收集效率方面的缺陷,
M·L·Minsky
于1963
年发表了著名的论文,CALISP Garbage Collector Algorithm Using Serial Secondary Storyage,M·L·Minsky
在该论文中描述的算法被人们称为复制算法,它也被M·L·Minsky
本人成功地引入到了Lisp
语言的一个实现版本中
核心思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
- 优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现碎片问题
- 缺点
- 此算法的缺点也是很明显的,就是需要两倍的内存空间
- 对于
G1
这种拆分称为大量region
的GC
,复制而不是移动,意味着GC
需要维护region
之间对象的引用关系,不管是内存占用或者时间开销也不小
- 注意:如果系统中的存活对象非常多,效率就会非常低
10.3.3、标记压缩算法
- 背景:
- 复制算法的高效性是建立在存活对象少、垃圾对象高的前提下,这种情况在新生代经常发生,但在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成品也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
- 标记清除算法的确可以应用在老年代,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以
JVM
的设计者需要再次基础之上进行改进。标记压缩算法由此诞生1970
年前后,G·L·Steele
、G·J·Chene
和D·S·Wise
等研究者发布标记压缩算法。在许多现代的垃圾收集器中,人们都是用标记压缩算法或者其改进版本
执行过程:
从根节点开始标记所有被引用的对象
将所有存活的对象压缩到内存的一段,按顺序排放
清理边界外所有的空间
- 标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称之为标记清除压缩算法
- 二者的本质差异在于标记清除算法是一种 非移动式的回收算法,标记压缩是 移动式 的。是否移动后回收的存活对象是一项优缺点并存的风险决策
- 可以看到,标记的存活对象将会被整理,按照内存地址一次排列,而未被标记的内存会被清理掉。如此依赖,但我们需要给新对象分配内存时,
JVM
只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销
- 优点
- 消除了标记清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,
JVM
只需要持有一个内存的起始地址即可- 消除了复制算法当中内存减半的高额代价
- 缺点
- 从效率上来说,标记整理算法要低于复制算法
- 移动对象的同时,如果对象被其他对象引用,则还需调整引用地址
- 移动过程中,需要全程暂停用户应用程序,即
STW
10.4、小结
Mark-Sweep Mark-Compact Copying 速度 中等 最慢 最快 空间 少(但会堆积碎片) 少(不堆积碎片) 两倍大小(不堆积碎片) 移动 否 是 是
- 效率上来说,复制算法是最优的,但是却浪费了太多内存
- 而为了尽量兼顾上面提到的三个指标,标记整理算法相对来说更平滑以下,但是效率上却不尽人意,比复制算法多了一个标记阶段,比标记清除多了一个整理内存的阶段
10.5、分代收集算法
- 前面所有的算法,并没有一种算法可以完全替代其他算法,它们都具有各自独特的优势和特点。分代收集算法应运而生
- 分代收集算法,是基于这样一个事实:不同的对象生命周期是不一样的,因此,不同声明周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把
Java
堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的垃圾回收算法,提高垃圾回收效率- 在
Java
程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http
请求中的Session
对象、线程、Socket
连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如String
对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收
- 目前几乎所有的
GC
都是采用分代收集(Generational Collecting)
算法执行垃圾回收的- 在
HotSpot
中,基于分代的概念,GC
所使用的内存回收算法必须结合年轻代和老年代各自的特点
- 年轻代:
- 特点:区域相对于老年代较小,对象生命周期短、存活率低,回收频繁
- 这种情况复制算法的回收整理速度是最快的,复制算法的效率只和当前存活对象的大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过
HotSpot
中的两个Survivor
的设计得到缓解- 老年代:
- 特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
- 这种情况存在大量存活率高的对象,复制算法明显不合适,一般是由标记清除算法或者标记清除算法、标记整理算法混合实习那
Mark
阶段的开销与存活对象的数量成正比Sweep
阶段的开销与所管理区域的大小成正比Compact
阶段的开销与存活对象的数据成正比
- 以
HotSpot
中的CMS
回收期为例,CMS
是基于Mark-Sweep
实现的,对于对象的回收效率很高。而对于碎片问题,CMS
采用基于Mark-Compact
算法的Serial Old
回收期作为补偿措施,当内存回收不佳(碎片导致的Concurrent Mode Failure
时),将采用Serial Old
执行Full GC
以达到堆老年代内存的整理- 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代
10.6、增量收集算法、分区算法
10.6.1、增量收集算法
- 上述现有的算法,在垃圾回收的过程中,应用软件将处于一种
Stop The World
的状态,在这个状态下,应用程序的所有线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量Incremental Collecting
算法的诞生- 基本思想:
- 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次 垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,如此反复,直到垃圾收集完成
- 总的来说,增量收集算法的基础仍是传统的标记清除算法和复制算法,增量收集算法通过 对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
- 缺点:使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是因为线程切换和上下文转换的小号,会使得垃圾回收的总体成本上升,使得系统吞吐量下降
10.6.2、分区算法
- 一般来说,在相同条件下,堆空间越大,一次
GC
时所需要的时间就越长,有关GC
产生的停顿也越长。为了更好地控制GC
产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC
所产生的停顿- 分代算法将按照对象的生命周期长短划分成两部分,分区算法将整个堆空间划分成连续的不同小区间
- 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间
十一、垃圾回收相关概念
11.1、System.gc()
- 在默认情况下,通过
System.gc()
或者Runtime.getRuntime().gc()
的调用,会 **显示触发Full GC
**,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存- 然而
System.gc()
调用附带一个免责申明,无法保证对垃圾收集器的调用JVM
实现者可以通过System.gc()
调用来决定JVM
的GC
行为。而一般情况下,垃圾回收应该是主动进行的,无需手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()
11.2、内存溢出与内存泄漏
11.2.1、内存溢出
- 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发车光绪崩溃的罪魁祸首之一
- 由于
GC
一直在发展,所以一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM
- 大多数情况下,
GC
会进行各种年龄段的垃圾回收,是在不行就来一次Full GC
,这时候会回收大量的内存,供应用程序使用JavaDoc
中对OutOfMemoryError
的解释是 没有空闲内存,并且垃圾收集器也无法提供更多内存- 首先说明没有空闲内存的情况
Java
虚拟机的堆内存设置不够:比如可能存在内存泄漏的问题,也有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM
堆大小或者指定数值偏小- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集:
- 对于老版本的
Oracle JDK
因为永久代的大小是非常有限的,并且JVM
对永久代垃圾回收非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError
也非常多见,也会导致OOM
,对应的异常信息会标记出来和永久代相关:java.lang.OutOfMemoryError: PermGen Space
- 随着元数据区的引入,方法区的内存已经不再那么窘迫,所以相应的
OOM
有所改观,出现OOM
,异常信息则变成了java.lang.OutOfMemoryError: Metaspace
。本地内存不足也会导致OOM
- 这里面隐含着一层意思是:在抛出
OutOfMemoryError
之前,通常垃圾收集器会被触发,尽其所能去清理空间
- 例如在引用机制分析中,涉及到
JVM
回去尝试回收 软引用指向的对象等- 在
java.nio.BIts.reserveMemory()
方法中,我们能清楚的看到,System.gc()
会被调用,以清理空间- 当然,也不是在任何情况下垃圾收集器都会被触发
- 例如我们分配一个超大对象,大小比堆的最大值还大,
JVM
可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemory
11.2.2、内存泄漏
- 也称作 “存储渗漏”,严格来说,只有对象不再被程序用到了,但是
GC
又不能回收它们的情况,才叫内存泄漏- 但实际情况很多时候一些不太好的实践会导致对象的生命周期变得很长,甚至导致
OOM
,也可以称作广泛意义上的 “内存泄漏”- 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现
OutOfMemory
异常,导致程序崩溃- 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小
- 举例
- 单例模式中引用了后续关联了一些临时的对象
- 连接未被
close
11.3、Stop The World
- 简称
STW
,指的是GC
事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW
- 可达性分析算法中枚举根节点
(GC Roots)
会导致所有Java
执行线程停顿
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
- 被
STW
中断的应用程序线程会在GC
完成之后恢复,频繁的中断会让用户感觉像是网速卡顿一样,所以我们需要减少STW
的发生STW
和采用什么GC
无关,所有的GC
都有这个事件- 哪怕是
G1
也不能完全避免Stop-The-World
发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停事件STW
是JVM
在 后台自动发起和自动完成 的,在用户不可见的情况下,把用户正常的工作线程全部停掉- 开发中不要用
System.gc()
,会导致Stop-The-World
11.4、垃圾回收的并行与并发
- 并发指多个事情,在同一时间段发生了
- 并行指多个事情,在同一时间点发生了
- 并发的多个任务之间是互相抢占资源的
- 并行的多个任务之间是不互相抢占资源的
- 只有在多个
CPU
或者一个CPU
多个核的情况中,才会发生并行- 否则看似同时发生的事情,其实都是并发执行的
11.4.1、并发
- 在操作系统中,是指一个时间段中有几个程序都已处于已启动运行到运行完毕之间,且这几个程序都是在一个处理器上进行的
- 并发并不是真正意义上的 “同时进行”,只是
CPU
把一个时间段划分成几个时间片段,然后在这几个时间区间之间来回切换,由于CPU
的处理速度非常快,只要时间间隔处理得恰当,即可让用户感觉是多个应用程序同时在进行
11.4.2、并行
- 当系统有一个以上
CPU
时,当一个CPU
执行一个进程时,另一个CPU
可以执行另一个进程,两个进程互补抢占CPU
资源,可以同时进行- 其实决定并行的因素不是
CPU
的数量,而是CPU
的核心数量,比如一个CPU
多个和也可以并行- 适合科学计算,后台处理等弱交互场景
11.4.3、垃圾回收的并发与并行
- 并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下
- 并行
(Parallel)
:
- 指 多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态,如
ParNew
、Parrallel Scavenge
、Parallel Old
- 串行
(Serial)
:
- 相较于并行的概念,单线程执行
- 如果内存不够,则程序暂停,启动
JVM
垃圾回收器进行垃圾回收。回收完,再启动程序的线程
11.5、安全点与安全区域
11.5.1、安全点
- 程序执行时并非所有地方都能停顿下来开始
GC
,只有在特定的位置才能停顿下来开始GC
,这些位置称为安全点- 安全点的选择很重要,如果太少可能导致
GC
等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据 “是否具有让程序长时间执行的特征” 为标准。比如选择以下执行时间较长的指令作为安全点,如方法调用、循环跳转和异常跳转等等
如何在发生
GC
的时候,检查所有线程都跑到最近的安全点停顿下来
- 抢先式中断:首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点
- 主动式中断:设置一个中断标志,各个线程运行到
Safe Point
的时候主动轮询这个标志,如果中断标志为真,则自行挂起
11.5.2、安全区域
Safe Point
机制保证了程序执行时,在不太长的时间内就会遇到可进入GC
的Safe Point
,但是程序 “不执行” 的时候呢?例如线程处于Sleep
状态或者Blocked
状态,这时候线程无法响应JVM
的中断请求,走到安全点去中断挂起,JVM
也不太可能等待线程被唤醒。对于这种情况,就需要安全区域来解决- 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始都是
GC
安全的。我们也可以把Safe Region
看作是被扩展了的Safe Point
- 当线程运行到
Safe Region
的代码时,首先标识已经进入了Safe Region
,如果这段时间内发生GC
,JVM
会忽略表示为Safe Region
状态的线程- 当线程即将离开
Safe Region
时,会检查JVM
是否已经完成GC
,如果完成了,则继续运行,否则必须等到GC
结束,才可以离开Safe Region
,即等待继续运行的信号
11.6、引用
我们希望能够描述这样一类对象:当内存空间还足够时,则能保留在内存中,如果内存空间在进行垃圾收集后仍然很紧张,则可以抛弃这些对象
JDK1.2
之后,Java
对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这四种引用强度依次逐渐减弱除强引用外,其他三种引用均可以在
java.lang.ref
包中找到
Reference
子类中只有终结器引用时包内可见的,其他3
中引用类型均为public
,可以在应用程序中直接使用
- **强引用
(Strong Reference)
**:最传统的 “引用” 定义,是指在程序代码之中最普遍存在的引用复制,即类似于Object obj = new Object();
这种引用关系,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象- **软引用
(Soft Reference)
**:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之内进行第二次回收。如果这次回收之后还没有足够的内存,才会抛出内存溢出异常- **弱引用
(Weak Reference)
**:被弱引用关联的对象只能生存道下一次垃圾收集之前。当垃圾收集器工作室,无论内存空间是否足够,都会回收弱引用关联的对象- 虚引用
Phantom Reference
**:一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是 **能在这个对象被收集器回收时收到一个系统通知
11.6.1、强引用
- 在
Java
程序中,最常见的引用类型是强引用,也就是我们最常见的普通对象引用,也是默认的引用类型- 当在
Java
语言中使用new
操作符创建一个新的对象,将其赋给一个变量的时候,这个变量就能成为指向该对象的一个强引用- 强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象
- 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应引用赋值为
null
,就可以当作垃圾被回收了,当然,具体地回收时机还是要看垃圾收集策略- 相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成
Java
内存泄漏的主要原因之一
- 强引用可以直接访问目标对象
- 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出
OOM
也不回收强引用所指向的对象- 强引用可能导致内存泄漏
11.6.2、软引用
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 软引用通常用来实现内存敏感的缓存,比如高速缓存就有用到软引用,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用滑轮的同时,不会耗尽内存
- 垃圾回收器在某个时刻决定回收软可达的对象时,会清理软引用,并可选地把引用放到一个引用队列
- 类似弱引用,只不过
Java
虚拟机会尽量让软引用地存活时间长一些,迫不得已才清理
11.6.3、弱引用
- 弱引用也是用来描述那些非必须对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统
GC
时,只要发现弱引用,不管系统堆空间是否充足,都会回收掉只被弱引用关联的对象- 但是,由于垃圾回收器的线程通常优先级很低,因此并不一定能很快的发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间
- 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
- 软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出,而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用
11.6.4、虚引用
- 也成为 “幽灵引用” 或者 “幻影引用”,是所有引用类型中最弱的一个
- 一个对象是否有虚引用存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收期回收
- 它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的
get()
方法取得对象时,总是null
- 为对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知
11.6.5、终结器引用
- 用于实现对象的
finalize()
方法- 无需手动编码,其内部配合引用队列使用
- 在
GC
时,终结器引用入队。由Finalizer
线程通过终结器引用找到被引用对象并调用它的finalize()
方法,第二次GC
时才能回收被引用对象
十二、垃圾回收器
-XX:+PrintCommanLineFlags
:查看命令行相关参数(包含使用的垃圾收集器)jinfo -flag 待查看参数 pid
:使用命令行查看指定的参数数值
12.1、GC 分类与性能指标
12.1.1、垃圾回收器概述
- 垃圾收集器没有在规范中进行过多的规定,可以由不同厂商、不同版本的
JVM
来实现- 由于
JDK
的版本处于高速迭代过程中,因此Java
发展至今已经衍生了众多的GC
版本- 从不同角度分析垃圾收集器,可以将
GC
分为不同的类型
12.1.2、分类
- 按线程数分
- 串行垃圾回收器:在同一时间内只允许有一个
CPU
用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束
- 在诸如单
CPU
处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client
模式下的JVM
中- 在并发能力比较强的
CPU
上,并行回收器产生的停顿时间要短于串行回收器- 并行垃圾回收器:可以运用多个
CPU
同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了Stop-The-World
机制- 按工作模式分
- 并发式垃圾回收器:回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
- 独占式垃圾回收器:回收器一旦运行,就停止应用程序中的所有用户线程
- 按碎片处理方式分
- 压缩式垃圾回收器
- 在回收完成后,对存活的对象进行压缩整理,消除回收后的碎片
- 再分配对象空间使用 指针碰撞
- 非压缩式回收器
- 再分配对象空间使用 空闲列表
- 按工作的内存区间分
- 年轻代垃圾回收器
- 老年代垃圾回收器
12.1.3、性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例
- 垃圾收集开销:垃圾收集所用时间与总运行时间的比例
- 暂停时间:执行垃圾收集时程序的工作线程被暂停的时间
- 收集频率:相对于应用程序的执行,收集操作发生的频率
- 内存占用:
Java
堆区所占的内存大小- 快速:一个对象从诞生到被回收所经历的时间
- 吞吐量、暂停时间、内存占用构成了一个 不可能三角,三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项
- 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即使提高了吞吐量。而随着内存的扩大,对延迟反而带来负面效果
- 简单来说,主要关注 吞吐量、暂停时间
12.1.3.1、吞吐量
吞吐量就是
CPU
用于运行应用代码的时间与CPU
总消耗时间的比值,即
$$
吞吐量 = \frac{运行应用代码的时间}{运行应用代码的时间+垃圾收集时间}
$$比如虚拟机总共运行了
100 min
,其中垃圾收集耗费1 min
,那么吞吐量就是99%
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的
吞吐量优先,意味着再单位时间内,
STW
的时间最短0.2 + 0.2 = 0.4
12.1.3.2、暂停时间
暂停时间是指一个时间段内应用程序线程暂停,让
GC
线程执行的状态
- 例如,
GC
期间100 ms
的暂停时间意味着在这100 ms
期间内没有应用程序线程是活动的暂停时间优先,意味着尽可能让单次
STW
的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5
12.1.3.3、吞吐量 VS 暂停时间
- 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做 生产性 工作,直觉上吞吐量越高,程序运行越快
- 低暂停时间较好因为从最终用户的角度来看不管是
GC
还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200 ms
暂停都能打断中断用户体验,因此,具有低的较大暂停时间是非常重要的,特别是对于一个 交互式应用程序- 不幸的是 高吞吐量 和 低暂停时间 是一对竞争关系
- 如果选择吞吐量优先,那么必然需要降低 内存回收的执行频率,但是这样会导致
GC
需要更长的暂停时间来执行内存回收- 如果选择低暂停时间,那么为了降低每次执行内存回收时的暂停时间,也 只能频繁的执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降
- 在设计算法时,必须确定我们的目标:一个
GC
算法只可能对一个进行针对,现在的标准是:在最大吞吐量优先的情况下,降低停顿时间
12.2、不同的垃圾回收器概述
- 垃圾回收机制是
Java
的招牌能力,极大的提高了开发效率,这样当然也是面试的热点
12.2.1、发展史
1999
年:随着JDK 1.3.1
一起来的是串行方式的Serial GC
,它是第一款GC
,ParNew
垃圾收集器是Serial
收集器的多线程版本2002
年:随着JDK 1.4.2
一起发布的有Parallel GC
和Concurrent Mark Sweep GC
,其中Parallel GC
在JDK 6
之后成为HotSpot
的默认GC
2012
年:在JDK 1.7u4
版本中,G1
正式启用2017
年:JDK 9
中G1
变成默认垃圾收集器,以替代CMS
2018
年3
月:JDK 10
中G1
垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟2018
年9
月:JDK 11
发布,引入Epsilon
垃圾回收器,又被称为No-Op
(无操作),同时引入ZGC (Experimental)
:可伸缩的低延迟垃圾收集器2019
年3
月:JDK 12
发布,增强G1
,自动返回未用堆内存给操作系统,同时引入Shennandoah GC
:低停顿时间的GC (Experimental)
2019
年9
月:JDK 13
发布,增强ZGC
,自动返回未用堆内存给操作系统2020
年3
月:JDK 14
发布,删除CMS
垃圾回收器,扩展ZGC
在MacOS
和Windows
上的应用
12.2.2、七种经典的垃圾收集器
- 串行回收器:
Serial
、Serial Old
- 并行回收器:
ParNew
、Parallel Scavenge
、Parallel Old
- 并发回收器:
CMS
、G1
12.2.3、七种经典垃圾收集器与垃圾分代之间的关系
- 新生代:
Serial
、ParNew
、Parallel Scavenge
- 老年代:
Serial Old
、Parallel Old
、CMS
- 整堆:
G1
12.2.4、垃圾收集器的组合关系
- 其中,
Serial Old
作为CMS
出现Concurrent Mode Failure
失败的后背方案- 红色虚线:由于维护和兼容性测试的成本,在
JDK 8
时将Serial + CMS
、ParNew + Serial Old
这两组声明为废弃,并在JDK 9
中完全取消了这些组合的支持- 绿色虚线:即
Parallel Scavenge + Serial Old
这个组合,在JDK 14
中已被弃用CMS GC
:在JDK 14
中已被删除
12.2.5、不同垃圾回收器的概述
- 由于
Java
的使用场景很多,移动端,服务器等,所以就需要针对不同的场景,提供不同的垃圾回收器,提高垃圾收集的性能- 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器。而是在 选择对具体应用最合适的收集器
12.3、Serial 回收器:串行回收
12.3.1、概述
Serial
收集器是最基本、历史最悠久的垃圾收集器,JDK 1.3
之前回收新生代唯一的选择Serial
收集器作为HotSpot
中client
模式下的默认新生代垃圾收集器Serial
收集器采用复制算法、串行回收和Stop-The-World
机制的方式执行内存回收- 除了老年代之外,
Serial
收集器还提供用于执行老年代的Serial Old
收集器,**Serial Old
收集器同样也采用了串行回收和Stop-The-World
机制,只不过内存回收算法使用的是标记压缩算法**
Serial Old
是运行在Client
模式下默认的老年代垃圾回收器Serial Old
在Server
模式下主要有两个用途
- 与新生代的
Parallel Scavenge
搭配使用- 作为老年代
CMS
收集器的后备垃圾收集方案- 这个收集器是一个单线程的收集器,但这并不仅仅说明它 只会使用一个
CPU
或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程 知道它收集结束- 在用户桌面应用场景中,可用内存一般不大(几十
MB
至一两百MB
),可以在短时间内完成垃圾收集,只要不频繁发生,使用串行收集器是可以接收的- 在
HotSpot
虚拟机中,使用-XX:+UserSerialGC
参数可以指定年轻代和老年代都使用串行收集器
12.3.2、优缺点
- 简单而高效(与其他收集器的单线程比),对于限定单个
CPU
的环境来说,Serial
收集器由于没有线程交互的开销,专心做垃圾收集,自然可以获得最高的单线程收集效率
12.4、ParNew 回收器:并行回收
12.4.1、概述
- 如果说
Serial GC
是年轻代中的单线程垃圾收集器,那么ParNew
收集器则是Serial
收集器的多线程版本
Par
是Parallel
的缩写,New
表示只能处理新生代ParNew
收集器除了采用 并行收集 的方式进行内存回收外,与Serial
几乎没有任何区别,ParNew
收集器在年轻代中同样也是采用 复制算法、Stop-The-World
机制ParNew
是很多JVM
运行在Server
模式下新生代的默认垃圾收集器- 由于
ParNew
收集器是基于并行回收,那么是否可以判定ParNew
收集器的回收效率在任何场景下都会比Serial
收集器更高效
ParNew
收集器运行在多CPU
的环境下,由于可以充分利用多CPU
、多核等物理硬件资源优势,可以更快速的完成垃圾收集,提升程序的吞吐量- 但是 在单个
CPU
的环境下,ParNew
收集器不比Serial
收集器高效。虽然Serial
收集器是基于串行回收,但是由于CPU
不需要频繁的做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销- 除了
Serial
之外,目前只有ParNew
能与CMS
收集器配合工作
12.4.2、参数设置
-XX:UseParNewGC
:手动指定使用ParNew
收集器执行内存回收任务,它表示年轻代使用并行收集器,不影响老年代-XX:ParallelGCThreads
:限制线程数量,默认开启和CPU
数据相同的线程数
12.5、Parallel 回收器:吞吐量优先
12.5.1、概述
HotSpot
的年轻代中除了拥有ParNew
收集器是基于并行回收的以外,Parallel Scavenge
收集器同样也采用了 复制算法、并行回收 和Stop-The-World
机制那么
Parallel
收集器的出现是否多此一举?
- 和
ParNew
收集器不同,Parallel Scavenge
收集器的目标是达到一个 可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器- 自适应调节策略也是
Parallel Scavenge
与ParNew
的一个区别高吞吐量则可以高效率地利用
CPU
时间,尽快完成程序地运算任务,主要 适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用
Parallel
收集器在JDK 1.6
时提供了用于执行老年代垃圾收集的Parallel Old
收集器,用来代替老年代的Serial Old
收集器
Parallel Old
收集器采用了 标记压缩算法,但同样也是 并行回收 和Stop-The-World
机制在程序吞吐量优先的应用场景中,
Parallel
收集器和Parallel Old
收集器的组合,在Server
模式下的内存回收性能很不错在
Java 8
中,默认是此垃圾收集器
12.5.2、参数设置
-XX:+UseParallelGC
:手动指定年轻代使用Parallel
并行收集器执行内存回收任务
-XX:+UseParallelOldGC
:手动指定老年代都是使用并行回收收集器
- 分别适用于新生代和老年代,默认
JDK 8
是开启的- 上面两个参数,默认开启一个,另一个也会被激活
-XX:ParallelGCThreads
:设置年轻代并行收集器的线程数,一般地最好与CPU
数量相等,以避免过多的线程数影响垃圾收集性能
- 在默认情况下,当
CPU
数量小于8
个,ParallelThreads
的值等于CPU
数量- 当
CPU
数量大于8
个,ParallelThreads
的值等于:$$
3 + \frac{5 * CPUCOUNT}{8}
$$
-XX:MaxGCPauseMillis
:设置垃圾收集器最大停顿时间,单位是毫秒
- 为了尽可能地把停顿时间控制在
MaxGCPauseMillis
以内,收集器在工作时会调整Java
堆大小或者其他一些参数- 对于用户来讲,停顿时间越短,体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务端适合
Parallel
进行控制- 谨慎使用此参数
-XX:GCTimeRatio
:垃圾收集时间占总时间的比例,用于衡量吞吐量的大小
$$
=\frac{1}{N + 1}
$$
- 取值范围
(1 ~ 100)
,默认99
,也就是垃圾收集时间不超过1%
- 与
-XX:MaxParallelMillis
有一定矛盾性,暂停时间越长,Ratos
参数就越容易超过设定的比例
-XX:+UseAdaptiveSizePolicy
:设置Parallel Scavenbe
收集器具有 自适应调节策略
- 在这种模式下,年轻代的大小、
Eden
和Survivor
的比例、晋升老年代的对象年龄等参数会被自动调整,已到达堆大小、吞吐量和停顿时间之间的平衡点- 在手动调优比较困难的场合,可以直接使用这种自适应的方式,禁止顶虚拟机的最大堆、目标吞吐量和停顿时间,让虚拟机自己完成调优工作
12.6、CMS 回收器:低延迟
12.6.1、概述

- 在
JDK 1.5
时期,HotSpot
推出了一款在 强交互应用 中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)
收集器,这款收集器是HotSpot
虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作CMS
收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短就越是和与用户交互的程序,良好的响应速度能提升用户体验
- 目前很大一部分的
Java
应用集中在互联网站或者B/S
系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS
收集器就是非常符合这类应用的需求CMS
垃圾收集算法采用 标记清除 算法,并且也会Stop-The-World
- 不幸的是,
CMS
作为老年代的收集器,却无法与JDK 1.4.0
中已经存在的新生代收集器Parallel Scavenge
配合工作,所以在JDK 1.5
中使用CMS
来收集老年代的时候,新生代只能选择ParNew
或者Serial
收集器中的一个- 在
G1
出现之前,CMS
使用还是非常广泛的,一直到今天,仍有很多系统在使用CMS GC
- 尽管
CMS
收集器采用并发回收,但是在其 初始标记 和 重新标记 这两个阶段中仍然执行了Stop-The-World
机制来暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要Stop-The-World
,只是尽可能地缩短暂停时间- 由于最耗费时间地并发标记与并发清除阶段都不需要暂停工作,所以整体地回收是低停顿的
- 另外,由于在垃圾收集阶段用户线程没有中断,有意在
CMS
回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS
收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是在 堆内存使用到某一阈值时便开始进行回收,以确保应用程序再CMS
工作过程中依然有足够的空间支持应用程序运行,要是CMS
运行期间预留的内存无法满足程序的需要,就会出现一次Concurrent Mode Failure
,这是虚拟机将启动后备方案,即使用Serial Old
收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了CMS
收集器的垃圾收集算法采用 标记清除算法,这意味着每次进行内存的回收后,由于被执行内存回收的无用对象所占用的空间极有可能是不连续的一些内存块,不可避免的会 产生一些内存碎片,那么CMS
在为新对象分配内存空间时,将无法使用指针碰撞技术,只能选择空闲列表执行内存分配
12.6.2、回收过程
CMS
的整个过程比之前的收集器要复杂得多,整个过程分为4
个主要阶段
- 初始标记
(Initial-Mark)
**:在这个阶段,程序中所有的工作进程都会因为Stop-The-World
机制出现短暂的暂停,这个阶段的主要任务 **仅仅是标记处GC Root
能直接关联到的对象,一旦标记完成之后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里的 速度非常快- 并发标记
(Concurrent-Mark)
**:从GC Rotts
的 **直接关联对象开始遍历整个对象图的过程,这个阶段 耗时较长,但是 不需要停顿用户线程,可以与垃圾收集线程一起并发运行- 重新标记
(Remark)
**:由于在并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或交叉运行,因此为了 **修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常回避初始标记稍长一些,但也远比并发标记阶段时间端- 并发清除
(Concurrent-Sweep)
**:此阶段 **清理删除掉标记阶段判断的已经死亡的对象,释放内存空间,由于不需要移动存活对象,所以这个阶段也是可以和用户线程同时并发的
12.6.3、优缺点
- 优点
- 并发收集
- 低延迟
- 弊端
- 产生内存碎片:导致并发清除后,用户线程可用的空间不足,在无法分配大对象的情况下,不得不提前触发
Full GC
CMS
收集器对CPU
资源非常敏感:在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低CMS
收集器无法处理浮动垃圾:可能出现Concurrent Mode Failure
失败,导致另一次Full GC
的开始,在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或交叉运行的,那么 在并发标记阶段如果产生新的垃圾对象,CMS
将无法对这些垃圾进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC
时释放这些之前未被回收的内存空间
12.6.4、参数设置
-XX:+UseConcMarkSweepGC
:手动指定使用CMS
收集器执行内存回收任务
- 开启该参数后将会自动将
-XX:+UseParNewGC
启动
-XX:CMSInitiatingOccupanyFraction
:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收
JDK 5
及以前版本的默认值为68
,即当老年代的空间使用率达到68%
时,会执行一次CMS
回收,JDK 6
及以上版本的默认值为92%
- 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低
CMS
的触发频率,减少老年代回收的次数,可以较为明显的改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器,因此 通过该选项便可以有效降低Full GC
的执行次数
-XX:+UseCMSCompactAtFullCollection
:用于指定在执行完Full GC
后对内存空间进行压缩整理,以此避免内存碎片的产生,不过由于内存压缩整理过程无法并发执行,所以带来的问题就是停顿时间更长了
-XX:CMSFullGCsBeforeCompaction
:设置在执行多少次Full GC
之后对内存空间进行压缩整理
-XX:ParallelCMSThreads
:设置CMS
的线程数量
CMS
默认启动的线程数:$$
= \frac{ParallelCMSThreads+3}{4}
$$
ParallelGCThreads
时年轻代并行收集器的线程数,当CPU
资源比较紧张时,收到CMS
收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕
12.7、小结
- 最小化使用内存和并行开销,选择
Serial GC
- 最大化应用程序的吞吐量,选择
Parallel GC
- 最小化
GC
期间的停顿时间,选择CMS GC
JDK 9
新特性:CMS
被标记为Deprecate
JDK 14
新特性:删除CMS
垃圾回收器
12.8、G1 回收器:区域化分代式
12.8.1、概述
- 随着应用程序所应对的 业务越来越庞大、复杂、用户越来越多,没有
GC
就不能保证应用程序正常进行,而经常造成STW
的GC
又跟不上实际的需求,所以才会不断尝试对GC
进行优化,G1
回收器是在JDK7u4
之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一- 与此同时,为了适应现在 不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量
- 官方给
G1
设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起全功能收集器的重任与期望- 因为
G1
是一个并行的回收器,把堆内存分割为很多不相关的区域(物理上不连续的)。使用不同的Region
来表示Eden
、幸存者0
区、幸存者1
区、老年代等G1
有计划的避免在整个Java
堆中进行全区域的垃圾收集,G1
跟踪各个Region
里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需的时间),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
- 由于这种方式的侧重点在于回收垃圾最大量的
Region
,所以取了Garbage First
的名字G1
是一款面向服务端应用的垃圾回收器,主要针对多核CPU
及大容量内存的机器,以及高概率满足GC
停顿时间的同时,还兼具高吞吐量的性能特征- 在
JDK 1.7
正式启用,移除了Experimental
的标识,是JDK 9
以后的默认垃圾回收器,取代了CMS
回收器以及Parallel Old
组合,被官方称为 “全功能的垃圾收集器”- 于此同时,
CMS
在JDK 9
中才被废弃,所以在JDK 8
中需要使用-XX:+UseG1GC
来启用G1
回收器
12.8.2、优缺点
- 优势
- 并行与并发
- 并行性:
G1
在回收期间,可以有多个GC
线程同时工作,有效利用多核计算机能力,此时用户线程STW
- 并发性:
G1
拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会再整个回收阶段发生完全阻塞应用程序的情况- 分代收集
- 从分代上看,
G1
依然属于分代型垃圾回收器,会区分年轻代和老年代,年轻代依然有Eden
区和Surviver
区。但从堆的结构上来看,它不要求整个Eden
区、年轻代或者老年代都是连续的,再也不坚持固定大小和固定数量- 将 堆空间分为若干个区域,这些区域中包含了逻辑上的年轻代和老年代
- 和之前的各类回收器不同,它同时 兼顾年轻代和老年代。对比其他回收器或者工作在年轻代、或者工作在老年代
- 空间整合
CMS
:标记清除算法、内存碎片、若干次GC
后进行一次碎片整理G1
将内存划分为一个个的Region
。内存的回收是以Region
作为基本单位的,**Region
之间是复制算法。但整体上实力上可看作是 **标记压缩算法,两种算法都可以避免内存碎片,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC
,尤其是当Java
堆非常大的时候,G1
的优势更加明显- 可预测的停顿时间模型
- 这是
G1
相对于CMS
的另一大优势,G1
除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M
毫秒的时间片内,小号在垃圾收集器上的时间不得超过N
毫秒- 由于分区的原因,
G1
可以只选取部分区域进行垃圾回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制G1
跟踪各个Region
里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
**,保证了G1
收集器在有限的时间内可以 **获取尽可能高的收集效率- 相比于
CMS
,G1
未必能做到CMS
在最好情况下的延时停顿,但最差的情况要好得多- 缺点:
- 相较于
CMS
,G1
还不具备全方面、压倒性优势,比如在用户程序运行过程中,G1
无论是为了垃圾回收产生的内存占用还是程序运行时的额外执行负载都比CMS
要高- 从经验上来说,在小内存应用上
CMS
的表现大概率会优于G1
,而G1
在大内存应用上则发挥其优势,平衡点在6~8GB
之间
12.8.3、参数设置
-XX:+UseG1GC
:手动指定使用G1
收集器执行内存回收任务-XX:G1HeapRegionSize
:设置每个Region
的大小,值是2
的幂,范围是1~32MB
,目标是根据最小的Java
堆大小划分出越2048
个区域,默认是堆内存的1/2000
-XX:MaxGCPauseMillis
:设置期望达到的最大GC
停顿时间指标(JVM
会尽力实现,但不能保证达到)默认值是200ms
-XX:ParallelGCThread
:设置STW
工作线程数的值,最多设置为8
-XX:ConcGCThreads
:设置并发标记的线程数,将n
设置为并行垃圾回收线程数的1/4
左右-XX:InitiatingHeapOccupancyPercent
:设置触发并发GC
周期的Java
堆占用率,超过此值就触发GC
,默认值是45
12.8.4、常见的操作步骤
- 开启
G1
垃圾收集器- 设置堆的最大内存
- 设置最大的停顿时间
12.8.5、适用场景
- 面向服务端应用,针对具有大内存、多处理器的机器(在普通大小的堆里表现并不惊喜)
- 最主要的应用是需要低
GC
延迟,并且有大堆的应用程序提供解决方案
- 如在堆大小约为
6GB
或更大时,可预测的暂停时间可以低于0.5s
(G1
通过每次只清理一部分而不是全部的Region
的增量式清理来保证每次GC
停顿的时间不会过长- 用来替换
CMS
(下面的情况中,使用G1
可能比CMS
好)
- 超过
50%
的Java
堆被活动数据占用- 对象分配频率或年代提升频率变化很大
GC
停顿时间过长> 0.5s ~ 1s
HotSpot
垃圾收集器里,除了G1
以外,其他的垃圾收集器使用内置的JVM
线程执行GC
的多线程操作,而G1
回收器可以采用应用线程承担后台的GC
工作,即当JVM
的GC
线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程
12.8.6、Region
- 使用
G1
收集器时,它将整个Java
堆划分成为约2048
个大小相同的独立Region
块,每个Region
块大小根据堆空间的实际大小而定,整体被控制在1MB
到32MB
之间,且为2
的次幂,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB
,可以通过-XX:G1HeapRegionSize
进行设置,所有的Region
大小相同,且在JVM
生命周期内不会改变- 虽然还保留有新生代和老年代的概念,但新生代和老年代已经不再是物理隔离的了,它们都是一部分
Region
的集合,通过Region
的动态分配方式实现逻辑上的连续- 一个
Region
有可能属于Eden
,Survivor
或者Old/Tenured
,但是一个Region
只可能属于一个角色,即一个Regoin
里的内存不会跨代G1
垃圾收集器还增加了一种新的内存区域,叫做Humongous
内存区域,如图中的H
块,主要用于存储大对象,如果超过1.5
个Region
就会放到H
- 对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成影响,为了解决这个问题,
G1
划分了Humongous
区,专门用来存放大对象,如果一个H
区装不下一个大对象,那么G1
会寻找连续的H
区来存储。为了能找到连续的H
区,有时候就不得不进行Full GC
,G1
大多数的行为都把H
区作为老年代的一部分来看待
12.8.7、回收过程
- 主要包括 年轻代、并发标记过程、混合回收 三个环节(如果需要,单线程、独占式、高强度的
Full GC
还是继续存在的,它针对GC
的评估失败提供了一种失败保护机制,即强力回收
- 应用程序分配内存,当年轻代的
Eden
区用尽,开始年轻代回收过程:G1
的年轻代收集阶段是一个 并行的独占式 收集器。在年轻代回收期,G1
回收器暂停所有应用程序线程,启动多线程执行年轻代回收。然后 从年轻代区间移动存活对象到Survivor
区或Old
区,也有可能是两个区间都会涉及- 当堆内存使用到一定值时,开始老年代并发标记过程
- 标记完成马上开始混合回收过程,对于一个混合回收期,
G1
回收器从老年区间移动对象到空闲区,这些空闲区也就成为了老年代的一部分,和年轻代不同,老年代的G1
回收器和其他GC
不同,**G1
的老年代回收器不需要整个老年代被回收,一次只需扫描一小部分老年代的Region
即可**。同时这个老年代Region
是和年轻代一起被回收的
- 比如:一个
web
服务器,Java
进程最大堆内存为4G
,每分钟响应2500
个请求,每45
秒钟会新分配大约2G
的内存,G1
会每45
秒钟进行一次年轻代回收,每31
个小时整个堆的使用率会达到45%
,会开始老年代并发标记过程,标记完后开始四到五次的混合回收
12.8.7.1、Remembered Set
- 一个对象被不同区域引用的问题
- 一个
Region
不可能是孤立的,一个Region
中的对象可能被其他任意Region
中的对象引用,判断对象存活时是否需要扫描整个Java
堆才能保证准确?- 在其他分代收集器也存在这样的问题,
G1
尤为突出- 回收新生代也不得不同时扫描老年代?
- 这样的话会降低
Minor GC
的效率
- 解决办法
- 无论是
G1
还是其他分代收集器,JVM
都是使用Remembered Set
来避免全局扫描- 每个
Region
都有一个对应的Remembered Set
- 每次
Reference
类型数据写操作时,都会产生一个Write Barrier
暂停中断操作- 然后检查将要写入的引用指向阿对象是否和该
Reference
类型数据在不同的Region
(其他收集器:检查老年代对象是否引用了新生代对象)- 如果不同,则通过
CardTable
把相关引用信息记录到引用指向对象所在的Region
对应的Rememberd Set
中- 当进行垃圾收集时,在
GC
根节点的枚举范围加入Remembered Set
就可以保证不进行全局扫描,也不会有遗漏
12.8.7.2、年轻代 GC
JVM
启动时,G1
先准备好Eden
区,程序在运行过程中不断创建对象到Eden
区,当Eden
区空间耗尽,G1
会启动一次年轻代垃圾回收过程- 年轻代垃圾回收只会回收
Eden
和Survivor
YoungGC
时,首先G1
停止应用程序的执行,G1
创建回收集,回收集指需要被回收的内存分段的集合,年轻代回收过程的回收集包括Eden
和Survivor
区所有的内存分段
- 第一阶段,扫描根:根是指
static
变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连通RSet
记录的外部引用作为扫描存活对象的入口- 第二阶段,更新
RSet
:处理dirty card queue
中的card
,更新RSet
,此阶段完成后RSet
可以准确的反映老年代对所在的内存分段中对象的引用
- 对于应用程序的引用复制语句
object.filed = object
,JVM
会在之前和之后执行特殊的操作以在dirty card queue
中入队保存了对象引用信息的card
。在年轻代回收的时候,G1
会对dirty card queue
中所有的card
进行处理,以更新RSet
,保证RSet
实时准确的反映引用关系- 那为什么不在引用复制语句处直接更新
RSet
呢?这是为了性能的需要,RSet
的处理需要线程同步,开销会很大,使用队列性能会好很多- 第三阶段,处理
RSet
:识别被老年代对象指向的Eden
中的对象,这些被指向的Eden
中的对象被认为是存活的对象- 第四阶段,复制对象:此阶段,对象树被遍历,
Eden
区内存段中存活的对象会被复制到Survivor
区中空的内存分段,Survivor
区内存段中存活的对象如果年龄未达阈值,年龄会加1
,达到阈值会被复制到Old
区中空的内存分段。如果Survivor
空间不够,Eden
空间的部分数据会直接晋升老年代- 第五阶段,处理引用:处理
Soft
、Week
、Phantom
、Final
、JNI Week
等引用,最终Eden
空间的数据为空,GC
停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片
12.8.7.3、并发标记过程
- 初始阶段:标记从根节点直接可达的对象,这个阶段是
STW
的,并且会触发一次年轻代GC
- **根区域扫描
(Root Region Scanning)
**:G1
收集器扫描Survivor
区直接可达的老年代区域对象,并标记被引用的对象,这一过程必须在Young GC
之前完成- 并发标记
(Concurrent Marking)
**:在整个堆中进行并发标记(和应用程序并发执行),此过程可能会被Young GC
中断,在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收**。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)- **再次标记
(Remark)
**:优于应用程序持续进行,需要修正上一次的标记结果,是STW
的,G1
中采用了比CMS
更快的初始快照算法SnapShot-At-The-Beginning (SATB)
- **独占清理
(Cleanup, STW)
**:计算各个区域的存活对象和GC
回收比例,并进行排序,识别可以混合回收的区域,为下阶段做铺垫,是STW
的
- 这个阶段并不会实际上去做垃圾的收集
- 并发清理阶段:识别并清理完全空闲的区域
12.8.7.4、混合回收
- 当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾回收器,也就是我们的
Mixed GC
,该算法并不是一个Old GC
除了回收整个Young Region
,还会回收一部分的Old Region
这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些Old Region
进行收集,从而可以对垃圾回收的耗时时间进行控制,也需要注意的是Mixed GC
并不是Full GC
- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算出来了,默认情况下,这些老年代的内存分段会被分
8
次(可通过-XX:G1MixedGCCountTarget
设置)被回收- 回合回收的回收集
Colleciton Set
包括1/8
的老年代内存分段,Eden
区内存分段,Survivor
区分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段,具体过程可参考年轻代回收的过程- 优于老年代中的内存分段默认分
8
次回收,G1
会优先回收垃圾多的内存分段。垃圾占内存分段比例越高,约会被先回收,并且有一个阈值会决定内存分段是否被回收-XX:G1MixedGCLiveThresholdPercent
默认为65%
,意思是垃圾占内存分段的比例要达到65%
才会被回收,如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间- 混合回收并不一定要进行
8
次,有一个阈值-XX:G1HeapWastePercent
,默认值为10%
,意思是允许整个堆内存中有10%
的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%
,则不再进行混合回收,因为GC
会花费很多的时间但是回收到的内存却很少
12.8.7.5、可选过程:Full GC
G1
的初衷就是要避免Full GC
的出现,但是如果上述方式不能正常工作,G1
会 停止应用程序的执行,使用 单线程 的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长- 要避免
Full GC
的发生,一旦发生需要进行调整,什么时候会发生Full GC
呢?比如 堆太小,当G1
在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC
,这种情况可以通过增大内存解决- 导致
G1
进行Full GC
的原因可能有两个
Evacuation
的时候没有足够的to-space
来存放晋升到的对象- 并发处理过程完成之前空间耗尽
12.8.8、补充
- 从
Oracle
官方透露出来的信息可获知,回收阶段(Evacuation)
其实本也想过设计成与用户程序一起并发执行,但是这件事情做起来比较复杂,考虑到G1
只是回收一部分Region
停顿时间是用户控制的,所以并不迫切区实现,而 选择把这个特性放到了G1
之后出现的低延迟垃圾收集器ZGC
中。另外,还考虑到G1
不仅仅面向低延迟,停顿用户线程能最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案
- 优化建议
- 年轻代大小
- 避免使用
-Xmn
或-XX:NewRatio
等相关选项显示设置年轻代的大小- 固定年轻代的大小会覆盖暂停时间目标
- 暂停时间目标不要太严苛
G1
回收器的吞吐量目标是90%
的用户程序时间和10%
的垃圾回收时间- 评估
G1
回收器的吞吐量时,暂停时间目标不要太严苛。目标泰国严苛标识你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量
12.9、垃圾回收器总结
12.9.1、如何选择垃圾回收器
- 优先调整堆的大小让
JVM
自适应完成- 如果内存小于
100M
,使用串行收集器- 如果是单核、单机程序,并没有停顿时间的要求,串行收集器
- 如果是多
CPU
、需要高吞吐量、允许停顿使时间超过1
秒,选择并行或者JVM
自己选择- 如果是多
CPU
、追求低停顿时间,需要快速响应,使用并发收集器
12.10、GC 日志分析
12.10.1、参数设置
-XX:+PrintGC
:输出GC
日志-XX:+PrintGCDetails
:输出GC
的详细日志-XX:+PrintGCTimeStamps
:输出GC
的时间戳(以基准时间的形式)-XX:+PrintGCDateStamps
:输出GC
的时间戳(以日期的形式,如20**-**-**T**:**:**.***+0800
)-XX:+PrintHeapAtGC
:在进行GC
的前后打印出堆的信息-Xloggc:path/gc.log
:设置日志文件的输出路径
12.10.2、日志分析
1
2
3 > [GC (Allocation Failure) 80832K->19298K (227840K), 0.0084018 secs]
> [GC (Metadata GC Threshold) 109499K->21465K (228352K), 0.0184066 secs]
> [Full GC (Metadata GC Threshold) 21465K->16716K (201728K),0.0619261 secs]
GC
、Full GC
:GC
的类型,GC
只在新生代上进行,Full GC
包括永久代,新生代,老年代Allocation Failure
:GC
发生的原因80832K -> 19298K
:堆在GC
前后的大小228840K
:现在堆的大小0.0084018 secs
:GC
持续的时间
1
2
3 > [GC(Allocation Failure)[pSYoungGen: 70640K->10116K(141312K)] 80541K->20017K (227328K),0.0172573 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]
> [GC(Metadata GC Threshold)[PsYoungGen: 98859K->8154K(142336K)] 108760K->21261K (228352K), 0.0151573 secs] [Times: user=0.00 sys=0.01, real=0.02 secs]
> [Full GC(Metadata GC Threshold)[pSYoungGen: 8154K->0K(142336K)][ParOldGen: 13107K->16809K(62464K)]21261K->16809K (204800K),[Metaspace: 20599K->20599K (1067008K)], 0.0639732 secs][Times: user=0.14 sys=0.00, real=0.06 secs]
GC
、Full GC
:同样是GC
的类型Allocation Failure
:GC
发生的原因ParOldGen
:使用了Parallel Old
并行垃圾收集器的老年代GC
前后大小的变化PSYoungGen
:使用了Parallel Scavenge
并行垃圾收集器的新生代GC
前后大小的变化MetaSpace
:元数据区GC
前后大小的变化,JDK 1.8
中引用了元数据区替换永久代x secs
:GC
所花时间Times
:
user
:指垃圾收集器花费的所有CPU
时间sys
:花费在等待系统调用或系统事件的事件real
:GC
从开始到结束的事件,包括其他进程所占用时间片的实际时间
[GC
和[Full GC
说明垃圾收集的停顿类型,如果有Full
则表示发生了Stop The World
- 使用
Serial
收集器在新生代的名字是Default New Generation
,因此显示[DefNew
- 使用
ParNew
收集器在新生代的名字会变成[ParNew
,意思是Parallel New Generation
- 使用
Parallel Scavenge
收集器在新生代的名字是[PSYoungGen
- 老年代的收集和新生代一样,名字也是收集器决定的
- 使用
G1
的话,会显示Garbage-First Heap

12.11、垃圾回收器的新发展
GC
仍然处于飞速发展之中,目前的默认选项G1
回收器在不断的进行改进,很多我们原来认为的缺点,例如串行的Full GC
、Card Table
扫描低效等,都已经被大幅改进,例如JDK10
以后,Full GC
已经是并行运行,在很多场景下,其表现还略优于Parallel GC
的并行Full GC
实现- 即使是
Serial
回收器,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC
相关数据结构的开销还是线程的开销都是非常小的,随着云计算的兴起,在Serverless
等新的应用场景下,Serial
回收器找到了新的舞台- 比较不幸的是
CMS
回收器,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9
中已经被标记为废弃,并在JDK 14
中移除
12.11.1、Shenandoah GC
Shenandoah
是众多GC
中最孤独的一个,是第一款不由Oracle
公司团队领导开发的HotSpot
垃圾回收器。不可避免地 受到官方的排挤。比如号称OpenJDK
和OracleJDK
没有区别的Oracle
公司拒绝在OracleJDK12
中支持Shenandoah
Shenandoah
垃圾回收器最初由RedHat
进行的一项垃圾收集器研究项目Pauseless
回收器的实现,旨在针对JVM
上的内存回收实现低停顿的需求。在2014
年贡献给OpenJDK
Red Hat
研发Shenandoah
团队对外宣称,**Shenandoah
回收器的暂停大小与堆大小无关,这意味着无论将堆设置为200MB
还是200GB
,99.9%
的目标都可以把垃圾收集的停顿时间限制在十毫秒以内**,不过实际使用性能将取决于实际工作堆的大小和工作负载
12.11.2、ZGC
- 在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟
- 《深入理解
Java
虚拟机》一书中这样定义ZGC
:是一款基于Region
内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现 可并发的标记压缩算法 的,以 低延迟为首要目标 的一款垃圾收集器ZGC
的工作过程可以分为4
个阶段:并发标记、并发预备重分配、并发重分配、并发重映射ZGC
几乎在所有地方并发执行的,除了初始标记的是STW
的,所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是很少的