深入理解Java虚拟机
2021-01-19 16:52:36 1835 举报
AI智能生成
Java虚拟机(JVM)是Java技术的核心,它提供了一个运行Java字节码的环境。JVM的主要任务是加载、验证和执行字节码,同时也负责内存管理、垃圾回收和安全管理等。JVM的跨平台特性使得Java程序可以在任何支持JVM的设备上运行,无需重新编译。此外,JVM还提供了丰富的工具和API,方便开发者进行性能调优和故障排查。深入理解JVM的原理和机制,对于编写高效、稳定的Java程序至关重要。
作者其他创作
大纲/内容
JVM与Java体系结构
跨平台的语言
得益于JVM虚拟机的存在,Java代码可以只编写一次,编译生成字节码文件,然后在各个操作系统上运行
但是由于容器(docker + k8s)的兴起,这个优势已经没有那么明显了
Java程序、JVM、操作系统之间的关系
跨语言的平台
得益于字节码文件的存在,只要将其他语言编写的源代码通过编译器生成对应的字节码,那么就可以在JVM上运行,间接地实现了跨语言的功能
JVM语言、字节码、JVM虚拟机之间的关系
Java虚拟机整体架构祥图
类加载器子系统
运行时数据区
字节码执行引擎
本地方法接口
本地方法库
图示
Java代码执行过程详图
Java源代码 -> 编译器生成字节码 -> 类加载子系统(将.class文件加载到内存中)-> 后端编译器翻译成本地机器码执行机器码或者解释器将字节码指令翻译成本地机器码,运行程序
汇编语言、机器语言、高级语言关系
高级语言 -> 汇编语言 -> 机器指令 -> 二进制指令
JVM的架构模型
基于栈式
优点
设计和实现简单,适用于资源受限的系统
避开了寄存器的分配难题:使用零地址指令方式分配
指令流中大部分都是零地址指令,执行过程依赖操作栈,指令集更小,编译器容易实现
8位字节码,所以说指令集更小,但是完成一项操作花费的指令相对多
不需要硬件支持,可移植性更好,更好实现跨平台
缺点
性能下降,实现同样的功能需要更多的指令,毕竟还要入栈出栈等操作
基于寄存器式
优点
性能优秀,执行更高效
花费更少的指令去完成一项操作
缺点
指令集架构完全依赖硬件,可移植性差
典型应用是X86的二进制指令集,比如传统的PC以及安卓的Davlik虚拟机
16位字节码
大部分情况下,指令集往往以一地址指令,二地址指令和三地址指令为主
javap命令查看字节码
-v输出附加信息
-l输出行号和本地变量表
-p显示所有类和成员
-c对代码进行反汇编
JVM的生命周期
虚拟机的启动
通过引导类加载器(Bootstrap Class Loader)创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的
JVM启动的入口就是main方法
一个JVM虚拟机就是一个进程
虚拟机的执行
执行一个所谓的Java程序的时候,真正执行的是一个叫Java虚拟机的进程
每个线程执行子类重写的run方法或者是Runable接口实现类中run方法
虚拟机的退出
程序正常执行结束
执行过程遇到异常或错误而异常终止
操作系统错误导致Java虚拟机进程终止
Runtime类或System类的exit()方法、Runtime类的halt()方法,并且Java安全管理器允许这次exit或halt操作
halt停止、停下、阻止
exit方法源码:static native void halt0(int status)
JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机退出的情况
没有一个前台线程了,也就是所有的线程都是后台守护线程,JVM会退出
JVM发展历程
sun Classic VM
世界第一款商用Java虚拟机
JDK1.4时被淘汰
只提供了解释器
如果使用JIT编译器,就需要外挂,但是JIT和解释器不能配合工作
Exact VM
为了解决上一个虚拟机问题,JDK1.2时,sun提供了此虚拟机
Exact Memory Management:准确式内存管理
虚拟机知道内存中某个位置的数据是什么类型
具有现代高性能虚拟机的雏形
热点探测
编译器与解释器混合工作模式
只在Solaris平台短暂使用,其他平台还是Classic VM
英雄气短,被HotSpot虚拟机替换
HotSpot虚拟机
最初由Longview Technologies的小公司设计,1997年被sun公司收购,2009年sun公司被甲骨文收购
JDK1.3时,HotSpot VM成为默认虚拟机
绝对市场地位,称霸武林
JDK6、7、8等均默认
HotSpot最大的卖点就是他的热点代码探测技术
通过方法调用计数器找到最具编译价值代码,触发即时编译或栈上替换
通过编译器与解释器协同工作,在优化响应时间和最佳执行性能中取得平衡
解释器响应快但运行慢,编译器运行快但响应慢
当程序启动时,解释器逐行解释执行字节码。随着程序的运行,通过计数器找出程序高频执行代码,直接翻译成本地机器码
再次执行代码时,直接执行本地机器码,提高程序运行的速度
JRockit虚拟机
BEA公司
专注服务器端应用
不太关注程序启动速度,引起JRockit内部不包括解析器实现,全部代码靠即时编译器编译后执行
世界上最快的JVM
全面的Java运行时解决方案组合
JRockit Real Time提供毫秒或微秒级的JVM响应时间,适合财务、军事指挥,电信网络的需要
MissionControl服务套件,极低的开销,来监控、管理和分析生成环境中的应用程序的工具
2008年BEA被oracle收购
JDK8中,在HotSpot的基础上,移植JRockit的优秀特性
IBM J9
全称:IBM Technology for java Virtual Machine 简称IT4J,内部代号J9
市场定位与HotSpot接近,服务器端、桌面应用,嵌入式等多用途VM
广泛应用于IBM的各种Java产品
IBM产品结合使用性能最好
有影响力的三大商用虚拟机之一
2017开源,OPEN J9
KVM和CDC/CLDC HotSpot
JavaME产品线产品
智能控制器、传感器、老人手机等
Azul VM
与特定硬件平台绑定、软硬件配合的专有虚拟机
运行于Azul Systems公司的专有硬件Vega系统上的虚拟机
每个实例可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器,专有硬件优化的线程调度等优秀特性
2010年,发布自己的Zing JVM,可以在通用X86平台上提供接近于vega系统的特性
Liquid VM
BEA公司开发的,运行在自家Hypervisor系统上
不需要操作系统支持,本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等
随着JRockit虚拟机终止开发,Liquid VM项目也停止了
Apache Harmony
JDK1.5、1.6兼容
IBM和Intel联合开发的开源JVM,2011年退役
Java类库代码吸纳进了Android SDK
Microsoft JVM
只能在Windows平台运行,xp系统中不用了
TaobaoJVM
基于OpenJDK开发了自己的定制版本AlibabaJDK
深度定制且开源的高性能服务器版JAVA虚拟机
GCIH:GC invisible heap,将生命周期较长的Java对象从heap中已到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率
GCIH中的对象还能够在多个JAVA虚拟机进程中实现共享
使用crc32指令实现JVM intrinsic降低JNI的调用开销
针对大数据场景的ZenGC
在阿里产品上性能高,硬件严重依赖intel的CPU,损失了兼容性,但是提高了性能
淘宝、天猫上线,把oracle官方JVM版本全部替换
Dalvik VM
谷歌开发,应用于Android系统,安卓2.2提供了JIT,发展迅猛
只能称作虚拟机,不能称作Java虚拟机,没有遵循Java虚拟机规范
不能直接执行Java的Class文件
基于寄存器架构,不是JVM的栈架构
执行的是编译后的dex文件,执行效率比较高
安卓5.0使用支持提前编译AOT的ART VM替换Dalvik VM
Grall VM
2018年4月,Oracle labs公开了GraalVM
跨语言全栈虚拟机,可以作为任何语言的运行平台使用
运行时数据区(重点)(Runtime Data Area)
运行时数据区概述
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域
虚拟机栈、堆内存、程序计数器、方法区、本地方法栈
虚拟机栈、本地方法栈、程序计数器为线程私有
方法区、堆内存为线程共享
程序计数器(Program Counter Register)
用于存储下一条即将要执行的字节码指令地址,由执行引擎读取下一条指令
JVM中运行速度最快的一块内存区域
JVM的PC寄存器是对物理PC寄存器的一种抽象模拟
运行时数据区中唯一不会出现OOM的区域没有垃圾回收
程序计数器为线程私有,每个线程都有自己的程序计数器。每个线程有一个独立的程序计数器,线程之间互不影响
记录对应线程所执行的字节码的行号指示器
为了保证线程切换后能恢复到正确的执行位置
如果线程执行的Java方法,则计数器记录正在执行的虚拟机字节码的指令的地址,如果正在执行的本地方法(native修饰),这个计数器值则应为空(undefined)
虚拟机栈(重点)
栈管运行,堆管存储
栈是运行时的单位,而堆是存储的单位
栈解决程序如何执行,如何处理数据。堆解决的是数据存储问题,即数据怎么放,放在哪里
基本内容
Java虚拟机栈,早期也叫Java栈,每个线程创建时都会创建一个虚拟机栈,内部保存一个个栈帧,对应着一次次的Java方法调用
生命周期和线程的一致,线程被销毁,虚拟机栈也就随之销毁
主管Java程序的运行(主要是方法的执行),保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分中间结果,并参与方法的调用和返回
局部变量vs成员变量
基本数据类型vs引用类型变量(类、数组、接口、枚举、注解)
特点
快速有效的存储方式,访问速度仅次于程序计数器
JVM直接对Java栈的操作只有两个
方法被调用,伴随着进栈(入栈、压栈)
执行结束的出栈(弹栈)
栈不存在垃圾回收,但是存在OOM和StackOverflowError
Java栈大小是动态或者固定不变的。如果是动态扩展,无法申请到足够内存OOM,如果是固定,线程请求的栈容量超过固定值,则StackOverflowError
使用-Xss的JVM参数,设置线程的最大栈空间
参数解释
栈的存储单位(栈帧)
每个线程都有自己的栈,栈中的数据以栈帧格式存储
线程上正在执行的每个方法都各自对应一个栈帧
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各个数据信息
一条活动的线程中,一个时间点上,只会有一个活动的栈帧。只有当前正在执行的方法的栈顶栈帧是有效的,这个称为当前栈帧,对应方法是当前方法,对应类是当前类
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
先进后出,后进先出,如果方法中调用了其他方法,对应的新的栈帧会被创建出来,放在顶端,成为新的当前帧
栈运行原理
不同线程中包含的栈帧不允许存在相互引用
当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的栈帧。
Java方法有两种返回方式
一种是正常的函数返回,使用return指令
另外一种是抛出异常,不管哪种方式,都会导致栈帧被弹出
栈的内部结构(局部变量表、操作数栈、方法返回地址、动态链接、附加信息)
局部变量表(Local Variable Table)
定义为一个字节数组,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型、对象引用以及return address类型
局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题
局部变量表容量大小是在编译期确定下来的
局部变量表存放编译期可知的各种基本数据类型(8种)、引用类型(reference)、return address类型
boolean、byte、short、char、int、float、引用数据类型都是4个字节,32位。long、double是8个字节,64位
最基本的存储单元是Slot(槽位)
32位占用一个Slot,64位类型(long和double)占用两个slot
为什么设计成32和64呢?因为操作系统一般就是32位和64位
局部变量表中的变量只有在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程
方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
关于Slot的理解
JVM虚拟机会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定位置的局部变量值
如果当前栈帧是由构造方法或者实例方法创建的,那么该对象的引用this变量,会存放在index为0的Slot处,其余的局部变量按照顺序继续排列,如果方法有形参,会将形参放置到局部变量表对应的槽位中
槽位图解
this引用图解
局部变量表中的槽位是可以复用的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的
典型例子就是方法内部存在着代码块,复用之前的槽位
静态变量与局部变量对比及小结
变量的分类
按照数据类型分
基本数据类型
引用数据类型
按照声明的位置
成员变量,在使用前经历过初始化过程
类变量
链接的准备阶段给类变量进行零值初始化,如果是final修饰且值为字面量,准备阶段进行显式赋值(不需要执行代码),否则初始化阶段显示赋值,即调用类的<clinit>()方法进行赋值
实例变量
随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值。会在对象的<init>()方法中进行显式赋值
局部变量
在使用前,必须进显式赋值,否则编译不通过,直接报错
补充
在栈帧中,与性能调优关系最密切的部分,就是局部变量表。在方法执行的过程中,虚拟机使用局部变量表完成方法参数的传递
局部变量表中的引用变量也是重要的垃圾回收根节点(GC Roots),只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
为什么需要操作数栈呢?因为Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈、出栈
流程示意图
如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好
栈中32位类型占用一个栈单位深度,64位类型占用两个栈单位深度
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
代码举例,演示字节码执行过程中如何使用局部变量表和操作数栈
栈顶缓存技术
由于操作数是存储在内存中,频繁地进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,依此降低对内存的读写次数,提升执行引擎的执行效率
动态链接
图例解释
指向常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中,该帧所属方法的引用。目的是为了支持当前方法的代码能够实现动态链接,比如invokedynamic指令
在.java源文件被编译成.class字节码文件中时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
常量池、运行时常量池
常量池在字节码文件中
运行时常量池,在运行时的方法区中
方法返回地址
概念:存放调用该方法的pc寄存器的值
例如A方法调用B方法,B方法结束后,需要返回A方法继续执行A方法的代码,那么如何知道呢下一步该执行A方法的哪个字节码指令呢?用B方法的方法返回地址来记录A方法的PC寄存器的值,即A方法的指令的下一条指令的地址
本质上方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表、操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去
方法的结束
正常执行完成(有返回值和无返回值)
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
返回指令包括
ireturn:返回值是boolean、byte、char、short和int类型时使用
lreturn:返回值是long
dreturn:返回值是double
areturn:返回值是引用数据类型
还有一个return指供声明为void的方法、实例初始化方法、类和接口的初始化方法使用
出现未处理异常,非正常退出
异常退出的,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
一些附加信息
允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。不确定有,可选情况
方法的调用
静态链接
当一个字节码文件被加载进JVM内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接
动态链接
如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接,例如子类重写父类方法
方法的绑定
绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程。仅仅发生一次
早期绑定
被调用的目标方法如果再编译期可知,且运行期保持不变
晚期绑定
被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
Java中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++中则使用关键字virtual来显式定义
如果在Java程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字final来标记这个方法,禁止子类重写该方法
虚方法和非虚方法
非虚方法
如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
其他方法称为虚方法
方法调用指令
普通调用指令
invokestatic
调用静态方法,解析阶段确定唯一方法版本
invokespecial
调用<init>()方法,私有及父类方法,解析阶段确定唯一方法版本
invokevirtual
调用所有虚方法
invokeinterface
调用接口方法
其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法
动态调用指令JDK1.7新增
invokedynamic
动态解析出需要调用的方法,然后执行
直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
invokedynamic指令截图
静态语言和动态语言
区别在于对类型的检查是编译器还是运行期,满足编译期就是静态类型语言,反之就是动态类型语言
Java是静态类型语言,动态调用指令增加了动态语言的特性
方法重写的本质
找到操作数栈顶的第一个元素所执行的对象的实际类型,记做C
如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回java.lang.IllegalAccessError异常
否则,按照继承关系从下往上依次对C的各个父类进行上一步的搜索和验证过程
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
虚方法表
面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM采用在类的方法区建立一个虚方法表,使用索引表来代替查找
每个类都有一个虚方法表,表中存放着各个方法的实际入口
虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕
虚方法表
本地方法接口
本地方法接口位置
什么是本地方法
简单讲,就是一个Java调用非Java代码的接口
为什么使用native method
与Java环境外交互
与操作系统底层或硬件交换信息时的情况
启动一个线程
本地方法栈
本地方法栈位置
Java虚拟机栈管理Java方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈,也是线程私有的
允许被实现成固定或者是可动态扩展的内存大小
内存溢出情况和Java虚拟机栈相同
使用C语言实现
具体做法是Native Method Stack中登记native方法,在执行引擎执行时加载到本地方法库
当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限
并不是所有的JVM都支持本地方法,因为Java虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
HotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一
堆内存(重点)
堆的核心概述
堆内存位置(线程共享)
一个JVM实例只存在一个堆内存,堆内存也是Java内存管理的核心区域
Java堆区在JVM启动的时候即被创建。堆内存的大小是可调节的,可以通过启动参数,或者自适应调节
Java虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
所有的线程共享Java堆内存,在这里还可以划分线程私有的缓冲区(TLAB)
"几乎"所有的对象实例都在这里分配内存(HostSpot并没有实现栈上分配)
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,引用指向对象或者数组在堆中的内存地址
方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
堆内存是GC执行垃圾回收的重点区域
堆空间细分
Java7及之前
内存逻辑上分为
新生代
Eden区
Survivor区
from
to
谁空谁是to
老年代
永久代
Java8及之后
内存逻辑上分为
新生代
Eden区
Survivor区
from
to
谁空谁是to
老年代
元空间
约定
新生区==新生代==年轻代
养老区==老年区==老年代
永久区==永久代
jvisualvm工具
安装插件后可查看
安装图解
-XX:+PrintGCDetails,打印垃圾回收的日志
虚拟机栈、堆内存、方法区关系
设置堆内存的大小与OOM
-Xms :堆内存的起始内存(memory start),等价于-XX:InitialHeapSzie
-Xmx:堆内存最大占用量(memory max),等价于-XX:MaxHeapSize
超过最大内存将抛出OOM
通常将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾会后清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能(防止内存抖动,降低GC频率,提高程序的吞吐量)
默认情况下
初始内存大小
物理电脑内存大小 / 64
最大内存大小
物理电脑内存 / 4
jps命令
查看当前操作系统中运行的Java进程,主要是获得进程id
jstat命令
查看JVM在gc时的统计信息
jstat -gc <pid>
年轻代与老年代
Java对象划分为两类:生命周期短的和长的
堆内存 = 新生代(YoungGen)(Eden + Survior 0)+ 老年代(OldGen)
新生代与老年代空间默认比例1 : 2
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1 / 3(ratio:比率比例的意思)
jinfo -flag NewRatio <pid>,查看参数设定值
在HotSpot中,Eden区大小和另外两个Survivor大小缺省所占的比例是:8 : 1 : 1
-XX:SurvivorRatio调整这个空间比例
Eden与Survivor区的比例
实际是6 : 1 : 1,因为有自适应机制
-XX:-UseAdaptiveSizePolicy:-表示关闭自适应,实际没有用。直接用Ratio分配即可
几乎所有的Java对象都是在Eden区被new出来的。Eden区放不了的大对象,直接进入老年代了
IBM研究表明,新生代99%的对象都是朝生夕死
-Xmn:设置新生代最大内存大小,如果同时设置了新生代比例与此参数冲突,则以此参数为准
图解对象分配一般过程
图示
1、new出来的对象先放在Eden区,此区有大小限制
2、当创建新对象,Eden空间填满时,会触发Minor GC,将Eden区不再被其他对象引用的对象进行销毁。再加载新的对象放到Eden区
3、将Eden中剩余的对象移到幸存者0区
4、再次触发垃圾回收,此时上次幸存者下来的,放在幸存者0区的,如果没有回收,就会放到幸存者1区
5、再次经历垃圾回收,又会将幸存者重新放回幸存者0区,依次类推
6、GC分代年龄默认是15次,超过15次,则会将幸存者区幸存下来的转去老年区
-XX:MaxTenuringThreshold=N进行设置晋升年龄
总结:
针对幸存者s0、s1区的总结:复制之后有交换,谁空谁是to
频繁在新生代收集,很少在老年代收集,几乎不在永久代|元空间收集
对象分配一般过程
流程图
触发YGC,幸存者区就会附带进行回收,不会主动进行回收
超大对象Eden放不下,就要看Old区大小是否可以放下
Old区也放不下,需要FullGC(MajorGC),这两GC概念还是有区别的
常用调优工具
JDK命令行
Eclipse:Memory Analyzer Tool
Jconsole
VisualVM
Jprofiler
Java Flight Recorder
MinorGC、MajorGC、FullGC
针对HotSpotVM的实现
GC按照内存回收区域分为
部分收集(partial)
新生代收集
MinorGC (YoungGC)
老年代收集
MajorGC(OldGC)
目前只有CMS GC会单独收集老年代的行为
很多时候MajorGC与FullGC混淆使用,具体分辨是老年代回收还是整堆回收
混合收集(MxiGC)
收集整个新生代以及部分老年代的垃圾收集
目前只有G1 GC会有这种行为(G1使用region划分内存,同时回收新生代和部分老年代)
整堆收集(FullGC)
收集整个Java堆和方法区的垃圾
MinorGC的触发条件
当年轻代空间不足时,就会触发MinorGC,这里的年轻代指的是Eden区满,Survivor满不会触发GC。每次MinorGC会清理年轻代的内存,顺带着清理幸存者区
因为Java对象大多朝生夕灭,所以MinorGC非常频繁
Minor翻译,较小的,未成年的
MinorGC会引发STW(Stop The World)
MajorGC解释说明
出现了MajorGC,经常会伴随至少一次MinorGC
非绝对,在Parallel Scavenge收集器的收集策略里就直接进行MajorGC的策略选择过程
也就是老年代空间不足,会先尝试触发MinorGC,如果之后空间还不足,则触发MajorGC
MajorGC的速度比MinorGC慢10倍以上,STW的时间更长
如果MajorGC后,内存还不足,就报OOM了。程序发生OOM之前,一定会执行一次FullGC
老年代GC(MajorGC|FullGC)触发条件
1、调用System.gc()时,系统建议执行FullGC,但是不必然执行
2、老年代空间不足
3、方法区空间不足
4、通过MinorGC后进入老年代的平均大小,大于老年代的可用内存
5、由Eden区,Survivor 0区向Survivor 1区复制时,对象的大小大于ToSpace可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
FullGC是开发或调优中尽量要避免的,这样暂停时间会短一些
堆空间分代思想
其实不分代也可以,分代的理由是优化GC性能
如果将所有生命周期对象全部放在一起,那判断对象是否存活就需要遍历整个堆内存,效率就大大降低
分代之后,大多数情况下只需要遍历新生代,也就是对象生命周期短的内存区域,就可以回收大量内存
内存分配策略
长期存活的对象分配到老年代
如果对象在Eden分配并经过第一次MinorGC后仍然存活,并且能被Survivor区容纳,则被移动到Survivor空间中,并将对象年龄设置为1,对象在Survivor区每熬过一次MinorGC,年龄就+1。当年龄增加到一定程度(默认为15,不同JVM,GC都所有不同)时,就会被晋升到老年代中
-XX:MaxTenuringThreshold参数设置晋升年龄的阈值
大对象直接分配到老年代
尽量避免程序中出现过多的大对象
只有Serial和ParNew垃圾收集器支持该行为
动态对象年龄分配
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄
空间分配担保
-XX:+HandlePromotionFailure
JDK7及以后这个参数就失效了
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
如果大于,则此次MinorGC是安全的
如果小于,则查看-XX:HandlePromotionFailure设置是否允许担保失败
true
会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
大于,则尝试进行一次MinorGC,但是这次MinorGC依然是有风险的
小于,则改为进行一次FullGC
false
则改为进行一次FullGC
jdk6update24之后,这个参数不会再影响到虚拟机的空间分配担保策略。
规则改为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行MinorGC
否则进行FullGC
为对象分配内存TLAB(Thread Local Allocation Buffer)
堆区是线程共享区域,任何线程都可以访问到堆区的共享数据
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
因此JVM采用两种策略(TLAB、CAS加失败重试)为对象分配内存
TLAB
从内存模型而不是垃圾收集的角度,对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域,包含在Eden空间中
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们将这种内存分配方式成为快速分配策略
openjdk衍生出来的JVM都提供了TLAB的设计
图解示例
几点补充
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实是将TLAB作为内存分配的首选
开发人员通过-XX:+UseTLAB设置是否开启TLAB空间
默认情况下,TLAB空间内存非常小,仅占有整个Eden空间的1%,通过-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小
一旦对象在TLAB空间分配内存失败,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
使用TLAB和Eden分配对象流程
使用TLAB的方式为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
小结堆空间的参数设置
堆内存是分配对象的唯一选择吗?
随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术,将会导致一些微秒变化,所有对象分配到堆上渐渐变得不那么绝对了
有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样无需堆上分配,也不需要垃圾回收了,也是最常见的堆外存储技术
TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,实现了将生命周期较长的Java对象从heap中移动heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
逃逸分析概述
逃逸分析的基本行为就是分析对象动态作用域
当一个对象在方法中定义后,对象只在方法内部使用,则认为没有发生逃逸
当一个对象在方法中被定义后,它被外部方法引用,则认为发生逃逸,例如作为调用参数传递到其他地方中
对象发生逃逸代码示例以及如何不发生逃逸示例
栈上分配
将堆分配对象转为栈分配对象,如果一个对象在栈中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
同步策略
如果一个对象被发现只能从一个线程被访问到,对于这个对象的操作可以不考虑同步
JIT编译器可以借助逃逸分析来判断同步块所使用的的锁对象,是否只能够被一个线程访问,而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候,就会取消对这部分代码的同步。这样就大大提高并发性和性能,这个取消同步的过程就叫同步省略,也叫锁消除
代码示例
标量替换(分离对象)
有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以不存储在内存。而是存储在CPU寄存器中
标量是指一个无法再分解的更小的数据的数据。Java中基本数据类型就是标量
可以分解的数据叫聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
原始代码
标量替换后的代码
标量替换参数:-XX:+|-EliminateAllocations,默认打开
方法区(重点)
虚拟机栈、堆内存、方法区交互关系
从线程是否共享该区域来看,方法区线程共享
从变量的存放区域来看,方法区存放类的元数据信息
方法区的理解
Java虚拟机规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但是一些简单的实现,可能不会选择去进行垃圾收集或者进行压缩。对于HotSpot而言,方法区还有一个别名叫Non-Heap(非堆),目的就是要和堆分开
所以方法区看作是一块独立于Java堆的内存空间
方法区和Java堆一样,是各个线程共享的内存区域
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样,都是可以不连续的
方法区的大小和堆空间一样,可以选择固定大小或者可扩展
方法区的大小决定了系统可以保存多少个类,如果定义太多类,加载大量的第三方的Jar包,Tomcat部署过多工程,导致方法区溢出,虚拟机同样会抛出内存溢出OOM:PermGenspace或者Metaspace
关闭JVM就会释放这个区域的内存
HotSpot中方法区的演进(JDK6、JDK7、JDK8)
在JDK7及以前,习惯上把方法区,称为永久代,JDK8开始,使用元空间取代了永久代
元空间的本质和永久带类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
根据JVM规范,如果方法区无法满足新的内存分配需求,将抛出OOM异常
本质上,方法区和永久代并不等价,仅是对HostSpot而言的。
Java虚拟机规范,对如何实现方法区,不做统一要求,例如BEA JRockit/IBM J9中不存在永久代的概念
现在来看,当年使用永久代,不是好的点子,导致Java程序更容易OOM
-XX:MaxPermSize
设置方法区大小与OOM
方法区大小不是固定的,JVM可以根据应用动态调整
JDK7及以前
通过-XX:PermSize 来设置永久代初始分配空间,默认值是20.75M
-XX:MaxPermSize来设定永久代最大可分配空间
32位机器默认是64M
64位机器默认是82M
如果JVM加载的类信息容量超过了这个值,会报OOM:PermGenspace
JDK8及以后
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
默认值依赖平台
windows下初始为21M,最大是-1即没有限制
如果不指定大小,虚拟机耗用所有可用系统内存,元数据区发生溢出,一样OOM:Metaspace
对于一个64位服务端JVM来说,默认的初始元数据区空间为21M,这就是初始的高水位线。一旦触及这个水位线,FulllGC会触发并卸载没有用的类,然后高水位线会被重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放空间不足,在不超过最大设定值时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,FullGC多次调用。为了避免频繁FullGC,建议将-XX:MetaspaceSize设置为一个相对较高的值
方法区的内部结构
方法区存储什么
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存
类型信息
对于每个加载的类型(类Class、接口Interface、枚举Enum、注解annotation)
JVM必须在方法区中存储以下类型信息
这个类的完整有效名称(全名=包名.类名)
这个类型直接父类的完整有效名,对于interface或Object没有父类
这个类型的修饰符:public、abstract、final等
这个类型直接接口的一个有序列表
域信息(Field字段)
JVM必须在方法区中保存类型的所有域的相关信息,以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)
方法信息(Method)
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序
方法名称
方法的返回类型
或void
方法参数的数量和类型
按顺序
方法的修饰符
public、private、protected、static、final、synchronized、native、abstract的一个子集
方法的字节码bytecodes、操作数栈、局部变量表及大小
异常表
abstract和native方法除外
每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
non-final的类变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,你也可以访问他
全局常量
static final
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。直接引用字面量
常量池
方法区,内部包含了运行时常量池
字节码文件,内部包含了常量池
运行时将常量池加载到方法区,就是运行时常量池
要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池
一个有效的字节码文件中除了包含的类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
为什么要用常量池?
一个Java源文件中的类、接口、编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码里。换一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接会用到运行时常量池
常量池有什么?
数量值
字符串值
类引用
字段引用
方法引用
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
运行时常量池
运行时常量池是方法区的一部分
常量池表是class文件的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
在加载类和接口到虚拟机后,就会创建对应的运行时常量池
JVM为每个已加载的类型都维护一个常量池,池中的数据像数组项一样,通过索引访问
运行时常量池包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后,才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址
运行时常量池,相对于class文件常量池的另一个重要特征是:具备动态性
例如:String.intern()方法可以将字符串也放入运行时常量池
当创建类或接口的运行时常量池,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值。则JVM会抛出OOM异常
这里注意,常量池数量为N,则索引为1到N-1
方法区使用举例
原始代码
字节码指令、程序计数器、局部变量表、操作数栈示例
sipush 500(把500这个数值压入操作数栈中)
istore_1(弹出操作数栈栈顶元素500,保存到局部变量表中索引为1的位置)
bipush 100(将100这个数值压入操作数栈栈顶)
istore_2(弹出操作数栈栈顶元素100,保存到局部变量表索引为2的位置)
iload_1(将局部变量表中索引为1处的数值500,压入操作数栈栈顶)
iload_2(将局部变量表中索引为2处的数值100,压入操作数栈栈顶)
idiv(依次弹出操作数栈中的两个元素100、500,然后进行两数相除,结果5压入操作数栈栈顶)
istore_3(弹出栈顶元素5,将局部变量表索引为3处的值修改为5)
bipush 50(将50压入操作数栈中)
istore 4(弹出操作数栈栈顶元素,存储到局部变量表索引为4的位置)
getstatic #2 (获取类或者接口字段的值并将其推入操作数栈 #2 对应常量池中的Fieldref #15.#16)
iload_3(将局部变量表中索引为3的位置的数据5压入操作数栈中)
iload_4(将局部变量表中索引为4的位置的数据50压入操作数栈中)
iadd(弹出栈顶两个int类型的数相加,结果压入操作数栈)
invokevirtual #3
return(结束方法的运行)
方法区的演进细节(重点)
首先明确,只有HotSpot才有永久代
HotSpot中方法区的变化
JDK1.6及之前
有永久代,静态变量存放在永久代上
JDK1.7
有永久代,但已经开始逐步去永久代,字符串常量池、静态变量从永久代中移除,转而保存在堆内存中
JDK1.8及之后
无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池,静态变量仍在堆
永久代为什么被元空间替换
随着 Java8的到来,HotSpotVM中再也见不到永久代了,但是并不意味着类的元数据信息也消失了,这些数据被转移到了一个与堆不相连的本地内存区域,这个区域叫做元空间MetaSpace
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统的可用内存空间
为永久代设置空间大小很难确定,在某些场景下,如果动态加载类过多,就容易产生OOM
而元空间并不在虚拟机中,而是使用本地内存,因此默认情况下,元空间的大小仅受本地内存限制
对永久代进行调优是很困难的
方法区的垃圾回收
有些人认为方法区是没有垃圾收集行为的,其实不然。Java虚拟机规范对方法区的约束非常宽松,提到过可以不要求虚拟机在方法区实现垃圾收集。事实上,也确实有未实现或未能完整实现方法区类型卸载的收集器,如JDK11 ZGC
方法区的垃圾收集主要回收两部分内容
常量池中废弃的常量
HotSpot对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以被回收
回收废弃常量与回收Java堆中对象非常类似
不再使用的类型(二进制数据)
需要同时满足三个条件
该类所有的实例已经被回收
java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问改类的方法
满足以上三个条件后,并不是和对象一样立即被回收,仅仅是允许
HotSpot虚拟机提供了-Xnoclassgc参数进行控制
在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
方法区内常量池中主要存放的两大类常量
字面量
比较接近Java语言层次的常量概念,如文本字符串,被声明为final的常量值等
符号引用(其实也是字符串)
属于编译原理方面的概念
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
直接内存
不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域
直接内存是在Java堆外的,直接向系统申请的内存区间
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会优于Java堆,即读写性能高
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
也可能导致OOM异常
直接内存在堆外,所以大小不受限于-Xmx指定的最大堆大小
但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
缺点
分配回收成本较高
不受JVM内存回收管理
如果出现内存泄漏难以排查
直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
HotSpot虚拟机对象探秘(重点)
对象的实例化
创建对象的方式
new
最常见的方式
变形:Xxx的静态方法
XxxBuilder/XxxFactory的静态方法
Class的newInstance
JDK9标记过时,反射的方式,只能调用空参的构造器,权限必须是public
Constructor的newInstance
反射的方式,可以调用空参,带参的构造器,权限没有要求
使用clone
不调用任何构造器,当前类需要实现Cloneable接口,实现clone()方法
使用反序列化
从文件、网络等获取一个对象的二进制流
第三方库Objenesis
创建对象的步骤
判断对象对应的类是否加载、链接、初始化
当虚拟机遇到一条字节码new等创建实例对象的指令时。首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、连接、初始化过
如果没有初始化,在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常。如果找到了.class文件,进行类的初始化过程
为对象分配内存
首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
分配内存的两种方式(指针碰撞、空闲列表)
空闲列表(内存不规整)
如果Java堆内存不规整,JVM虚拟机就必须维护一个列表,记录哪些内存可用,哪些不可用
分配的时候在列表中找一个足够大的空间分配,然后更新列表
这种分配方式叫空闲列表(Free List)
指针碰撞(内存规整)
假设Java 堆中内存时绝对规整的,所有被使用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点指示器
那么内存分配就是指针指向空闲的方向,挪动一段与对象大小相等的距离
这种分配方式成为指针碰撞(Bump The Pointer)
选择哪种分配方式由Java堆是否规整决定,Java堆是否规整由所采用的的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
当使用Serial、ParNew等带有压缩整理过程的收集器,指针碰撞简单高效
当使用CMS基于清除(Sweep)算法收集器时,只能采用空闲列表来分配内存
CMS为了能在多数情况下分配内存更快,设计了一个Linear Allocatioin Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞方式分配
处理并发安全问题
对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象A分配内存,指针还未修改,对象B又使用原来的指针分配内存。解决问题有两种可选方案:
a、对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性
b、把内存分配的动作按照线程划分到不同的空间中进行,每个线程在Java堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定
虚拟机是否使用TLAB,可以通过-XX: +|-UseTLAB参数来设定
初始化分配到的空间
内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,这个工作可以提前到TLAB分配时进
这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值
设置对象的对象头
接下来Java虚拟机还要对对象进行必要的设置,例如对象时哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的HashCode会延后真正调用Object的hashCode()方法时才计算)、对象的GC分代年龄等信息
这些信息存放到对象的对象头(Object Header)
执行类的构造器<init>()方法进行初始化
上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从Java程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class文件中<init>()方法)还未执行,所有字段都是默认的零值
new指令之后接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来
对象的内存布局(对象头、实例数据、对齐填充)
对象头
包含三部分(运行时元数据、指向方法区中的KClass对象、如果为数组需要有数组的长度(数据类型为int))
这部分数据的长度在32位和64位的虚拟机分别是4字节和8字节,官方称为Mark Word运行时元数据
哈希值
GC分代年龄
锁状态标志
线程持有的锁
偏向线程ID
偏向时间戳
对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,根据对象状态的不同,MarkWord可以复用自己的空间
类型指针(开启指针压缩4字节,未开启8字节)
即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确认该对象属于哪个类的实例
该指针指向方法区中类的KClass对象
对象头已经精心设计为8字节的整数倍,1倍或者2倍
说明:如果是数组,还需要记录数组的长度,4字节
实例数据
对象的实例数据部分,是对象的真正存储的有效信息,即我们在程序代码中定义的各种类型的字段,无论是父类继承下来,还是子类中定义的字段都要记录下来
这里不包括静态字段,因为静态字段属于类,而不属于类的实例对象
为字段分配内存的策略
这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响
分配策略参数-XX:FieldsAllocationStyle=1
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放
在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前
如果HotSpot虚拟机的+XX:+CompactFields参数(默认开启),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省一点点空间
对齐填充
对其填充,这并不是必然存在,没有特别的意义,它仅仅起着占位符的作用
因为HotSpot虚拟机自动内存管理系统,要对对象的起始地址必须是8字节的整数倍,换句话就是任何对象的大小都必须是8字节的整数倍
对象实例数据部分如果没有对齐的话,就需要通过对其填充来补全
Java对象指针压缩
什么是对象指针压缩(Ordinary Object Pointers Compress)
1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
2.JVM配置参数:UseCompressedOops,compressed--压缩、oop--对象指针
3.启用指针压缩:-XX:+UseCompressedOops,禁止指针压缩:-XX:-UseCompressedOops
如果开启了指针压缩,那么引用数据类型的指针大小就是4个字节共32位。如果进行内存寻址,最大只能访问4G(2的(4 * 8)次方 / 1024 / 1024 / 1024 )的内存。堆内存如果大于4G,那么如何访问4G之外的内存呢?
Java对象的大小必须是8字节的整数倍,如果是2进制表示,最后三位必然都是000,那么就可以将32位进行无符号左移3位,变成35位。那么可以最大内存寻址的范围就是32G
举例一下,假如对象实际偏移量为24字节,那么指针只存储3字节,实际内存寻址的时候,进行无符号左移3位,其实就是乘以8,得到24,达到节约内存的目的。其实这也是时间换空间思想的体现,利用CPU的计算资源,减少内存消耗
如果堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对Java对象寻址
如果堆内存小于4G,就没必要使用指针压缩了
对象的访问定位(句柄和直接指针)
使用句柄
使用句柄,Java堆中将划出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄包含对象实例数据与类型数据各自的具体信息
访问示例
直接指针
使用指针,reference中存储的直接就是对象地址,如果访问对象本身,不需要多一次的间接访问的开销
访问示例
两种方式各有优势
使用句柄最大好处是reference中存放的是稳定句柄地址,在对象被移动(垃圾回收时会产生)时只改变句柄中实例数据指针,reference本身不用改变
使用指针最大好处就是速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本
HotSpot主要是用指针,进行对象访问(例外情况,如果使用Shenandoah收集器的话,也会有一次额外的转发)
字节码执行引擎
执行引擎位置
执行引擎是Java虚拟机核心的组成部分之一
虚拟机的执行引擎由各大厂商自行实现,物理机的执行引擎是操作系统层面上
能够执行不被硬件直接支持的指令格式
执行引擎的工作过程
执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
Java代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤
为什么说Java是半编译半解释型语言
JVM在执行Java代码的时候,通常会将解释执行与编译执行二者结合起来进行
Javac编译器的执行过程(Javac编译器完全使用Java语言编写,这种用自己的语言写的编译器来编译自己的方式叫自举)
Java字节码的执行流程
各种JVM语言源代码、编译器、字节码、解释器、JIT、机器码的关系
机器码、指令、汇编语言、字节码
机器码
各种采用二进制编码方式表示的指令,叫做机器指令码
机器指令与CPU紧密相关,不同种类的CPU所对应的机器指令也就不同
指令
由于机器码由01组成,可读性太差。于是人们发明了指令
指令就是把机器码特定的0和1序列,简化成对应的指令,一般为英文编写如mov、inc等,可读性稍好
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同。所以不同的硬件平台的同一种指令,对应的机器码也可能不同
指令集
不同硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集
x86指令集,对应的x86架构的平台
ARM指令集,对应的是ARM架构的平台
汇编
由于指令的可读性太差,于是又有了汇编语言
汇编语言用助记符代替机器指令的操作码,用地址符号或标号,代替指令或操作数的地址
汇编语言要翻译成机器指令码,计算机才能识别和执行
字节码
字节码是一种中间状态的二进制代码,它比机器码更加抽象,需要直译器转义后才能完成机器码
字节码主要为了实现特定软件运行和软件环境,与硬件环境无关
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以执行的指令。典型的应用为Java bytecode
解释器
当Java虚拟机启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行
解析器真正意义上所承担的角色就是一个运行时翻译者,将字节码文件中的内容翻译为对应的平台的本地机器指令执行
当一条字节码指令被解释执行完成后,接着在根据PC寄存器中的记录下一条需要被执行的字节码执行解释执行
现在普遍使用的模板解释器
模板解释器将每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码,提高解释器的性能
HotSpot中
Interpreter模块
实现了解释器的核心功能
Code模块
用于管理HotSpot在运行时生成的本地机器指令
JIT编译器(Just In Time)
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
JVM平台支持一种叫做即时编译的技术,目的是避免解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,使执行效率大幅提升
为什么两条腿走路?
首先程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行
编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地机器码后执行效率更高
对于服务端应用,启动时间并非关注重点,但是对于看重启动时间的应用场景,就需要找到一个平衡点
当Java虚拟机启动时,解释器可以首先发挥作用,而不是等待即时编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率
概念解释
前端编译器
把.java文件转换为.class文件的过程
sun的Javac编译器
后端运行期编译器
把字节码转为机器码的过程
JIT编译器:HotSpot的C1、C2编译器
静态提前编译器
Ahead of Time Compliler AOT,直接把.java文件编译器本地机器代码(可直接运行的二进制文件)的过程
GNU Compiler for the Java(GCJ)
JIT简单介绍
热点代码及探测方式
需要根据代码被调用执行的频率而定,需要被编译为本地代码的字节码,也称之为热点代码
JIT编译器会在运行时针对频繁调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能
一个被多次调用的方法,或者一个方法体内部循环次数较多的循环体,都可以被称之为热点代码
因此可以通过JIT编译器编译为本地机器指令,由于这种编译方法发生在方法的执行过程中,因此也被称之为栈上替换,OSR On Statck Replacement
一个方法调用都少次才能达到标准?这个依靠热点探测功能
HotSpot采用的基于计数器的热点探测
方法调用计数器
统计方法调用次数
默认阈值,Client模式下是1500次,Server模式下是10000次
-XX:CompileThreshold
热度衰减
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数
如果超多一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器,那么方法调用计数器的值就会减少一半,这个过程称之为方法调用计数器热度衰减,而这个时间值就是半衰周期
进行热度衰减的动作是在JVM进行垃圾收集时顺便执行的,可以使用-XX:-UseCounterDecay来关闭热度衰减
另外可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒
回边计数器
统计循环体执行的循环次数
执行示意图
HotSpot可以设置程序执行的方式
-Xint完全采用解释器模式执行
-Xcomp完全采用即时编译器模式执行,如果即时编译器出现问题,解释器会介入执行
-Xmixed采用解释器+即时编译器的混合模式共同执行
默认使用混合模式
HotSpot中JIT分类
内嵌两个JIT编译器
client
server
大多情况下简称C1、C2
-client:指定Java虚拟机在Client模式下,并使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度
方法内联
将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化
对唯一的实现类进行内联
冗余消除
在运行期把一些不会执行的代码折叠掉
-server:指定虚拟机在server模式下,并使用C2编译器
C2进行耗时较长的优化,以及激进优化,单优化后的代码执行效率更高
逃逸分析是优化的基础,基于逃逸分析在C2上有几种优化
标量替换
用标量值代替聚合对象的属性值
栈上分配
对于未逃逸的对象分配在栈而不是堆
同步消除
清除同步操作,通常指synchronized
随着JIT编译器的存在,可以在项目启动时,使用压测工具对程序进行代码预热,提高程序的运行效率
最后
JDK9引入了AOT编译器
JDK10起,HotSpot又引入了个全新的即时编译器Graal编译器
StringTable
StringTable为什么要调整
JDK7中将StringTable放到了堆空间中,因为永久代的回收效率很低。在FullGC的时候才触发,而FullGC是老年代空间不足,永久代不足时才触发
这就导致了StringTable回收效率不高,而我们开发中会创建大量的字符串,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
String的基本特性
字符串,用""引起来表示
声明为final的不可被继承的
实现了Serializable接口,表示支持序列化
实现了Comparable接口,表示可以比较大小
JDK8及以前,内部定义了final char[] value用于存储字符串数据
JDK9时改为byte[]字节数组
char数组一个char占2字节,String是堆空间的主要部分,大部分是latin-1字符,一个字节就够了,这样会有一半空间浪费
中文等UTF-16的用两个字节存储
StringBuffer、StringBuilder同样做了修改
String代表不可变的字符序列
简称不可变性
当字符串重新赋值,需要重写指定内存区域赋值,不能使用原有的value进行赋值
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能对使用原有的value进行赋值
当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
由于String的不可变性,因此String也是线程安全的对象
通过字面量的方式,区别与new给一个字符串赋值,此时的字符串值声明在字符串常量池中
字符串常量池中不会存储相同的字符串的
String的String pool是一个固定大小的HashTable,默认大小长度是1009,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了,直接影响就是调用String.intern()方法时性能会大幅下降
-XX:StringTableSize可设置StringTable的大小
JDK6固定1009,JDK7中StringTable默认的长度是60013,JDK8时默认是60013,1009是可设置的最小值
String的内存分配
Java语言中有8种基本数据类型和一种比较特殊的类型String,这些类型为了使他们再运行过程中速度更快,更节省内存,都提供了一种常量池的概念
String的常量池比较特殊,主要使用方法有两种
直接使用双引号,声明出来的String对象会直接存储在常量池中
如果不是双引号声明的String对象,可以使用String提供的intern()方法
JDK6及之前,字符串常量池存在永久代
JDK7中,字符串常量池调整到Java堆中
调优时仅需调整堆大小就可以
JDK8中,字符串常量在堆中
为什么要调整?
永久代默认情况下比较小,永久代垃圾回收频率低,大量字符串容易导致OOM
String的基本操作
Java语言规范要求完全相同的字符串字面量,应该包含同样的Unicode字符序列,包含同一份码点序列的常量,并且必须指向同一个String类实例
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
字节码ldc # 2 <abc>,是指加载常量池中索引为2的字面量abc压入操作数栈
常量池中不存在相同内容的常量
代码演示
初始状态
执行了String a = "mysql";
此后每执行一行代码,String类的实例个数一次增加1,直到执行String e = "mysql";之前
再次执行String e = "mysql";代码发现String类的实例个数并没有增加,这就间接证明了常量池中不存在相同内容的常量
只要其中有一个变量,拼接结果就在堆中(常量池以外的堆),变量的拼接原理是StringBuilder
代码截图
代码截图
如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
字符串拼接操作不一定使用的是StringBuilder如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式
针对final修饰类,方法,基本数据类型,引用数据类型变量的结构时,能使用final尽量使用上
截图
由于使用了final修饰变量,在编译器就对其进行了优化,使之变成了常量
对比用+号拼接字符串和StringBuilder.append操作对比
拼接10万次,+号4000毫秒,append用了7毫秒,原因是+号每次循环创建一个StringBuilder,还要通过toString创建一个String对象
内存中由于创建了较多的对象,内存占用更大,如果需要GC需要花费额外的时间
改进空间:StringBuilder默认是16长度的char型数组,不够的时候会扩容,可以一次建一个比较大长度的数组
String类的intern()方法
如果字符串常量池中,通过equals判断是否相同,如果没有则在常量池中生成
确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度,注意,这个值会被存放在字符串内部池
面试题
代码
JDK6执行结果
false false
调用s.intern()方法之前,字符串常量池已经有1
内存图
JDK7/8
false true
s3的变量地址为:new String("11")。执行完字符串常量池中不存在11,执行s3.intern()方法会在字符串常量池生成"11",s4变量记录的地址是常量池中的
JDK7:此时常量池中并没有创建"11",而是创建一个指向堆空间的中new String("11")的地址
内存图
变形
截图
new String("ab")会创建几个对象?
2个对象,查看字节码验证。一个是常量池ab,一个是new出来对象在堆空间。(前提是常量池没有ab)
new String("a") + new String("b")?
对象1,有拼接操作就new StringBuilder()
对象2,new一个String
对象3,常量池a
对象4,new String
对象5,常量池b
对象6,StringBuilder,toString方法会new String返回
此时字符串常量池中没有ab
String.intern()方法总结
JDK6中,将这个字符串对象放入串池
如果串池中有,则并不会放入,返回已有串池中的对象的地址
如果没有,会把对象复制一份,放入串池,并返回串池中的对象地址
JDK7起,将这个字符串对象尝试放入串池
如果串池中有,则并不会放入,返回已有的串池中的对象的地址
如果没有,则会把对象的引用地址复制一份,放入串池,并返回对象的引用地址
练习
截图
intern()的效率测试
大的网站平台,需要内存中存储大量的字符串,比如社交网站,很多人存储:北京市、海淀区等信息,这时候如果字符串调用intern()方法,则会明显降低内存的大小
FastJson将json字符串反序列化成Java对象的时候,将key进行了使用了intern()方法,理由是key是经常出现的,放入String Table中可以大大减少内存消耗
Stringtable的垃圾回收
-XX:+PrintStringTableStatistics
G1中String去重操作
背景:对许多Java应用,做的测试结果如下
堆存活数据集合里面String对象占了25%
堆存活数据集合里面重复的String对象有13.5%
String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存
Java堆中存活的数据集合差不多25%是String对象,这里差不多一半的String对象是重复的, 重复是指equals方法=true,堆上重复的String对象必然是一种内存的浪费
G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样避免浪费
垃圾回收概述
Java和c++在内存方面的区别(内存动态分配、垃圾自动回收)
垃圾回收技术需要考虑的三个基本问题
哪些内存需要回收?
什么时候需要回收内存?
如何回收内存?
什么是垃圾
垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间会一直保留直到应用程序结束,被保留的空间无法被其他对象使用。最终甚至可能导致内存溢出
需要回收的内存区域
回收的区域(堆内存和方法区)
总结:频繁回收新生代,较少回收老年代,基本不回收方法区
垃圾回收相关算法(重点)
标记阶段
如何判断对象已经死亡?当一个对象已经不再被任何存活的对象引用时,认为该对象已经死亡
引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况
被对象引用了就+1,引用失效就-1。0表示不可能再被使用,可进行回收
优点:实现简单,垃圾便于辨识,判断效率高,回收没有延迟性
缺点
需要单独的字段存储计数器,增加了存储空间的开销
每次赋值需要更新计数器,伴随加减法操作,增加了时间开销
无法处理循环引用的情况,致命缺陷,导致Java的垃圾回收器中没有使用这类算法
案例演示(判断Java采用引用计数,还是可达性分析法)
Java代码(JVM参数:-XX:+PrintGCDetails -Xms30m -Xmx30m -Xmn20m)
分析解释(这里通过GC日志分析,伊甸园区被回收了大概15M,说明Java没有采用引用计数法,而是采用可达性分析法)
[GC (System.gc()) [PSYoungGen: 12699K->808K(17920K)] 12699K->816K(28160K), 0.0010634 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] (12699-808)/ 1024 ≈ 11M
小结
引用计数算法,是很多语言的资源回收选择,例如python,它更是同时支持引用计数和垃圾回收机制
Python如何解决循环引用
手动解除
使用弱引用,weakref,python提供的标准库,旨在解决循环引用
可达性分析算法
Java、C#选择的
基本思路
是以根对象(GCRoots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达(递归)
使用可达性分析算法后,内存中存活的对象都被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
如果目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活的对象
GC Roots包括
虚拟机栈中引用的对象(局部变量表中的引用数据类型的变量)
比如各个线程被调用的方法中使用到的参数、局部变量
本地方法栈内JNI、引用的对象
方法区中静态属性引用的对象
比如:Java类的引用类型静态变量
方法区中常量引用的对象
比如字符串常量池里的引用
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用
基本数据类型对应的class对象,一些常驻的异常对象,如NullpointerException、OOMError、系统类加载器
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等
除了固定的GC Roots集合之外,根据用户选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同构成完整GCRoots集合,比如分代收集和局部回收
如果只针对Java堆中某一块内存区域进行垃圾回收,必须要考虑这个区域的对象可能被其他区域对象所引用,这是需要一并将关联的区域对象加入GC Roots集合中去考虑,才能保证可达性分析的准确性。(跨代引用)
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就是一个GC Root
如果需要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证
这也是GC进行时必须STW的一个重要原因,即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的
对象的finalization机制
Java语言提供了对象终止finaliztion机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法
finalize()方法被定义在Object类中,允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库链接等
定义虚拟机的对象可能的三种状态
可触及的
从根节点开始,可以到达这个对象
可复活的
对象的所有引用都被释放了,但是对象有可能在finalize()中复活
不可触及的
对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
只有对象是不可触及时才可以被回收
具体过程
判断一个对象objA是否可以被回收,至少需要经历两次标记过程
1、如果对象到GCRoots没有引用链,则进行第一次标记
2、进行筛选,判断此对象是否有必要执行finalize()方法
如果对象A没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为没有必要执行,对象A被判定为不可触及的
如果对象A重写finalize()方法,且还未执行过,那么A会被插入到F-queue队列中,有一个虚拟机自动创建的,低优先级的Finalizer线程触发其finalize()方法执行
finalize方法是对象逃脱死亡的最后机会,稍后GC会对F-queue队列中的对象进行第二次标记,如果A在finalize方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,A会被移除即将回收集合。之后,对象会再次出现没有引用存在的情况下,finalize方法不会再被调用,对象直接变为不可触及状态,这时候A对象会被直接回收
代码演示
代码
执行结果
Finalizer线程
在Finalizer类中有静态内部类FinalizerThread继承自Thread,重写run()方法,并且在静态代码块中启动了Finalizer线程
重写的run()方法中的逻辑就是从队列中获取对象,然后执行finalize()方法,当然了finalize()方法只会被执行一次
Finalizer线程的优先级低finalizer.setPriority(Thread.MAX_PRIORITY - 2)
Finalizer线程为后台守护线程finalizer.setDaemon(true)
MAT与JProfiler的GC Roots溯源
Eclipse MAT是Memory Analyzer的简称,是一款功能强大的Java堆内存分析器。用于查找内存泄露以及查看内存消耗情况,基于Eclipse开发的一款免费性能分析工具
JProfiler提供直观的用户界面帮助您解决性能瓶颈,确定内存泄漏并了解线程问题
关于这两款工具的使用到故障排查和工具的使用重点讲解
清除阶段
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
标记-清除算法(Mark-Sweep)
标记
从引用根节点开始遍历(需要停止所有用户线程Stop The World),标记所有被引用的对象,一般是在对象头中记录为可达对象
注意标记引用对象,不是垃圾对象,是存活的对象
清除
对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
算法图示
缺点
效率不算高,当需要大量对象被回收时,进行大量的标记和清除,执行时间较长
在GC的时候,需要停止整个应用程序(STW),导致用户体验差
这种方式清理出来的空闲内存不连续,产生内存碎片,需要维护一个空闲列表
何为清除?
所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放
复制算法(Mark-Copy)
将或者的内存空间分为两块,每次使用其中一块。在垃圾回收时,将正在使用的内存中的存活的对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有的对象,交换两个内存的角色,最后完成垃圾回收
算法图示
优点
没有标记和清除的过程,实现简单高效
复制过去以后的保证空间的连续性,不会出现碎片的问题
缺点
需要两倍的内存空间
对于G1这种拆分为大量region的GC,复制而不是移动,意味着GC需要维护region之间的引用关系,不管是内存占用或者时间开销也不小
如果系统中的垃圾对象很多,需要复制的存活对象数量并不会太大,或者非常低才行
应用场景
新生代对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是使用这种收集算法回收新生代
标记-压缩算法(标记-整理算法)(Mark-Sweep-Compact)
标记阶段(Mark)标记清除算法一样,从根节点开始标记所有被引用的对象
清除阶段(Sweep)第二阶段将所有的存活对象压缩在内存的一端,按照顺序排放,之后清理边界外所有的空间
整理阶段(Compact)标记清除算法执行完成后,再进行一次内存碎片整理
算法图示
与标记清除算法本质区别,标记清除算法是非移动式的算法,标记压缩是移动式的
是否移动回收后的存活对象时一项优缺点并存的风险决策
优点
消除了标记清除算法内存区域分散的缺点
消除了复制算法中,内存减半代价
缺点
从效率上来讲,标记整理算法要低于复制算法
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
移动的过程中,需要全程暂停用户应用程序,即STW
各个垃圾回收算法优缺点
从效率上来说,复制算法是当之无愧的老大,但是却浪费了太多的内存。为了尽量兼顾上面提到的三个指标,标记-整理算法相对平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记阶段,比标记-清除多了一个整理内存的阶段
图示
分代收集算法
不同生命周期的对象可以采取不同的收集方式,以便提高回收效率
几乎所有的GC都采用分代收集算法执行垃圾回收
HotSpot中
新生代
生命周期短,存活率低,回收频繁
适合使用复制算法,复制算法内存利用率不高的问题,通过两个survior的设计得到缓解
老年代
区域较大,生命周期长,存活率高,回收不及年轻代频繁
这种大量存活率高的对象,复制算法明显不合适。一般由标记-清除或者是标记-整理的混合实现
增量收集算法、分区算法
增量收集算法思想(CMS、G1、ZGC)
每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成
通过对线程间冲突的妥善管理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
缺点
线程和上下文切换导致系统吞吐量的下降
分区算法(G1、ZGC)
为了控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的时间
分代算法是将对象按照生命周期长短划分为两个部分,分区算法是将整个堆划分为连续的不同的小区间
每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间
垃圾回收相关概念(重点)
System.gc()的理解
System.gc()或Runtime.getRuntime().gc()的调用,会显示触发FullGC,同时会对老年代和新生代进行回收,尝试释放垃圾对象占用的内存
然而System.gc()调用无法保证对垃圾收集器的调用
一些特殊情况下,比如编写性能基准,我们可以在运行之间调用System.gc()
内存溢出与内存泄露
内存溢出
Java虚拟机的堆内存设置不够
代码创建大量大对象,并且长时间不能被垃圾收集器收集(仍然有GC Roots引用这这些对象)
内存泄露
只有对象不再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露
实际情况有一些疏忽导致对象的生命周期变的很长甚至OOM,宽泛意义上的内存泄露
举例
单例的生命周期和程序是一样长,如果单例程序中,持有对外部对象的引用的话,那么这个外部对象是不能被回收的,导致内存泄露
一些提供close的资源未关闭导致内存泄露,如数据库链接、网络链接和IO
ThreadLocal不恰当地使用,set数据,get完数据后并没有及时remove数据
Stop-The-World(重点)
简介
简称STW,指的是GC事件发生过程中,会发生应用程序的停顿
停顿产生式整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW
为什么需要STW
可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,确认哪些对象存活,哪些对象死亡
分析工作必须保证在一个能确保一致性的快照中进行
一致性是指整个分析期间整个执行系统看起来像被冻结在某个时间点上
如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
总结
STW事件和采用哪款GC无关,所有的GC都有这个事件
哪怕是G1也不能完全避免STW情况的发生,只能说GC越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间
STW是JVM后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉
开发中不要使用System.gc(),会导致STW事件的发生
垃圾回收的并行与并发(重点)
并发
一段时间内,同一个CPU处理运行多个线程的代码
CPU切换
并行
一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互相不抢占资源,可以同时进行,我们称之为并行
并行因素取决于CPU的核心数量
并发与并行对比
并发指的是多个事情,在同一时间段内交替发生了
并行指的是多个事情,在同一个时间点上同时发生了
并发的多个任务之间抢占资源
并行多个任务之间不互相抢占资源
只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则看似同时发生的事情,其实都是并发执行的
垃圾回收的并发与并行(基于垃圾回收线程的数量)
串行(Serial)
垃圾收集线程单线程执行
例如Serial Old垃圾收集器
并行(Parallel )
多条垃圾收集线程并行工作,用户线程处于等待状态
例如ParNew、Parallel Scavenge、Parallel Old垃圾收集器
并发(Concurrent)
用户线程与垃圾回收线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行
用户线程继续运行,而垃圾收集线程运行在另一个CPU上,例如CMS、G1垃圾收集器
四大引用(强、软、弱、虚引用)
继承关系图
前言
在JDK1.2之前,Java的引用还是很传统的,reference类型的数据就是另外一块内存的起始地址
如果我们想要描述这样一些对象,当内存空间足够时,保留在内存中。当内存空间在垃圾回收之后仍然非常紧张,那么就可以回收这些对象
在JDK1.2之后,Java对引用的概念进行了扩充,分为强引用、软引用、弱引用和虚引用,这4中引用强度依次减弱
强引用(Strongly Reference)
最传统的引用定义,程序代码中普遍存在的引用赋值,类似Object obj = new Object()这种引用关系,无论任何情况下,强引用存在,垃圾收集器永远不会回收掉被引用的对象
强引用是造成Java内存泄露的主要原因之一
强引用可以直接访问目标对象
软引用(Soft Reference)
用来描述一些还有用,但是非必须的对象。系统将要发生内存溢出之前,会将这些对象列入回收范围之中进行第二次回收,如果这次回收后还没有足够内存,才会抛出内存溢出异常
软引用通常用来实现内存敏感的缓存,高速缓存就有用到软引用
垃圾回收器在某个时间决定回收软可达的对象的时候,会清理软引用,并可选的把引用存放到一个引用队列
代码演示
测试代码,需要手动设置JVM参数调整堆内存大小
控制台输出
弱引用(Weak Reference)
弱引用也是用来描述那些非必须的对象,只被弱引用关联的对象只能够生存到下一次垃圾收集器之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
代码演示
虚引用(Phantom Reference)
一个对象是否有虚引用存在,完全不会对其生存时间构成影响。唯一目的就是在这个对象被收集器回收时收到一个系统通知
他不能单独使用,也无法通过虚引用获取被引用的对象
测试代码
控制台输出
在JDK源码中DirectByteBuffer使用了虚引用
当DirectByteBuffer对象被回收时,通过引用队列得到通知,回收申请的直接内存
DirectByteBuffer中有一个静态内部类Deallocator实现了Runnable接口,重写了run()方法,run()方法内部调用了unsafe.freeMemory(address),也就是释放申请的直接内存的逻辑
DirectByteBuffer的构造方法中139行cleaner = Cleaner.create(this, new Deallocator(base, size, cap));创建了Deallocator对象,同时添加到Clear类内部的链表中
Deallocator重写的run()方法什么时候被调用
Reference类内部静态内部类ReferenceHandler的静态代码块启动了Reference Handler线程,专门处理ReferenceQueue中额外的回收任务
ReferenceHandler类的run()方法内部有代码((Cleaner)r).clean()间接调用了Deallocator重写的run()方法,至此申请地直接内存被回收
终结器引用
用以实现对象的finalize()方法,所以被称为终结器引用
无需手动编码,其内部配合引用队列使用
GC时,终结器引用入队,由finalizer线程通过终结器引用找到被引用对象并调用他的finalize方法,第二次GC时才能回收被引用对象
对象可达性判断(GC引用链路存在多种引用,如何判断对象的引用类型)
当一个对象被一个或者多个对象同时引用,且引用还有强、软、弱、虚那么如何判定引用关系呢?
下图是一个引用关系图。在这个树形引用链中,对象的箭头代表了引用关系。比如到达对象5就有① - ⑤和③ - ⑦这两种途径
由此带来了一个问题,某个对象的可达性如何判断
单条引用路径可达性判断:在这条路径中,最弱的一个引用决定对象的可达性
多条引用路径可达性判断:几条路径中,最强的一条引用决定对象的可达性
比如,假设①、③为强引用,⑤为软引用,⑦为弱引用。对于对象5按照这两个原则,路径① - ⑤取最弱的引用⑤,因此该路径对象5的引用为软引用。同样③ - ⑦为弱引用。在这两条路径之间取最强的引用,最终对象5是一个软引用
内存回收细节(重点)
概述
HotSpot虚拟机如何发起内存回收、如何加速内存回收、以及如何保证回收的正确性
枚举GC Roots(加速内存回收)
虚拟机并不是从GC Roots可能存在的位置(局部变量表、方法区静态变量、方法区常量等)开始查找,而是使用一组称为OopMap的数据结构记录GC Roots
一旦类加载动作完成时,HotSpot就会把对象内引用数据类型(普通对象指针 Ordinary Object pointer OOP)计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用
安全点(SafePoint)(解决如何停止用户线程)
程序执行并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点
安全点的选定既不能太少以至于让收集器等待时间太长,也不能太过于频繁以至于过分增加运行时的内存负荷
大部分指令执行都比较短,通常会根据是否具有让程序长时间执行的特征为标准选择一些执行时间较长的指令作为安全点,比如方法调用,循环跳转和异常跳转等
如何在垃圾收集发生时让所有的线程都跑到最近的安全点,然后停顿下来。这里有两种方案:抢先式中断和主动式中断
停止用户线程的方式
抢先式中断
JVM中断所有用户线程,如果还有线程不在安全点,就恢复线程,让线程跑到最近的安全点
没有虚拟机采用
主动式中断
设置一个中断标志,各个线程运行到安全点的时候,主动轮询这个标志,如果标志为真,则将自己进行中断挂起
轮询标志的地方和安全点是重合的
HotSpot虚拟机使用内存保护陷阱,把轮询操作精简到只有一条汇编指令test
安全区域(SafeRegion)(解决如何停止用户线程)
安全区域流程图
如果线程处于sleep或者blocked状态,这时候线程无法响应JVM中断请求,走到安全点去中断挂起。对于这种情况,就需要安全区域来解决
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的
当线程运行到安全区域代码时,首先标志已经进入了安全区域,如果进行GC,JVM会忽略标识为安全区域状态的线程
当线程即将离开安全区域时,会检查JVM是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,则继续运行。否则线程必须等待直到收到可以安全离开安全区域的信号为止(枚举GC Roots完成之后,所有的用户线程才可以运行)
记忆集与卡表(解决跨代引用问题)
在分代收集理论中,可能存在着跨代引用的问题,例如老年代引用新生代中的对象。因此在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加入GC Roots扫描范围
跨代引用的产生
老年代引用新生代的对象,对象从新生代晋升到老年代
新生代引用老年代,用户线程手动修改老年代对象的引用,使其指向新生代的对象
事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器(使用分区算法)G1、ZGC和Shenandoah收集器,都会存在着相同的问题
记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构
在新生代中开辟一块内存记录老年代中哪些内存区域存在着对新生代对象的引用(GC Roots)
如何记录这种关系呢?一般而言有三种方式
字段精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字段包含跨带指针
对象精度:每个记录精确到一个对象,该对象里有字段包含跨代指针
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨带指针
第三种"卡精度"使用一种称为卡表(Card Table)的方式去实现记忆集。可以理解为记忆集是一种抽象的接口,卡表是记忆集的一种具体实现
卡表使用字节数组实现,字节数组的每一个元素都对应着一块内存,这个内存块称为"卡页"(Card Page)。一般来说卡页大小都是2的N次幂的字节数,HotSpot虚拟机中使用卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)
记忆集合实际上就是内存空间的粗粒度的位图表示(BitMap)
写屏障(变量赋值前后进行记录,类似于AOP操作)
使用记忆集来缩减GC Roots扫描范围,但是没有解决卡表元素如何维护的问题。例如它们何时变脏、谁来把他们变脏
卡表变脏时机:跨代引用,本代对应区域的卡表变脏,时间点原则上是引用字段赋值的那一刻
HotSpot虚拟机通过写屏障(Write Barrier)技术来维护卡表状态
写屏障可以看做是在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是赋值的前后都在写屏障的覆盖范围内
在赋值前的部分的写屏障叫做写前屏障,在赋值后的叫做写后屏障。HotSpot虚拟机大多数垃圾收集器只用了写后屏障,除了G1收集器
三色标记法(解决并发标记地正确性)(重点)
前面多次强调枚举GC Roots的时候需要保证在一个满足一致性的快照中才能进行正确地遍历对象图,但是这会停止所有用户线程(STW事件)。那么有没有办法让用户线程和垃圾收集器线程并发执行呢,降低GC停顿时间呢?
如果用户线程和收集器线程并发执行可能导致对象关系发生变化,导致两种结果
把原本消亡的对象标记位存活,这不是好事,但其实是可以接受的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理就好了
把原本存活的对象错误标记为已消亡,这就是非常致命的错误了
现代垃圾收集器基本上都是基于三色标记法遍历对象图,来确定对象间的引用关系
三色标记法基本概念(白色、黑色、灰色)
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始阶段,所有的对象都是白色的,若分析结束阶段,仍然是白色的对象,即代表不可达
黑色:表示对象已经被垃圾收集器访问过,且这个对象所有直接引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过
其基本的运行过程如下:假设现在有黑、白、灰三个集合(表示当前对象的颜色),其遍历访问过程为:
(1)初始时,所有对象都在白色集合
(2)将GC Roots直接引用的对象挪到灰色对象中
(3)从灰色对象集合中获取对象obj
(3.1)将obj对象引用到的其他对象全部挪到灰色集合中
(3.2)当obj对象的所有直接引用都已经遍历完成,将obj对象挪到黑色集合里面
(4)重复步骤(3)直至灰色集合为空时结束
(5)结束后,仍在白色集合的对象即为GC Roots不可达,可以进行内存回收
从上面的过程可以看出对象图的遍历方式是广度优先遍历的方式,当然没有考虑循环引用的情况
三色标记遍历过程(git图片)
当Stop The World(STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。但是当需要支持并发标记时,即标记期间用户线程还在继续跑,对象间的引用关系可能发生变化,多标和漏标的情况可能发生
多标 - 浮动垃圾
示意图
假设已经遍历到E(变成了灰色),此时用户线程执行了objD.fieldE = null; 此刻之后,对象E、F、G是应该被回收的。然而因为E已经变成了灰色,其仍然会被当做存活的对象继续遍历下去。最终的结果:部分对象(F、G)仍然会被标记为存活,即本轮GC不会回收这部分内存
这也就导致了并发标记阶段不能等到内存不够,才来回收内存,而是当内存使用达到一定比例就需要提前开始回收内存
这部分本来应该回收但是没有回收到的内存,被称之为浮动垃圾。浮动垃圾不会影响程序的正确性,这是需要等待下一轮垃圾回收
另外,针对并发标记阶段生成的新对象,通常的做法是直接全部当成黑色的,本轮不会清除。这部分对象可能变成垃圾,这也算是浮动垃圾的一部分
漏标 - 读写屏障
示意图
假设GC线程已经遍历到了对象E(已经变成了灰色),此时用户线程执行了如下代码
此时切回GC线程继续跑,因为对象E已经没有对对象G的引用了,所以不会将对象G放到灰色集合;尽管对象D重新引用了对象G,但是因为对象D已经是黑色的,不会重新做遍历处理
最终导致的结束是:对象G会一直停留在白色集合中,最后被当做垃圾进行清除。这直接影响到了程序的正确性,是不可接受的
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,才会产生漏标
所有灰色对象断开了对白色对象的直接或者间接引用,即灰色对象原来成员变量的引用发生了变化
有一个或者多个黑色对象引用白色对象,即黑色对象成员变量增加了新的引用
过程分析(添加读写屏障)
从代码的角度来看
(1)读取对象E的成员变量fieldG的引用值,即对象G
(2)对象E往成员变量fieldG,写入null值
(3)对象D往成员变量fieldG,写入对象G
我们可以将对象G记录起来,然后作为灰色对象再进行遍历。不管对象G是不是垃圾,都不能回收。比如放入到一个特定的集合,等待初始的GC Roots遍历完(并发标记),该集合的对象遍历即可(重新标记)。重新标记需要STW,因为程序如果一直跑,该集合可能会一直增加新的对象,导致永远跑不完
读屏障拦截第一步,写屏障拦截第二步和第三步。它们拦截的目的很简单:就是在读写前后,将对象G给记录下来
写屏障(Store Barrier)
给某个对象的成员变量赋值时,底层代码大概为
所谓的写屏障就是在赋值前后,加入一些处理(可以参考AOP的环绕通知概念)
写屏障 + SATB
当对象E的成员变量的引用发生变化时(objE.fieldG = null),我们可以利用写屏障,将E原来成员变量的引用G记录下来
模拟代码
当成员变量的引用发生变化之前,记录下原来的引用对象。这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning STAB),当某个时刻的GC Roots确定后,当时的对象图已经确定了
比如当时D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots
可以简单理解为无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索,防止产生漏标,将不是垃圾的对象进行了回收
SATB破坏了条件一:灰色对象断开了白色对象的引用,从而保证了不会漏标
一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断
底层代码
写屏障 + 增量更新
当对象D的成员变量的引用发生变化时(objD.fieldG = G),可以利用写屏障,将D新的成员变量引用对象G记录下来
底层代码
当有新引用插入进行时,记录下新的引用对象。这种做法的思路是:不保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)
可以简单地理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象,重新进行遍历
增量更新破坏了条件二:黑色对象重新引用了该白色对象,从而保证了不会漏标
读屏障(Load Barrier)
读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来
底层代码
这种做法是保守的,但也是安全的。因为条件二中黑色对象重新引用了白色对象,重新引用的前提条件是:得到该白色对象,此时读屏障就发挥作用了
三色标记法与现代垃圾回收器
现代追踪式(可达性分析法)的垃圾收集器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色、黑色集合一般不会出现,灰色集合可以是通过栈、队列、缓存日志的方式实现,遍历的方式可以是广度、深度遍历等等
对于读写屏障,以Java HotSpot VM为例,并发标记对于漏标的处理方案如下:CMS:写屏障 + 增量更新、G1:写屏障 + STAB、ZGC:读屏障
只有理解了三色标记法,才能够理解CMS和G1的垃圾收集过程
垃圾回收器(重点)
如果说垃圾收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者
GC分类与性能指标
垃圾回收器分类
按垃圾回收线程数(串行和并行)
串行垃圾回收器
串行回收指同一个时间段内,只允许一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直到垃圾收集工作结束
在单CPU处理器或者较小应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以串行回收默认被应用在客户端的client模式下的JVM中
在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器
并行垃圾回收器
和串行相反,并行收集可以运用在多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了STW机制
按照工作模式分(并发式和独占式)
并发式
垃圾回收器与应用程序交替工作,以尽可能减少应用程序的停顿时间
独占式
一旦运行,就停止应用程序中所有的用户线程,直到垃圾回收过程完全结束
按照碎片处理方式(是否压缩内存)
压缩式
非压缩式
按个工作内存区间分
年轻代
老年代
性能指标
吞吐量
运行用户代码的时间占总运行时间的比例
总运行时间:程序的运行时间 + 内存回收的时间
吞吐量优先,意味着单位时间内,STW的时间最短
垃圾收集开销
吞吐量的补数,垃圾收集所占用的时间与总运行时间的比例
暂停时间
执行垃圾收集时,程序的工作线程被暂停的时间
暂停时间优先,意味着单次STW的时间最短,但是频率可能增加
收集频率
相对于应用程序的执行,收集操作发生的频率
内存占用
Java堆区所占的内存大小
快速
一个对象从诞生到被回收经历的时间
不可能三角
简单来说抓住两点:吞吐量和暂停时间
高吞吐量与低暂停时间,是一对互相竞争的。因为如果高吞吐量优先,必然需要降低内存回收的执行频率,导致GC需要更长的暂停时间来执行内存回收
如果选择低延迟优先为原则,也只能频繁的执行内存回收,引起程序吞吐量的下降
普遍的标准,在最大吞吐量优先的情况下,降低停顿时间
不同的垃圾回收器概述
垃圾回收器的发展迭代史
Serial GC
1999年JDK1.3.1
第一款GC
ParNew
是SerialGC收集器的多线程版本,主要是配合CMS使用
Parallel GC和Concurrent Mark SweepGC
JDK1.4.2
2002年2月26日
ParallelGC在JDK1.6之后称为HotSpot默认的GC
G1
2012年
JDK1.7u4
2017年JDK9中G1变成默认的垃圾收集器,以替代CMS
2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性改善最坏情况下的延迟
Epsilon 垃圾回收器、ZGC,可伸缩低延迟垃圾回收器
2018年9月JDK11
Shenandoah GC:低停顿时间的GC,实验版
2019年3月JDK12
增强ZGC
2019年9月JDK13
删除CMS垃圾回收器,扩展ZGC在macOS和Windows上的应用
2020年3月JDK14
7款经典垃圾收集器和垃圾分代之间的关系
截图
垃圾收集器的组合关系
JDK8之前,可以用虚线参考关系
CMS下面的实线,是CMS回收失败的后备方案
JDK8中取消了红线的组合,标记为废弃的。如果要用也可以用
JDK9中将红线做了remove
JDK9中标记CMS为废弃的
JDK14中弃用了绿线组合
JDK14中删除了CMS
JDK9默认使用G1
JDK8默认使用Parallel Scavenge、Parallel Old GC。新生代用了Parallel Scavenge则老年代自动触发用Parallel Old
Parallel底层与ParNew底层不同,所以不能和CMS组合
总结一下随着JDK版本的更新,出现了不同垃圾收集器且组合之间关系也发生变化
Client模式下默认使用Serial GC和Serial Old GC,JDK8默认使用Parallel Scavenge和Parallel Old GC,JDK9默认使用G1,JDK15默认使用ZGC
如何查看默认的垃圾收集器
-XX:+PrintCommandLineFlags
jinfo -flag 相关垃圾回收器参数 进程ID
演示jinfo -flag命令
Serial回收器:串行回收
Serial收集器采用复制算法,串行回收和STW机制的方式执行内存回收
除了年轻代,还有用于执行老年代的Serial Old收集器,同样采取了串行回收,但是用标记压缩算法
执行流程图
使用一个CPU或者一条收集线程去完成垃圾收集工作,在进行垃圾收集时,必须暂停其他所有工作线程
优势
简单而高效,对于限定单个CPU的环境来说,由于没有线程交互的开销,可以获取最高的单线程收集效率
HotSpot虚拟机中,使用-XX:+UseSerialGC指定年轻代和老年代使用串行收集器
对于交互强的应用而言,不会采取串行垃圾收集器
ParNew回收器:并行回收
除了采用并行回收,其他方面和Serial之间几乎没有任何区别
执行流程图
-XX:UseParNewGC手工指定ParNew收集器执行内存回收任务,它表示年轻代使用,不影响老年代
-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数
Parallel回收器:吞吐量优先
也是并行回收
和ParNew不同,它的目标是达到一个可控制的吞吐量
自适应调节策略也是Parallel与ParNew的一个重要区别
适合后台运算不需要太多交互的任务,例如执行批量处理、订单处理、工资支付、科学计算的应用程序
Parallel Old采取标记压缩算法,同样基于并行回收和STW机制
在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在JDK8中是默认的垃圾收集器组合
执行流程
参数配置
-XX:+UseParallelGC
手动指定年轻代使用此收集器执行内存回收任务
-XX:+UseParallelOldGC
手工指定老年代使用并行回收收集器,分别适用于新生代和老年代,默认JDK8是开启的
与上面这两个参数关联,开启一个,默认开启另一个
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数,一般与CPU数量相同,如果CPU数量大于8个,则值 = 3 + (5 * N / 8)
-XX:MaxGCPauseMillis
设置收集器最大停顿时间,单位毫秒
该参数谨慎使用
-XX:GCTimeRatio
垃圾收集占总时间比,用于衡量吞吐量大小
默认99,取值范围0-100,也就是垃圾回收时间不超过1%
与上一个参数矛盾,暂停时间越长,Ratio参数就容易超过设定比例
-XX:+UseAdaptiveSizePolicy
开启自适应调节策略
这种模式下,年轻代大小,Eden和Survivor的比例,晋升老年底对象年龄参数都会被自动调整
为了达到堆大小、吞吐量和停顿时间之间的平衡点
在手动调优比较困难的场景下,可以直接用自适应方式,仅指定虚拟机最大堆,目标吞吐量和停顿时间,让虚拟机自己完成调优工作
CMS回收器:低延迟
JDK1.5推出Concurrent Mark Sweep并发的标记清除垃圾收集器,工作在老年代,单独回收老年代,第一次实现了让垃圾收集线程与用户线程同时工作
执行流程图
初始标记:STW,仅仅只是标记出GC Roots能直接关联的对象,一旦标记完成后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里速度非常快
并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程。可以与垃圾收集线程一起并发运行
重新标记:为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象的标记记录(增量更新,这一步需要STW)
并发清除:清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也可以与用户线程同时并发
初始标记和重新标记阶段仍然需要STW机制
由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此CMS收集器不能像其他收集器那样等到老年代几乎填满再进行回收,而是当堆内存使用率达到某一阈值时,便开始进行回收
要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机启用备用方案,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就长了
CMS采取标记清除算法,会产生内存碎片,只能够选择空闲列表执行内存分配
为什么不采取标记压缩呢?
因为并发清除时,如果用压缩整理内存,对象的地址就会发生变化,用户线程使用对象就会发生错误
标记压缩更适合STW场景下使用
优点
并发收集
低延迟
缺点
会产生内存碎片
对CPU资源非常敏感
在并发阶段会占用一部分线程导致应用程序变慢
无法处理浮动垃圾
并发标记阶段是与工作线程同时运行,如果并发阶段产生垃圾对象,CMS无法进行标记,导致新产生的垃圾对象没有被及时回收,只能在下一次执行GC时释放空间
参数
-XX:+UseConcMarkSweepGC
手工指定CMS收集器执行内存回收任务
开启后,自动将-XX:UseParNewGC打开,即ParNew(Young区)+ CMS(Old区)+ Serial GC组合
-XX:CMSlnitiatingOccupanyFraction
设置堆内存使用率的阈值
一旦达到该阈值,则开始进行回收
JDK5及之前默认68,即老年代的空间使用率达到68%时会执行一次CMS回收
JDK6及以上默认值为92%
如果内存增长缓慢,可以设置一个稍大的值,有效降低CMS的触发频率,减少老年代回收的次数
如果应用程序内存使用率增加很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器
-XX:+UseCMSCompactAtFullCollection
用于执行完Full GC后对内存空间进行压缩整理
不过内存压缩无法并发执行,会带来停顿时间更长的问题
-XX:CMSFullGCsBeforeCompaction
设置执行多少次FullGC后对内存空间进行压缩整理
-XX:ParallelCMSThreads
设置CMS的线程数量
默认启动的线程数是(ParallelGCThreads+3) / 4
ParallelGCThreads是年轻代并行收集器的线程数
小结
如果想要最小化使用内存和并行开销,选择Serial GC
如果最大化应用程序的吞吐量,选择Parallel GC
如果想要最小化的GC的中断或停顿时间,选择CMS GC
CMS在JDK9被标记为废弃的,在JDK14正式删除
G1回收器:区域化分代式
官方给G1设定的目标
就是在延迟可控的情况下,获得尽可能高的吞吐量,所以才担当起全功能收集器的重任和期望
Garbage First
G1是一个并行回收器,他把堆内存分割为很多不相关的区域(Region)(物理上不连续)
使用不同的region表示Eden、S0、S1、老年代等
G1跟踪各个region里面垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
JDK7版本正式启用,JDK9以后默认垃圾回收器
JDK8还不是默认的,需要用-XX:+UseG1GC参数来启用
优势
并行与并发
分代收集
同时兼顾年轻代与老年代
空间整合
region之间用复制算法,整体可以看做是标记压缩算法
两种算法都避免内存碎片,有利于程序长时间运行,分配大对象不会因为无法找到连续空间提前触发下一次GC,尤其当Java堆非常大的时候,G1优势更加明显
可预测的停顿时间模型
能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不能超过N毫秒
缺点
相较于CMS,G1不具备全方位,压倒性优势。比如用户程序运行中,G1无论是为了垃圾收集产生的内存占用,还是程序运行时的额外执行负载都要比CMS要高
经验上来说,小内存应用CMS表现大概率优于G1,在大内存上G1优势发挥更多,平衡点在6 - 8GB
常见参数设置
-XX:+UseG1GC
启用G1垃圾收集器
-XX:G1HeapRegionSize
设置每个Region大小,值是2的幂次方,范围是1MB到32MB之间,目标是根据最小的Java堆划分出约2048个区域,默认是堆内存的1 / 2000
-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标,JVM尽力但不保证,默认200ms
-XX:ParallelGCThreads
设置STW工作线程数的值,最多设置8
-XX:ConcGCThreads
设置并发标记的线程数,将N设置为并行垃圾回收线程数(ParallelGCThreads)的1 / 4左右
-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的Java堆占用率阈值,超过此值就触发GC,默认是45
常见调优
第一步开启G1垃圾收集器
第二步设置堆的最大内存
第三步设置最大的停顿时间
G1提供了三种垃圾回收模式在不同的条件下触发
YoungGC
MixedGC
FullGC
适用场景
面向服务器端应用,针对具有大内存,多处理器的机器
最主要应用是需要低GC延迟
如:在堆大小约6GB或更大,可预测的暂停时间可以低于0.5s,G1每次清理一部分region来保证每次GC停顿时间不会过长
用来替换1.5中的CMS
超过50%的Java堆被活动数据占用
对象分配频率或年代提升频率变化很大
GC停顿时间过长,长于0.5~1秒
region
所有region大小相同,且在JVM生命周期内不会改变
结构图
region可以充当多个角色
垃圾回收过程
年轻代GC(STW)
当年轻代eden区用尽时
并行独占式收集器
老年代GC,并发标记过程
当堆内存使用到一定值,默认45%
混合回收
标记完成马上开始混合回收
G1老年代回收器不需要整个老年代都被回收,一次只需要扫描回收一小部分老年代的region就可以了
同时老年代是和年轻代一起被回收的
有可能FullGC
记忆集
每个region对应一个记忆集
通过记忆集避免全局扫描
每次引用类型数据写操作时,会产生一个写屏障暂时中断操作
然后检查将要希尔的引用指向的对象是否和该引用对象类型数据在不同的region,如果不同就通过Card Table把相关的引用信息记录到引用指向对象所在的Region对应的记忆集中
当进行垃圾收集时,在GC根节点枚举范围加入记忆集,就可以保证不进行全局扫描,也不会有遗漏
G1回收过程一:年轻代GC(STW)
1、扫描GC Roots
根是指static变量指向的对象,正在执行的方法调用链上的局部变量等。根引用连同Rset记录的外部引用作为扫描存活对象的入口
2、更新Rset
处理dirty card queue中的card,更新Rset,此阶段完成后,Rset可以准确的反应老年代所在的内存分段中对象的引用
3、处理Rset
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
4、复制对象
对象图被遍历,Eden区内存段中存活的对象会被复制到Survivor去中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,会加一,达到阈值会被复制到Old区中空的内存分段,如果Survivor区空间不够,Eden空间的部分数据会直接晋升到老年代空间
5、处理引用
处理强软弱虚,终结器引用,本地方法接口引用等,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片
G1回收过程二:老年代GC
初始标记阶段(STW)
标记从根节点直接可达的对象,并且触发一次年轻代GC
根区域扫描阶段
扫描Survivor区直接可达老年代区域对象,并标记被引用的对象,这个过程在YoungGC之前完成
并发标记
和应用程序并发执行,并发标记阶段若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收
并发标记过程中,会计算每个区域的对象活性,存活对象的比例
再次标记
由于应用程序持续进行,需要修正上次标记结果,G1采取比CMS更快的原始快照算法(STAB)(STW)
独占清理
计算各个区域存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下个阶段做铺垫(STW)
这个阶段并不会实际上去做垃圾的收集
并发清理阶段
识别并清理完全空闲的区域(STW)
G1回收过程三:混合回收
当越来越多的对象晋升到老年代Old Region时,为了避免内存被耗尽,虚拟机会触发一次混合的垃圾收集器,该算法除了回收整个Young Region,还会回收一部分的Old Region。也要注意Mixed GC并不是Full GC
并发标记结束后,老年代中百分百为垃圾的Region被回收了。部分为垃圾的内存分段被计算出来了,默认情况下,这些老年代的内存分段会分8次被回收,由-XX:G1MixedGCCountTarget参数设置
混合回收的回收集包括八分之一的老年代、Eden区内存分段、Survivor区内存分段
由于老年代中内存分段默认分8次回收,G1会优先回收垃圾多的内存分段,并且有一个阈值会决定内存分段是否被回收。-XX:G1MixedGCLiveThresholdPercent,默认为65%。意思是垃圾占比达到65%才会被回收。如果垃圾占比比较低,意味存活对象较高,复制的时候花更多时间
混合回收不一定要进行8次,有一个阈值:-XX:G1HeapWastePercent
默认值是10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存比例低于10%,则不再进行混合回收,因为GC花费更多的时间,但是回收到的内存却很少
G1可选过程四:FullGC
G1初衷就是要避免FullGC,如果上述方式不能正常工作,G1会停止应用程序的执行。使用单线程的内存回收算法进行垃圾回收,性能非常差。应用程序停顿时间长
比如堆太小,当G1复制存活对象的时候没有空的内存分段可用,则会回退到FullGC
导致FullGC原因可能有两个
回收阶段的时候没有足够的to-space存放晋升的对象
并发处理过程完成之前空间耗尽了
优化建议
避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
固定的年轻代大小会覆盖暂停时间目标
暂停时间目标不要太苛刻,太苛刻会影响吞吐量
垃圾回收器总结
GC日志分析
参数
-verbose:gc参数解析
-XX:+PrintGCDetails参数解析
日志补充说明1
日志补充说明2
GC日志详细解释
GC EASY
关于日志分析工具可以到后面的故障排查章节继续讲解
垃圾回收器的新发展
Shenandoah GC测试结果
Shenandoah GC
强项
低延迟时间
弱项
高运行负担下的吞吐量下降
ZGC(染色指针、读屏障、不分代、内存多重映射)
在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10毫秒以内的低延迟
标记阶段和整理阶段都是并发的,移动对象的过程中也可以并发的
GC过程分为初始标记、并发标记、并发预备重分配、并发重分配、并发重映射
除了初始标记是STW,其他地方几乎都是并发执行的
故障排查与性能调优(重点)
概述
背景说明
生产环境中的问题
生产环境发生了OOM,该如何处理?如何判断是否是内存泄漏导致的?
生产环境应该给Java进程分配多少内存?
生产环境应该如何选择垃圾收集器?
生产环境如何设置JVM参数?
如何对垃圾收集器的性能进行调优?
生产环境CPU负载飙高如何处理?
生产环境线程池的参数如何设置?
如何查看生产环境代码和本地代码是否一致
不重启服务,修改代码加log,如何确定请求是否执行了某一行代码
不重启服务,修改代码加log,如何实时查看某个方法的入参和返回值
为什么要进行调优
防止出现OOM
解决OOM
减少Full GC出现的频率
提高程序的吞吐量
减少GC停顿时间
减少Java进程的内存占用
不同阶段进行考虑
上线前,在本地开发环境
项目运行阶段
线上出现OOM
调优概述
监控的依据
运行日志
堆栈异常信息
GC日志
线程快照
堆转储快照(内存快照)
调优的大方向
合理编写代码
充分并合理的使用硬件资源
合理地进行JVM调优
性能优化的步骤
第一步(发现问题):性能监控
GC频繁
CPU load过高
发生了OOM
线程死锁
内存泄漏
程序响应时间较长
第二步(排查问题):性能分析
打开GC日志,通过GC分析工具,分析GC日志
灵活运用命令行工具:jstack、jmap、jinfo等
使用阿里Arthas实时查看JVM状态
jstack查看堆栈信息
第三步(解决问题):性能调优
适当增加内存,根据业务情况选择垃圾收集器
优化代码,控制内存使用
增加机器,分散节点压力
合理设置线程池参数
使用中间件提高效率,比如缓存、消息队列等
性能评价
1. 停顿时间
在垃圾回收环节中,执行垃圾收集时,用户线程被暂停的时间,一般而言10ms就为优
有些注重低延迟的服务要求接口在50ms以内返回,接口执行的平均时间在40ms左右,那么就要求GC停顿时间不能超过10ms
2. 吞吐量
运行用户代码时间占Java进程总时间的比例(运行总时间:程序运行时间 + 内存回收的时间)
3. QPS
每秒响应请求数。例如1000个人同时在线,估计并发数在5% - 15%之间,也就是QPS在50 - 150之间
4. 内存占用
Java进程内存占用大小
5. 请求收到GC影响比例计算
受GC影响请求占比 = (接口响应平均时间(ms) + GC平均时间(ms)) * GC发生次数N / 程序运行时间T(ms)
该指标用于描述GC对接口的响应时间的影响,在注重程序可靠性时比较关心该指标
示例:例如在程序运行时间T内,发生N次GC,接口响应平均时间为50ms,GC平均时间为25毫秒,那么受GC影响请求占比 = (50 + 25) * N / T
使用jstat -gc -t <pid>命令,找到Timestamp、YGC、YGCT、FGC、FGCT列
Timestamp:JVM进程的运行时间,单位秒
YGC:YGC发生的次数
YGCT:YGC总共耗时,单位秒
FGC:FGC发生的次数
FGCT:FGC的总耗时,单位秒
受YGC影响请求占比 = (接口响应平均时间(ms) + YGCT / YGC * 1000) * YGC / (Timestamp * 1000)
受FGC影响请求占比 = (接口响应平均时间(ms) + FGCT / YGC * 1000) * FGC / (Timestamp * 1000)
JVM监控及诊断工具
命令行工具(重点)
jps(虚拟机进程状况工具)
JDK的很多小工具的名字都参考了UNIX命令的命名方式,jps(JVM Process Status Tool)是其中的典型
可以列出当前主机上正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)
该命令配合其他命令使用,主要是获取Java进程的id
与ps -ef | grep java命令类似
位置在JAVA_HOME的bin目录下
位置图片
基本语法
基本格式:jps [options]
options参数
-q:只显示pid用户进程
-l:输出main类或Jar的全限名
-v:输出传入JVM的参数
-m:输出传入给main()方法的参数
hostid参数:查看远程主机上的Java进程,一般不使用。一般都是使用Xshell直接连接至远程服务器
实际使用jps -lmv命令就可以了
示例
直接使用jps -mlv命令
第一列是进程id:pid
第二列是全类名、传递给main方法的参数、JVM参数
jinfo(实时查看和修改JVM配置参数)
基本情况
有些时候我们希望查看Java进程的某些JVM参数,或者实时修改某些JVM参数,那么jinfo命令可以帮我们做到这点
位置在JAVA_HOME/bin目录下
基本语法jinfo -option <pid>
查看
jinfo -sysprops PID
查看系统参数,Java代码通过System.getProperties("参数名")获取
通过JVM参数-Dkey=value,可以设置系统参数
jinfo -flags PID
查看曾经赋值过的一些参数
jinfo -flag 具体参数名 PID
查看对应参数的值
修改
针对boolean类型
jinfo -flag +|- 具体参数名 PID
针对非boolean类型
jinfo -flag 具体参数名=具体参数值 PID
并不是所有参数都支持动态修改
查看支持动态修改的参数:java -XX:+PrintFlagsFinal -version | grep manageable,参数是manageable才能够进行动态修改
拓展
java -XX:+PrintFlagsInital
查看所有JVM参数的启动初始值
java -XX:+PrintFlagsFinal
查看所有JVM参数的最终值
java -XX:+PrintCommandLineFlags
查看那些已经被用户或者JVM设置过的详细XX参数的名称和值
示例
查看系统参数jinfo -sysprops PID
查看赋值的参数jinfo -flags PID
查看对应参数的值jinfo -flag 具体参数名 PID
针对boolean类型参数进行修改jinfo -flag +|- 具体参数名 PID
针对非boolean类型
jstat(虚拟机统计信息监视工具)
基本情况
jstat是JDK自带的一个轻量级小工具。全称Java Virtual Machine statistics monitoring tool
jstat工具特别强大,有众多的可选项,详细查看堆内各个部分的使用量,以及加载类的数量。使用时,需加上查看进程的进程id和所选参数
在JAVA_HOME的bin目录下
基本语法
帮助信息
基本格式 jstat -<option> [-t] [-h<lines>] <vmid> [<interval>] [<count>]
option参数
所有可用的option参数
class 显示classLoader相关信息
compiler 显示JIT编译相关信息
gc 显示与GC相关堆信息
gccapacity 显示各个代的容量以及使用情况
gccause 显示垃圾收集相关信息,最后一次垃圾回收的原因
gcnew 显示新生代信息
gcnewcapacity 显示新生代使用情况
gcold 显示老年代和永久的信息
gcoldcapacity 显示老年代的大小
gcpermcapactiy 显示永久代的大小
gcutil 显示垃圾收集信息
printcompilation 输出JIT编译的方法信息
interval 可选参数,指定输出统计数据的周期,单位毫秒,即为查询间隔
count 可选参数,指定查询的总次数
-t 可以在输出信息之前加上一个timestamp列,显示程序运行的时间,单位秒
-h 可以在周期性输出数据的同时,在输出多少行之后输出一个表头信息
示例1:jstat -gc pid 显示与GC相关的堆内存信息
打印时间戳,每隔1000毫秒统计堆内存以及GC信息,总共3次
字段解释
这里可以计算一下吞吐量以及GC停顿时间
吞吐量:1 - (GC耗费的时间 / 程序运行的总时间)
1 - GCT / Timestamp
GC停顿时间:单次GC所消耗的时间,单位毫秒
(YGCT + FGCT) / (YGC + FGC) * 1000
一般而言GC的停顿时间在200ms以内,响应时间算不错
示例2:jstat -gccause pid 显示伊甸园区、幸存者区、老年代、元空间使用比例,以及上一次GC发生的原因
打印时间戳,每隔1000毫秒统计堆内存以及GC信息,总共3次
字段解释
示例3:jstat -gccapacity pid 显示堆内存各区域初始化大小、最大大小和占用大小
打印时间戳,每隔1000毫秒统计堆内存各区域初始化大小、最大大小和占用大小,总共3次
字段解释
jmap(导出内存映像文件&内存使用情况)
基本情况
jmap全称(JVM Memory Map):作用一方面获取dump文件(堆转储快照,二进制文件),它还可以获取目标Java进程的内存相关信息,包括Java堆内存各区域的使用情况、堆中对象的统计信息、类加载信息等
位置在JAVA_HOME/bin目录下
基本语法
帮助信息
基本格式:jmap [option] pid
option参数
-dump
生成Java堆转储快照:dump文件
特别地:-dump:live只保存堆中存活的对象
-heap
输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等
-histo
输出堆中对象的统计信息,包括类、实例数量和合计容量
特别的:-history:live只统计堆中存活的对象
-finalizerinfo
显示在F-queue中等待Finalizer线程执行finalize方法的对象
-F
当虚拟机进程对-dump没有任何响应时,可以使用此参数强制生成dump文件
示例1:导出堆转储快照文件
手动的方式
jmap -dump:live,format=b,file=<filename.hprof> <pid>
dump指定Java进程的内存快照,只保存存活对象,并且保存到指定路径下
自动的方式(添加JVM参数)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=<filename.hprof>
代码和JVM参数
示例2:堆内存使用情况(jmap -heap <pid>)
显示Java进程堆内存的配置信息和使用情况
示例3:查看存活对象情况(jmap -histo:live <pid>)
查看堆内存中存活的对象情况,按照类的实例总大小降序排序
类名解释如下
可以通过该命令可以判断对象生成的速率
例如前后间隔5秒,打印出对象直方图。查看所属类实例的增长情况
注意事项
jmap相关的命令不能在生产环境随便使用,尤其是dump内存快照这个命令
当想要获取JVM运行时的内存快照时,需要满足让所有的用户线程都停止在安全点或者是安全区,停止所有的用户线程(STW)这样对象间的引用关系不会发生变化,得到的dump结果才是正确的
jstack(Java堆栈跟踪工具)
基本情况
jstack命令可以dump当前运行JVM的线程快照
线程快照是当前Java虚拟机内每一条线程正在执行的方法堆栈的集合
生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等
在JAVA_HOME/bin目录下
基本语法
帮助信息
基本格式 jstack -<option> <pid>
option参数
-F 当正常输出的请求不被响应时,强制输出线程堆栈
-l 除了堆栈外,显示关于锁的附加信息
-m 如果调用了本地方法,显示C/C++的堆栈
示例:dump某个Java进程的线程快照,并且输出锁的附加信息
Java进程中包含JVM线程和用户线程
JVM线程
JIT编译线程:如C1 CompilerThread0、C2 CompilerThread0
GC线程: 如GC Thread0、G1 Young RemSet Sampling
其它内部线程: 如VM Periodic Task Thread、VM Thread、Service Thread
Finalizer线程:用于执行对象重写Object类的finalize()方法的线程
Referencer线程:用于执行在引用队列中的事件
Attach Listener:接收外部命令(jstat、jinfo、jmap等)的线程
Signal Dispatcher:当Attach Listener线程接收到命令后,会交给Signal Dispatcher线程去进行分发到各个不同的模块处理命令,并且返回处理结果
DestroyJavaVM:JVM在服务器启动之后,就会唤起DestroyJavaVM线程,处于等待状态,等待其它线程(Java线程和native线程)退出时通知它卸载JVM。线程退出时,都会判断自己当前是否是整个JVM中最后一个非daemon线程,如果是,则通知DestroyJavaVM线程卸载JVM
打印内容
线程名称
线程优先级
线程16进制id
线程的状态
线程等待的锁对象
线程的方法调用栈
dump线程内容图示
arthas(Java应用诊断利器)
基本概述
背景
Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱
当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决
这个类从哪个jar包加载的?为什么会报各种类相关的Exception?
我改的代码为什么没有执行到?难道是我没commit?分支搞错了?
遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
线上遇到某个用户的数据处理有问题,但线上同样无法debug,线下无法重现!
是否有一个全局视角来查看系统的运行状况?
有什么办法可以监控到JVM的实时运行状态?
怎么快速定位应用的热点,生成火焰图?
Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的Tab自动补全功能,进一步方便进行问题的定位和诊断
官方文档:https://arthas.aliyun.com/doc/
安装、卸载和启动
安装
1. 下载arthas-boot.jar
通过curl -O https://arthas.aliyun.com/arthas-boot.jar命令下载
如果下载速度比较慢,可以使用aliyun的镜像:java -jar arthas-boot.jar --repo-mirror aliyun --use-http
2. 使用as.sh
Arthas 支持在 Linux/Unix/Mac 等平台上一键安装,请复制以下内容,并粘贴到命令行中,敲回车执行即可:
curl -L https://arthas.aliyun.com/install.sh | sh
上述命令会下载启动脚本文件as.sh到当前目录
3. 通过rpm/deb来安装
在releases页面下载rpm/deb包: https://github.com/alibaba/arthas/releases
安装deb sudo dpkg -i arthas*.deb
安装rpm sudo rpm -i arthas*.rpm
deb/rpm安装的用法。在安装后,可以直接执行:as.sh
卸载
在Linux/Unix/Mac平台删除下面文件:rm -rf ~/.arthas/ rm -rf ~/logs/arthas
Windows平台直接删除user home下面的.arthas和logs/arthas目录
启动
jar包的方式:java -jar arthas-boot.jar
脚本的方式:sh as.sh或者是./as.sh
查看日志
cat ~/logs/arthas/arthas.log
查看帮助
java -jar arthas-boot.jar -h
退出
如果只是退出当前的连接,可以用quit或者exit命令。Attach到目标进程上的arthas还会继续运行,端口会保持开放,下次连接时可以直接连接上
如果想完全退出arthas,可以执行stop命令
诊断命令
基础指令
help——查看命令帮助信息
显示所有的命令以及对命令的简单介绍
cat——打印文件内容,和linux里的cat命令类似
echo–打印参数,和linux里的echo命令类似
grep——匹配查找,和linux里的grep命令类似
pwd——返回当前的工作目录,和linux命令类似
cls——清空当前屏幕区域
history——打印命令历史
quit——退出当前Arthas客户端,其他Arthas客户端不受影响
stop——关闭Arthas服务端,所有Arthas客户端全部退出
JVM相关
dashboard——当前系统的实时数据面板
help命令帮助信息
显示线程信息、内存使用情况、垃圾回收汇总信息、系统总体运行情况等
thread——查看当前JVM的线程堆栈信息
帮助信息
示例1:thread -n 3,一键展示当前最忙的前N个线程并打印堆栈
如果后台GC线程经常性非常繁忙,说明GC频繁,这时需要观察应用的运行状况了,可能是堆内存不够了,即将要发生OOM了
示例2:thread -b, 找出当前阻塞其他线程的线程
示例3:thread --state ,查看指定状态的线程
heapdump——dump java heap,类似jmap命令的heap dump功能
和jmap命令类似,dump当前JVM进程的内存快照
-l或者是--live参数,只dump存活的对象
示例:只dump存活的对象到指定文件
logger——查看和修改logger
可以热更新某个类的日志级别,对于生产环境排查错误非常有用
例如当生产环境发生异常,线上无法debug,线下无法复现。可以动态修改类的日志级别为debug,打印更多的日志信息帮助排查
当然了前提是输出的debug日志对你有帮助,不然就没有意义了
示例:动态修改日志级别从info为debug,排查完成之后改回成info级别
演示项目是一个简单的springboot的web项目
HelloController的代码,访问http://localhost:8080/test,控制台打印不同级别的日志信息
logback-spring.xml的配置文件,默认为info级别
控制台输出info级别及以上的日志信息
接下动态修改HelloControlller的日志级别为debug,并进行访问测试
使用sc -d 全类名命令,查看加载该类的类加载器的hashcode值
使用logger -n 全类名命令,查看待修改类的日志级别
使用logger -c 类加载器的hash值 -n 全类名 -l debug命令,修改日志级别
使用curl命令进行访问测试
控制台输出debug及以上级别的日志
使用logger -c 类加载器的hash值 -n 全类名 -l info命令重新修改为info级别
jvm——查看当前JVM的信息
可以显示当前JVM的运行时信息、内存信息和加载的类信息等
sysprop——查看和修改JVM的系统属性
帮助信息
和jinfo -sysprops <pid>命令类似,查看JVM系统属性
查看JVM某个系统属性(file.encoding)
修改JVM某个系统属性
将user.country从CN修改为US,修改完成之后查看
sysenv——查看JVM的环境变量
查看当前JVM的环境属性(System Environment Variables)
vmoption——查看和修改JVM里诊断相关的option
帮助信息
vmoption基本上都是可以通过jinfo查看以及动态修改的JVM参数
查看所有参数:vmoption
查看指定参数的值:vmoption PrintGCDetails
修改指定参数的值:vmoption HeapDumpOnOutOfMemoryError true
class、classloader相关
jad——反编译指定已加载类的源码
这个指令非常有用,可以将类进行反编译
jad命令将类反编译后就可以查看源码,与本地代码进行比对
帮助信息
反编译java.lang.String类的toString()方法,并且重定向到String.java中,命令jad --source-only java.lang.String
sc——查看JVM已加载的类信息
sc是search class的简写,顾名思义查看类的详细信息
该命令主要是查看加载该类的类加载器的hash值
帮助信息
查看java.lang.String的信息
mc——内存编译器,内存编译.java文件为.class文件
编译生成.class文件之后,可以结合retransform命令实现热更新代码
注意,mc命令有可能失败。如果编译失败可以在本地编译好.class文件,再上传到服务器
帮助信息
先使用jad命令进行反编译,然后使用vim命令,修改源码,添加了一条测试输出
使用mc -c 类加载器的hash值 源码文件名命令,进行内存编译
retransform——加载外部的.class文件,retransform到JVM里
加载指定的.class 文件,然后解析出class name,再使用retransform命令替换JVM中已加载的对应的类
每加载一个.class 文件,则会记录一个retransform entry
不同的是这个只是临时替换,还有机会可以反悔,也就是删除retransform entry即可
示例:修改HelloController中的代码进行测试
使用curl命令进行测试
消除retransform的影响,只需要删除对应的entry即可
重新使用curl命令进行测试,发现测试日志没有了
retransform的限制
不允许新增、删除field、method
不允许修改field名
不允许修改方法参数、方法名称及返回值
只能修改方法体中的内容
正在跑的函数,没有退出不能生效
上传.class文件到服务器的技巧
有的服务器不允许直接上传文件,可以使用base64命令来绕过
在本地先转换.class文件为base64,再保存为result.txt
base64 < Test.class > result.txt
到服务器上,新建并编辑result.txt,复制本地的内容,粘贴再保存
把服务器上的result.txt还原为.class
base64 -d < result.txt > Test.class
redefine——加载外部的.class文件,redefine到JVM里
可以实现代码的热更新
redefine的class不能修改、添加、删除类的field和method,包括方法参数、方法名称及返回值
和retransform命令类似,区别在于redefine后的原来的类不能恢复,retransform可以通过删除entry恢复
sm——查看已加载类的方法信息
Search-Method的简写,这个命令能搜索出所有已经加载了Class信息的方法信息
查看java.lang.String的toString方法
classloader——查看classloader的继承树、urls、类加载信息
帮助信息
按类加载实例查看统计信息
可以查看某个类加载器实际的urls,这里的urls指类加载器加载的路径,也就是说会把该路径下的所有类加载到内存中
可以让指定的classloader去getResources,打印出所有查找到的resources的url。对于ResourceNotFoundException比较有用
查看URLClassLoader实际的urls
使用ClassLoader去查找resource
使用ClassLoader去查找class文件
watch、trace、monitor、stack
watch——方法执行数据观测
观察指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写OGNL表达式进行对应变量的查看
命令格式watch [-option] class-pattern method-pattern [express] [condition-express]
命令解释:观察类的某个方法的执行情况,包括入参、返回值(express表达式:"{params,returnObj}")等。有时候观察所有情况无意义,可以在特定条件下触发,例如运行时间大于100ms(condition-express:'#cost>200')
参数解释
express:观察表达式
观察表达式的构成主要由ognl表达式组成,可以写"{params,returnObj}",只要是一个合法的ognl表达式,都能被正常支持
支持的变量
condition-express:条件表达式
当条件表达式为true时,才进行输出
例如发生异常才输出(-e选项)
例如当耗时大于200ms时才会输出,过滤掉执行时间小于200ms的调用(#cost>200(单位是ms))
例如第一个参数小于0时才输出("params[0]<0")
特别说明
watch命令定义了4个观察事件点,即-b(before)方法调用前,-e(exception)方法异常后,-s(success)方法返回后,-f(e + s)方法结束后
4个观察事件点-b、-e、-s默认关闭,-f默认打开,当指定观察点被打开后,在相应事件点会对观察表达式进行求值并输出
这里要注意方法入参和方法出参的区别,有可能在中间被修改导致前后不一致,除了-b事件点params代表方法入参外,其余事件都代表方法出参
当使用-b时,由于观察事件点是在方法调用前,此时返回值或异常均不存在
启动快速入门里的arthas-demo,以便后续的演示案例
示例
示例1:观察方法出参和返回值
示例2:观察方法入参
对比前一个例子,返回值为空(事件点为方法执行前,因此获取不到返回值)
示例3:同时观察方法调用前和方法返回后
参数里-n 2,表示只执行两次
这里输出结果中,第一次输出的是方法调用前的观察表达式的结果,第二次输出的是方法返回后的表达式的结果
结果的输出顺序和事件发生的先后顺序一致,和命令中-s -b的顺序无关
示例4:条件表达式的例子
只有满足条件的调用,才会有响应
示例5:观察异常信息的例子
-e表示抛出异常时才触发
express中,表示异常信息的变量是throwExp
示例6:按照耗时进行过滤
#cost>200 (单位是ms)表示只有当耗时大于0.02ms时才会输出,过滤掉执行时间小于0.02ms的调用
trace——方法内部调用路径,并输出方法路径上的每个节点上耗时
trace命令能主动搜索class-pattern/method-pattern对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路
命令格式trace [-options] class-pattern method-pattern [condition-express]
命令解释:在满足条件表达式(condition-express)的情况下,根据options选项输出某个类下的某个方法的调用节点耗时
options参数解释
案例
案例1:打印方法执行时间超过0.1ms的,节点耗时
案例2:打印包含JDK的函数耗时
--skipJDKMethod <value> skip jdk method trace, default value true
默认情况下,trace不会包含JDK里的函数调用,如果希望trace JDK里的函数,需要显式设置--skipJDKMethod false
演示案例:打印包含JDK的函数耗时
monitor——方法执行监控
对匹配class-pattern/method-pattern/condition-express的类、方法的调用进行监控
可以统计某个方法在一段时间的执行时间和结果,对于排查系统性能瓶颈很有帮助
monitor命令是一个非实时返回命令。实时返回命令是输入之后立即返回,而非实时返回的命令,则是不断的等待目标Java进程返回信息,直到用户输入Ctrl+C为止
命令格式monitor [-option] class-pattern method-pattern [condition-express]
options参数
示例
示例1:计算条件表达式过滤统计结果(方法执行完毕之后)
示例2:计算条件表达式过滤统计结果(方法执行完毕之前)
stack——输出当前方法被调用的调用路径
很多时候我们都知道一个方法被执行,但这个方法被执行的路径非常多,或者你根本就不知道这个方法是从那里被执行了,此时你需要的是stack命令
命令格式stack [-options] class-pattern [method-pattern] [condition-express]
案例
案例1:根据条件表达式来过滤
案例2:根据执行时间来过滤
tt、profiler
tt——方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测
缘由
watch虽然很方便和灵活,但需要提前想清楚观察表达式的编写(condition-express编写),这对排查问题而言要求太高,因为很多时候我们并不清楚问题出自于何方,只能靠蛛丝马迹进行猜测
这个时候如果能记录下当时方法调用的所有入参和返回值、抛出的异常会对整个问题的思考与判断非常有帮助
于是乎,TimeTunnel命令就诞生了
命令格式tt [-options] [class-pattern] [method-pattern] [condition-express]
options参数
-t参数,必带参数
tt 命令有很多个主参数,-t 就是其中之一。这个参数的表明希望记录下类*Test的print方法的每次执行情况
-n 3
当你执行一个调用量不高的方法时可能你还能有足够的时间用CTRL+C中断tt命令记录的过程,但如果遇到调用量非常大的方法,瞬间就能将你的JVM内存撑爆
此时你可以通过-n 参数,指定你需要记录的次数,当达到记录次数时Arthas会主动中断tt命令的记录过程,避免人工操作无法停止的情况
-l
展示所有的方法调用的记录
-s <value>
一般和-l一起使用,和condition-express类似,进行过滤
-i <value>
index的简写,显示某个具体调用的方法,value的值可以从-l打印的index值获得
解决方法重载和指定参数
方法参数个数不一致
tt -t *Test print params.length==1
方法参数数据类型不一致
tt -t *Test print 'params[1] instanceof Integer'
解决特定参数
tt -t *Test print params[0].mobile=="13989838402"
案例演示
记录调用
对于一个最基本的使用来说,就是记录下当前方法的每次调用环境现场
记录10次primeFactors方法的调用
字段解释说明
检索调用记录
tt -l
查看所有的调用记录
tt -s <搜索表达式,ognl表达式>
tt -s 'isReturn==false',只查看失败的调用
查看调用信息
对于具体一个时间片的信息而言,你可以通过-i参数后边跟着对应的INDEX编号查看到他的详细信息
tt -i 1024,查看index为1024的方法调用过程
重做一次调用
tt命令由于保存了当时调用的所有现场信息,所以我们可以自己主动对一个INDEX编号的时间片自主发起一次调用
-p参数进行重新调用
--replay-times <value>指定调用次数
--replay-interval <value>指定多次调用间隔(单位ms, 默认1000ms)
tt -i 1024 -p --replay-times 1
你会发现结果虽然一样,但调用的路径发生了变化,由原来的程序发起变成了Arthas自己的内部线程发起的调用了
观察表达式
表达式核心变量所有变量都可以打印,如成员变量、方法输入参数、返回参数等
调用静态方法
案例1:打印静态成员变量
案例2:调用静态成员的方法
删除调用记录
tt --delete-all,删除所有的方法调用记录
GUI可视化工具
MAT(Memory Analyzer Tool)
基本概述
MAT是Memory Analyzer tool的缩写,是一种快速,功能丰富的Java堆分析工具,能帮助你查找内存泄漏和减少内存消耗
使用MAT工具主要是用来分析内存泄漏的问题
安装
MAT有eclipse插件版本和独立安装版本
现在开发基本上用的都是IDEA,一般选择独立安装版本
下载地址
获取堆dump文件
程序发生OOM自动dump内存快照
JVM参数:-XX:+HeapDumpOnOutOfMemoryError
JVM参数:-XX:HeapDumpPath=XXX.hprof
jmap命令
jmap -dump:live,format=b,path=dump.hprof <pid>
arthas的heapdump命令
使用arthas工具attach到目标JVM
heapdump --live /temp/dump.hprof
可视化工具,点击生成dump文件
jconsole
JProfiler
VisualVM
MAT工具简介
histogram(直方图)
直观列出每个类所对应的对象个数、浅堆大小和深堆大小
图示
字段解释
字段一:表示当前类所对应的对象数量
字段二:Shallow Size是对象本身占据的内存的大小,不包含其引用的对象。对于常规对象(非数组)的Shallow Size由其成员变量的数量和类型来定,而数组的ShallowSize由数组类型和数组长度来决定,它为数组元素大小的总和
字段三:Retained Size=当前对象大小+当前对象可直接或间接引用到的对象的大小总和。(间接引用的含义:A->B->C,C就是间接引用) ,并且排除被GC Roots直接或者间接引用的对象
改变对象的分组方式,默认按照Class进行分组
thread overview
dump内存快照时,同时会dump线程快照
查看系统中的Java线程
点击导航栏中的小齿轮
查询局部变量的信息
对象间引用关系
主要是查看当前对象引用了谁以及谁引用了当然对象
通过查看对象间的引用关系,进而判断该对象是不是应该被回收
如何查看呢?点击对象,右击选择List objects就可以查看了
如何理解incoming和outgoing
图示
以对象为中心,对象引用了别人,那就是outgoing
以对象为中心,别人引用了对象,那就是incoming
with outgoing references
查看当前对象引用了哪些对象
选择上面的outgoing
outgoing,从上往下看
图示
with incoming references
查看哪些对象引用了当前对象
选择上面的incoming
incoming,从下往上看
图示
浅堆和深堆
对象的内存布局(对象头、实例数据、对齐填充)
对象头
运行时元数据(Mark Word),8字节
指向类元数据的类型指针,4字节
如果是数组,还有需要记录数组的长度,4字节
实例数据
基本数据类型
byte、boolean,1字节
short、char,2字节
int、float,4字节
long、double,8字节
引用数据类型,4字节
如果开启了指针压缩,引用都为4字节
注意
不包含static修饰的字段(该字段属于类,而非实例对象)
包含从父类继承的字段
对齐填充
如果前面对象头和实例数据计算出来的大小,不是8字节的整数倍,需要补齐至8字节整数倍大小
浅堆(shallow heap)
指一个对象在堆内存中消耗的内存
如果字段为引用数据类型,不计算实际大小,只计算指针大小
一个类的所有对象的浅堆大小相同
以JDK8中,64位的VM,且开启了指针压缩,String对象的浅堆大小为例(24字节)
JOL计算大小
对象头
运行时元数据,8字节
类指针,4字节
实例数据
char[]数组,引用数据类型,4字节
int hash,基本数据类型,4字节
对其填充
4字节
保留集(retained set)
只能被对象A直接或者间接访问到的对象的集合
如果对象A被回收,那么对象A保留集中的对象都会被回收
深堆(retain heap)
深堆大小就是对象的保留集中所有对象的浅堆大小之和与对象本身浅堆大小的总和
也可以是对象本身浅堆大小和成员变量的深堆大小总和,成员变量必须是保留集中的对象
深堆大小就是该对象被回收后能够直接回收的内存大小
对象的深堆大小和浅堆大小相等对象说明对象没有引用数据类型的字段
举例
图示
对象A的浅堆大小 = (对象头 + C的引用大小 + D的引用大小 + 对齐填充(如果存在))
对象A的保留集 = 对象D
对象A的深堆大小 = (对象A的浅堆大小 + 对象D的浅堆大小)
补充:对象实际大小
一个对象所能访问的所有对象的浅堆大小之和。不管是否在保留集中
案例分析:StudentTrace
代码截图
Student
WebPage
main方法
JVM参数:-XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof
点击齿轮,打开线程快照,找到main线程的,找到main()方法的方法调用栈,这里展示的是对象引用图
Student类的对象浅堆大小(24字节)= 对象头(12字节)+ 实例数据(int 4字节 + String 4字节 + ArrayList 4字节)
Stduent类对象Tom,深堆大小3784字节计算,Tom中放置i被3整除的WebPage对象
3784 = 24(对象本身浅堆大小)+ 48(Tom字符串深堆大小)+ 3712(history的ArrayList深堆大小)
Tom字符串深堆大小48字节计算
48 = 24(字符串本身浅堆大小24字节)+ 24(char[3]数组深堆大小)
所有字符串对象浅堆大小都是24字节
char[3]数组对象深堆大小24字节计算
char[3]数组对象没有任何引用类型字段了,所以浅堆大小就是深堆大小
对象头
运行时元数据(Mark Word),8字节
char[].class指针,4字节
数组长度,4字节
实例数据,1个char为2字节,长度为3,则为2 * 3 = 6字节
对齐填充,前面总共22字节,2字节填充至24字节
history的ArrayList深堆大小3712字节计算
3712 = 24 (ArrayList对象本身浅堆大小)+ 3688(elementData的Object[49]数组深堆大小)
elementData的Object[49]数组深堆大小3688字节计算
3688 = 216(本身浅堆大小)+ 3 * 144(i为3、6、9的WebPage对象) + 20 * 152(i被3整除,不能被5和7整除的WebPage对象)
0~99中例如0、15、21、30、42、45、60、63、75、84、90能够被5或者7整除
也就是说被这些被Jerry或者Lily共同引用,不能作为保留集中的对象
elementData的Object[49]数组浅堆大小216字节计算
对象头
运行时元数据(Mark Word),8字节
类指针,4字节
数组长度,4字节
实例数据
数组长度为49,内部为Object,指针4字节,4 * 49
对其填充
4字节
WebPage对象浅堆大小24字节,深堆大小152字节或者是144字节如何计算,请自己演算一下
支配树(Dominator Tree)
支配树的概念来源于图论
MAT提供了一个称为支配树(Dominator Tree)的对象图
支配树体现了对象间的支配关系
在对象引用图中,如果所有指向对象B的路径都经过对象A,则认为对象A支配对象B
如果对象A离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者
支配树基于对象间的引用图所建立,具有如下性质
对象A的支配子树表示对象A的保留集(retained set),即深堆
如果对象A支配对象B,那么对象A的直接支配者,也支配对象B,也就说支配具有传递性
支配树的边与对象引用图的边不直接对应
举例
图示,左边是对象间引用关系图,右边是支配树图,箭头指向表示引用关系
对象A和对象B由根节点直接支配
到达对象C,可以经过对象A,也可以经过对象B,因此对象C由根节点直接支配
到达对象D和对象E,只能经过对象C,因此对象C是对象D和对象E的直接支配者
到达对象F只能经过对象D,因此对象D是对象F的直接支配者
到达对象G只能经过对象E,因此对象E是对象G的直接支配者
到达对象H必须经过对象C,因此对象C是对象H的直接支配者
查看Student类Tom对象的支配树
图示,点击支配树按钮,搜索main线程
Object[49]数组直接支配的对象总共23个对象
Object[49]引用的对象35个,35个如何计算
直接支配23个WebPage对象
共享Object[].class对象
与Jerry、Liliy共享i为0的WebPage对象
与Jerry共享i为15、30、45、66、75、90的WebPage对象
与Lily共享i为21、42、63、84的WebPage对象
MAT排查内存泄漏案例
说明
通过监控手段发现可能发生内存泄漏,每次GC后,老年代内存持续增长
说明老年代总有一点内存无法回收,且无法回收的内存随着事件的推移在逐渐增加
分析过程
dump多天内存快照进行比较
比较相同对象的深堆大小,查看增长情况
查看泄漏对象中的内容
定位到代码错误处
分析泄漏原因
VisualVM(多和-故障处理工具)
基本概述
插件的安装
主要功能
生成/读取堆内存快照
查看JVM参数和系统属性
查看JVM内存使用情况
生成/读取线程快照
JProfiler
基本概述
介绍
特点
主要功能
方法调用
内存分配
线程和锁
高级子系统
安装与配置
下载与安装
JProfiler中配置IDEA
IDEA集成JProfiler
具体使用
数据采集方式
遥感监测
内存视图
堆遍历
CPU视图
线程视图
监视器&锁
案例分析
案例1
案例2
OQL(对象查询语句)
SELECT子句
FROM子句
WHERE子句
内置对象与方法
JVM运行时参数
JVM参数选项类型
类型一:标准参数选项
特点
以-开头
比较稳定,后续版本基本不会发生变化
例如-D<名称>=<值>,设置环境变量的值,Java代码可以通过System.getenv(名称)方法获取设置的值
各种选项
运行java或者java -help可以看到所有的标准选项
类型二:-X参数选项
特点
非标准化参数
功能比较稳定,后续版本可能发生变化
以-X开头
各种选项
运行java -X命令可以看到所有的X选项
JVM的JIT编译模式相关的选项
-Xint,禁用JIT,所有的字节码都被解释执行,这个模式是最慢的
-Xcomp,所有字节码第一次使用就都被编译成本地机器码,然后执行
-Xmixed,混合模式,默认的模式,根据程序的运行情况,JIT编译线程选择性地将某些热点代码编译成本地机器码
特别地
-Xms<size>,设置初始Java堆大小,等价于-XX:InitialHeapSize
-Xmx<size>,设置最大Java堆大小,等价于-XX:MaxHeapSize
-Xss<size>,设置java线程栈大小,等价于-XX:ThreadStackSize
类型三:-XX参数选项
特点
非标准参数
使用最多的参数类型
这类选项属于实验性,不稳定
以-XX开头
作用
用于开发和调试JVM
分类
Boolean类型格式
-XX:+<option>表示启用option属性
-XX:-<option>表示禁用option属性
例如:-XX:+PrintGCDetails,打印GC详细日志
非Boolean类型格式(key-value类型)
数值类型格式,-XX:<option>=<number>
修改Eden区和Survivor区比例大小-XX:SurvivorRatio=8
非数值类型格式,-XX:<name>=<string>
设置内存快照dump文件的路径-XX:HeapDumpPath=./dump
需要注意的是,如果填写的是目录,目录必须存在
特别地
-XX:+PrintFlagsFinal可以输出所有参数的名称和默认值
添加JVM参数选项
IDEA
运行jar包
命令如下java -Xmx50m -Xms50m -XX:+PrintGCDetails -jar Demo.jar [args]
通过Tomcat运行war包
在Linux系统下,tomcat_home/bin/catalina.sh中添加类似如下配置:JAVA_OPTIONS=-Xms512m -Xmx1024m
在window系统下,catalina.bat中添加如下配置 set "JAVA_OPTS=-Xms521m"
程序运行过程中
使用jinfo -flag <name>=<value> <pid>命令,设置非Boolean类型参数
使用jinfo -falg [+|-]<name> <pid>命令,设置Boolean类型参数
使用arthas的vmoption命令修改
当然这些参数都只能是在运行期间能够动态修改的参数
可以使用java -XX:+PrintFlagsFinal -version | grep manageable命令进行查看哪些参数支持动态修改
常用JVM参数选项
打印设置的XX选项及值
-XX:+PrintCommandLineFlags,打印程序运行前用户手动设置,或者JVM自动设置的参数
-XX:+PringFlagsInitial,打印所有XX选项的默认值
-XX:+PrintFlagsFinal,打印XX选项在程序运行时生效的值
-XX:+PrintVMOptions,打印JVM的参数
堆、栈、方法区等内存大小设置
栈
-Xss128k,设置每个线程的栈大小为128k,等价于-XX:ThreadStackSize=128k
堆内存
-Xms3550m
等价于-XX:InitialHeapSize,设置JVM初始堆内存为3550m
-Xmx3550m
等价于-XX:MaxHeapSize,设置JVM最大堆内存为3550m
-Xmn2g
设置新生代大小为2g
官方推荐配置为整个堆大小的3 / 8
-XX:NewSize=1024m
设置新生代的初始值为1024m
-XX:MaxNewSize=1024m
设置新生代最大值为1024m
-XX:NewRatio=4
设置老年代和新生代(Eden伊甸园区和两个Survivor幸存者区)的比值
默认值为2
-XX:SurvivorRatio=8
设置Eden和Survivor的比例大小为8
默认为8
-XX:+UseAdaptiveSziePolicy
自动选择各区大小比例
-XX:MaxTenuringThreshold
每经历过一次MinorGC后,仍然存活的对象,年龄+1
对象年龄晋升的阈值,当大于该值时,晋升到老年代
默认值为15
-XX:PretenureSizeThreadshold=1024
设置让大于此阈值的对象直接分配在老年代,单位为字节
只对Serial、PerNew垃圾收集器有效
-XX:+PrintTenuringDistribution
让JVM在每次MinorGC后打印当前使用的Survivor中对象的年龄分布
-XX:TargetSurvivorRatio
表示MinorGC结束后Survivor区域中占用空间的期望比例
方法区
永久代
-XX:PermSize=256m,设置永久代初始值为25m
-XX:MaxPermSize=256m,设置永久代最大值为256m
元空间
-XX:MetaspaceSize,设置初始元空间大小
-XX:MaxMetaspaceSize,设置最大元空间大小
-XX:+UseCompressOops,启用压缩对象指针
-XX:CompressClassSpaceSize,设置Klass Metaspace的大小,默认1G
直接内存
-XX:MaxDirectMemorySize,设置直接内存大小,未指定默认和Java堆内存的最大值一样
OutOfMemory相关设置
-XX:+HeapDumpOnOutOfMemoryError,表示出现OOM的时候,dump内存快照
-XX:+HeapDumpBeforeFullGC,表示出现FullGC之前,生成dump文件
-XX:+HeapDumpAfterFullGC,表示出现FullGC后,生成dump文件
-XX:HeapDumpPath=<path>,指定dump文件路径
-XX:OnOutOfMemoryError,指定一个脚本,当发生OOM的时候,执行这个脚本
这个选项非常有用,一般而言,生成发生OOM是很严重的错误,需要及时通知到运维人员
可以在脚本中设置发短信、钉钉、邮件等方式通知运维人员
垃圾收集器相关设置
垃圾收集器组合关系
新生代和老年代垃圾收集器图示
新生代和老年代垃圾收集器组合关系以及后续变化
红线组合在JDK8中标记为过期
红线组合在JDK9中进行了移除
绿线组合在JDK14中进行了移除
在JDK9中标记CMS为过期的
在JDK14中删除了CMS垃圾收集器
在JDK8中默认的组合是Parallel Scavenge GC和Parallel Old GC
在JDK9中默认的组合是G1 GC
在Client模式下默认的组合是Serial GC和Serial Old GC
查看默认的垃圾收集器
java -XX:+PrintCommandLineFlags -version
JAVA8默认使用Parallel并行垃圾收集器
使用jinfo -flag 垃圾回收器选项命令,查看是否启用对应的垃圾收集器
Serial收集器
Serial收集器作为HotSpot中Client模式下默认的新生代垃圾收集器
Serial Old是运行在Client模式下老年代的垃圾收集器
-XX:+UseSerialGC
启用Serial收集器
开启后,新生代和老年代默认都启用串行也就是Serial和Serial Old收集器
ParNew收集器
-XX:+UseParNew
启用ParNew收集器
手动指定年轻代使用ParNew垃圾收集器,主要是配合CMS垃圾收集器使用
老年代默认Serial Old垃圾收集器,可以手动指定使用CMS垃圾收集器
-XX:ParallelGCThreads=N
指定并行线程数量,默认值为CPU核心数
Parallel收集器
-XX:+UseParallelGC
手动指定年轻代启用Parallel并行垃圾收集器
-XX:+UseParallelOldGC
手动指定老年代启用Parallel Old并行垃圾收集器
JDK8默认启用这两个垃圾收集器
一个被激活,另一个也会默认开始(互相激活)
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数
一般的,默认与CPU核心数相等,以避免过多的线程数量影响垃圾收集器性能
默认情况下,当CPU数量小于8个时,ParallelGCThreads的值等于CPU数量
当CPU数量大于8个时,ParallelGCThreads的值等于3 + 5 * CPU_Count / 8
-XX:MaxGCPauseMillis
设置垃圾收集器最大停顿时间(即STW时间),单位是毫秒
为了尽可能把停顿时间控制在MaxGCPauseMillis以内,收集器会在工作时调整JAVA堆大小或者其他参数
该参数需要谨慎使用
-XX:GCTimeRatio=N
用户线程运行时间和垃圾回收线程运行时间比例
取值范围(0, 100)。默认值99,也就是垃圾回收时间不超过1%
垃圾收集器时间占总时间的比例(1 / (N + 1))
与前一个参数-XX:MaxGCPauseMillis参数存在一定矛盾性
-XX:+UseAdaptiveSizePolicy
设置Parallel Scavenge收集器具有自适应调节策略
在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数都会被自动调整,已达到堆大小、吞吐量和停顿时间之间的平衡点
在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量和停顿时间,让虚拟机自己完成调优工作
CMS收集器
-XX:+UseConcMarkSweepGC
老年代手动启用CMS垃圾收集器
开启该参数后-XX:+UseParNewGC参数自动打开。即使用ParNew(Young)+ CMS(Old)+ Serial Old(后备方案)的组合
-XX:CMSInitiatingOccupancyFraction
设置堆内存使用率的阈值,一旦达到该阈值,便开始进行内存回收
JDK5及以前默认值为68,JDK6及以后默认值为92
如果内存增长缓慢,可以设置一个较大的值,该参数可以有效降低Full GC的执行次数
-XX:+UseCMSCompactAtFullCollection
指定执行完Full GC后,对内存空间进行压缩整理,以避免内存碎片的产生
不过由于压缩整理过程无法并发执行,停顿时间也就更长了
-XX:CMSFullGCsBeforeCompaction
在执行多少次Full GC后对内存进行压缩整理
默认值为0,表示每次进入Full GC时都进行碎片整理
-XX:ParallelCMSThreads
设置CMS的线程数量
CMS默认启动的线程数是(ParallelGCThreads + 3) / 4,ParallelGCThreads是年轻代并行收集器的线程数
补充参数
另外CMS收集器还有其他如下参数
-XX:ConcGCThreads
设置并发垃圾收集的线程数
-XX:+UseCMSInitiatingOccupancyOnly
是否动态可调节,用这个参数可以使CMS一直按CMSInitiatingOccupancyFraction设定的值启动
-XX:+CSMScavengeBeforeRemark
强制HotSpot虚拟机在CMS的remark阶段之前做一次Minor GC,用于提高remark阶段的速度
-XX:+CMSClassUnloadingEnable
如果有的话,启动回收Perm区(JDK8之前)
-XX:+CMSParallelInitialEnabled
用于开始CMS的initial-mark阶段采用多线程的方式进行初始标记,在JAVA8默认开启
-XX:+CMSParallelRemarkEnabled
用于开启CMS的remark阶段采用多线程的方式进行重新标记,默认开启
-XX:+ExplicitGCInvokesConcurrent、-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
这两个参数用户指定HotSpot虚拟机在执行System.gc()时使用CMS收集器
-XX:+CMSPrecleaningEnabled
指定CMS是否需要进行Pre cleaning这个阶段
特别说明
在JDK9中,CMS被标记为过期的
如果在JDK9以上版本的HotSpot虚拟机使用-XX:+UseConcMarkSweepGC来开启使用CMS,用户会收到一个警告信息,提示未来会被废弃
在JDK14中,删除CMS垃圾收集器
移除了CMS垃圾收集器,如果在JDK14中使用-XX:+UseConcMarkSweepGC,JVM不会报错,只是给出一个Warning信息,但是不会exit
JVM会自动使用默认的GC方式启动JVM
G1收集器
-XX:+UseG1GC
启用G1垃圾收集器,G1垃圾收集器既可以工作在老年代也可以工作在新生代
-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间,单位是毫秒。默认值是200ms
-XX:ParallelGCThreads
设置STW时GC线程的数量,最多设置为8
-XX:ConcGCThreads=N
设置并发标记的线程数
将N设置为ParallelGCThreads的1 / 4左右
-XX:InitiatingHeapOccupancyPercent
设置触发GC周期的堆内存占用比例阈值,超过该值就会触发GC
默认值是45
-XX:G1NewSizePercent、-XX:G1MaxSizePercent
整个新生代占整个堆内存的最小百分比(默认5%)、最大百分比(默认60%)
-XX:G1ReservePercent=10
保留内存区域,防止to space(Survivor中的to区)溢出
Mixed GC调优参数
注意G1收集器也有Mixed GC,Mixed GC会回收Young区和部分的Old区
-XX:InitiatingHeapOccupancyPercent
设置堆内存的使用比例,达到这个数值会触发global concurrent marking(全局并发标记),默认为45%
值为0表示间断进行全局并发标记
-XX:G1MixedGCLiveThresholdPercent
设置Old区region被回收时候的对象占比,默认85%
只有Old区region中存活的对象占用达到了这个百分比才会在Mixed GC中被回收
-XX:G1HeapWastePercent
在global concurrent marking(全局并发标记)结束后,可以知道所有的region有多少空间要被回收
在每次Young GC之后和再次发生Mixed GC之前,会检查垃圾占比是否到达此参数,只有达到了下次才会发生Mixed GC
-XX:G1MixedGCCountTarget
在global concurrent marking(全局并发标记)之后,最多执行Mixed GC的次数,默认是8
-XX:G1OldCSetRegionThresholdPercent
设置Mixed GC收集周期中要收集的Old region数的上限
默认值是Java堆的10%
如何选择垃圾收集器
生产环境的JDK一般都是8及其以上
优先使用默认的垃圾收集器也就是Parallel Scavenge + Parallel Old组合
优先调整堆的大小让JVM自适应完成
如果内存小于100M,使用Serial收集器
如果是单核、单机程序,并且没有停顿时间要求,使用Serial收集器
如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者让JVM自行选择
如果是多CPU、追求低延迟,需要快速响应,使用并发收集器。官方推荐G1,性能高
特别说明
没有最好的收集器,更没有万能的收集器
调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
GC日志相关参数
常用参数
-verbose:gc
输出GC日志信息,默认输出到标准输出中
可以独立使用
-XX:+PrintGC
等同于-verbose:gc
可以独立使用
-XX:+PrintGCDetails
在发生垃圾回收时打印内存回收详细信息,并且在进程退出时输出内存各区域的使用情况
可以独立使用
-XX:+PrintGCTimeStamps
输出GC发生的时间戳。到发生GC时,程序应该运行了多少秒
不可以独立使用,需要配置-XX:+PrintGCDetails使用
-XX:+PrintGCDateStamps
输出GC发生的时间戳。发生GC时的时间戳
不可以独立使用,需要配置-XX:+PrintGCDetails使用
-XX:+PrintHeapAtGC
每一次GC前和GC后,都打印信息
可以独立使用
-Xloggc:<file>
把GC日志写入到一个文件中,而不是打印到标准输出中
-XX:+UseGCLogFileRotation
启动GC日志进行滚动
-XX:NumberOfGClogFiles=1
GC日志文件的循环数目
-XX:GCLogFileSize=1M
当GC日志文件达到多大时,进行日志滚动
如果需要对GC日志进行滚动切割,可以使用logrotate切割GC日志
其他参数
-XX:+TraceClassLoading
监控类的加载
-XX:+PrintGCApplicationStoppedTime
打印GC时线程的停顿时间
-XX:+PrintGCApplicationConcurrentTime
垃圾收集之前打印出应用未中断的执行时间
-XX:+PrintReferenceGC
记录回收了多少种不同引用类型的引用
-XX:+PrintTenuringDistribution
打印每次MinorGC后当前使用的Survivor中对象的年龄分布
其他参数
-XX:+DisableExplicitGC
禁止执行System.gc(),默认禁用
-XX:ReservedCodeCacheSize=<n>[g|m|k]、-XX:InitialCodeCacheSize=<n>[g|m|k]
指定代码缓存的大小
-XX:+UseCodeCacheFlushing
让JVM放弃一些被编译的代码,避免代码缓存被占满时JVM切换到interpreted-only的情况
-XX:+DoEscapeAnalysis
开启逃逸分析
-XX:+UseBiasedLocking
开启偏向锁
-XX:+UseTLAB
使用TLAB,默认打开
-XX:+PrintTLAB
打印TLAB的使用情况
-XX:TLABSize
设置TLAB大小
通过Java代码获取JVM参数
可以使用RunTime类获取相关参数
分析GC日志
GC日志参数
-verbose:gc
输出GC日志信息,默认输出到标准输出中
-XX:+PrintGC
等同于-verbose:gc
-XX:+PrintGCDetails
在发生垃圾回收时打印内存回收详细信息,并且在进程退出时输出内存各区域的使用情况
-XX:+PrintGCTimeStamps
输出GC发生的时间戳。到发生GC时,程序应该运行了多少秒
-XX:+PrintGCDateStamps
输出GC发生的时间戳。发生GC时的时间戳
-XX:+PrintHeapAtGC
每一次GC前和GC后,都打印信息
-Xloggc:<file>
把GC日志写入到一个文件中,而不是打印到标准输出中
-XX:+UseSerialGC
启用Serial + Serial Old垃圾收集器
-XX:+UseConcMarkSweepGC
启用ParNew(Young)+ CMS(Old)+ Serial Old(后备方案)的组合
-XX:+UseParallelGC
启用Parallel Scavenge + Parallel Old垃圾收集器
-XX:+UseG1GC
启用G1垃圾收集器
GC分类
针对HotSpot VM的实现,它里面的GC按照回收区域分为两大种类型:一种部分收集(Partial GC),一种整堆收集(Full GC)
部分收集
新生代收集(Minor GC|Young GC),只是新生代(Eden、S0、S1)的垃圾收集
老年代收集(Major GC|Old GC),只是老年代的垃圾收集
目前只有CMS GC会有单独收集老年代的行为
注意,很多时候Major GC和Full GC混淆使用,需要根据上下文具体分辨是老年代还是整堆回收
混合收集(Mixed GC),收集整个新生代以及部分老年代
目前只有G1 GC会有这种行为
整堆收集(Full GC),收集整个Java堆和方法区
Young GC和Old GC的一般格式
GC类型和GC发生的原因
垃圾收集器
GC发生的内存区域
GC前后情况区域的使用情况
GC前后堆内存的使用情况
GC时间
Serial、ParNew、Parallel的Young GC和Full GC日志(分代且没有使用region)
Young GC日志解析
2021-02-22T16:48:58.733+0800
-XX:+PrintGCDateStamps参数控制
打印GC发生时的时间戳
0.197
-XX:+PrintGCTimeStamps参数控制
GC发生时,JVM虚拟机启动以来经过的秒数
单位秒
GC (Allocation Failure)
发生了一次垃圾回收,括号里面是发生本次GC的原因
这里的Allocation Failure是指新生代内存不足,不能够为新对象分配内存
[PSYoungGen: 46137K->776K(59904K)]
PSYoungGen:表示GC发生的区域,区域名称与使用的垃圾收集器是密切相关的
Serial收集器:Default New Generation,显示DefNew
ParNew收集器:ParNew
Parallel Scavenge收集器:PSYoung
G1收集器:garbage-first heap
老年代和新生代同理,也是和收集器名称相关
46137K->41744K(196608K)
GC前新生代使用大小 -> GC后新生代使用大小(新生代总大小)
新生代(Eden + 1个Survivor)
46137K->41744K(196608K)
YoungGC前堆内存占用 -> YoungGC后堆内存占用(堆内存总大小)
0.0088510 secs
整个GC所花费的时间
单位秒
[Times: user=0.00 sys=0.00, real=0.01 secs]
user
指CPU工作在用户态所花费的时间
sys
指CPU工作在内核态所花费的时间
real
此次GC所花费的总时间
详细图解
Full GC日志解析
2021-02-22T16:48:58.766+0800
-XX:+PrintGCDateStamps参数控制
打印GC发生时的时间戳
0.230
-XX:+PrintGCTimeStamps参数控制
GC发生时,JVM虚拟机启动以来经过的秒数
单位秒
Full GC (Ergonomics)
表示这是一次Full GC,整堆回收
回收新生代、老年代、方法区等
Full GC (Ergonomics)JVM自适应调整导致的GC
Full GC (Metadata GC Threshold)元空间发生了GC
Full GC(System)调用了System.gc()方法导致的GC
[PSYoungGen: 664K->0K(59904K)]
PSYoungGen:表示GC发生的区域,区域名称与使用的垃圾收集器是密切相关的
Serial收集器:Default New Generation,显示DefNew
ParNew收集器:ParNew
Parallel Scavenge收集器:PSYoung
老年代和新生代同理,也是和收集器名称相关
664K->0K(59904K)
GC前新生代使用大小 -> GC后新生代使用大小(新生代总大小)
新生代 = Eden + 1个Survivor
[ParOldGen: 122888K->123491K(136704K)]
GC前老年代使用大小 -> GC后老年代使用大小(老年代总大小)
123552K->123491K(196608K)
GC前堆内存使用大小 -> GC后堆内存使用大小(堆内存总大小)
堆内存总容量 = 新生代(Eden + 1个Survivor)+ 老年代
单位千字节
[Metaspace: 3149K->3149K(1056768K)]
GC前元空间使用大小 -> GC后元空间使用大小(堆内存总大小)
0.0224613 secs
整个GC所花费的时间
单位秒
[Times: user=0.06 sys=0.00, real=0.02 secs]
user
指CPU工作在用户态所花费的时间
sys
指CPU工作在内核态所花费的时间
real
此次GC所花费的总时间
详细图解
CMS的Old GC日志
G1的GC日志
ZGC的GC日志
GC日志分析工具
GCEasy
基本概述
上节介绍了GC日志的打印及含义,但是GC日志看起来比较麻烦
通过GC日志可视化分析工具,我们可以很方便的看到JVM各个分代的内存使用情况、垃圾回收次数、垃圾回收的原因、垃圾回收占用的时间、吞吐量等
使用
将GC日志上传到GCEasy中,点击分析即可
故障排查
如何排查内存泄漏
内存泄漏是指程序申请并使用内存后一直不释放,内存一直占用着。一次泄露不会有明显影响,累积泄露会导致OOM(内存溢出)
内存泄漏是导致内存溢出的原因之一
如果应用周期性发布,重新部署,内存泄漏可能就非常隐蔽,难以发现
内存泄漏是由于代码不善引起的,要注意程序一些容易犯错的地方
内存泄漏的典型现象
随着时间的推移,老年代内存占用呈现上升趋势,因为泄漏的对象在经历多次GC后肯定会晋升到老年代
每次执行Old GC或者是Full GC后,也就是老年代被回收后,老年代的内存占用在逐步上升。当然了这需要排除用户线程产生对象的干扰,可以在每天零点进行观察老年代内存使用情况
图示是一个博客关于内存泄漏的案例,可以看到老年代内存占用呈现逐步上升趋势,且每次Old GC后,内存占用仍然呈现上升趋势
内存泄漏的案例
典型内存泄漏用法
静态成员变量例如ArrayList,add了大量数据,然后没有clear
单例对象大量持有其他对象
数据库连接、网络连接等
及时关闭资源,在finally里面释放,或者用try-with-resources自动关闭
使用ThreadLocal进行set存储数据后,没有进行remove数据
使用复杂对象(多字段)作为HashMap的key存入到Map中或者是复杂对象添加到HashSet
HashMap判断两个对象是否相等会先调用hashCode()方法,然后是equals()方法
如果只重写hashCode()方法,没有重写equals()方法,则没有起到去重的目的,HashMap或者是HashSet中存在大量重复对象
同时重写了hashcode()和equals()方法,但是在实现hashCode()方法的代码中,使用的字段在后续程序中进行了修改,那么必然导致内存泄漏
实现hashCode()方法时要注意使用不可变的字段来生成hash值
不要使用会在运行期间可能发生修改的字段来生成hash值
为什么使用String类作为key,就没有发生内存泄漏呢?
因为String类手动重写了hashCode()和equals()方法
并且String类是不可变的,不能进行修改,只会返回一个新的String对象
如何排查OOM
一般的手段是通过内存映像分析工具(主要是MAT工具),对dump出来的堆转存储快照进行分析,重点确认内存中的对象是否是必要的,先分清楚到底是出现了内存泄露,还是内存溢出
如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链,于是就能找到内存泄露对象时通过怎样的路径与GC Roots相关联,导致垃圾收集器无法自动回收他们。根据引用链信息,可以较准确的定位出泄露代码的位置
如果不存在内存泄露,或者说内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与物理机器内存对比是否还可以调大,从代码检查是否某些对象生命周期过长,持有状态时间过长,尝试减少程序运行时的内存耗用
CPU使用率过高
原生命令
使用top命令,找到CPU使用率高对应Java进程的pid
使用top -Hp <pid>,找到事故线程的tid
使用jstack <pid> | grep $(printf '%x' <tid>) -A 20,dump对应线程方法调用栈后20行
jstack <pid>,dump对应Java进程的线程快照
printf '%x' <tid>,转换成16进制
根据线程的16进制id进行过滤
grep 16进制id -A 20,匹配行的后20行(A:After、B:Before、C:Context)
show-busy-java-threads,显示繁忙的java线程脚本
arthas的thread -n 线程数命令
由安全点导致长时间停顿
线上环境Young GC频繁如何排查
Young GC频繁的直接原因是频繁创建对象,且伊甸园区放不下,进行了Young GC,且没有回收对少内存。因为这些对象还存活着,业务过程还没有结束
调大新生代,查看Young GC的执行频率
jstat -gccause -t <pid> [interval] [count] 观察Young GC发生的次数,以及频率
线上环境Full GC频繁如何排查
直接原因
内存不够用了,JVM频繁调用垃圾收集器回收内存但是回收的内存较少或者根本就没有回收内存,导致Full GC频繁执行
Full GC可能会发生OOM,也可能不会发生OOM
排查步骤
一般发生事故需要先保护现场然后排查问题
保护现场
dump线程快照
jstack Java进程的pid
查看GC线程CPU使用情况
打印堆内存中对象数量
jmap -histo:live <pid>
发生Full GC前,dump内存快照
jinfo -flag +HeapDumpBeforeFullGC <pid>
发生OOM后自动dump内存快照
jinfo -flag +HeapDumpOnOutOfMemoryError <pid>
打印各区域内存使用情况
jstat -gc <pid>
打印GC日志
-XX:+PrintGCDetails
-Xloggc:<file>
查看Full GC发生的原因
jstat -gccause -t <pid>
可能原因
应用访问量突然增加,业务数据生成了大量的对象,堆内存被迅速耗尽,频繁执行Full GC回收内存
设置的JVM内存大小确实不够用。解决方法,调整内存参数,增加Java进程内存大小
代码存在bug,发生了内存泄露,进而导致了内存溢出
使用MAT工具分析多次dump的内存快照,排查内存中存在的大对象,也就是可能泄露的对象
Metaspace内存溢出
可能是加载了大量的重复Class对象,且没有及时进行卸载
使用MAT工具加载dump文件,点击Duplicate Classes查看重复加载的类
Full GC发生在老年代
年轻代内存设置太小,大量对象晋升到老年代
创建了大量大对象,例如大数组,年轻代放不下,进入老年代
类的加载过程(重点)
概述
按照Java虚拟机规范,从Class文件到加载到内存中的类,到类卸载出内存位置,它的整个生命周期包括如下七个阶段:加载 -> 链接(验证、准备、解析) -> 初始化 -> 使用 -> 卸载。其中链接分为验证、准备、解析
从程序中类的使用过程来看
加载(Loading)阶段
加载刚好是类加载过程的一个阶段,二者不能混淆
加载的理解
所谓加载,就是将字节码文件加载到内存中,并且在内存中构建出Java类的原型--类模板对象
类模板对象(instanceKlass),其实就是Java类在JVM内存中的一个快照
Java的对象并没有映射成C++的原生对象,而是使用了OOP-KLASS模型来表示Java对象
JVM将从字节码文件中解析出的常量池、 类字段、类方法等信息存储到模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用
反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射
加载过程三个步骤
通过一个类的全限定名获取定义此类的二进制字节流
本地系统获取,典型例如.class文件
网络获取,Web Applet
zip压缩包获取,jar,war
运行时计算生成,动态代理
有其他文件生成,jsp
专有数据库提取.class文件,比较少见
加密文件中获取,防止Class文件被反编译的保护措施
将这个字节流所代表的的静态存储结果转化为方法区的运行时数据结构
在堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
类模型与Class实例的位置
类模型的位置
加载的类由JVM创建相应的类结构,类结构会存储在方法区(JDK 1.8之前:永久代;JDK1.8之后:元空间)
Class实例的位置
JVM将.class文件加载到方法区后,会在堆内存中创建一个java.lang.Class类的对象实例,用来封装类位于方法区内的数据结构。该Class对象是在加载类的过程中创建的,每个类都对应一个Class对象
图示
Class类的构造方法是私有的,只有JVM能够创建。java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据和入口。通过Class类提供的接口,可以获得目标类所关联的.class文件具体的数据结构:方法、字段
链接(Linking)阶段(验证、准备、解析)
验证(Verification)
目的
确保class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
四种验证
验证流程
文件格式验证
是否以0xCAFEBABE开头(魔数,Java虚拟机识别)
主次版本号(Minor Version、Major Version)
常量池的常量是否有不被支持的常量类型
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
语义验证(需要符合Java语法)
类是否有父类,除了Object类之外,所有的类都应该有父类
类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
类的字段,方法是否与父类的产生矛盾。例如方法参数都一样,返回值不同
字节码验证
通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的
对类的方法体,进行校验分析,保证在运行时不会做出危害虚拟机的行为
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个int类型的数据,使用时却按照long类型加载到本地变量表中的情况
保障任何跳转指令都不会跳转到方法体之外的字节码指令上
符号引用验证
通过字符串描述的全限定名是否能找到对应的类
符号引用中的类、字段、方法的可访问性是否可被当前类访问
准备(Preparation)
为类变量(static修饰的成员变量)分配内存,并且设置该类变量的初始值,即零值
各种数据类型(基本数据类型和引用数据类型)的零值
不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象一起分配到Java堆中
在这个阶段不会像初始化阶段那样会有初始化或者代码被执行
注意
基本数据类型:非final修饰的变量,在准备环节进行默认初始化赋值。final修饰以后,在准备环节直接进行显式赋值
如果使用字面量的方式定义一个字符串的常量的话,也是在准备环节直接进行显式赋值
解析(Resolution)
将类、接口、方法、字段的符号引用转换为直接引用
符号引用就是一组符号来描述引用的目标。符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中
直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄
解析动作主要针对类、接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
具体解释说明
符号引用就是一些字面量的引用,和虚拟机内部数据结构和内存分布无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的的,比如println()方法被调用时,系统需要知道该方法的位置,以及对应的方法体
举例:输出操作System.out.println()对应的字节码:invokevirtual # 24 <java/io/PrintStream.println>
具体符号引用
以方法为例,Java虚拟机为每个类都准备一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,需要知道这个方法在方法表中的偏移量就可以直接调用该方法
通过解析操作,符号引用就可以转变为目标方法在方法表中的位置,从而使得方法被成功调用
事实上,在HotSpot VM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但是解析操作往往会伴随着JVM在执行完初始化之后再执行
初始化(Initialization)
初始化阶段是类加载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时类才会执行Java字节码(到了初始化阶段,才真正开始执行类中定义的Java代码)
初始化阶段是执行类构造器方法<clinit>()的过程,该方法只会被执行一次,且虚拟机的实现需要保证多线程情况下被正确地同步枷锁
代码演示多线程环境下执行<clinit>()方法发生死锁
执行结果(代码发生死锁,使用jstack -l <pid>命令无法检测出死锁)
此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
非法的前向引用问题
如果没有类变量和静态代码块,也不会有<clinit>()方法
构造器方法中指令按照语句在源文中出现的顺序执行
<clinit>()不同于类的构造器(关联:构造器是虚拟机视角下的<init>())
若该类具有父类,JVM需要保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
Java编译器并不会为所有的类都产生<clinit>()方法。哪些情况下不会生成<clinit>()方法
一个类中没有任何类变量以及静态代码块
一个类声明了类变量,但是没有显式赋值
一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式
代码示例1
代码示例2
使用(Using)
使用new关键字创建对象
使用反射的方式创建对象
调用clone()方法创建对象
使用反序列化方式得到对象
卸载(Unloading)
类、类的加载器、类的实例之间的引用关系
在类加载器内部实现中,用一个Java集合(Vector)存放所加载类的引用
一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法就可以获得它的类加载器
由此可见某个类的Class实例与其加载的类加载器之间为双向关联关系
一个类的对象实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法,这个方法返回对象所属类的Class对象的引用
类的生命周期
当Sample类被加载、链接、初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象的生命周期就结束了,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期
具体例子
引用示例
事实上Sample对象也指向Sample类的二进制数据结构,在对象头中有一个指向方法区类元数据的指针
loader1变量和obj变量间接引用Sample类的Class对象,而objClass变量则直接引用它
如果在运行中,将上图左侧三个引用类型变量都置为null,此时Sample对象生命周期结束,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据也被卸载
当再次需要使用时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载
如果不存在Sample类会被重新加载,在Java虚拟机的堆内存中生成一个新的代表Sample类的Class实例
类的卸载
满足以上三个条件后,并不是和对象一样立即被回收,而是仅仅允许回收
被启动类加载器的类型在整个运行期间是不可能被卸载的(JVM和JSL规范)
被系统类加载器和扩展类加载器加载的类型在运行期不太可能被卸载,因为系统类加载器或者扩展类的实例基本上在整个运行期间总能直接或者间接访问到
被开发者自定义的类加载器加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于调用虚拟机的垃圾收集功能才可以做到
总和以上三点,一个已经加载的类型被卸载的几率很小,几乎不会被卸载
方法区的垃圾回收
方法区的垃圾收集主要是常量池中废弃的常量和不再使用的类型
只要常量池中的常量没有被任何地方引用,就可以被回收
判断一个类型能否被回收
需要同时满足满足三个条件
该类的所有实例都已经被回收,Java堆内存中不存在该类以及派生子类的实例
加载该类的类加载器已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
满足以上三个条件,仅仅是允许被回收,并不是和对象一样,没有引用了就必然被回收
类的主动使用和被动使用
类的主动使用和被动使用(类都会被加载到方法区中,区别在于是否执行类的<clinit>()方法)
如何判断呢?如果类变量需要运行才能确定值(执行代码),那么肯定是主动使用,会执行<clinit>()方法,除此之外都是被动使用
-XX:+TraceClassLoading。该参数可以打印出JVM虚拟机加载的类
Java虚拟机规范严格规定了,有且仅有六种情况,必须立即对类进行初始化(主动使用),除此之外都是被动使用
遇到new、getstatic、putstatic或invokestatic这四条字节码指令时
使用new关键字实例化对象
读取或设置一个类型的静态字段(final修饰已在编译期将结果放入常量池的静态字段除外)
调用一个类型的静态方法的时候
对类型进行反射调用,如果类型没有经过初始化,则需要触发初始化
初始化类的时候,发现父类没有初始化,则先触发父类初始化
虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会初始化这个主类
只用JDK7中新加入的动态语言支持,如果一个java.lang.invoke.MethodHandler实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法对应的类没有进行初始化,则先触发其初始化
当一个接口中定了JDK8新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
测试代码
测试结果
除了以上几种情况,其他使用类的方式被看做是对类的被动使用,都不会导致类的初始化(被动使用)
补充说明
加载、验证、准备、初始化、使用和卸载这六个阶段的顺序是确定的
解析阶段不一定,在某些情况下可以在初始化阶段之后再开始,为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定,其实就是多态),例如子类重写父类方法
类加载器(重点)
作用
负责从文件系统或者网络中加载Class文件,Class文件开头有特定标识、魔数、咖啡Baby
ClassLoader只负责class文件的加载,至于是否可运行,则由执行引擎决定
加载的类信息存放于称为方法区的内存空间,除了类信息,方法区还会存放运行时常量池信息,还可能包括字符串字面量和数字常量
常量池运行时加载到内存中,即运行时常量池
必要性
一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是如果遇到相关问题,需要了解内部原理才能够进行排查和解决
避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDeFoundError异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题
需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了
开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑
命名空间
何为类的唯一性?
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性
每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义
否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等
命名空间
每个类加载器都有自己的命名空间,命名空间由该加载器所有的父加载器所加载的类组成
在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两 个类
在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本
三个基本特征
双亲委派模型
当类加载器接收到加载一个类的请求时,自己并不会去加载这个类,而是委托给父类加载器进行加载,如果父类加载器仍然存在父类加载器,则递归向上传递,直至顶层的启动类加载器
如果父类加载器成功加载该类,则成功返回
如果父类加载无法加载该类,则由子类尝试加载
事实上是一种任务委派的模型
可见性
子类加载器可以访问父加载器加载的类型,但是反过来是不允许的
不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑
单一性
由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载
但是注意,类加载器"邻居"间, 同一类型仍然可以被加载多次,因为相互并不可见
角色
将.class文件加载到内存中,经过一系列的操作(加载、链接、初始化),在方法区生成类的模板信息,作为方法区这个类的各种数据访问接口
类加载器分类
引导类加载器和自定义加载器
概念上,将所有派生于抽象类ClassLoader的类加载器都划分为自定义加载器
图示
类图
代码样例,获取类加载器
代码截图
对于用户自定义类,默认使用系统类加载器进行加载
Java的核心类库,使用引导类加载器进行加载
启动类加载器
C/C++语言实现,嵌套JVM内部
用来加载Java核心类库,rt.jar、resources.jar、sun.boot.class.path路径下的内容
代码获取加载路径
并不继承java.lang.ClassLoader,没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为启动类加载器
从java.ext.dirs系统属性所指定的目录中加载类库,或从jre/lib/ext子目录下加载类库
代码
应用程序类加载器(系统类加载器)
Java语言编写,由sun.misc.Launcher$AppClassLoader实现
派生于ClassLoader类
父类加载器为扩展类加载器
负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
该类加载器是程序中默认的类加载器。一般来说Java应用的类都是由它来完成加载
通过ClassLoader.getSystemClassLoader()方法可以后去到改类加载器
用户自定义类加载器
为什么要用自定义类加载器
隔离加载类
例如中间件的Jar包与应用程序Jar包不冲突
修改类加载的方式
启动类加载器必须使用,其他可以根据需要自定义加载
扩展加载源
防止源码泄露
对字节码进行加密,自定义类加载器实现解密
可以实现代码的热部署
实现步骤
继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器
1.2之前,继承并重写loadClass()方法
1.2之后,建议把自定义的类加载逻辑写在findClass()方法中
如果没有太过复杂的需求,可以直接继承URLClassLoader类,可以避免自己编写findClass()方法,及其获取字节码流的方式,使自定义类加载器编写更加简洁
数组的类加载器
数组类的Class对象,不是由类加载器去创建的,而是在运行期JVM根据需要自动创建的
对于元素为基本数据类型的数组,没有类加载器
对于元素为引用数据类型的数组,类加载器就是对应的加载该类的类加载器
测试
测试代码
控制台输出
为什么String[]数组的ClassLoader为null呢?因为String.class由启动类加载器加载,在堆内存中没有实例对象
双亲委派模型
原理
Java虚拟机对Class文件采用的是按需加载,而且加载class文件时,Java虚拟机使用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式
运行流程图
1、如果一个类加载器收到了类加载请求,它并不会自己先去加载。而是把这个请求委托给父类的加载器去执行
2、如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
3、如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
优势
避免类的重复加载
保护程序安全,防止核心API被篡改
沙箱安全机制
保证对Java核心源代码的保护,防止核心API被篡改
补充
在JVM中表示两个class对象,是否为同一个类存在两个必要条件
类的完整类名必须一致,包括包名
加载这个类的ClassLoader必须相同
JVM必须知道一个类型是由启动类加载器加载的,还是由用户类加载器加载的。如果是用户类加载器加载的,JVM会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中
ClassLoader类源码分析
关于ClassLoader类
是一个抽象类,除了启动类加载器,其他类加载器都继承自他
ClassLoader方法详细解释
loadClass()方法源码分析
双亲委派模型的实现就在loadClass()方法中
破坏双亲委派机制
在Java的世界中大部分的类加载器都遵循这个模型,但也有例外情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模"被破坏"的情况
第一次破坏
双亲委派模型的第一次"被破坏"其实发生在双亲委派模型出现之前——即JDK1.2面世以前的"远古"时代由于双亲委派模型在JDK1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在
面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一 些妥协,为了兼容这些已有的代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass() 中编写代码
上节我们已经分析过loadClass()方法, 双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则
第二次破坏
双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为"基础",是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户代码,那该怎么办?
造成问题的根源是类加载器的单一性,子类加载器可以访问父类加载器加载的类,但是父类加载器无法访问子类加载加载的类
例如JDBC系列接口,使用线程上下文类加载去加载MySQL的驱动类
为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)
这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器
有了线程上下文类加载器,程序就可以做一些"舞弊"的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情
Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI 等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的方式 ,在JDK6时 ,JDK提供了java.util.ServiceLoader类 ,以META-INF/Services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案
过程图示
第三次破坏
双亲委派模型的第三次"被破坏"是由于用户对程序动态性的追求而导致的。 如:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
自定义类加载器
目的
隔离加载类
在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境
修改类加载的方式
类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点按需进行动态加载
扩展加载源
比如从数据库、网络、甚至是电视机机顶盒进行加载
防止源码泄露
Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码
常见场景
Tomcat这类Web容器同时部署多个war包,服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。同时多个war之间的类也应该隔离
为了实现类隔离的目标,Tomcat制定了如下规则
放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用
放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见
放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见
放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器, 这些类加载器按照经典的双亲委派模型来实现,如下所示
实现方式
Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类
方式一:重写loadClass()方法
方式二:重写findClass()方法
代码编写实现代码热替换
自定义类加载器重写findClass()方法
findLoadedClass(全类名)方法,查看是否加载过
根据全类名获取字节数组
调用defineClass()方法,根据字节数组,得到Class对象
main()方法测试,循环创建类加载的实例,加载测试类
MyClassLoader类代码
TestClass类代码
点击运行MyClassLodaer的main()方法,控制台输出
修改TestClass.java源代码,同时使用javac编译器进行编译生成.class文件
观察控制台输出,代码热替换成功
Class文件结构
概述
Java语言:跨平台的语言(write once,run anywhere)
当Java源代码成功编译成字节码后,如果想在不同的平台上面运行, 则无须再次编译
这个优势不再那么吸引人了。Python、PHP、Perl、Ruby、Lisp等有强大的解释器
跨平台似乎已经快成为一门语言必选的特性
Java虚拟机:跨语言的平台
Java虚拟机不和包括Java在内的任何语言绑定,它只与.class文件这种特定的二进制文件格式所关联
无论使用何种语言进行软件开发, 只要能将源文件编译为正确的.class文件,那么这种语言就可以在Java虚拟机上执行,可以说统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁
所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的, 这样一来字节码文件可以在各种JVM上进行
各语言、编译器、字节码、虚拟机的关系
想要让一个Java程序正确地运行在JVM中,Java源码就是必须要被编译为符合JVM规范的字节码
前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件
javac是一种能够将Java源码编译为字节码的前端编译器
javac编译器在将Java源码编译为一个有效的字节码文件过程中经历了4个步骤,分别是词法分析、语法分析、语义分析以及生成字节码
javac编译器整个程序中的位置
透过字节码指令看代码细节
有些问题需要从字节码的角度来看待问题,知道了字节码才知道程序到底是怎么运行的
例子
比较两个Integer的大小
代码实例
通过反编译得到的字节码
java.lang.Integer.valueof(int i)方法源码
low就是-128,high就是127,可以看到jvm内部对-128~127进行了缓存,所以如果在-128~127之间一定是true,否则就是false
比较new String("a") + new String("b")和new String("ab")是否相等
代码示例
反编译得到的字节码
虚拟机的基石:Class文件
字节码文件里是什么
源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种十六进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成本地机器码
什么是字节码指令(byte code)
Java虚拟机的指令由一个字节长度的,代表着某种特定操作含义的操作码 (opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成
虚拟机中许多指令并不包含操作数,只有一个操作码
由于指令只有一个字节大小,所以最多只有256个,对于添加新的指令要非常小心,超过256个可就完蛋了
如何解读供虚拟机解释执行的十六进制字节码
1. 使用Notepad++查看,安装HEX-Editor插件后进行查看
2. 使用javap指令,JDK自带的反解析工具
3. 使用IDEA插件,jclasslib或jclasslib bytecode viewer客户端工具
Class文件结构
Class类的本质
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说Class文件实际上它并不一定以磁盘文件形式存在。Class文件是一组以字节为基础单位的二进制流,在内存中的表现形式为字节数组
Class文件格式
Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限定的。哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变
Class文件格式采用一种类似于C语言结构体的方式进行数据存储,这种结构中只有两种数据类型:无符号数和表
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明
Class文件格式图示
Class文件结构概述
魔数(Class文件的标志)
Magic Number(魔数)
每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)
它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符
魔数值固定为0xCAFEBABE,不会改变
如果一个 Class 文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:
Class文件版本号
紧接着魔数的4个字节存储的是Class文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_version,而第7个和第8个字节就是编译的主版本号major_version
它们共同构成了Class文件的格式版本号。譬如某个Class文件的主版本号为M,服版本号为m,那么这个Class文件的格式版本号就确定为M.m
版本号和Java编译器的对应关系如下表:
Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1
不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常(向下兼容)
在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的JDK版本和生产环境的JDK版本是否一致
虚拟机JDK版本为1.k (k >= 2)时,对应的Class文件格式版本号的范围为45.0 - 44 + k.0(含两端)
常量池(存放所有常量)
常量池是Class文件中内容最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用
随着Java虚拟机的不断发展,常量池的内容也日渐丰富,可以说常量池是整个Class文件的基石
在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项
常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constant_pool_count),与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的
通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为 它把第0项常量空出来了
这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的含义,这种情况可用索引值0来表示
例如后面的父类索引,Object类没有父类,所以它的父类索引在常量池中就是0
常量池表是一种表结构,索引为1到(constant_pool_count - 1)
常量池中主要存放两大常量:字面量和符号引用
字面量:使用""引起来的字符串、使用final修饰的基本数据类型变量
符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
常量项大概有14种,结构都是标记字节(1字节) + 其他。比较常见的有CONSTANT_utf8_info、CONSTANT_class_info、CONSTANT_Methodref_info、CONSTANT_Fieldref_info等
14种常量项图示
常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一
访问标识
在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息
这个Class是类还是接口
是否定义为public类型
是否定义为abstract类型
如果是类的话,是否被声明为final等
各种访问标记如下所示:
类索引(this_class)、父类索引(super_class)、接口索引集合(interfaces_count、interfaces[])
这三项数据来确定这个类的继承关系以及实现的接口,就是用来描述类的全限定名、父类是谁、实现了多个个接口,具体是哪些接口
三个数据的格式
类索引(this_class)
类索引用于确定这个类的全限定名,2字节无符号整数,指向常量池的索引
常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个Class文件所定义的类或接口
父类索引(super_class)
2字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名
如果我们没有继承任何类,其默认继承的是java/lang/Object 类。同时,由 于Java不支持多继承,所以其父类只有一个
接口索引集合(interfaces_count、interfaces[])
interfaces_count项的值表示当前类或接口的直接超接口数量
interfaces[]中每个成员的值必须是对常量池表中某项的有效索引值,它的长 度为interfaces_count
每个成员interfaces[i]必须为CONSTANT_Class_info结构,其中0 <= i < interfaces_count
在interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即 interfaces[0]对应的是源代码中最左边的接口
字段表集合
概述
字段表集合包含字段计数器以及字段表
字段表用于描述接口或类中声明的变量
字段(field)包括类级变量以及实例级变量, 但是不包括方法内部、代码块内部声明的局部变量
字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述
它指向常量池索引集合,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final 修饰符)等
字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原Java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段
在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管 是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个 字段的描述符不一致,那字段重名就是合法的
字段计数器
fields_count的值表示当前Class文件fields表的成员个数。使用两个字节来表示
字段表
fields表中的每个成员都必须是一个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述
字段表结构
字段访问标志(access_flags)
各个访问标志图示
字段名索引(name_index)
根据字段名索引的值,查询常量池中的指定索引项即可
描述符索引
字段的数据类型(基本数据类型、引用数据类型和数组)
属性表集合(属性个数和属性表数组)
一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、 一些注释信息等
属性个数存放在attribute_count中
属性具体内容存放在attributes数组中
以常量属性为例,结构为:
常量属性结构
示例
方法表集合
分为方法表计数器以及方法表数组
方法表计数器:有多少个方法表
方法表:用于表示当前类或接口中某个方法的完整描述
方法表结构图
访问标志
方法名索引:对应常量池中CONSTANT_Uft8_info方法的名称
描述符索引:对应常量池中符号引用方法的返回值和参数列表
属性计数器:有多少个属性
属性表:和前面字段表中的属性表类似,后面详细解释
属性表集合
属性计数器:有多少个属性表
属性表
属性表通用格式
属性名索引:在常量池中的索引,其实引用的字符串常量
属性长度:有多少个字节,便于校验
Java8中定义了23中属性表
常见的Code属性表
Code属性就是存放方法体里面的代码,但是并非所有方法表都有Code属性,像接口或者抽象方法,他们没有具体的方法体,因此也就不会有Code属性了
Code属性表结构
常见的LineNumberTable属性表
LineNumberTable属性是可选变长属性,位于Code结构的属性表
LineNumberTable属性是用来描述Java源码行号与字节码行号之间的对应关系,这个属性可以用来在调试的时候定位代码执行的行数
LineNumberTable属性表结构
常见的LocalVariableTable属性表
LocalVariableTable是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息
LocalVariableTable属性表结构
attribute_name_index:属性表名称常量池表索引
attribute_length:属性表长度
local_variable_table_length:局部变量个数
start_pc + length:这个变量在方法内的作用于
name_index:变量名在常量池表的索引
descriptor_index:局部变量数据类型在常量池表的索引
index:变量在局部变量表中的槽位
总结
Class文件其实就是对类的整体描述
类有哪些字段?有哪些方法?类的全限定名?类的父类是谁?类实现的接口有哪些?方法中具体的方法体是什么?类的访问权限和修饰符等
Class文件中有一块非常重要的内容就是常量池,通过上面的分析可以得知,常量池中存储的符号引用,其他描述的地方引用常量池中的符号引用,最后都定位到字面量(字符串、基本数据类型)
小结
随着Java平台的不断发展,在将来Class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整
从Java虚拟机的角度看,通过Class文件,可以让更多的计算机语言支持Java虚拟机平台
因此,Class文件结构不仅仅是Java虚拟机的执行入口,更是Java生态圈的基础和核心
解析举例
原始字节码文件
cafe babe 0000 0034 0016 0a00 0400 1209
0003 0013 0700 1407 0015 0100 036e 756d
0100 0149 0100 063c 696e 6974 3e01 0003
2829 5601 0004 436f 6465 0100 0f4c 696e
654e 756d 6265 7254 6162 6c65 0100 124c
6f63 616c 5661 7269 6162 6c65 5461 626c
6501 0004 7468 6973 0100 184c 636f 6d2f
6174 6775 6967 752f 6a61 7661 312f 4465
6d6f 3b01 0003 6164 6401 0003 2829 4901
000a 536f 7572 6365 4669 6c65 0100 0944
656d 6f2e 6a61 7661 0c00 0700 080c 0005
0006 0100 1663 6f6d 2f61 7467 7569 6775
2f6a 6176 6131 2f44 656d 6f01 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0021
0003 0004 0000 0001 0002 0005 0006 0000
0002 0001 0007 0008 0001 0009 0000 0038
0002 0001 0000 000a 2ab7 0001 2a04 b500
02b1 0000 0002 000a 0000 000a 0002 0000
0007 0004 0008 000b 0000 000c 0001 0000
000a 000c 000d 0000 0001 000e 000f 0001
0009 0000 003d 0003 0001 0000 000f 2a2a
b400 0205 60b5 0002 2ab4 0002 ac00 0000
0200 0a00 0000 0a00 0200 0000 0b00 0a00
0c00 0b00 0000 0c00 0100 0000 0f00 0c00
0d00 0000 0100 1000 0000 0200 11
0003 0013 0700 1407 0015 0100 036e 756d
0100 0149 0100 063c 696e 6974 3e01 0003
2829 5601 0004 436f 6465 0100 0f4c 696e
654e 756d 6265 7254 6162 6c65 0100 124c
6f63 616c 5661 7269 6162 6c65 5461 626c
6501 0004 7468 6973 0100 184c 636f 6d2f
6174 6775 6967 752f 6a61 7661 312f 4465
6d6f 3b01 0003 6164 6401 0003 2829 4901
000a 536f 7572 6365 4669 6c65 0100 0944
656d 6f2e 6a61 7661 0c00 0700 080c 0005
0006 0100 1663 6f6d 2f61 7467 7569 6775
2f6a 6176 6131 2f44 656d 6f01 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0021
0003 0004 0000 0001 0002 0005 0006 0000
0002 0001 0007 0008 0001 0009 0000 0038
0002 0001 0000 000a 2ab7 0001 2a04 b500
02b1 0000 0002 000a 0000 000a 0002 0000
0007 0004 0008 000b 0000 000c 0001 0000
000a 000c 000d 0000 0001 000e 000f 0001
0009 0000 003d 0003 0001 0000 000f 2a2a
b400 0205 60b5 0002 2ab4 0002 ac00 0000
0200 0a00 0000 0a00 0200 0000 0b00 0a00
0c00 0b00 0000 0c00 0100 0000 0f00 0c00
0d00 0000 0100 1000 0000 0200 11
原始代码
Demo.class解析图解
对Demo.class文件(16进制文本)一点一点进行解析
字节码指令集
概述
字节码指令对于虚拟机,就好像汇编语言对于计算机,属于基本执行命令
字节码指令由一个字节长度的,代表着某种特定操作含义的数字(称为操作码:Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数:Operands)构成
由于Java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码
由于限制了操作码的长度为一个字节(即0 ~ 255),这意味着指令集的操作码总数不可能超过256条
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
执行模型
如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解
执行流程图
字节码与数据类型
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息
例如,iload指令用于从局部变量表中加载int类型的数据到操作数栈中,而fload指令加载的则是float类型的数据
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务
大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型
还有一些指令,比如无条件跳转指令goto则是与数据类型无关
指令的分类
加载与存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递
算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈
类型转换指令
将两种不同的数值类型进行相互转换
对象的创建与访问指令
创建对象、访问字段、数组操作和类型检查
方法调用与返回指令
进行方法调用,结束方法以及放回相应的值
操作数栈管理指令
用于操作操作数栈
控制转移指令
进行流程控制例如if、if...else、if...else if...else、switch、for、while等
异常处理指令
例如对于throw等关键字的处理
同步控制指令
使用了synchronized关键字的处理
加载与存储指令
作用
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递
常用指令
局部变量压栈指令
将局部变量表中对应索引位置的变量压入操作数栈栈顶
指令格式
xload index(其中x 为 i、l、f、d、a,其中index为局变量表索引值)
xload_<n> (其中x 为 i、l、f、d、a,n为0到3)
x为数据类型
n表示局部变量表索引的位置
当局部变量表索引大于3时,就使用xload index指令
常量入栈指令
将一个常量加载到操作数栈,分为const系列、push系列和ldc系列
const指令系列
用于对特定的常量压入操作数栈,入栈的常量隐含在指令本身里
iconst_<i>(第二个i从-1到5)
lconst_<l>(第二个l从0到1)
fconst_<f>(第二个f从0到2)
dconst_<d>(第二个d从0到1)
aconst_null
从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i表示整数、l表示长整型、f表示浮点数、d表示双精度浮点,习惯上用a表示对象引用
如果指令隐含操作的参数,会以下划线形式给出
push指令系列
主要包括bipush和sipush,它们的区别在于接受数据类型的不同
bipush接收8位整数作为参数,将参数压入栈
sipush接收16位整数,将参数压入栈
ldc指令系列
如果以上指令都不能满足需求,那么可以使用万能的ldc指令
它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的字面量压入操作数栈栈顶
ldc_w指令系列
它接收两个8位参数,能支持的索引范围大于ldc如果要压入的元素是long或者double类型的,则使用ldc2_w指令,使用方式都是类似的
出栈装入局部变量表指令
弹出操作数栈栈顶元素,存储到局部变量表对应索引处,给局部变量赋值
xstore index(x 为i、l、f、d、a,index为局部变量表索引值)
指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置
xstore_<n>(其中x为i、l、f、d、a,n为0到3)
弹出操作数栈对应栈顶元素,为局部变量表对应索引处的局部变量进行赋值
xastore(x 为i、l、f、d、a、b、c、s)
专门针对数组操作,给数组索引处进行赋值
以iastore为例,它用于给一个int数组的给定索引赋值。在iastore执行前,操作数栈顶需要以此准备3个元素:值、索引、数组引用,iastore会弹出这3个值,并将值赋给数组中指定索引的位置
案例
总结
算术指令
作用
算术指令用于两个操作数栈上的值进行某种特定运算,并且把计算后的结果重新压入操作数栈
byte、short、char和boolean类型说明
Java虚拟机中没有直接支持byte、short、char和boolean类型的算术指令,而是使用int类型的指令来代替处理
在处理这些类型的数组时,也会转换成对应的int类型的字节码指令来处理
Java虚拟机中的实际类型与运算类型
运算时溢出
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数(补码)
其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException
分类
加法指令
iadd
ladd
fadd
dadd
减法指令
isub
lsub
fsub
dsub
乘法指令
imul
lmul
fmul
dmul
除法指令
idiv
ldiv
fdiv
ddiv
求余指令
irem
lrem
frem
drem
取反指令
ineg
lneg
fneg
dneg
自增指令
iinc
位运算指令
位移指令
ishl
ishr
iushr
lshl
lshr
lushr
按位或指令
ior
lor
按位与指令
iand
land
按位异或指令
ixor
lxor
比较指令
dcmpg
dcmlp
fcmpg
fcmpl
lcmp
数值类型的数据才可以谈大小,boolean、引用数据类型不能比较大小
举例
原始代码
字节码
类型转换指令
说明
类型转换指令可以将两种不同的数值类型进行相互转换
这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题
类型转换分成两种
宽化类型转换
窄化类型转换
宽化类型转换
转换规则
Java虚拟机直接支持以下数值的宽化类型转换(Widening Numeric Conversion,小范围类型向大范围类型的安全转换)
也就是说,并不需要执行字节码指令,包括
从int类型到long、float或者double类型,对应的指令为:i2l、i2f、i2d
从long类型到float、double类型。对应的指令为:l2f、l2d
从flaot类型到double类型。对应的指令为:f2d
简化为:int -> long -> float -> double
精度丢失问题
从byte、char和short类型到int类型的宽化类型转换实际上是不存在的,对于byte类型转换为int,虚拟机并没有处理。而byte转为long 时,使用的是i2l
int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的
从int、long类型数值转换到float,或者long类型树脂转换到double时,将可能发生丢失精度
窄化类型转换
转换规则
从int类型至byte、short或者char类型。对应的指令有:i2b、i2c、i2s
从long类型到int类型。对应的指令有:l2i
从float类型到int或者long类型。对应的指令有:f2i、f2l
从double类型到int、long或者float类型。对应的指令有:d2i、d2l、d2f
精度丢失问题
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此, 转换过程很可能会导致数值丢失精度
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况
但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常
对象的创建与访问指令
前言
Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持
有一系列指令专门用于对象操作,可进一步细分为创建指令、 字段访问指令、数组操作指令和类型检查指令
创建指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
创建类实例的指令
指令
new
它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后, 将对象的引用压入栈
创建数组的指令
指令
newarray
创建基本类型数组
anewarray
创建引用类型数组
multianewarray
创建多维数组
上述创建指令可以用于创建对象或者数组,由于对象和数组在Java中的广泛使用,这些指令的使用频率也很高
字段访问指令
对象创建后,就可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素
访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic
访问类实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield
数组操作指令
数组操作指令主要有:xastore和xaload指令,其中x为具体的数据类型
xaload指令
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、 laload、faload、daload、aaload
指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组
指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入堆栈
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、 iastore、lastore、fastore、dastore、aastore
图示
取数组长度的指令:arraylength
该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
类型检查指令
检查类实例或数组类型的指令:instanceof、checkcast
指令checkcast用于检查类型强制转换是否可以进行
如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常
指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈
方法调用与返回指令
方法调用指令
方法调用指令主要有5个:invokevirtual、invokeinterface、invokespecial、invokestatic、invokedynamic
invokevirtual
指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态
这也是Java语言中最常见的方法分派方式
invokeinterface
指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用
invokespecial
指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法
这些方法都是静态类型绑定的,不会在调用时进行动态派发
invokestatic
指令用于调用命名类中的类方法(static方法)。这是静态绑定的
invokedynamic
调用动态绑定的方法,这个是JDK1.7后新加入的指令
用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法
前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
方法返回指令
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的
包含xreturn和return指令
xreturn指令
xreturn返回指令需要返回数据,x表示具体的数据类型
ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn
return指令
实例方法返回void,无返回值
实例初始化方法
类和接口的类初始化方法使用
总结
说明
通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃
如果当前返回的是synchronized方法,那么还会执行一 个隐含的monitorexit指令,退出临界区
最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者
操作数栈管理指令
前言
如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令
pop、pop2
将一个或两个元素从栈顶弹出,并且直接废弃
pop:将栈顶的1个Slot数值出栈。例如1个short类型数值
pop2:将栈顶的2个Slot数值出栈。例如1个double类型数值,或者2个int类型数值
dup、dup2、dup_x1、dup2_x1、du p_x2、dup2_x2
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶
swap
将栈最顶端的两个Slot数值位置交换:swap
Java虚拟机没有提供交换两个64位数据类型(long、double)数值的指令
nop
指令nop是一个非常特殊的指令,它的字节码为0x00
和汇编语言中的nop一样,它表示什么都不做,这条指令一般可用于调试、占位等
控制转移指令
前言
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令
大体上可以分为比较指令、条件跳转指令、比较条件跳转指令、多条件分支跳转指令、无条件跳转指令等
条件跳转指令
比较条件跳转指令
多条件分支跳转
无条件跳转
异常处理指令
抛出异常指令
athrow指令
在Java程序中显式抛出异常的操作(throw语句)都是由athrow指令来实现的
除了使用throw语句显式抛出异常情况之外,JVM规范还规定了许多运行时异常会在其它Java虚拟机指令检测到异常状况时自动抛出
例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常
注意
正常情况下,操作数栈的压入弹出都是一条条指令完成的
唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上
异常的处理
过程一:异常对象的生成过程 -> throw(手动/自动) -> 指令:athrow
过程二:异常的处理:抓抛模型try - catch - finally -> 使用异常表
处理异常与异常表
处理异常
在Java虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的
异常表
如果一个方法定义了一个try - catch或者try - finally的异常处理,就会创建 一个异常表
异常表包含了每个异常处理或者finally块的信息
异常表保存了每个异常处理信息
起始位置
结束位置
程序计数器记录的代码处理的偏移地址
被捕获的异常类在常量池中的索引
详细处理过程
当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理
如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)
如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止
如果这个异常在最后一个非守护线程里抛出,将会导致JV 自己终止,比如这个线程是个main线程
不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行
在这种情况下, 如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标
同步控制指令
前言
Java虚拟机支持两种同步结构:方法级同步和方法内部一段指令序列的同步,这两种同步都是使用monitor(本质上是对象中的对象头支持的)来支持的
方法级的同步
方法级的同步:是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中
虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法
当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置
具体过程
如果设置了,执行线程将先持有同步锁,然后执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁
在方法执行期间,执行线程持有了同步锁,其它任何线程都无法再获得同一个锁(底层依赖操作系统的互斥锁mutex)
如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常, 那么这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放
具体
示例代码
对应字节码
说明
这段代码和普通的无同步操作的代码没有什么不同,没有使用monitorenter和monitorexit进行同步区控制
这是因为,对于同步方法而言,当虚拟机通过方法的访问标识符判断是一个同步方法时,会自动在方法调用前进行加锁,当同 步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这 个锁
因此,对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未直接出现在字节码中
方法内指定指令序列的同步
同步一段指令集序列
通常是由Java中的synchronized代码块来表示的
JVM的指令集有monitorenter和monitorexit两条指令来支持synchronized关键字的语义
执行流程
当一个线程进入同步代码块时,它使用monitorenter指令请求进入
如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入(可重入性),否则进行等待,知道对象的监视器计数器为0,才会被允许进入同步块当线程退出同步块时,需要使用monitorexit声明退出
在Java虚拟机中, 任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的
锁必须要释放
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束
为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令
Java内存模型(重点)
硬件的效率和一致性
主内存和工作内存
内存间交互操作
针对volatile类型变量的特殊规则
原子性、可见性和有序性
as if serial和happens before规则
Java中的线程
操作系统、用户空间和内核空间、用户态和内核态、系统调用等概念简单解释
计算机组成
计算机由CPU、内存、磁盘、输入设备(鼠标和键盘)、输出设备(显示器)等组成
CPU:中央处理器,计算机最核心的配件,负责所有的计算
内存:你编写的程序、运行的游戏、打开的浏览器都要加载到内存中才能运行,程序读取的数据、计算的结果也都在内存中,内存的大小决定了你能加载的东西的多少
磁盘:提供数据的持久化存储,程序可以进行多次读取
输入设备:一个程序有标准输入、标准输出以及标准错误输出。由鼠标或者是键盘输入数据,提交给程序进行运行
输出设备:程序计算完成后,需要将计算结果进行输出,可以输出到屏幕进行显示
操作系统
程序员不需要直接和底层硬件打交道,那是因为在底层硬件的基础之上,计算机安装了一层软件
这层软件能够通过响应用户输入的指令进行控制硬件,从而满足程序需求,这种软件称之为操作系统 Operating System(OS)
操作系统对应用程序提供底层硬件支持,例如从网卡中读取输入、从磁盘中读取数据等
操作系统和硬件、应用程序关系图
内核就是操作系统最为核心的部分,操作系统包含内核
操作系统还包括像用户界面(shell、gui、工具和服务)的应用程序
用户空间和内核空间
虚拟内存被操作系统划分成两块:内核空间和用户空间,内核空间是内核代码运行的地方,用户空间是用户程序代码运行的地方
用户态和内核态
大部分计算机有两种运行模式:内核态和用户态,软件中最基础的部分是操作系统,它运行在内核态中。操作系统具有硬件的访问权,可以执行机器能够运行的任何指令。软件的其余部分运行在用户态下
如果需要底层硬件支持例如分配内存,就需要进行系统调用。程序也就是从用户态转到了内核态,当系统调用完成也就从内核态转到了用户态
用户态到内核态的切换有三种情况(系统调用、异常事件、外围设备的中断)
系统调用(软中断,典型如int 80H终端)
异常事件:当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常
外围设备的中断(硬中断):当外围设备完成用户的请求操作后,会向CPU发出中断信号。此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换
例如网卡收到数据,向CPU发出中断指令,CPU将数据从网卡缓冲区拷贝到内核空间
为什么用户态和内核态的切换耗费时间(保护现场和恢复现场耗费大量时间)
系统调用
系统调用是操作系统的最小功能单位
内核提供了一系列系统函数,在Linux系统中和网络相关有socket、bind、listen、accept、select、poll、epoll等
用户态程序执行系统函数,也就称程序进行了系统调用
这是用户态进程主动要求切换到内核态的一种方式
用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如fork()就是执行了一个创建新进程的系统调用
系统调用的机制核心是使用了操作系统为用户特别开放的一个中断来实现,如Linux的int 80h中断(软中断)
系统调用开销大,从用户态到内核态进行切换
用户空间和内核空间、用户态和内核态、系统调用关系图示
线程的实现
程序、进程、线程之间的关系
程序
是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码,它是应用程序执行的蓝本
进程
程序加载到内存之后就是进程。进程是资源分配的基本单位,进程按照程序中定义的指令和数据进行执行
进程有唯一的进程id,pid
线程
线程是调度执行的基本单位
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度
线程和进程的区别
实现线程主要有三种方式
使用内核线程实现(1:1实现)
使用用户线程实现(1:N实现)
使用用户线程加轻量级进程混合实现(N:M实现)
内核线程实现(1:1实现)
使用内核线程实现的方式也被称为1:1实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程
这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程
这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型
1:1模型图
内核线程优势和劣势
优势
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作
劣势
首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换
其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的
用户线程实现(1:N实现)
使用用户线程实现的方式被称为1:N实现
用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的
用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的
1:N模型图
用户线程优势和劣势
优势
不需要系统内核支援,减少了用户空间和内核空间的切换,能够大幅提高执行效率
劣势
由于没有了内核的帮助,线程的创建、销毁、切换和调度都是自己需要考虑的,实现起来非常复杂
对于一些特定问题,如阻塞如何处理、如何将线程映射到其他处理器上这类问题解决起来将会异常困难,甚至有些是不可能实现的
混合实现(N:M实现)
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现
在这种混合实现下,既存在用户线程,也存在轻量级进程
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系
N:M模型图
Java线程的实现
主流的HotSpot虚拟机采用1:1的线程模型
每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构
HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议Thread.setPriority(priority)),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的
Java线程调度
线程调度是指操作系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式 (Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度
协同式
线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去
优点
协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题
缺点
线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里
只要有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃
抢占式
线程的执行时间由操作系统分配,线程的切换也是由操作系统来完成
线程本身无法主动获取执行时间,但是可以让出执行时间。如Thread.yield()方法
线程的执行时间可控,不会因为某个程序的代码出现故障导致整个系统崩溃
Java使用的线程调度方式就是抢占式调度
Java线程状态转换(重点)
Java语言定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换
在java.lang.Thread类内部有一个枚举定义了线程的6种状态
6种状态详细解释
新建(New):创建后尚未启动的线程处于这种状态
运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间
运行可以分为两种情况:Ready(就绪)、Running(真正运行)
Ready(就绪):线程调用了start()方法后,就进入到了Ready状态。也有可能是从Running状态转换成Ready状态(CPU的时间片用完)
Running(真正运行):获得CPU的时间片,执行代码
如果执行网络IO或者是文件IO代码后,线程处于Runnable状态,但是没有分配CPU的时间片
无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
没有设置Timeout参数的Object::wait()方法
没有设置Timeout参数的Thread::join()方法
LockSupport::park()方法
限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
Thread::sleep()方法
设置了Timeout参数的Object::wait()方法
设置了Timeout参数的Thread::join()方法
LockSupport::parkNanos()方法
LockSupport::parkUntil()方法
阻塞(Blocked):线程被阻塞了,"阻塞状态"与"等待状态"的区别是"阻塞状态"在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而"等待状态"则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态
结束(Terminated):已终止线程的线程状态,线程已经结束执行
状态转换图
线程各状态转换图
New和Terminated状态分别为起始和结束状态,和Runable状态不可逆。其他状态均能和Runnable状态相互转换
New -> Runnable为单向,不可能再次变成New状态,一个已经调用start()方法的线程再次调用start()方法直接报错,start()方法内部对线程状态进行检查,也就是说start()方法只能够被调用一次
Runnable -> Terminated为单向,当线程处于死亡状态后,不能重新变成Runnable状态,再次调用start()方法直接报错,start()方法内部对线程状态进行检查,也就是说start()方法只能够被调用一次
流程一:NEW -> RUNNABLE(READY <-> RUNNING) -> TERMINATED
Thread thread = new Thread(...),thread对象就被创建,线程就处于New状态,线程的生命周期开始
调用thread.start()方法,thread就从New状态变为Ready状态
当分配到CPU的时间片时,从Ready状态变成Running状态
当CPU的时间片用完,或者执行了Thread.yield()方法,让出CPU的执行权,线程从Running状态变成Ready状态
重复步骤3、步骤4,如此循环往复
当线程的run()方法执行结束或者代码执行过程中发生异常,变成Terminated状态,整个线程的生命周期结束
代码测试
测试代码
控制台输出
流程二:NEW -> READY -> RUNNING -> TIMED WAITING -> RUNNABLE(READY <-> RUNNING) -> TERMINATED
Thread thread = new Thread(...),thread对象就被创建,线程就处于New状态,线程的生命周期开始
调用thread.start()方法,thread就从New状态变为Ready状态
当分配到CPU的时间片时,从Ready状态变成Running状态
调用Thread.sleep(...)或者Thread.join(...)方法,线程从Running状态变成Timed waiting状态
代码测试
测试代码
控制台输出
流程三:NEW -> READY -> RUNNING -> BLOCKED -> RUNNABLE(READY <-> RUNNING) -> TERMINATED
线程被创建后处于New状态,线程调用start()方法后,处于Ready状态
分配到时间片后执行run()方法,处于Running状态,遇到synchronized关键字,没有抢到锁,线程进入Blocked状态
当其他线程释放锁后,当前线程抢到了锁,从Blocked状态变成Ready状态
线程分配到CPU时间片,执行run()方法或者执行过程中抛出异常,变成Terminated状态
代码测试
测试代码
控制台输出
Java与协程
内核线程的局限性
之前的讲述中说过,Java线程的实现方式是1:1,也就是一个Java线程映射到内核线程
这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,操作系统能容纳的线程数量也很有限
可能用户线程切换的成本接近于计算本身的开销
线程数量越多,切换的成本越高
可能代码还没有跑几行,就切换到其他线程去执行另外的代码。有可能大部分的时间都花在线程上下文切换,这是极为浪费CPU资源的
这对于大并发的场景无能为力
为什么内核线程切换成本高
内核线程的调度成本主要来自于用户态与内核态之间的状态转换
这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本
线程上下文切换解释说明
线程A -> 系统中断 -> 线程B
从线程A切换到线程B为什么会有系统中断呢
Java线程是1:1映射到内核线程,线程的切换需要由操作系统帮忙完成
从线程A切换到线程B是用户态程序主动发起,需要进行系统调用(软中断),来完成线程切换
处理器要去执行线程A的程序代码,并不是只有代码程序就能够跑起来
程序是数据和代码的组合体,代码执行时还必须要有上下文数据的支撑
从不同的视角来理解上下文
从程序员的角度来看是方法调用过程中各种局部变量与资源
从线程的角度来看是方法调用栈中存储的各类信息(局部变量表、操作数栈)
从操作系统和硬件的角度来看是存储在内存、缓存和寄存器中的一个个具体数值
详细过程
当线程A切换到线程B,去执行线程B的代码,操作系统需要把线程A的上下文数据妥善保存好,接着把线程A挂起
然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活后,才能够仿佛从来没有被挂起过
这种保护和恢复现场的工作,免不了在一系列数据在各种寄存器、缓存中来回拷贝,当然不可能是一种轻量级操作
协程的复苏
内核线程(1:1)切换成本高昂,那么之前的用户线程(1:N)就非常合适了
例如其他语言提供了自己的解决方案
GO语言的协程
Kotlin语言的协程
python语言的协程
Java的解决方案
OpenJDK在2018年创建了Loom项目,这是Java用来应对本节开篇所列场景的官方解决方案
在Java中协程被叫做纤程(Fiber)
纤程的出现并不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机中并存,可以在程序中同时使用
新模型有意地保持了与目前线程模型相似的API设计,用户甚至不需要改动大量代码就能够使用纤程
纤程对Java来说是一个划时代的出现,是自Java8以来最有革命性的改动,有可能在JDK17中以预览版出现
如果在Java中正式使用了纤程,那么一直以来Go语言簇拥着,Go具有高并发的优势将不复存在。JDK16以后ZGC可以将GC停顿控制在1ms以内,随着JIT技术的发展,C++对于Java高性能的优势也将不复存在
线程安全与锁优化
什么是线程安全和线程不安全
多个线程同一时刻对同一个全局变量(同一份资源)做写操作(读操作不会涉及线程安全)时,如果跟我们预期的结果一样,我们就称之为线程安全,反之,线程不安全
例如卖票程序,假设有100张票进行售卖,有三个窗口(也就是三个线程)同时售卖这100张票,期望100票卖完,绝对不能发生超卖或者多卖
不能同时卖出第20张票
不能卖出第-1张票
如果卖票程序发生了超卖(卖出-1张票)或者是多卖(同时卖出第20张票)那么就说明程序是线程不安全的,和我们预计的结果不相符合
线程安全的严格定义
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
Java语言中的线程安全
按照线程安全的"安全程度"由强至弱来排序,将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
不可变
在Java语言里面(特指JDK5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施
如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的
如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行
把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的
java.lang.String就是一个典型的不可变对象
首先底层的char[]数组被声明为final,在构造方法的时候进行赋值
同时substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象
总结来说就是将变量声明为final,变成只读的,不提供修改的机会
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全
测试Vector是否为绝对线程安全
测试代码
控制台输出
可以看到即使Vector的add()、remove()、get()方法都被synchronized关键字修饰,但是在多线程并发访问的情况下仍然是线程不安全的
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java 语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免
一个线程对立的例子是Thread类的suspend()和resume()方法
如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了
也正是这个原因,suspend()和resume()方法都已经被声明废弃了
常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等
线程安全的实现方法
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用
而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式
在"互斥同步"这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令
monitorenter字节码指令只有一次,monitorexit字节码指令有两次,必须释放锁
这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象
synchronized锁的锁对象
synchronized修饰实例方法
锁对象为this,即当前方法调用者
synchronized修饰静态方法
锁对象为对应类型的Class对象,即XXX.class对象
synchronized代码块
使用synchronized(obj) {...}的形式,锁对象就是obj
synchronized锁两大特性
可重入性
一个线程能够反复进入被它自己持有锁的同步块的特性,即锁关联的计数器,如果持有锁的线程再次获得它,则将计数器的值加一,每次释放锁时计数器的值减一,当计数器的值为零 时,才能真正释放锁
这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况
这个特性很重要,基本上所有的锁都必须要满足这个特性
阻塞
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入
无法强制正在等待锁的线程中断等待或超时退出
从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作
主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间
尤其是对于代码特别简单的同步块,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长
因此才说, synchronized是Java语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作
而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程, 以避免频繁地切入核心态之中
自JDK5起,Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段
重入锁(ReentrantLock)是Lock接口最常见的一种实现
特点
可重入
持有锁的线程可以多次进行同一把锁锁住的代码
等待可中断
是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
可中断特性对处理执行时间非常长的同步块很有帮助
tryLock(long timeout, TimeUnit unit)方法
公平锁
是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁
synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平 锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量
public ReentrantLock(boolean fair)构造方法,true表示公平,false表示不公平,默认不公平
锁绑定多个条件
是指一个ReentrantLock对象可以同时绑定多个Condition对象
在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一 个的条件关联的时候,就不得不额外添加一个锁。而ReentrantLock则无须这样做,多次调用newCondition()方法即可
ReentrantLock只是提供了synchronized更丰富的功能,而不一定有更优的性能,所以在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步
非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)
互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会 出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销
基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了
如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止
这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程
这里需要保证操作和冲突检测这两个步骤具备原子性,本质上是CPU指令lock cmpxchg 指令
常用的指令为Compare-and-Swap(比较并交换),下文称CAS
CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)
CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断
CAS存在ABA问题
如果一个变量V初次读取的时候是A值,中间其他线程修改为B,然后再次修改为A,并且在准备赋值的时候检查到它仍然为A值。这是不符合定义的,因为变量在中间过程中被其他线程修改过
J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性
不过目前来说这个类处于相当鸡肋的位置,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效
总结一下synchronized关键字依赖于JVM,JVM依赖于底层操作系统的Mutex Lock,CAS依赖于底层的CPU指令lock cmpxchg 指令
无同步方案
如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性
因此会有一些代码天生就是线程安全的,其中有两类:可重入代码(Reentrant Code)和线程本地存储(Thread Local Storage)
可重入代码
可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等
线程本地存储
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行
通过java.lang.ThreadLocal类来实现线程本地存储的功能
锁优化
自旋锁与自适应自旋
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除
锁粗化
轻量级锁
偏向锁
synchronized锁升级过程
前言
这部分不是很有用,但是面试经常问,掌握和理解也比较困难,这个知识点并不需要掌握
0 条评论
下一页