JVM笔记
一、JVM概述
1. JVM内部结构
跨语言的平台,只要遵循编译出来的字节码的规范,都可以由JVM运行
虚拟机
系统虚拟机 VMvare
程序虚拟机 JVM
JVM结构 HotSpot虚拟机
详细结构图
前端编译器是编译为字节码文件
执行引擎中的JIT Compiler编译器是把字节码编译成机器码
Java编译器(java c):
词法分析,语法分析->语法/抽象语法树,语义分析->注解抽象语法树->字节码生成器-> .class字节码文件 (汇编知识)
解释执行和编译执行(返回执行的热点代码,编译成机器指令,缓存到方法区)并行
2. JVM的指令集架构
Java编译器输入的指令流是一种基于栈的指令集架构。
另一种指令集架构是基于寄存器的指令集架构
二者的区别:
基于栈式架构:
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器的分配难题,使用零地址指令方式分配(操作树,没有地址)
- 指令集更小,编译器更容易实现
- 不需要硬件的支持,可移植性好。
基于寄存器的架构的特点
- x86的二进制指令集,pc android
- 依赖硬件,可移植性差
- 性能优秀和执行更加高效
- 花费更少的指令去完成一项操作(指令集更大,16位)
- 指令集往往以一地址、二地址、三地址指令为主
javap 执行反编译
基于栈:指令集小,指令多,跨平台性,执行性能比寄存器差
3. JVM的生命周期
虚拟机启动 bootstrap class loader(引导类加载器) 创建一个初始类(initial class),这个类是由虚拟机(不同的虚拟机不同)的具体实现指定的。调用初始类中的main方法,加载其他的类
虚拟机执行:执行java程序,程序结束的时候,就停止。执行一个java程序实际上是执行一个叫做java虚拟机的进程
虚拟机退出:执行结束,程序执行异常终止,操作系统出现错误。某些线程调用结束虚拟机进程的方法。
Runtime类(运行时数据区)中的halt() System中的exit()
JNI java native interface
4. JVM发展历程
Classic
Java1.0 1.4不再使用
只提供解释器,没有即时编译器 ,二者是并行的。
解释器:逐行解释字节码 效率比较低
JIT 编译成为指令
classic能外挂,但只能二选一,不能二者协同运作】
hotspot内置了该虚拟机
Exact VM
JDK1.2
准确式内存管理,可以知道内存中某个位置的数据具体是什么类型
编译器和解释器混合工作模式
热点探测(知道哪些是热点代码)
短暂使用,被hotspot替代
Hotspot
从服务器、桌面、移动端、嵌入式都有应用
JDK1.3 默认虚拟机
后面都主要是针对Hotspot虚拟机讲
名称:热点代码探测技术
通过计数器找到最具编译价值的代码,触发即时编译或栈上替换
通过编译器和解释器的协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡
JRocket
商用 。BEA 已经被Oracle收购
专注于服务器端,不太关注程序的启动速度。
不包含解释器实现,全部代码都是编译器编译后执行
最快的JVM
MissionControl 服务套件,可以监控分析程序运行中的状态 内存泄漏 、运行时分析器、管理控制台
J9
IBM 三大有影响力的商用虚拟之一
从服务器、桌面、移动端、嵌入式都有应用
OpenJ9
其他
KVM 和 CDC/CLDC Hotspot JavaME 现在已经较少使用
KVM : 智能控制器、传感器、老人机
Azul VM 和特定硬件平台绑定 Zing JVM
Liqud VM 不需要操作系统的支持,可以直接进行 线程调度、文件系统、网络支持 项目停止了。
Apache Harmony IBM 和intel 没有大规模商用的案例,被吸收到了Android SDK
JCP(Java Community Process)成立于1998年,是使有兴趣的各方参与定义Java的特征和未来版本的正式过程。 JCP使用JSR(Java规范请求,Java Specification Requests)作为正式规范文档,描述被提议加入到Java体系中的的规范和技术。
Microsoft JVM 当时为了支持 Java applets
TaobaoJVM 基于openJDK的深度定制的版本
Dalvik VM 安卓系统5.0 之前 之后使用ART VM替换 基于寄存器结构
JVM的内存结构取决于不同的实现。各个厂商的实现都有所不同。
Graal VM oracle 跨语言的全栈虚拟机。可以作为任何语言的运行平台使用。
二、类加载子系统
课程结构
类加载器子系统负责从文件系统(从硬盘中的文件)或者网络中加载Class文件(根据class文件可以实例化出多个一模一样的实例),class文件在文件开头有文件标识,ClassLoader只负责文件的加载,至于它是否可以运行,则有ExecutionEngine决定
加载的类信息存放于一块成为方法区的内存空间,方法区还会存放运行时常量池信息。
1. 类的加载过程
(1) 加载 loading
-
通过一个类的全类名获取定义此类的二进制字节流
-
将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
-
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问的入口
加载 .class 文件的方式:
从本地文件系统
网络 web applet
zip jar war
运行时生成 动态代理
其他文件生成 jsp
专有数据库中提取 .class文件 较少见
从加密文件中获取,防止反编译的保护
(2)链接 linking
-
验证 verify 确保class文件的字节流中包含的信息符合虚拟机要求,保证加载类的正确性,不会危害虚拟机自身安全CAFEBABY 开头的字节码文件。四种验证方式 文件格式、元数据、字节码、符号引用
-
准备 prepare为类变量(static的)分配内存并且设置该类变量的默认初始值,即零值,不包括final,在编译的时候已经被分配了,不会为实例变量分配初始值,类变量分配在方法区中,而实例变量是会随着对象一起分配到堆中
-
解析 resolve 在初始化之后进行,符号引用 引用相关的结构,将常量池中符号引用转换为直接引用的过程。符号引用就是一组符号描述所引用的目标,符号引用的字面量形式明确定义在虚拟机规范的class文件格式中,直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄(handle)
(3)初始化 initialization
执行类构造器方法 <clinit>()
的过程,就是用于赋值的,如果没有赋值的动作,和静态代码块就不会执行<clinit>()
,类变量的属性值就会是prepare中指定的初始值,实例变量则没有初始化值
不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
构造器方法中的指令按语句在源文件中出现的顺序执行
<clinit>()
不同于类的构造器,类中的构造器在虚拟机视角是<init>()
方法 每一个类都至少有一个构造器。
如果该类有父类,会保证父类的<clinit>()
已经先被执行
虚拟机必须保证一个类的<clinit>()
方法在多线程下是同步加锁的,因为类只会被初始化一次,到方法区中,是单例的
2. 类加载器的分类
从概念上只有两种加载器 Bootstrap ClassLoader 和 User-defined ClassLoader
将派生于抽象类 ClassLoader的类加载器都划分为 User-defined ClassLoader
所以Extention ClassLoader 和 System ClassLoader 都视为自定义的类加载器
Bootstrap ClassLoader是用C和cpp实现的,后面的都是用java实现的。
用户自定义的类的都是System ClassLoader加载的
String类等核心类库都是使用Bootstrap ClassLoader加载的
(1)Bootstrap ClassLoader
启动类加载器,嵌套在jvm内部
用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resources.jar 、sun.boot.class.path),用于提供jvm自身需要的数据
没有父类加载器,加载扩展类和应用程序类加载器,并制定为他们的父类加载器
在加载包名为java、javax 、sun等开头的类
嵌入式还是C系列语言的效率更高
不能直接在java中获取到
(2)ExtClassLoader
java编写,派生于ClassLoader类
父类加载器为启动类加载器
java.ext.dirs系统属性指定的目录下加载,或者从jdk安装目录的jre/lib/ext子目录下加载类库,如果用户创建的jar放在此目录,也会又扩展类加载器加载
(3)AppClassLoader
父类加载器为ExtClassLoader 负责加载环境变量或系统属性java.class.path指定路径下的类库
类加载是程序中默认的类加载器。java应用的类都是他来加载
(4)自定义加载器
场景:隔离加载类(中间件,不同的环境避免类的冲突,实现隔离),修改类加载的方式(根据需要去加载非核心的类库),扩展加载源(从数据库等其他地方加载字节码),防止源码泄漏(对字节码文件进行加密,在加载的时候解密)
自定义的步骤:继承ClassLoader 重写findClass() ,读入二进制字节流
如果没有复杂的需求,可以继承ClassLoader 子类URLClassLoader 不用编写findClass() ,读入二进制字节流
(5)ClassLoader抽象类
除了Bootstrap ClassLoader所有类加载器都是继承自ClassLoader
获取ClassLoader的途径
-
获取当前类的ClassLoader clazz.getClassLoader()
-
获取当前线程上下文的ClassLoader Thread.currentThread().getContextClassLoader()
-
获取系统的ClassLoader ClassLoader.getSystemClassLoader()
-
获取调用这的ClassLoader DriverManager.getCallerClassLoader()
3. 双亲委派机制
面试:
Java虚拟机对class文件进行按需加载,当需要使用的时候才会把class文件加载到内存中生成class对象。而且加载某个类的class文件的时候,Java虚拟机采用的双亲委派模式,即把请求交由父类处理,是一种任务委派模式
避免类的重复加载。
保护程序的安全,防止核心的api被随意的窜改
沙箱安全机制:对核心api的保护
两个class对象是同一个类的条件
包名、全类名一致,classLoader也要一样
类的主动使用和被动使用,会不会初始化 调用了clinit()与否
三、运行时数据区
running data area
内存是CPU和硬盘的中间仓库和桥梁
内存的申请、分配、管理,不同的JVM是有区别的,JRocket就没有方法区
class loader subsystem ->running data area -> execute engine
运行时数据区有些会随着的虚拟机(进程)启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应,这些与县城对应的数据区会随着线程开始和结束而创建和销毁
每个线程:独立包括程序计数器、栈、本地栈
一个虚拟机实例(一个进程):线程间共享,堆、堆外内存(永久代或元空间(就是方法区的实现,名称,是同一个东西)、代码缓存)
Runtime实例 相当于running data area,一个虚拟机实例就只有一份Runtime实例
虚拟机栈、方法区、堆 三大重点 本地方法栈和程序计数器次重要。
线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行执行。在HotSpot里,每个线程都与操作系统的本地线程直接映射,当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建,Java线程执行终止后,本地线程也回收。释放相应的资源。
操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,他会调用Java线程的run()方法
守护线程和普通线程。如果程序中最后一个非守护线程结束,JVM的进行也就结束了。
jconsole
虚拟机线程:JVM到大安全点才会出现。?
周期任务线程 周期事件的体现,比如终端,一般用于周期性操作的调度执行。
GC线程 守护线程 垃圾收集
编译线程 将字节码编程成本地代码
信号调度线程 接收信号,并发送给JVM,在内部通过适当方法进行处理
1. 程序计数器
PC 寄存器 programer counter register 源于CPU的寄存器,寄存器存储指令相关的线程信息,CPU只有把数据装载到寄存器才能够运行。
也称为程序钩子,JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟
用来存储指向下一条指令的地址,即将要执行的指令代码,由执行引擎读取下一条指令
是一块很小的内存空间。几乎可以忽略不计,运行速度最快的存储区域。每个线程也有自己的程序计数器、线程私有的,用来存储程序执行位置的信息,生命周期与线程的生命周期保持一致。
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法,程序计数器会存储当前线程正在执行的Java方法的JVM指令地址。或者如果是在执行Native方法,则是未指定值undefined,调用C语言的时候,无法获取。
唯一一个没有OutOfMemoryError的区域。没有GC和OOM
StackArea没有GC。 Method Area 和Heap Area 都有GC。三者都有OOM
程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能需要依赖计数器完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
1、使用PC寄存器存储字节码指令地址有什么用?
因为CPU需要不停的在各个线程之间切换。切换回来时候需要知道从哪接着开始继续执行。
JVM的字节码解释器需要通过改变PC寄存器的值来明确下一条应该执行怎么样的字节码命令。
CPU并发执行多个线程。
2、为什么PC计数器是线程私有的。
因为要知道某个线程执行的位置。当然不能共享PC计数器。每一个PC计数器需要单独记录执行到位置的指令地址值。
CPU时间片轮限制。任何一个确定的时刻,一个处理器或者多核处理器的一个内核,都只会执行某一个线程中的一条指令
这样必然导致经常中断或恢复。因此某个线程在创建之后,都会产生自己的程序计数器和栈帧,使得各个线程之间不会相互影响。
并发(一个核快速切换),并行(串行),同一个时间点多个线程同时执行。
2. 虚拟机栈
(1)概念
java virtual machine stack
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据,堆解决的是数据存储的问题,数据放哪,怎么放。
每一个线程创建都会创建一个虚拟机栈(线程私有的),内部保存一个栈帧 stack frame(对应一次java方法调用)
生命周期和线程一致。
保存方法的局部变量(8种基本数据类型,引用数据类型的地址)、部分结果,并参与方法的调用和返回
-
局部变量 vs 成员变量
-
基本数据变量 vs 引用类型变量
栈的访问速度仅次于 PC寄存器
每个方法执行,伴随着进栈,执行结束后出栈工作。不存在垃圾回收问题,只有内存溢出问题(OOM)
开发中的遇到的异常有哪些?
Java栈的大小可以是动态的或者固定不变的。
- 如果是固定大小的虚拟机栈,每一个线程的虚拟机栈容量在线程创建的时候独立选定。如果线程请求分配的栈容量超过虚拟机栈允许的最大容量。虚拟机栈会抛出 StackOverFlowError
- 如果是动态扩展的,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存区创建对应的虚拟机栈,抛出OutOfMemoryError
在VM options中设置参数-Xss
设置线程的最大栈空间,可以请求的最大内存空间,栈的大小直接决定了方法调用的深度
-Xss1m
-Xss1024k
-Xss1048576
(2)栈的存储单位: 栈帧
以栈帧为基本单位,线程上正在执行的每个方法都对应一个栈帧 一一对应
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
类中的基本结构:field method
一条活动的线程上,一个时间点上,只有一个活动栈帧。当前正在执行的栈帧称为当前栈帧,对应的方法就是当前方法,方法所属的类就是当前类。执行引擎运行的字节码指令只针对当前栈帧进行操作。PC计数器也是指向当前栈帧的地址。如果在该方法中调用了其他方法,就会创建新的栈帧,成为栈顶,成为新的当前帧
不同线程的栈帧是不允许存在互相引用的,不可能在一个栈帧中引用另一个线程的栈帧。因为栈是线程私有的
如果当前方法调用了其他方法,方法返回之际,当前栈帧后传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
返回函数有两种方式,一种是正常的函数返回,使用return指令,另外一种是抛出异常。不管使用哪种方式都会导致栈帧被弹出。
调用方法,进栈。执行结束,弹栈
(3)栈帧的内部结构
局部变量表 local variables
操作数栈 operand stack 表达式栈
动态连接 dynamic linking 指向运行时常量池的方法引用
方法返回信息 return address 方法正常退出或者异常退出的定义
一些附加信息
栈帧的大小由其内部结构决定。
① 局部变量表 local variables
又称局部变量数组,本地变量表
数字数组,用于存储方法参数和定义在方法体内部的局部变量。这些数据类型包括基本数据类型(最终都会转换为int存储)、对应引用refrence 以及return address的类型(异常、还是成功)
线程私有的,不存在数据安全问题。
局部变量表所需的容量大小是在编译期确定下来的。并保存在code属性中的maxium local variables 数据项中,在方法运行期间是不会改变局部变量表的大小的。在编译的时候确定
参数和局部变量越多,局部变量表膨胀,栈帧越大,能嵌套调用的方法次数的减少。
局部变量表只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放 在局部变量数组的index0开始,到数组长度-1的索引结束
局部变量表,最基本的存储单元是slot(变量槽)
局部变量表中存放编译器可知的各种基本数据类型(8种),引用类型reference,retrun address类型的变量
在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress),64位的类型(long double 8bytes)占用两个slot
构造方法和实例方法(非静态的方法)有this的局部变量,而静态方法是没有的,所以在静态方法中是不能使用this的。构造器也是有this局部变量的,可以调用当前对象的其他构造器。
this 关键字
this
refers to the current object.
Each non-static method runs in the context of an object. So if you have a class like this:
public class MyThisTest {
private int a;
public MyThisTest() {
this(42); // calls the other constructor // 调用其他的构造器
}
public MyThisTest(int a) {
this.a = a; // assigns the value of the parameter a to the field of the same name
}
public void frobnicate() {
int a = 1;
System.out.println(a); // refers to the local variable a
System.out.println(this.a); // refers to the field a
System.out.println(this); // refers to this entire object
}
public String toString() {
return "MyThisTest a=" + a; // refers to the field a
}
}
Then calling frobnicate()
on new MyThisTest()
will print
1
42
MyThisTest a=42
So effectively you use it for multiple things:
- clarify that you are talking about a field, when there's also something else with the same name as a field
- refer to the current object as a whole
- invoke other constructors of the current class in your constructor
Slot是可以重复利用的。如果一个局部变量过了其作用域,在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
前面的局部变量已经销毁了,后面定义的局部变量可以使用前面的索引的slot
静态变量和局部变量的对比
基本数据类型 和 引用数据类型
在类中声明的位置
- 成员变量:在使用前都经历过默认初始化赋值。
- 类变量(静态变量) prepare中进行默认赋值 -> initial阶段给类变量显式赋值,或者静态代码块赋值
- 实例变量(非静态变量)随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前必须要进行显式赋值,否则都无法编译。参数(是通过调用方法进行赋值的)
栈帧中,与性能调优关系最为密切的就是局部变量表,在方法执行的时候,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要局部变量表中直接或间接引用的对象都不会被回收。
② 操作数栈 operand stack
栈可以使用数组或者链表来实现。操作数栈使用数组存储(顺序表或者是链表)
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈push、出栈pop
某些字节码指令将值压入操作数栈,其余的字节码指令操作数取出,使用它们后再把结果压入栈。
比如执行复制、交换、求和 operant(被运算的对象)
把前两个pop出来,执行iadd后,再push回去。如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。这有编译器在编译期间进行验证,同时在类的加载过程中的类检验阶段的数据流分析阶段要再次验证。
我们所说的Java虚拟机的执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
用于保存计算过程中的中间结果,同时作为计算过程中变量临时存储空间
操作数栈就是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,此时操作数栈是空的。
因为操作数栈是一个数组,每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定好了(怎么在编译期间就确定的?),保存在方法的code属性中,为max_stack的值
栈中的任何一个元素都可以是任意的java数据类型
32bit占用一个栈单位深度
64bit占用两个栈单位深度
public void sum(){
byte i = 15;
int j = 8;
int k = i + j;
}
public void getsum(){
int i = sum();
int j = 10;
}
代码追踪
在getSum对应的栈帧当中:
静态方法的局部变量表的索引为0 的地方放的this 局部变量
左边是字节码文件bytecode中对应的指令。通过PC寄存器来控制执行的顺序,
bipush(b表示是byte,根据数据的大小而变化 b s i)会先把局部变量i的值push 到操作数栈中,在istore_1 把 i 的值放到局部变量表中索引为1的位置,然后操作数栈就pop了。
然后又bipush j的值 8 到操作数栈中索引为零的位置(只有一个数,栈顶),接着又存到局部变量表
iload_1,iload_2 就是取出局部变量表中索引为1和2的数据。放到了操作数栈当中,此时栈中就有8,15两个数据,接下来执行 iadd (这些指令都是执行引擎做的,把字节码指令翻译成机器指令)把8+15相加后的值23压入操作数栈当中
在istore_3中再讲23存在局部变量k当中,并且从操作数栈中弹出。
方法没有返回值。return直接就结束了
操作数栈的深度(stack)为2,locals (局部变量表的变量数量)为4。
在testGetSum()对应栈帧中
先aload_0,加载当前类的对象 this
然后invokevirtual 调用当前对象中的getSum()方法。istore存到局部变量表中,
在操作数栈中push10,存放到局部变量表中的索引位置2
return结束
面试题: i++ 和 ++i的区别
++i 是先自增,后赋值
i++ 是先赋值,后自增
public void add(){
//第1类问题:
int i1 = 10;
i1++;
System.out.println(i1);//11
int i2 = 10;
++i2;
System.out.println(i2);//11
//第2类问题:
int i3 = 10;
int i4 = i3++;
System.out.println(i4);//11
int i5 = 10;
int i6 = ++i5;
System.out.println(i6); //10
//第3类问题:
int i7 = 10;
i7 = i7++;
System.out.println(i7); //10
int i8 = 10;
i8 = ++i8;
System.out.println(i8); //11
//第4类问题:
int i9 = 10;
int i10 = i9++ + ++i9;
System.out.println(i10); //22
}
iinc 2 by 1
就是把局部变量表中索引为2的变量值加上1。此处需要重点注意的是,iinc
自增指令直接对局部变量表的元素进行累加,而不是在栈中。
iinc
指令,它是int类型的局部变量自增指令(将后者数据加到前者下标的int类型局部变量中)
第一类问题:二者完全是一致的
//第1类问题:
int i1 = 10;
i1++;
int i2 = 10;
++i2;
第二类问题:因为iinc是直接在局部变量表中进行操作的,因此前者就是store了load在操作数栈中未加一的10,而第二种情况则是load把局部变量表中的值又重新load了一遍,因此就是加了1的值
int i3 = 10;
int i4 = i3++;
int i5 = 10;
int i6 = ++i5;
第三类问题:和第二类情况是一致的。
//第3类问题:
int i7 = 10;
i7 = i7++;
int i8 = 10;
i8 = ++i8;
第四类问题 10+ 12 前面先load了10, i9++ 自增1 这时候局部变量表中已经是11了,++i9又加一这时候就是12了,执行加法的时候就是12了。
//第4类问题:
int i9 = 10;
int i10 = i9++ + ++i9; // 10+ 12
System.out.println(i10); //22
栈顶缓存技术
零地址指令。更加紧凑,指令集小,完成一个操作需要使用更多的指令,内存读写的次数多,因此效率也不如基于寄存器的架构。因此为了提高效率,出现了栈顶缓存技术 top-of-stack caching,将栈顶元素全部缓存到物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
③ 动态链接 dynamic linking
方法返回地址、动态链接,其他附加信息,也会被称为帧数据区。
每一个栈帧内部都包含着一个指向运行时常量池中该栈帧所属的方法的引用。包含这个引用的目的就是为了支持当前方法的代码可以实现动态连接。比如invokedynamic指令
在java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个调用了其他方法时,就是通过常量池中指向方法的符号引用来表示的。动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
在栈帧中的动态链接引用到方法区(常量池运行起来就放到了方法区)
执行引擎运行方法的时候,字节码中的常量池会被加载到运行时常量池。
为什么需要常量池(常量池在字节码文件中,运行起来在方法区中的就是运行时常量池)
为了提供一些符号和常量,便于指令的识别,字节码文件比较小(?)
方法的调用
在JVM中,把Java方法调用的符号引用转换为直接引用,和方法的绑定机制直接相关。
静态链接:当一个字节码文件被将在进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程成为静态链接。
动态链接
如果被调用的方法在编译器无法被确定下来。也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此被称为动态链接。
方法的绑定机制:绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅发生一次
早期绑定:早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与所属类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪个,因此也就可以使用静态链接的方法将符号引用转换为直接引用。
晚期绑定:被调用的方法在编译期无法被确定下来,只能在程序运行期根据实际的类型绑定相关的方法,这种绑定方式成为晚期绑定。(多态性就需要晚期绑定)
Java中的任何一个方法都具有虚函数的特征,如果不希望其具有虚函数的特征,则需要使用final来标记这个方法。
final 关键字
- final类不能被继承,没有子类,final类中的方法默认是final的。
- final方法不能被子类的方法覆盖,但可以被继承。
- final成员变量表示常量,只能被赋值一次,赋值后值不再改变。
- final不能用于修饰构造方法。
虚方法vs非虚方法
非虚方法:静态方法、私有方法、final方法、实例构造器、父类方法。 在编译器就确定了具体的调用版本,这个版本在运行时是不可变的,就成为非虚方法。方法都是不能被重写的。
其他的方法都是虚方法。对应着多态性。调用父类的方法的时候,可能实际上调用的是子类中的方法,所以在编译期间是无法确定的实际上调用的谁,因此是虚方法,需要晚期绑定和动态链接
子类对象的多态性的使用前提:
类的继承关系。
方法的重写。(调用的是父类的方法,实际运行的是重写的方法,接口)
调用方法的指令:
普通调用指令
- invokestatic 调用静态方法,解析阶段确定唯一方法版本。
- invokespecial 调用
<init>
方法 私有及父类方法(不包括父类的静态方法),解析阶段确定唯一方法版本 - invokevirtual 调用所有的虚方法(如果是final修饰的,虽然使用invokevirtual 指令调用,但是实际上是非虚方法)
- invokeinterface 调用接口中的方法,虚方法。在调用的时候不知道实现类中具体是怎么实现的
动态调用指令
- invokedynamic 动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部。方法的调用执行不可人为干预,而invokedynamic指令则指出用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(非final修饰的)成为虚方法。
invokedynamic 动态类型语言。知道Java8的lambda表达式,invokedynamic 指令在java中才有了直接的生成方式。
动态类型语言 运行期间 动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有。JavaScript、Python
静态类型语言 编译期间 静态是陈判断变量自身的类型信息。 Java
区别在于,对于类型的检查是在编译期间还是在运行期间进行的。
增加动态语言类型本质是对java虚拟机规范的修改,而不是对java语法规则的修改。最直接的受益者是运行在java平台上的动态语言(使用jvm的其他动态编程语言)的编译器。
方法重写的本质。先去找子类重写的方法,如果有,进行权限的验证,如果通过则返回这个方法的直接引用。如果没有,去找父类。
虚方法表:为了提高效率,如果调用过就会在虚方法表中建立对应关系。不用每次都去找这个方法是在哪个类(子类 父类 的继承树上)中实现的,虚方法表是在类加载的时候,Linking步骤中的resolve阶段创建的。
④ 方法返回地址 return address
存放的是该方法的PC寄存器的值,把值给返回给执行引擎,让其继续执行。
调用的方法结束(正常结束,异常退出),回到该方法的调用位置,正常退出的时候,调用者的PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址,而异常退出的,返回地址是要通过异常表(不再有方法返回地址)来确定,栈帧中一般不会保存这部分信息。
方法在正常调用完成之后使用哪个返回指令还需要根据方法返回值的实际数据类型而定。
ireturn(boolean byte char short int)
lreturn long
freturn float
dretrun double
areturn 引用
return void返回的方法 实例初始化方法 类和接口的初始化方法 (静态代码块)
异常退出,如果进行了catch的,则会在本方法的异常处理表中去匹配异常处理器;如果没有且没有catch的,在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。
方法的退出实际上是当前栈帧出栈的过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值(不是方法返回地址,是方法调用产生的返回值)压入调用者栈帧的操作数栈,设置PC寄存器的值等,让调用者方法继续执行下去。
异常退出的不会给他的上层调用者产生任何的返回值
⑤ 附加信息
携带虚拟机实现相关的信息,如对程序调试提供支持的相关信息。
虚拟机栈的相关面试题
1、栈溢出的情况? StackOverFlowError 调整栈的大小,也不能保证不溢出
通过-Xss设置栈的大小。OOM out of memory
2、垃圾回收是否会涉及到虚拟机栈吗?(不会),虚拟机栈(及本地方法栈)只有OOM,不会垃圾回收。就pop出栈,不会有显式的垃圾回收。程序计数器既没有OOM,也不存在GC。
方法区和堆二者都有。
Out of Memory Error | Garbage Colletion | |
---|---|---|
PC register | no | no |
native method stack | yes | no |
Java vm stack | yes | no |
heap | yes | yes |
method area | yes | yes |
3、分配的栈内存不是越大越好。
4、方法中定义的局部变量是否是线程安全的?不一定
如果只有一个线程在操作此数据。则是线程安全的。如果传入的参数,在其他的线程中也在被操作。就会有线程不安全的问题。
主要看这个局部变量是否在方法方法内部消亡。如果不是内部产生的,或者内部产生再返回给外部,都有可能出现线程不安全。
3. 本地方法栈
native method stack
用于管理本地方法的调用。
线程私有的。
允许被实现为固定或者是可动态扩展的内存大小。
在本地方法栈去登记本地方法。在execution engine执行时加载本地方法库。
当某一个线程调用一个方法时,它就进入一个全新的不受虚拟机限制的世界,和虚拟机拥有同样的权限。
本地方法可以通过本地方法接口,访问虚拟机内部的运行时数据区。
设置可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存。
并不是所有的JVM都支持本地方法。JVM规范并没有规定语言、实现方式、数据结构
在HOTSPOT JVM中,直接将本地方法栈和虚拟机栈合二为一。
本地方法接口
Native Method Interface(JNI)
JNI (Java Native Interface,Java本地接口)是一种编程框架,使得Java虚拟机中的Java程序可以调用本地应用/或库,也可以被其他程序调用。 本地程序一般是用其它语言(C、C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。
Java调用非Java代码,一个Native Method:该方法的实现不是由Java语言实现的,而是使用C或C++实现的。这个特征不是Java所特有的。很多其他的编程语言也有这一机制。比如在C++中可以使用extern "C" 告知编译器去调用C的函数。
融合其他语言。
Java底层系统交互不如C语言。Native和public、static、synchronized等修饰符都是可以共同使用的,和abstract不能共存。
native 关键字可以应用于方法,以指示该方法是用Java以外的语言实现的,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。。
Java不是完美的,Java的不足除了体现在运行速度上要比传统的C++慢许多之外,Java无法直接访问到操作系统底层(如系统硬件等),为此Java使用native方法来扩展Java程序的功能。
可以将native方法比作Java程序同C程序的接口,其实现步骤:
1、在Java中声明native()方法,然后编译;
2、用javah产生一个.h文件;
3、写一个.cpp文件实现native导出方法,其中需要包含第二步产生的.h文件(注意其中又包含了JDK带的jni.h文件);
4、将第三步的.cpp文件编译成动态链接库文件;
5、在Java中用System.loadLibrary()方法加载第四步产生的动态链接库文件,这个native()方法就可以在Java中被访问了。
JAVA本地方法适用的情况
1、为了使用底层的主机平台的某个特性,而这个特性不能通过JAVA API访问
2、为了访问一个老的系统或者使用一个已有的库,而这个系统或这个库不是用JAVA编写的
3、为了加快程序的性能,而将一段时间敏感的代码作为本地方法实现。
原因:
1、需要与Java外面的环境交互。与底层系统,操作系统、硬件交换信息。本地方法正是这是一种交流机制,提供了一个非常简洁的接口,无需去了解Java应用之外细节的实现。效率更高
2、操作系统都是c like的语言写的。JVM的一些部分都是C写的。
3、Sun的解释器是用C实现的
4. 堆 heap
(1)堆空间的结构
方法区和堆是一个进程(对应一个JVM实例)中唯一的。
在JVM启动的时候就被创建,同时其空间也是确定的。是JVM管理的最大的一块内存空间,也是最重要的一块空间。
堆内存的大小是可以调节的。
堆可以处于物理上不连续的空间,但是在逻辑上是连续的。
所有的线程共享java的堆,但是在这里可以划分线程私有的缓存区(不会有线程安全问题)。thread local allocation buffer TLAB
几乎所有的对象实例及数组,都应该在运行时分配在堆上。
数组和对象可能永远不会存储在栈上,因为栈帧中只保存引用,这个引用指向对象、数组在堆中位置。
逃逸分析?栈上分配。标量替换
在方法结束后,堆中对象不会被马上移除,仅仅在垃圾收集的时候才会被移除
堆是GC执行垃圾回收的重点区域。
方法区中是类及方法的结构。
堆空间不足。GC来判断堆中的实例是否有来自栈中的指针。在一定时间后才回收。防止过高的GC频率。不是栈把局部变量pop出去就开始回收的。
new的时候在堆空间中开辟内存空间,初始化实例变量。
元空间是方法区的一部分。
-Xms 用来设置堆空间初始内存大小(只包括新生代和老年代)
-X JVM运行参数 memory start
-Xmx 堆空间最大的内存 memory max
默认的堆空间的大小。
初始:物理电脑内存/64
最大:物理内存/4
运行时数据区是单例的。
初始的内存大小和最大的内存大小一般设置为一致的。因为GC的时候不用重新划分空间,效率更高。减少GC频繁的扩容和释放。
s0(from)和s1(to)区中总有一片空间的是空的(垃圾回收需要)
OOM内存溢出
(2)年轻代与老年代
Java中的对象有生命周期的长短。有些对象和JVM生命周期一致。在老年代中
有些迅速的消亡的对象就在年轻代。
old/young 的比例是 NewRatio
-XX:SurvivorRatio = 8
-XX:-UseAdaptiveSizePolicy 自适应内存分配策略。-是关闭 +是开启。
实际上是6:1:1 不是8:1:1
-Xmn 设置新生代的空间的大小。优先于NewRatio 但是一般不设置
几乎所有的Java对象都是在Eden区中new出来的。
新生代中的80%的对象都是声明周期极短的。
(3)内存分配
内存如何分配和在哪里分配。以及垃圾回收都是密切相关的。
对象先分配到Eden区,当满了的时候,触发YGC (YoungGC) 判断哪些是垃圾,如果还有被占用的就会被放到S0
年龄计数器(每个对象都有)在进行一次YGC的时候就加一。
to区就空的。from区是非空的survivor区。Eden中还被占用的对象,会被放到空的S1区,S0中的对象也会进行判断。如果还在被使用的也是要移到S1。这时候AGE就又增加1了。
当年龄计数器达到threshold,promoted to Tenured
YGC只会在Eden区满的时候才会触发。
YGC在触发的时候会同时回收Eden和Survivor。
如果一些特殊情况。Survivor满了,会在没有达到threshold的后就被promote to tenure
-XX:MaxTenuringThrethold = 15 默认值
复制算法。谁空谁是TO
GC频繁在YoungGen 中进行。很少在Tenured中进行, 几乎不在MetaSpace中进行。
FGC full GC
JVisualVM
常用调优工具。 JProfiler等。
(4)GC
YGC和Minor GC是完全一样的。
Full GC 和 Major GC
GC线程会导致用户线程的暂停。调优的目的就是减少GC的频率
Full GC 和 Major GC的暂停时间是YGC的10倍
MinorGC只有在Eden区满的时候才会触发。Survivor区是被动的。不会主动触发MinorGC
MinorGC频率很高。
会导致STW(stop to World) 暂停其他用户线程。等待垃圾回收结束。用户线程才会恢复。
Major GC 通常会伴随MinorGC 时间慢10倍以上。如果Major GC后还不行就OOM
Full GC 整堆回收。
-
调用System.gc() 建议执行 不是必然执行
-
老年代空间不足
-
方法区空间不足
-
通过Minor GC 进入老年代的平均大小大于老年代的可用内存
-
Eden区、from space 到 to space 。大于to space 。转存到老年代。老年代空间也放不下
full gc是开发中尽可能避免的。暂停时间长。
ps process status 进程状态
分代的目的就是为了优化GC的性能。
内存分配策略
优先分配到Eden
大对象直接分配到老年代。尽量避免过多的大对象
长期存活的对象分配到老年代
动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和大于等于Survivor空间的一半,年龄大于该年龄的对象可以直接进入老年代。不用达到阈值。减少反复的复制。
空间分配担保 -XX:HandlePromotionFailure。老年代为survivor提供空间的担保。
(5)TLAB
thread local allocation buffer
堆区是线程共享的。由于对象的实例的创建非常频繁。在并发的环境下会出现线程不安全。
为避免多个线程操作同一个地址。使用加锁机制会影响的效率。
在Eden区为每个Thread分配一个私有的缓存区域。
快速分配策略。
只占Eden空间的1%
设置参数 -XX:UseTLAB 默认情况是开启的。
(6)参数设置
-XX:+PrintFlagsInitial 查看所有的参数的初始默认值
-XX:+PrintFlagsFinal 查看参数的最终值
-Xms 初始堆空间
-Xmx 最大堆空间
-Xmn 新生代
-XX: NewRatio 老年代空间/新生代
-XX:SurvivorRatio eden/s0
-XX:MaxTenuringThrethold 新生代垃圾的最大年龄
-XX:+PrintGCDetails GC日志
-XX:HandlerPromotionFailure 设置空间分配担保 如果老年代的连续空间大于新生代对象总大小或者历次晋升的对象的总大小。则进行MinorGC 否则就FullGC
堆是分配对象的唯一选择吗
JIT编译器的发展。逃逸分析技术,发现一个对象并没有逃逸出方法,那么就可能被优化成栈上分配
栈上分配。标量替换优化技术,在栈上分配就不用进行垃圾回收。
逃逸分析。能使用局部变量的,就不要在方法外定义。没有发生逃逸的,可以使用一下优化的策略
栈上分配
标量替换
同步省略。
-XX:+EliminateAllocation 开启标量替换
-XX:+DoEscapeAnalysis 开启逃逸分析
目前还不是特别成熟。
5. 方法区 method area
虚拟机启动的时候会创建堆、栈、方法区。
逻辑上方法区也被视为堆的一部分,但一些简单的实现是不会进行垃圾回收和压缩
物理上可以是不连续的内存,逻辑上的是连续的
Hotspot方法区还有一个别名叫做Non-heap,所以可以看做是独立于堆的内存空间
各个线程共享的区域。方法区的大小和堆一样,可以选择固定大小和可扩展
方法区的大小决定了系统可以保存多少个类。如果系统定义了太多的类(大量的第三方jar包、tomcat部署的工程过多,动态的生成反射类)。导致方法区溢出OOM metaspace
虚拟机关闭的时候,内存被释放
方法区可以视作接口。元空间和永久代可以视作实现。以后都是叫metaspace了。
方法区和永久代不是等价的,仅对Hotspot二者可以视为等价的。
是在虚拟机的内存中使用,更容易导致OOM错误。Jdk8以后 在本地内存中实现Metaspace 永久代,不再使用虚拟机设置的内存。
元空间的内部结构也做了一些调整。
JDK7
-XX:PermSize
-XX:MaxPermSize
JDK8
-XX:MetaspaceSize 依赖于平台。21M windows平台下。
-XX:MaxMetaspaceSize -1 无限制
一旦触及这个水位线。FullGC将会触发,卸载,没用的类,即这些类对应的类加载器不再存活,然后这个高水位线会被重置。新的高水位线取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过Max的情况下会适当提高该水平线。如果释放的空间过多,则会适当降低。
为了减少FullGC,通常设置为一个较高的值。
OOM异常或者heap space 。dump出堆转储快照进行分析。重点是确认内存中的对象是否必要。要区分清楚是内存泄漏还是内存溢出
方法区存储的信息:类型信息(域信息、方法信息)、常量、静态变量、即时编译器(JIT)编译后的代码缓存、类的加载器信息、运行时常量池
类型信息:class interface enum annotation JVM必须在方法区中存储一下类型信息
- 类型完整有效名称 全名= 包名.类名
- 类型直接父类完整有效名(除了interface和java.lang.Object)
- 类型的修饰符(public abstract final)
- 类型直接接口的一个有效列表
域信息(Field)成员变量
- field name
- class
- 修饰符 public 等
方法信息 Method
- 方法名称
- 方法参数的数量和类型
- 方法的修饰符
- 方法的字节码、操作数栈、局部变量表的大小 abstract 、native除外,返回值类型
- 异常表 abstract 、native除外。每个异常处理的开始位置、结束位置、代码处理在程序计数器的位置。被捕获的异常类的常量池索引
常量 static final 每个全局常量在编译的时候就会被分配其值。不同于静态变量是在类加载的时候加载的(静态变量在prepare、initialization阶段初始化)。
静态变量 类变量随着类的加载而加载,被所有的类的实例共享。即使没有类的实例也能访问它。
JIT编译后的代码
都会从字节码文件中加载到方法区当中。
运行时常量池。字节码文件中有常量池Constant Pool 加载到方法区中。就城变成了运行时常量池。
classfile
magic cafebaby
为什么需要常量池。在动态链接的时候会用到运行时常量池。类似于C里的动态链接库。这些是需要反复复用的东西。加载在常量池的时候,所有的类在加载的时候就指向常量池。解耦、模块化的一种。
常量池中存储的数据类型
- 数量值 ? 不是在栈中吗?
- 字符串值
- 类引用
- 字段引用
- 方法引用
常量池可以视作一张表。虚拟机指令根据表找到要执行的类名、方法名、参数类型、字面量等数据
运行时常量池是方法区的一部分。常量池表是字节码文件的一部分,这部分内容加载后放到方法区的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护一个常量池,池中的数据像数组项一样,通过索引访问。
运行时常量池包括编译期就已经明确的数值字面量、也包括运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址。相当于传统语言的Symbol table只不过存储的数据更丰富。
类加载之后,常量池的内容会进入运行时常量池,这时候里面的数据也许还保持着符号引用。 (因为解析的时机由JVM自己设定) 如果在虚拟机栈的 栈帧中,我准备调用 main() 函数,那么会通过栈帧中持有的动态连接,找到运行时常量池, 然后找到main函数的常量 比如 #2 ,如果这个常量没有被解析过,那么就通过这个常量进行解析过程, 其中包括,通过常量 找到 类名 和 nameAndType,通过 nameAndType 找到方法名和返回值。 这时候 我手里有 类名/方法名/方法返回值,下一步,我通过类名和方法名,通过JVM记录的方法列表,找到对应的方法体。 而这个方法体实际上是一段内存地址,那么这时候我就把这段内存地址复制给 #2,并且给 #2设定一个已经解析的 flag。 这样就完成了 符号引用到直接引用的过程。
虚拟机栈是一个大的栈,里面存放的单位是栈帧。操作数栈是虚拟机栈里栈帧的一部分,是另外一个栈,存放操作数。
方法区的演进细节
为什么使用要使用元空间替换永久代?
- 永久代设置空间大小是很难确定的。如果动态加载的类太多,容易产生OOM
元空间并在不在虚拟机中,而是使用本地内存,因此默认情况下,元空间大小仅受本地内存限制。元空间的最大内存没有进行限制(默认情况下)
- 对永久代进行调优是很困难的
为什么要把静态变量和StringTable放到堆(老年代)里呢?
- 因为永久代的GC效率比较低。因为fullGC只有在老年代空间不足的时候才进行回收。String比较少回收。
new的对象始终是放在堆空间的。但是静态变量是放在老年代中。
非静态的成员变量是随着类的实例方法在堆空间中,方法中的局部变量是放在栈帧中的局部变量表里。
而静态变量是存放在堆空间的老年代里(JDK6及以前是放在方法区的)
虚拟机规范中对方法区的约束比较松散,一些简单的实现可以不用实现垃圾回收和压缩(GC的碎片压缩)
hotspot有方法区的GC,主要涉及一下两部分内容:
-
废弃的常量。包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。如果没有引用指向常量池中的常量,就可以被回收。
-
不再使用的类型(类型的卸载,条件比较苛刻)
类所有的实例被回收了,以及其所有的子类的实例
加载类的类的加载器也被回收了。
该类对应的java.lang.Class对象没有任何引用。
只有满足这些才有可能被卸载。
运行时常量池(不包括字符串常量池)和类型信息(类、方法、域)、JIT编译后的代码、静态变量
常量池主要存放两大类的常量:字面量(final常量值、文本字符串等)和符号引用(编译原理方面的概念)
一个程序运行的流程:
- 编译成字节码。
- 类加载子系统加载进入虚拟机 加载的过程
- 在一个方法里new 对象,局部变量在栈中,引用指向堆里的实例。堆中的分区。进行垃圾回收
- 方法区中加载了类的信息、运行时常量池
四、对象的实例化、内存布局与访问定位
1. 对象的实例化
对象创建的方式
- new()
- 直接new
- 单例模式的获取实例的静态方法
- XxxBuilder/Factory
- Class的newInstance()。只能调用空参的构造器。权限必须是Public的
- Constructor的newInstance() 也是反射。可以调用空参的或者是带参的构造器,对权限也没有要求。jdk8以后替代上面的
- clone() 不调用任何构造器。但是要实现Cloneable接口,实现Clone() 浅拷贝
- 使用反序列化。从文件、网络获取一个对象的二进制流
- 第三方库Objenesis
创建对象的步骤
- 判断对象对应的类是否加载、链接、初始化 (new的指令,去常量池中定位到到一个类的符号引用。并且检查这个类对应的符号引用是否已经被加载、链接初始化。如果已经有了,直接加载类;如果没有,双亲委派机制模式下,使用类加载器去找class文件进行加载,如果没有就是ClassNotFoundException)
- 为对象分配内存 计算对象所需要的内存大小,引用变量是4个字节,是一个地址值。
- 如果内存规整 指针碰撞(在空闲的内存和被占用的内存中有一个指针,向空闲那边挪动,如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式,一般使用带有Compact过程的收集器时,使用指针碰撞)
- 如果不规整 虚拟机 需要维护一个列表,空闲列表分配 CMS标记清除算法
- 处理并发安全问题 堆空间是线程共享的,可能存在创建对象抢空间的问题
- CAS失败重试,区域加锁
- 每个线程预先分配一块TLAB
- 初始化分配到的空间 所有属性设置默认值。保证对象字段在不赋值的情况下可以直接使用
- 设置对象的对象头 对象所属的类、HashCode 、GC信息、锁信息等数据
- 执行init方法。 调用类的构造方法、并把堆内对象的首地址值赋值给引用变量,对象显式初始化
2. 对象的内存布局
3. 对象的访问定位
栈帧中的局部变量存了堆空间中的对象的地址值。通过栈上的refrence访问到
访问的方法主要有两种
-
句柄访问 handler pool 里面存放了到对象的指针,以及到类型的指针
空间浪费、效率低。但是栈上的引用时很稳定的。一旦对象进行了移动。指针就会修改。
-
直接指针(Hotspot使用的)。直接指向对象,对象实体中的对象头指向类型。
直接从栈就能访问到对象的实体。当对象的地址改变之后需要修改
五、直接内存
元空间使用的就是本地内存。Java堆外的,直接向系统申请的内存区间
来源于NIO,通过DirectByteBuffer直接操作本地内存。
直接内存效率更高,直接从物理内存中进行物理层磁盘的读取。不需要从用户态切换到内核态。
直接内存访问速度高于Java堆。
分配回收成本较高
不受JVM内存回收管理
仍然还是会有OOM。系统内存的限制
六、执行引擎
执行引擎概述
Java代码编译、执行过程
机器码、指令、汇编语言
解释器
JIT编译器
物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面的。而虚拟机的执行引擎则是由软件自行实现的。虚拟机不受物理条件的限制,能够执行那些不被物理层面支持的指令
执行引擎的任务是将字节码指令解释/编译为对应平台上的本地机器指令才可以。
执行引擎在执行的过程中,需要执行什么样的字节码指令由程序计数器决定。去地址中执行指令。
方法压入栈帧之后解释执行。编译成汇编代码,CPU和内存分配资源给进程。
下面的就是编译,上面的是解释执行。
解释器:对字节码采用逐行解释的方式执行。
JIT just in time compiler 将虚拟机源代码直接编译成和本地机器平台相关的机器语言。
为什么要二者都使用呢?半编译半解释型语言。
JIT编译缓存在方法区当中。
机器码
指令
指令集 x86 ARM指令集
汇编语言。助记符mnemonics代替机器指令的操作码
汇编语言还需要翻译成机器指令码。
高级语言
解释器:模板解释器,把每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码
解释器和JIT并存。解释器响应速度快。JIT执行快。
JIT的热点代码探测功能,翻译成本地机器指令,存到方法区的缓存中。
AOT Ahead of time compiler
前端编译器:源码到字节码
JIT Hotspot 的C1 C2 (client 使用C1,server使用C2。64位默认是server )
C1 方法内联、去虚拟化、冗余消除
C2 标量替换 栈上分配 同步消除
AOT编译器 GNU compiler for the java /excelsior JET
热点代码及其探测方式。根据代码执行的频率。在方法的执行过程中,进行栈上替换。on stack replacement OSR
方法调用计数器(统计调用次数)、回边计数器。
-Xint 仅适用解释器
-Xcomp 仅适用编译器
-Xmixed 混合模式 ( 默认)
JVM server & client Mode
This is really linked to HotSpot and the default option values (Java HotSpot VM Options) which differ between client and server configuration.
From Chapter 2 of the whitepaper (The Java HotSpot Performance Engine Architecture):
The JDK includes two flavors of the VM -- a client-side offering, and a VM tuned for server applications. These two solutions share the Java HotSpot runtime environment code base, but use different compilers that are suited to the distinctly unique performance characteristics of clients and servers. These differences include the compilation inlining policy and heap defaults.
Although the Server and the Client VMs are similar, the Server VM has been specially tuned to maximize peak operating speed. It is intended for executing long-running server applications, which need the fastest possible operating speed more than a fast start-up time or smaller runtime memory footprint.
The Client VM compiler serves as an upgrade for both the Classic VM and the just-in-time (JIT) compilers used by previous versions of the JDK. The Client VM offers improved run time performance for applications and applets. The Java HotSpot Client VM has been specially tuned to reduce application start-up time and memory footprint, making it particularly well suited for client environments. In general, the client system is better for GUIs.
So the real difference is also on the compiler level:
The Client VM compiler does not try to execute many of the more complex optimizations performed by the compiler in the Server VM, but in exchange, it requires less time to analyze and compile a piece of code. This means the Client VM can start up faster and requires a smaller memory footprint.
The Server VM contains an advanced adaptive compiler that supports many of the same types of optimizations performed by optimizing C++ compilers, as well as some optimizations that cannot be done by traditional compilers, such as aggressive inlining across virtual method invocations. This is a competitive and performance advantage over static compilers. Adaptive optimization technology is very flexible in its approach, and typically outperforms even advanced static analysis and compilation techniques.
Note: The release of jdk6 update 10 (see Update Release Notes:Changes in 1.6.0_10) tried to improve startup time, but for a different reason than the hotspot options, being packaged differently with a much smaller kernel.
七、String Table
String的基本特性
内存分配
基本操作
字符串拼接
intern()的使用
StringTable的垃圾回收
G1中的String去重操作
八、垃圾回收
1. 概要
内存动态分配。垃圾收集技术
垃圾收集。Lisp语言就有了
三大问题:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
垃圾是运行程序中,没有任何指针指向的对象。这个对象就是需要被回收的垃圾。
引用类型的才需要考虑回收。如果不进行垃圾回收,会一直保留到应用程序结束,空间会被占用。可能会导致内存溢出问题。
内存溢出vs内存泄漏
内存会被消耗完。
清理内存中的记录碎片。将整理出的内存分配给新的对象(腾出连续的空间)
没有GC就不能保证应用的程序的正常运行,因为应用程序所应付的业务越来越大。
早期的垃圾回收。C/C++需有手动申请释放,如果忘记回收就会导致内存泄漏
自动化的内存分配和垃圾回收技术是现代高级语言发展的趋势。
Java的垃圾回收机制。
降低内存泄漏和内存溢出的风险。
更专注于业务开发。
弱化Java开发人员在程序出现问题时的问题定位和解决问题的能力。
进行必要的监控和调节。方便排查和定位解决问题。
只针对堆和方法区。
2. 垃圾回收算法
哪些是垃圾?怎么回收?
标记阶段 -> 清除阶段
(1)垃圾标记:引用计数算法
垃圾标记阶段。对象存活判断
有些JVM实现是不回收方法区的。
主要是针对堆中的对象。对象死亡(没有任何指针引用,对象就死亡了)
Refrence Counting
为每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
优点:实现简单,垃圾对象便于辨识,判断效率高,回收没有延迟性(怎么知道被引用或者不再被引用)
缺点:
- 需要单独的字段存储计数器,增加了存储空间的开销
- 每次赋值都需要更新计数器,需要加减法,增加了时间的开销
- 严重的问题,无法处理循环引用的情况。Java的GC没有使用该类算法
python就使用了引用计数算法。
- 手动解除
- 使用弱引用weakref python标准库,就可以解决循环引用
(2)垃圾标记:可达性分析算法
又称作根搜索算法、追踪性垃圾收集
可以解决循环引用。同样具备实现简单执行高效的优点。
Java C#都采用这种算法
GC Roots根集合就是一组必须活跃的引用。
以GC Roots为起点,从上至下,搜索被根对象集合所连接的目标对象是否可达。
使用可达性分析算法后,内存中的存活对象都会被根队形集合直接或间接连接着,搜索所走过的路径称为引用链
如果目标对象没有任何引用链相连,则是不可达的。就意味着该对象已经死亡,可以标记为垃圾。
GC Roots包括以下几类元素
- 虚拟机栈中引用的对象 如各个线程被调用的方法中使用的从的参数,局部变量
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象 Java类的引用类型静态变量
- 方法区中常量引用的对象 字符串常量池的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用,基本数据类型对象的Class对象,一些常驻的异常对象,系统类加载器
- 反应java虚拟机内部请清的JMXBean JVMTI 中注册的回调、本地代码缓存等
一个指针指向了堆内存里面的对象,但是自己又不存放在堆内存里,那它就是一个Root
临时性的对象可以加入Roots。如分代收集和局部回收。
分析工作必须在一个能保障一致性的快照中进行。所以需要STW来保持一致性。
即使是号称几乎不会发生停顿的CMS收集器,枚举根节点时也是必须要停顿的。
对象的Finalization机制
允许开发人员提供对象被销毁之前的自定义处理逻辑
在垃圾回收器执行之前,就会调用对象的finalize()方法,在Object类中。可以重写该方法
一般用于在对象被回收时进行资源的释放,比如关闭文件,套接字和数据库连接。
不要主动去调用,应该交给垃圾回收机制去调用
- finalize()可能导致对象复活
- 方法的执行时间没有保障,完全由GC线程决定,如果不GC,该方法不会执行
- 一个糟糕的finalize()会严重影响GC的性能
由于finalize()的存在。虚拟机中的对象一般又三种状态
- 可触及的 根节点可以到达的对象
- 可复活的 对象的所有引用都被释放、但是可能在finalize()中复活
- 不可触及的 对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态,finalize()只会被调用一次
一个对象是否可回收需要经历两次标记过程:
- 没有GC Roots引用链进行一次标记
- 判断此对象是否有必要执行finalize()
使用MAT和JProfiler进行GC Roots溯源
Eclipse Memory Analyzer Tool dump出堆快照,在MAT中的分析
(3)垃圾清除:标记清除算法 mark-sweep
当堆中的内存空间耗尽,STW 然后进行两项工作 第一项是标记,第二项是清除
标记:Collector从引用节点开始遍历,标记所有被引用的对象。一般在对象的Header中记录为可达对象
清除:发现某个对象在其Head中没有标记为可达对象,则将其回收。
优点:
- 简单
缺点:
- 效率不算高。标记的时候递归遍历,清除的时候遍历整个堆
- 在进行GC的时候,需要停止整个应用程序
- 清理出来的空闲内存是不连续的,会产生内存的碎片,需要维护一个空闲列表
何为清除:
并不是真的置空,而是把需要清除的对象地址保存在空闲地址列表,下次有对象需要加载的时候,判断垃圾的位置空间是否够,如果空间够就覆盖原有的数据。
(4)垃圾清除:复制算法 copying
Lisp 语言
将活着的内存空间分为两块,每次使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
就是Survivor区的设计。
优点:
- 没有标记和清除过程,实现简单,运行高效
- 不会有内存的碎片,保证空间的连续性。使用指针碰撞为对象分配内存。
缺点:
- 需要两倍的空间
- 对于G1这种分拆成大量region的GC,复制而不是移动。意味着GC需要维护region之间对象引用关系(因为内存地址的变化,从引用到对象的关系需要进行大量的调整)。不管是内存占用还是时间开销都不小
另外,如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。但是如果系统中都是存活的对象,垃圾回收的效率就很低。
就像Eden区会有大量的生命周期很短的对象,就很适合使用复制算法来进行垃圾回收。
(5)垃圾清除:标记压缩算法 mark-compact
也称为标记整理算法。
复制算法在老年代中就不太适合,因为老年代中很多的对象的存活周期长,垃圾少。
标记清除算法也可以使用在老年代中,但是会导致内存碎片,而老年代通常需要大的连续空间,因此使用标记清除算法效率比较低。所以大部分现代的垃圾回收器使用的是标记压缩算法或其改进版本
在标记之后直接就把存活的对象压缩到内存的一段,按顺序排放,清理边界之外的所有空间。直接移动,而不是复制。
等同于在标记清除算法的基础上加了一次内存整理。
标记清除算法是非移动式的,维护一个空闲空间表。
而标记压缩算法是移动式的。
移动需要修改引用,维护引用关系,但是整理了空间,不需要维护空闲列表。
优点:
- 清理了内存碎片
- 只保存一个内存的起始地址就可以
- 不用复制算法的双倍空间
缺点:
- 效率不如复制和标记清除算法
- 移动对象还是需要调整引用的地址
- 移动过程中需要STW,STW时长会稍微长一些
三个算法的对比
(6)分代收集算法
没有一种完美的普适的算法
不同的对象的生命周期不同,不同的生命周期使用不同的垃圾回收算法
目前几乎所有的垃圾回收算法都是使用分代收集算法的。
年轻代使用复制算法,使用Survivor区域缓解了空间利用率的问题。
老年代使用标记清除和标记整理算法的混合实现
- mark阶段的开销与存活对象的数量成正比
- sweep阶段的开销与所管理区域的大小成正相关
- compact阶段的开销与存活对象的数据成正比
Hotspot中的CMS回收器(老年代)是基于Mark-Sweep实现的,对于对象的回收效率很高,而针对碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的ConcurentModeFailure),将采用SerialOld执行Full GC以达到老年代内存的的整理。
分代的思想被现有的虚拟机广泛的使用,几乎所有垃圾回收器都会区分新生代和老年代。
(7)增量收集算法
为了解决STW的问题,一次性把垃圾收进行处理,需要造成系统长时间的停顿。那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。
基础仍然是传统的标记-清除和赋值算法,增量收集算法通过对线程间冲突的妥善出里。允许垃圾收集线程以分阶段的方式完成标记、清理、复制工作
缺点:使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间,但是因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
低延迟和吞吐量是相互矛盾的两个标准。更关注低延迟
(8)分区算法
堆空间越大,GC的时间越长,为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿
分区算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间分成连续的小区间region.
每个小区间独立使用,独立回收,可以控制一次回收的区间的个数。可以控制目标停顿时间。
实际上垃圾收集器都是复合的算法,并行并发兼备
3. 垃圾回收相关概念
(1)System.gc()
会显式触发Full GC,但其调用附带一个免责声明,无法保证对垃圾收集器的调用,不保证什么时候执行
Runtime.getRuntime.gc() 底层调用的
(2)内存溢出和内存泄漏
内存溢出会导致程序崩溃。没有内存空闲,并且垃圾收集器也无法提供更多的内存。由于GC的发展,其实不太容易出现OOM错误。
原因:
- 堆内存设置的不够
- 创建了大量的大对象,并且长时间不能被垃圾收集器手机,存在被引用
内存泄漏 memory leak
严格的说,只有对象不会再被程序用到了,但是GC不能回收他们的情况,才称为内存泄漏
但实际情况中,由于一些不好的实践,导致对象的生命周期变得很长甚至导致OOM,也可以称为宽泛意义的内存溢出。
尽管内存泄漏不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食。直至耗尽所有的内存,最终可能会导致OOM
虚拟内存。取决于磁盘交换区的大小。
Java没有使用引用计数算法,所以其实不会出现循环引用的情况。
举例:
单例模式:单例的生命周期和应用程序一样长,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
一些需要关闭的close()的资源为关闭。如数据库,网络连接,io连接必须要手动关闭,否则会内存泄漏
(3)STW
Stop the world 应用程序的线程会被停止。
需要确保一致性的快照中进行GC Roots的分析。如果分析过程中,对象的引用关系还在不断的变化,则分析结果的准确性无法保证。
被STW中断的应用程序线程会导致卡顿。
所有的垃圾回收器都会有STW,不能完全避免。只能提高回收的效率,尽可能缩短暂停的时间。
STW是JVM在后台自动发起和完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
开发中的不要使用System.gc() 会进行Full GC导致STW的发生。
(4)垃圾回收的并行与并发
并发concurrent。在一个时间段中,有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在一个处理器上运行的。并发并不是真正意义上的同时进行,只是CPU把一个时间段划分成几个时间片,然后在几个时间区间来回切换,由于CPU的运行速度非常快。只要时间间隔处理得到,就会感觉是多个应用程序同时进行。
并行:多核cpu,各个核执行各自的进程,不存在抢占资源。适合科学计算、后台处理弱交互操作(GPU?)
并发是在一个时间段同时发生
并行是同一个时间点同时发生
并发的任务互相抢占资源
并行的多个任务之间是不互相抢占资源的
只有在多核CPU中才有并行。
垃圾回收中并发与并行
(5)安全点与安全区域
安全点,程序并不是在任何时间点都可以停顿下来开始GC的,这些位置称为安全点
Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题,大部分指令的执行时间都非常短暂,通常会根据是否具有让程序长时间执行的特征,选择一些执行时间较长的指令作为safe point 如方法调用、循环跳转、异常跳出
在GC的时候,如果检查线程是否到达安全点
抢先式中断 (目前没有虚拟机采用)
先中断所有线程,如果发现有线程不在安全点,就恢复线程,让线程跑到安全点
主动式中断
设置一个中断标志,各个线程运行到Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
安全区域,如果有些线程sleep或者blocked了。安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域的任何位置开始GC都是安全的,可以视作是安全点的扩展。
当线程运行到SafeRegion的时候,首先标识已经进入了SafeRegion。如果这段时间进行GC,JVM会自动忽略标识为safe region状态的线程。
当线程即将离开Safe region的时候,会检查JVM是否已经完成GC 如果完成了,就继续执行线程,否则就必须等到GC完成,可以安全离开的信号为止。
(6)强引用、软引用、弱引用、虚引用、终接器引用
当内存空间够的时候,则能够保留在内存中,如果内存空间在进行GC后还是很紧张,就可以抛弃这些对象。
偏门且高频的面试题:强引用、软引用、弱引用、虚引用区别及使用场景
Strong Soft Weak Phontom Refrence 4种引用强度依次逐渐减弱。
强引用:new 对象,如果强引用还存在,就不会被GC掉 不回收。
软引用:在系统将要发生内存溢出之前,将会把这些对象列入回收范围进行第二次回收。如果这次回收之后还是没有足够的内存,则抛出OOM。内存不足就回收。
弱引用:被弱引用关联的对象只能生成到下一次垃圾收集前,当垃圾收集器工作时,无论内存空间是否足够,弱引用都会被回收掉。发现就回收。
虚引用:一个对象是否有虚引用的存在,完全不会对其生存的时间构成影响,也无法通过虚引用来获得一个对象的实例,为一个对象设置虚引用关联唯一的目的就是能在这个对象被收集器收回时收到一个通知。跟踪对象回收。
强引用都是可触及的,不会被回收。强引用是造成内存泄漏的主要原因
软可触及、弱可触及、虚可触及
软引用在第一次回收之后还是空间不足,就会把软引用列入第二次回收的范围。用来实现内存敏感的缓存,比如告诉缓存就有用到软引用。如Mybatis中使用到了。当内存不足的时候就清除缓存。尽量会让软引用存活的时间长一些,迫不得已才回收。
Object obj = new Object();
SoftReference<Object> sr = new SoftReference<Object>(obj)
obj = null 销毁强引用。
弱引用来存储可有可无的缓存数据。当弱引用对象被回收时,会被进入指定的引用队列,通过这个队列可以跟踪对象的回收情况。弱引用更简单、发现就GC,而软引用还需要使用算法判断。
WeakHashMap
虚引用:不能单独使用,也无法通过虚引用获取被引用的对象,当试图通过虚引用的get()方法区取得对象时,总是Null
PhantomRefrence 。对象回收跟踪。
总结器引用:FinalRefrence 实现对象的finalize() 无需手动编码,在GC的时候,终接器入队,由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize(),第二次GC时才能回收被引用对象。
4. 垃圾回收器
GC分类与性能指标
不同的垃圾回收器概述
Serial 回收器 串行回收
ParNew回收器 并行回收
Parallel回收器 吞吐量优先
CMS 回收器 低延迟
G1回收器 区域化分代式
垃圾回收器总结
GC日志分析
垃圾回收器新发展
(1)分类与指标
Java不同版本的新特性:
- 语法 Lambda 表达式
- API层面 Stream API 时间日期
- 底层优化 JVM的优化、GC的变化
分类:
-
按线程分:串行和并行(多个GC线程)
-
工作模式:并发式(和应用程序线程同时进行)和独占式
-
按碎片处理方式:压缩式、非压缩式
-
按工作的内存区间:年轻代GC 老年代GC
指标:
- 吞吐量 运行用户代码的时间占总运行时间的比例 (总运行时间 = 程序的运行时间+GC的时间)
- 垃圾收集开销:吞吐量的补数 1-吞吐量
- 暂停时间 执行垃圾收集时,程序的工作线程被暂停的时间 (这个指标是日益凸显的,因为内存便宜了)
- 收集频率 相对于应用程式的执行,收集操作发生的频率。收集频率高就会暂停时间短,但是吞吐量低,收集频率低就会暂停时间长,吞吐量大。
- 内存占用 Java堆区所占的内存大小
- 快速 一个对象从诞生到被回收所经历的时间
吞吐量 throughput
低延迟和高吞吐量是不可兼得的。
现在的标准:在最大吞吐量优先的情况下,降低停顿时间(可控的时间范围)
(2)不同垃圾回收器概述
7款经典的垃圾收集器
串行回收器:Serial、 Serial Old
并行回收器:ParNew 、Paraller Scavnege、Parallel Old
并发回收器:CMS G1
未来的方向
ZGC、 Shenandoah
不同的垃圾回收器适用的场景不同。
JDK8 默认使用ParallelGC(在新生代)
JDK9使用G1 GC
(3)Serial GC :串行回收
JDK1.3之前回收新生代的唯一选择,Hotspot Client模式下的默认新生代垃圾收集器
Serial 收集器 采用复制算法,串行回收和STW机制的方式执行内存回收,
Serial Old 也采用了串行回收和STW机制,只不过内存回收算法使用的标记-压缩算法,是运行在Client模式下默认的老年代垃圾回收器
Serial Old与新生代的Parallel Scavenge配合使用,作为老年代CMS收集器的后备垃圾收集方案。
优势:简单高效(相较于其他收集器的单线程),对于限定单个CPU的环境,没有线程交互的开销
对运行在Client模式下的JVM是不错的选择
在用户的桌面应用场景,可用内存不大(一两百MB),可以在较短时间完成垃圾收集(100多ms),只要不频繁发生,使用串行回收是可以接受的。
-XX:+UseSerialGC 新生代和老年代都使用Serial
基本不使用,只在单核的CPU场景使用,对于交互较强的应用,不会采用这种GC
(4)ParNew GC:并行回收
是Serial GC的多线程版本,在新生代中收集 Parallel New
采用了并行回收,其他和Serial GC几乎没有区别,共享了很多底层代码。同样采用了复制算法、STW机制
ParNew是很多JVM运行在Server模式下默认的新生代GC
配合老年代的SerialOld GC(JDK9 后不支持配合使用) 和CMS GC(JDK14后移除了CSM GC)
被Deprecated了,较少使用了。
最新的JDK14只有三种组合
Serial GC + Serial Old 应付低性能的场景
Parallel Scanvenge + Parallel Old 应付性能高的场景 多个GC线程并行
G1 新生代和老年代都使用G1
多核CPU使用ParNew效率更高,可以充分使用硬件资源,可以提升吞吐量
如果是单核的还是Serial效率高一点
-XX:+UseParNewGC
-XX:+ParallelGCThreads 设置GC可以使用的线程
(5)Parallel Scavenge GC: throughput first
同样使用了复制算法、并行回收、STW机制
Parallel Scavenge为了达到一个可控制的吞吐量。和ParNew使用的底层GC框架不同。
自适应调节策略,根据性能监控的调整最优的内存回收策略。
高吞吐量:适合在后台运算的而不需要太多的交互的任务,如执行批处理,订单处理、工资支付、科学计算等应用
JDK1.6之后提供了Parallel Old,用来替代Serial Old在老年代的作用
在Server模式下能发挥良好的性能,是JDK8默认使用的组合,JDK9就开始使用G1了。
-XX:+UseParallelGC
-XX:+UseParallelOldGC
二者会互相激活。使用其中一个,会默认的使用另一个。
-XX:+ParallelGCThreads 设置GC可以使用的线程。最好和CPU的核相同(小于8的时候)。不会有线程切换的效率损失。
大于8的时候
-XX:MaxGCPauseMillis 停顿时间。低延迟。调整堆的大小或者其他一些参数。该参数谨慎使用
-XX:GCTimeRatio 垃圾收集时间占总时间的比例。吞吐量,和MaxGCPauseMillis参数矛盾。
-XX:UseAdaptiveSizePolicy自适应策略
(6)CMS GC: low latency
Concurrent Mark Sweep 第一款真正意义上的并发收集器,第一次实现类垃圾收集线程与用户线程同时工作。
停顿时间短,适合和用户交互的程序,良好的响应速度,提升用户体验
Java应用集中在互联网网站或者B/S系统的服务端上,重视响应速度。
使用标记清除算法,只能配合Serial GC 和ParNew GC
在G1出现之前,CMS使用广泛。
仍然需要STW,只是时间更短。
初始标记 initial mark:STW 标记出GC Roots直接关联到的对象,时间非常快
并发标记 concurrent mark: 从直接关联到的对象开始遍历这个对象图,耗时较长,但是不需要停止用户线程,并发运行
重新标记 remark 对并发标记的修正,因为用户线程执行会出现变化,STW,但是时间短,比初始标记时间长
并发清理 Concurrent sweep 清理删除掉标记阶段判断已经开死亡的对象,释放内存空间,不需要移动对象,维护一个空闲列表。
重置线程
当堆内存使用率达到一定阈值的时候就开始CMS,因为并行的原因。如果CMS期间预留的内存无法满足需要,就会出现Concurrent Mode Failure 启动Serial Old 预案。
会产生内存碎片。只能使用空闲列表的算法分配对象的内存空间
为什么不适用标记压缩算法?因为要保证用户线程的正常进行,不能修改内存的地址。
弊端:
- 会产生内存碎片。会提前触发Full GC
- CMS收集器对CPU资源非常敏感,不会造成用户停顿,但吞吐量会下降
- 无法处理浮动垃圾。在并发标记阶段产生的新的垃圾,CMS无法对这些对象进行标记,不会被回收。(重新标记只是再次确认上一个阶段怀疑时垃圾的)
CMS默认搭配的是ParNew
-XX:+UseConcMarkSweepGC
-XX:+CMSInitiatingOccupanyFraction 设置内存使用率的阈值,一旦达到阈值就开始回收。JDK6以后是92% 用户线程增长快的时候要降低阈值
-XX:+UseCMSCompacyAtFullConllection 在执行完Full GC 进行内存空间的整理
-XX:CMSFullGCsBeforeCompaction 每多少次Full GC整理一次
-XX:ParallelCMSThreads
最小化内存使用和并行开销 Serial GC
最大化应用程序的吞吐量 Parallel GC
最小化GC的中断和停顿时间 CMS(9 deprecated 、14删除)
(7)G1 GC 区域化分代式
JDK9开始默认使用
原因:业务越来越庞大复杂,用户越来越多。
为了适应现在不断扩大的内存和不断增加的处理器的数量,进一步降低pause time,同时兼顾良好的吞吐量
目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担起全功能收集器的重任与期望。
把堆内存分割成不相关的Region(物理上不连续),使用不同的Regison来表示Eden S0 S1 老年代等
有计划的避免在整个Java堆中进行安全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
面向服务端,针对多核CPU和大容量内存的机器。
-XX:+ UseG1GC
JDK9以后取代Parellel组合和CMS
并行:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,此时用户线程STW
并发:G1拥有与应用程序交替执行的能力,部分工作可以与应用程序同时执行,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集。G1仍然属于分代型垃圾回收器,他会区分年轻代和老年代,但不需要各个区是连续的。不再需要固定大小和固定数量。
同时兼顾年轻代和老年代
空间整合:Region之间是复制算法,但整体上世纪可看做标记压缩算法。可以避免内存碎片,程序的连续运行,当堆大的时候优势明显
可预测的停顿时间模型 soft real-time(软实时):可以明确指定在一个长度的时间片段内进行垃圾收集的时间,G1在Region进行区域回收。缩小了回收的范围。维护一个优先列表,根据回收价值进行区域回收。保证了G1收集器在有限的时间内获取尽可能高的收集效率
相比于CMS GC ,G1未必能CMS的最好情况下的延时停顿,但是最差情况要好很多
G1无论是为了垃圾收集产生的内存占用footprint还是程序运行时的额外执行负载Overload都要比CMS高
开启垃圾回收器
设置堆的最大内存
设置最大的停顿时间,
G1提供了3种模式,Young GC Mixed GC Full GC
G1回收器的适用场景。
服务端、大内存、多处理器。 6GB或者更大的堆
每次只清理一部分而不是全部的Region的增量式清理
可以使用应用线程帮组加速垃圾回收过程
Region分区。
将整个Java堆划分成2048个大小相同的独立Region,每个Region块大小根据堆空间的实际大小而定,整体控制在1-32MB之间。通过-XX:G1HeapReguinSize设定。所有的Region大小相同,且在JVM生命周期内才不会改变
新生代和老年代不再是物理隔离,而是Region的集合,通过Region的动态分配实现逻辑上的连续。
GC后的Region会被记录在空闲列表中,下一次可以分配给其他功能区。
在每一个Region中
- bump-the-pointer 指针碰撞
- TLAB
三个环节:
- Young GC
- 老年代并发标记过程
- Mixed GC
- 如果需要,单线程、独占式、高强度的Full GC 还是继续存在。失败保护机制,强力回收。
记忆集 Remembered Set
互联网项目都是使用G1
(8)GC日志分析
ZGC
Shenandauh GC