JVM系列:尚硅谷JVM笔记总和
JVM和Java体系结构
Java虚拟机
Java虚拟机(JVM,Java Virtual Machine):
- 一台执行Java字节码的虚拟计算机,拥有独立的运行机制
- 转载字节码到内部,解释/编译为对应平台上的机器指令
特点:
一次编译到处运行
自动内存管理
自动垃圾回收
Java代码执流程
flowchart LR code("Java源码(.java)") --> a("Java(前端)编译器") a --> cls("字节码(.class)") subgraph Java虚拟机 direction TB 类加载器 --> 字节码校验器 subgraph 执行引擎 direction TB jsq("解释执行(解释器)") jit("编译执行(JIT)") end 字节码校验器 --> 执行引擎 end cls --> Java虚拟机 Java虚拟机 --> 操作系统
JVM位置
JVM运行在操作系统之上,与硬件没有直接的交互
JVM整体结构
HotSpot VM是目前高性能虚拟机代表之一,采用解释器与即使编译器并存的架构
Class loader:将字节码文件,加载到内存中,生成Class对象
Runtime Data Area:JVM内存,上一步加载的Class的实例就是存放在方法区中
堆,方法区:多线程共享
虚拟机栈,本地方法栈,程序计数器栈:每个线程一份
执行引擎:解释器,即时编译器(JIT编译器),垃圾回收器
JVM架构模型
JVM指令集架构为:基于栈的指令集架构
基于栈的指令集架构
- 设计和实现简单
- 避开寄存器分配难题
- 零地址指令,指令集更小
- 不需要硬件支持
基于寄存器的指令集架构
- 典型:X86的二进制指令集
- 指令集架构完全依赖于硬件
- 性能优秀,执行高效
- 花费更少指令,去完成一项操作
- 以一地址指令、二地址指令和三地址指令为主
两种架构举例
- 基于栈的计算流程(以Java虚拟机为例):
1 | iconst_2 // 常量2入栈 |
- 基于寄存器的计算流程
1 | mov eax,2 // 将eax寄存器的值设为1 |
考虑跨平台性与移植性,Java指令集都是根据栈来设计的
优点:跨平台,指令集小,编译器容易实现
缺点:性能比寄存器差一些
JVM生命周期
JVM的启动
通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的。这个初始类由虚拟机的具体实现来指定。
JVM的执行
- JVM有一个清晰的任务:执行Java程序
- 程序开始他才运行,程序结束他就停止
JVM的退出
- 程序正常退出
- 程序遇到异常或错误而停止
- 操作系统出现错误导致JVM停止
- 某线程调用了Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit()或halt()操作
JVM发展历程
Sun Classic VM
- 1996年Java1.0时出现,世界第一款商用Java虚拟机,JDK1.4时被淘汰
- 只提供解释器
- 要使用JIT编译器,需要进行外挂,且会完全接管解释器。
- 解释器和JIT编译器无法共存
- 只使用JIT,需要把所有字节码都翻译成机器指令,翻译时间过长。程序启动的时候,等待时间长。
- 目前Hotspot内置此虚拟机
Exact VM
- JDK1.2时,Sun提供了此虚拟机
- 虚拟机可以知道内存中某个位置的数据具体是什么类型
- 具备现代高性能虚拟机的维形
- 热点探测(寻找出热点代码进行缓存)
- 编译器与解释器混合工作模式
- 只在Solaris平台短暂使用,终被Hotspot虚拟机替换
HotSpot VM(重点)
JDK1.3时,成为oracle JDK和openJDK的默认虚拟机
HotSpot:热点代码探测技术
- 通过计数器,找到最具编译价值代码,触发即时编译或栈上替换
- 编译器与解释器协同工作
JRockit(商用三大虚拟机之一)
- 即时编译器编译后执行
- 世界上最快的JVM
IBM的J9(商用三大虚拟机之一)
- 号称是世界上最快的Java虚拟机
KVM和CDC/CLDC Hotspot
- 面向更低端的设备,比如塞班
Azul VM
- 与特定硬件平台绑定
Liquid VM
- 运行在自家Hypervisor系统上
- 不需要操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等
- 随着JRockit虚拟机终止开发,Liquid vM项目也停止了
Apache Marmony
- 并没有Apache Harmony被大规模商用的案例,但是它的Java类库代码吸纳进了Android SDK
Micorsoft JVM
- 微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM
Taobao JVM
- 目前已经在淘宝、天猫上线,把Oracle官方JVM版本全部替换了
Dalvik VM
执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高
Android 5.0使用支持提前编译(Ahead of Time Compilation,AoT)的ART VM替换Dalvik VM
Graal VM(未来虚拟机)
- Run Programs Faster Anywhere
- 跨语言全栈虚拟机:Java,Scala,Groovy,Kotlin,C,C++,JavaScript,Ruby,Python,R
- 如果说HotSpot有一天真的被取代,Graalvm希望最大
课程学习路线
graph TB 类的加载器 ---> 内存结构 a["class文件结构"] ---> 内存结构 执行引擎 ---> 内存结构 内存结构 ---> 内存的分配与回收 内存的分配与回收 ---> b["性能监控(命令行、可视化工具)"] b ---> 性能优化
类加载子系统
如果想手写一个Java虚拟机的话,需要考虑哪些结构?
- 类加载器(ClassLoader)
- 执行引擎
ClassLoader作用
- 从文件系统或网络中加载class文件
- Class Loader只负责加载,能否运行由执行引擎决定
- 加载的类信息放在一块称为“方法区”的内存空间,除此之外,方法区还会存放运行时常量池信息(比如字符串字面量,数字常量)
(补充)加载class文件的方式:
- 从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从zip压缩包中读取,成为日后jar、war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
ClassLoader角色
- class file存在硬盘上(纸上的模板),最终需要实例化到JVM中(做好的成品)
- class file加载到JVM中,被称为DNA元数据模板,放在方法区中
- class file -> DNA元数据模板,需要一个运输工具,也即ClassLoader
类加载过程
阶段一:Loading
- 通过类全限定名,获取此类的二进制字节流
- 将字节流所代表的静态存储结构,转化为方法区的运行时数据结构
- 在内存中生成一个代表此类的java.lang.Class对象
阶段二:Linking
验证
确保Class文件的字节流符合要求。主要包含四种验证:文件格式,元数据,字节码,符号引用
举例:字节码文件开头都是CA FE BA BE
准备
为类变量(static)分配内存,并设置初始值(零值)
fianl static编译时就分配好了默认值,此时会显式初始化
类变量分配在方法区中
举例:变量a在准备阶段会赋初始值0。初始化时才会赋值为1
1 | private static int a = 1; |
解析
- 将常量池内的符号引用转换为直接引用的过程
- 符号引用就是一组符号来描述所引用的目标
- 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
举例:javap -v Hello.java
反编译后,可以查看符号引用,下面带#的就是符号引用
阶段三:Initialization
- 就是执行**类构造器方法
<clinit>
**的过程- 此方法不需要定义,是javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来的
- 执行顺序按照语句在源码中的顺序执行
- 如果没有类变量或者static块,就不会有
<clinit>
方法了 - 如果该类有父类,会保证父类的
<clinit>
先执行 - 虚拟机保证
<clinit>
方法在多线程下被同步加锁
1 | public class Main { |
ClassLoader分类
引导类加载器(Bootstrap ClassLoader)
- C/C++实现,在JVM内部
- 用来加载Java核心库(
JAVA_HOME/jre/lib/rt.jar、resources.jar
、或sun.boot.class.path
路径下的内容) - 用来加载扩展类加载器,系统类加载器,并指定为他们的父加载器
- 只加载包名为java、javax、sun等开头的类
- 没有父加载器
扩展类加载器(Extension Class Loader)
- Java语言编写,派生自ClassLoader类,父类加载器为引导类加载器
- 从系统属性
java.ext.dirs
指定的目录加载类库,或从JDK安装目录jre/lib/ext
目录下加载类库- 如果用户创建JAR包放入其中,也会自动加载
系统类加载器(System Class Loader)
- Java语言编写,派生自ClassLoader类,父类加载器为扩展类加载器
- 加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 是程序中的默认类加载器,可以
ClassLoader.getSystemClassLoader()
来获取
1 | public class Test { |
自定义加载器(User-Defined ClassLoader)
- 所有派生于抽象类ClassLoader的类加载器
- 为什么需要自定义类加载器
- 隔离加载类(例如:应用的jar包,中间件的jar包不会冲突)
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
自定义类加载器简单实现
开发人员可以继承抽象类java.lang.Classloader,实现自己的类加载器
JDK1.2之前,重写loadClass方法;JDK1.2之后,推荐把自定义类加载逻辑写在findClass中
如果没有太复杂的需求,可以直接继承URLClassLoader,避免去编写findClass()方法及其获取字节流的方式
1 | public class CustomClassLoader extends ClassLoader { |
ClassLoader常用方法
方法名称 | 描述 |
---|---|
getParent() | 返回该类加载器的父·类加载器 |
loadClass(String name) | 加载名称为name的类,返回java.lang.Class类的实例 |
findClass(String name) | 查找名称为name的类,返回java.lang.Class类的实例 |
findLoadedClass(String name) | 查找名称为name的已被加载过的类,返回java.lang.Class类实例 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组中的内容转换为一个Java类,返回java.lang.Class类实例 |
resolveClass(Class<?> c) | 连接指定的一个Java类 |
获取ClassLoader的方法
方法名称 | 描述 |
---|---|
clazz.getClassLoader() | 当前类的ClassLoader |
Thread.currentThread().getContextClassLoader() | 当前线程上下文的ClassLoader |
ClassLoader.getSystemClassLoader() | 系统的ClassLoader |
DriverManager.getCallerClassLoader() | 调用者的ClassLoader |
双亲委派机制
JVM对Class文件采用的是按需加载方式。加载某个类的class文件时,JVM采用的是双亲委派机制,即把请求交给父类处理。
思考问题:假如我们创建一个java.lang.String类,new String()
会被影响吗?
1 | package java.lang; |
答:java.lang.String
由引导类加载器加载,根据双亲委派机制,不会被影响
工作原理
- 类加载器收到了类加载请求,不会直接去执行,而是委托父类的加载器去执行
- 父类如果还有父类,则进一步向上委托
- 如果父类加载器可以加载,就成功返回;不能加载,子加载器才会尝试加载
优势
- 避免类的重复加载
- 保护程序安全:防止核心API被篡改
- java.lang.String:不会被篡改
- java.lang.MyTest:无法添加(因为包名java开头,引导类加载器不会加载)
沙箱安全机制
1 | package java.lang; |
加载java.lang.String
时,会率先使用引导类加载器。引导类加载器会先加载jdk自带的文件(rt.jar包中的java\lang\String.class)
报错信息说:没有main方法
这样可以保证对java核心源代码的保护,这就是沙箱安全机制
其他
在JVM中两个class对象是否为同一个?
类的完整类名必须一致,包括包名
加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
JVM必须知道一个类型是由启动加载器加载的,还是由用户类加载器加载的
- 如果是用户类加载器加载的,那么JVM会将这个类加载器的引用作为类型信息的一部分,存放在方法区中
- 当解析一个类型到另一个类型的引用时,JVM需要保证这两个类型的类加载器是相同的
Java程序对类的使用分为主动使用和被动使用
- 主动使用
加载时机 | 说明 |
---|---|
创建实例 | 创建类的实例 |
静态变量 | 访问(或赋值)类或接口的静态变量 |
静态方法 | 调用静态方法 |
反射 | 反射,如:Class.forName(“com.example.Test”) |
初始化子类 | 初始化一个类的子类 |
指明为启动类 | JVM启动时被标明为启动类的类 |
动态语言支持 | JDK7开始提供的动态语言支持: java.lang.invoke.MethodHandle实例的解析结果: REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化 |
- 被动使用:其他对类的使用都是被动使用,不会导致类的初始化(Initialization)
运行时数据区
基本情况
不同的JVM对于内存的划分方式和管理机制存在着部分差异。我们结合JVM虚拟机规范,来探讨一下经典的JVM内存布局
内存区域 | 生命周期 |
---|---|
方法区,堆(图中棕色) | 一个进程只有一份,生命周期与进程对应 |
程序计数器,本地方法栈,虚拟机栈(图中灰色) | 每个线程一份,生命周期与线程对应 |
Runtime类:运行时环境,也即被框出来的部分。每个JVM只有一个Runtime实例
线程
基本知识
- 在Hotspot JVM中,每个线程都与本地线程直接映射
- 当一个Java线程准备好执行后,一个操作系统线程也同时创建
- Java线程执行终止后,本地线程也会回收
- 操作系统会将线程调度到任何可用CPU上。一旦本地线程创建成功,就会调用Java线程中的run()方法
JVM系统线程
除了main线程和main创建的线程外,还有很多后台线程(简单了解下):
- 虚拟机线程:JVM达到安全点才会出现
- 周期任务线程
- GC线程
- 编译线程
- 信号调度线程
程序计数器
PC Register介绍
程序计数寄存器(Program Counter Register),并非物理意义上的寄存器,而是一种抽象模拟
- 作用:用来指向下一条指令的地址
- 它是一块很小的内存空间,运行速度最快
- 每个线程都有自己的PC计数器,生命周期与线程一致
- 一个线程只会有一个方法在执行,也即当前方法
- PC计数器会存储当前线程正在执行的Java方法的JVM指令地址
- 如果是native方法,则是未指定值(undefined)
- 它是程序控制流的指示器
- 分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成
- 解释器工作时,改变这个计数器的值,来选取下一条需要执行的指令
- 在JVM中,唯一没有规定任何OutofMemoryError情况的区域
举例说明
- 源代码
1 | public class Test { |
javap -verbose Test.class
反编译(部分代码)
1 | 0: bipush 10 |
两个面试问题
为什么使用 PC 寄存器来记录当前线程的执行地址?
- CPU会不断切换各线程,切换回来后,需要知道从哪里继续
- JVM字节码解释器需要通过改变PC寄存器的值,来明确下一条应该执行的指令
PC寄存器为什么被设定为私有的?
- CPU会不断做任务切换,必然导致线程中断和恢复
- 为了准确记录各线程当前执行的字节码地址,最好为每个线程都分配一个PC寄存器
虚拟机栈
基本内容
- Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack),也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,栈是线程私有的
虚拟机栈的生命周期
生命周期和线程一致
虚拟机栈的特点
- 快速有效,访问速度仅次于PC寄存器
- JVM对虚拟机栈的操作只有两个
- 方法执行,对应入栈
- 方法结束,对应出栈
- 栈不存在垃圾回收问题
- 栈不需要GC,但是可能存在OOM
虚拟机栈的异常
Java规定虚拟机栈的大小是动态或者固定不变的
- 如果固定大小:每个线程的虚拟机栈可以在创建时设定,执行时如果超过,JVM将抛出
StackOverflowError
异常 - 如果动态扩展:扩展时无法获
- 得足够内存,或创建时无法获取足够内存创建,JVM将抛出
OutofMemoryError
异常
设置栈大小
使用-Xss选项,在VM options中设定
1 | -Xss1m |
栈的存储单元
栈中存储什么?
- 每个线程都有自己的栈,栈中数据以栈帧(Stack Frame)的格式存在
- 正在执行的方法,都对应一个栈帧(Stack Frame)
- 栈帧是一个内存区块,维系着方法执行过程中的各种数据信息
栈运行原理
- JVM对虚拟机栈只能进行压栈和出栈
- 一个线程中,只能有一个活动的栈帧
- 当前栈帧(Current Frame)
- 当前方法(Current Method)
- 当前类(Current Class)
- 执行引擎运行的字节码指令只针对当前栈帧
- 如果该方法调用了其他方法,会创建新的栈帧,放在栈顶,成为新的当前帧
- 方法返回时,当前栈帧会传回执行结果给前一个栈帧。虚拟机丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- 不同线程的栈帧之间,不允许相互引用
- Java方法有两种返回方式
- 正常返回:使用return指令
- 异常返回:执行中出现未捕获的异常
栈帧内部结构
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表(Local Variables)
- 定义为一个数字数组,存储方法参数和局部变量
- 数据类型包括:基本数据类型、对象引用(reference)、returnAddress返回值类型
- 线程的私有数据,不存在数据安全问题
- 容量大小在编译期就确定下来,保存在字节码中:方法的Code属性的maximum local variables数据项中
- 只在当前方法中有效
Slot的理解
- 局部变量表,最基础的存储单位是Slot
- 32位的变量占一个Slot,64位的占两个
- byte,char,short,boolean存储前都转换位int
- long,double占两个slot
- 局部变量表每个Slot都有一个索引(从0开始),通过索引即可访问变量值
- 64位变量使用前一个索引访问即可
- 方法被调用时,方法参数和局部变量,将会按照顺序被复制到局部变量表的每一个slot上
- 如果是构造方法或实例方法,this将被存放在index为0的slot处
Slot的重复利用
一个栈帧中的Slot可以重复利用。一个局部变量过了其作用域,新的局部变量可以使用之前的槽位
1 | public void test4() { |
操作数栈(Operand Stack)
也被称为:表达式栈
操作数栈的作用
- 主要用于保存计算中间结果,同时作为变量临时存储的空间
- 可以视为JVM执行引擎的工作区。方法开始执行时,新的栈帧也随之创建,此时方法操作数栈是空的
- 操作数栈有明确的栈深度,所需最大深度在编译期就已定义好了,保存在字节码中方法的Code属性中(maxstack)
- 栈中的元素可以是任意Java类型
- 32bit类型占一个栈深度
- 64bit类型占两个栈深度
- 如果被调用函数有返回值,其返回值会被压入当前栈帧的操作数栈中
- 然后更新PC寄存器中下一条要执行的指令
- 操作数栈中的元素数据类型,必须与字节码指令的序列严格匹配
- 在编译阶段,编译器验证
- 在类加载过程中,类检验阶段(Linking-检验)的数据流分析阶段再次检验
举例说明
1 | byte i = 15; |
1 | 0 bipush 15 |
- 第一条指令,PC寄存器指向0。使用bipush让操作数15入操作数栈
- PC寄存器+1,指向下一行代码。使用istore_1将操作数栈的元素存储到局部变量表1的位置(局部变量表0存的是this)
- 同理,bipush将操作数8入栈,istore_2将操作数栈的元素存储到局部变量表2的位置
- 使用iload_1,iload_2,从局部变量表中取出两个操作数,放入栈中
- 使用iadd,将操作数栈顶端两个元素相加,然后入栈。再使用istore_3将其存储在变量表3的位置
关于类型转换
int j = 8;
8可以存储在byte类型中,所以压入栈的类型为byte(bipush 8)
存储为局部变量时,会转为int类型(istore_4)
int j = 800;
- byte存储不了,改为short型(sipush 800)
关于返回值的问题
1 | public int getSum() { |
getSum()
方法字节码指令,最后带着ireturn
testGetSum()
方法字节码指令,一上来就加载getSum()
方法的返回值
栈顶缓存技术
HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术
- 将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接(Dynamic Linking)
- 每个栈帧中都包含一个:指向常量池中,该方法的引用
- 包含这个引用的目的,就是为了实现动态链接(Dynamic Linking)
- 在字节码文件中,所有变量和方法的引用都作为符号引用(Symbolic Reference)保存在class文件的常量池中
- 比如:调用一个方法,就是通过常量池中指向方法的符号引用来表示的
- 动态链接作用就是为了将这些符号引用转换为直接引用
1 | public class Test { |
1 | # 字节码中的常量池 |
1 | # 字节码中methodB的Code |
methodB()方法通过
invokevirtual #33
调用了方法A常量池中:
#33 = Methodref #8.#34
#8 = Class #10
#10 = Utf8 org/example/Test
,找到了类名
#34 = NameAndType #35:#6
#35 = Utf8 methodA
,找到了方法名#6 = Utf8 ()V
,方法没有形参,返回值为void
结论:通过#33,我们找到了需要调用的methodA()方法,并进行调用
方法的调用
方法绑定机制
在JVM中,将被调用方法的符号引用转换为直接引用,与方法的绑定机制相关
- 静态链接(方法的绑定机制:早期绑定)
- 被调用的目标方法在编译期可知,将调用方法的符号引用转换为直接引用
- 动态链接(方法的绑定机制:晚期绑定)
- 如果在编译器无法确定,只能在程序运行期,将符号引用转换为直接引用
具体例子
1 | class Cat extends Animal implements Huntable { |
虚方法与非虚方法
多态使用的前提:① 类的继承; ② 方法的重写
- Java中任何一个方法都具备虚函数的特征
- 相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)
- 不希望方法拥有虚函数的特征时,可以使用关键字final来标记
虚方法与非虚方法:
非虚方法
- 编译器就确定了具体的调用版本,运行时不可变
- 如:静态方法,私有方法,final方法,实例构造器,父类方法
虚方法
- 其他所有方法,都是虚方法
普通指令与动态指令
普通指令
- invokestatic:调用静态方法,(早期绑定)
- invokespecial:调用唯一确认的方法,(早期绑定)
- invokevirtual:调用虚方法,(晚期绑定,但是final方法例外)
- invokeinterface:调用接口方法,(晚期绑定)
动态指令
- invokedynamic:动态解析出需要调用的方法
- Java7出现
- Java8开始有用(Lambda表达式)
动态类型语言 VS 静态类型语言
- 静态类型:判断变量自身的类型
- 动态类型:变量没有类型信息,变量值才有类型信息
体验动态指令
1 |
|
方法重写的本质
当我们执行对象的方法时(如果是虚方法):
- 对象压入操作数栈
- 找到操作数栈顶元素的实际类型(记作C)
- 如果C中找到与常量池中相符的方法,则进行访问权限校验
- 通过,返回方法的直接引用,查找结束
- 不通过,返回
java.lang.IllegalAccessError
异常
- 如果找不到相符的方法,按照继承关系依次找
- 如果最终依然找不到,抛出
java.lang.AbstractMethodError
异常
提升性能:
- 如果每次都要向上层找(动态分派),十分影响效率
- JVM会在类的方法区建立一个虚方法表(virtual method table)(非虚的方法不会在表中),使用索引表来代替查找。
- 每个类都有一个虚方法表,存放虚方法的实际入口
- 创建时间:类加载的Linking阶段。类变量初始值准备完成后,JVM会把类的方法表也初始化完毕
- 虚方法表的例子:
方法返回地址(return address)
- 存放调用方PC寄存器的值
- 无论有无异常,方法退出后都返回到该方法被调用的位置
- 方法正常退出:调用方PC寄存器的下一条指令的地址,作为返回地址
- 方法异常退出:返回地址通过异常表来确定,栈帧中不会有这部分信息
- 方法退出其实就是当前栈帧出栈的过程
- 需要恢复上层方法的:局部变量表,操作数栈
- 还需:将返回值压入调用者操作数栈,设置PC寄存器
- 正常完成和异常完成的区别在于:异常完成不会给上级调用者产生任何返回值
只有两种方式可以退出方法:
- 正常完成出口:执行引擎遇到方法返回的字节码指令(return),会有返回值传递给上层的方法调用者
- 返回不同类型的值,字节码指令不同:
- ireturn(boolean,byte,char,short,int)
- lreturn
- freturn
- dreturn
- areturn
- return(void,实例初始化方法
,类和接口初始化方法 )
- 返回不同类型的值,字节码指令不同:
- 异常完成出口:方法执行过程中遇到了异常,且方法内无法处理
- 异常处理,存储在一个异常处理表中,方便在发生异常的时候找到处理异常的代码
4-16行字节码出了异常,就按照19行字节码指令进行处理,针对任何异常类型
栈帧中的一些附加信息
栈帧中允许携带与Java虚拟机实现相关的一些附加信息。如:对程序调试提供支持的信息
虚拟机栈的5道面试题
举例栈溢出的情况?
- StackOverflowError,可以通过-Xss设置栈的大小
调整栈大小,就能保证不溢出吗?
- 不能,比如以前递归5000次StackOverflowError,现在调大栈的大小,可能7000次才会StackOverflowError。调大只会保证出现晚一些,无法一定保证不会溢出
分配的栈内存越大越好吗?
- 不是的,会挤占其他的内存空间(比如影响线程的数量)
垃圾回收是否会涉及到虚拟机栈?
- 不涉及
方法中定义的局部变量是否线程安全?
- 如果对象在方法内部产生,内部消亡,则是线程安全的;反之线程不安全
1 | // s1是线程安全的(只在方法内部用了) |
1 | // s1是线程不安全的(作为参数传进来,可能被其它线程操作) |
1 | // s1是线程不安全的(有返回值,可能被其它线程操作) |
1 | // s1是线程安全的(s1自己消亡了,最后返回的只是s1.toString的一个新对象) |
本地方法接口
什么是本地方法
Native Method:Java调用非Java代码接口,初衷是融合C/C++程序
1 | public class IHaveNatives { |
为什么要使用本地方法
与Java环境外的交互
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因
与操作系统的交互
通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的
Sun’s Java
Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互
目前该方法使用越来越少了,除非是与硬件相关的应用。异构领域间通信很发达,可以使用Socket通信,也可以使用Web Service等
本地方法栈的理解
本地方法栈:
管理本地方法的调用,也是线程私有的
允许被设为固定大小的或可动态扩展的
- 固定大小,超过本地方法栈最大容量:StackOverflowError异常
- 动态扩展,创建或扩展时没有足够的内存:OutOfMemoryError异常
假如我们调用一个Java方法,方法栈帧会被压入Java虚拟机栈中;
如果我们需要调用本地方法了,本地方法的栈帧会被压入到本地方法栈中,执行引擎通过动态链接的方式调用C的相关的库
当某一线程调用本地方法时,就进入了一个全新的不受虚拟机限制的世界。他和虚拟机有同样的权限
- 访问虚拟机内部的运行时数据
- 世界使用本地处理器的寄存器
- 直接从本地内存的堆中分配任意大小内存
并不是所有JVM都支持本地方法
- Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一
堆
堆的核心概念
- 一个JVM实例(进程)只有一个堆内存,是Java内存管理的核心区域
- 在JVM启动时就被创建,空间大小就已确定
- 堆空间大小可以调节
- 堆可以处于物理上不连续的内存空间中,但逻辑上它被视作连续的
- 所有线程共享Java堆,还可以划分线程私有缓冲区(Thread Local Allocation Buffer,TLAB)
- (几乎)所有的对象和数组,都分配在堆上
- 方法结束后,堆中的对象不会立刻移除,仅在垃圾回收时移除
- 堆,是GC(Garbage Collection,垃圾回收器)执行垃圾回收的重点区域
可视化工具
启动一个JVM实例
1 | // 虚拟机参数:-Xms10m -Xmx10m |
JDK14之后,已不再集成visualvm,手动安装
Eden Space + Survivor 0 + Survivor 1 + Old Gen就是我们堆空间的大小
内存细分
Java7及之前:
- 新生代,老年代,永久代
Java8及以后
新生代(New Gen)
Eden Space
Survivor 0
Survivor 1
老年代(Old Gen)
元空间(MetaSpace)
设置堆内存大小与OOM
设置大小
- -Xms:堆起始内存大小,等价于-XX:InitialHeapSize
- -Xmx:堆最大内存大小,等价于-XX:MaxHeapSize
- 堆超过此大小,会报OutOfMemoryError错误
通常,会将这两个值设为一样。以便在GC完成后不需要重新分隔堆区的大小,提高性能
默认情况下:初始内存大小=物理内存大小 / 64;最大内存大小=物理内存大小 / 4
查看大小
代码查看一下大小
(我的电脑内存40GB)
1 | long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; // 初始大小 |
命令行查看大小
jps:查看java进程
jstat -gc <进程id>:查看进程中内存使用情况
1 | SOC: S0区总共容量 |
有个细节:S0和S1之间只能二选一,算总量的时候应该:S0C(或者S1C) + EC + OC
虚拟机选项查看大小
-XX:+PrintGCDetails
OOM举例
1 | public class HeapDemo1 { |
年轻代与老年代
存储在JVM中的数据分为两类:
一类生命周期较短,创建和消亡都非常迅速
另一类生命周期非常长,甚至可能和JVM生命周期一致
Java堆进一步细分,可以划分为:年轻代(YoungGen)和老年代(OldGen)
- 年轻代可以继续细分为:Eden空间,Survivor0空间,Survivor1空间
- 年轻代内部比例:8 : 1 : 1
(几乎)所有Java对象都在Eden区被new出来
- 比如:Eden空间装不下…
绝大多数Java对象都是在新生代销毁的(IBM研究80%都是)
相关虚拟机选项
一般不会调整
设置新生代空间大小:
-Xmn50m
:如果和下面设置比例的选项冲突,还是优先使用它
年轻代与老年代比例:
默认:
-XX:NewRatio=2
,表示新生代占1,老年代占2(新生代占1/3)可以修改:
-XX:NewRatio=2
,表示新生代占1,老年代占4(新生代占1/5)
年轻代内部比例:
- 默认:
-XX:SurvivorRatio=8
,表示8 : 1 : 1(但未必一定是这个比例,因为有自适应策略)
使用自适应的内存比例:
- 默认使用:
-XX:+UseAdaptiveSizePolicy
- 关闭策略:
-XX:-UseAdaptiveSizePolicy
图解对象分配过程
具体过程
- new对象先放伊甸园区
- 如果伊甸园区满了,进行垃圾回收:
- 将进行Minor GC,不被引用的对象将被销毁
- 幸存的对象移动到S0中
- 下一次伊甸园满了,进行垃圾回收时:
- 这一次幸存的对象移动到S1中
- S0中也进行垃圾回收,幸存的也移动到S1中
- 每进入一次垃圾回收,幸存者age加一
- 在从S0->S1或者S1->S0过程中,如果age达到阈值(默认15),会进入老年区(promotion,晋升)
- 可以设置参数:
-XX:MaxTenuringThreshold=15
- 可以设置参数:
注意:
伊甸园区满的时候:会触发伊甸园区和幸存者区的垃圾回收
特殊情况
- 伊甸园放得下:分配内存
- 伊甸园放不下:触发Minor GC
- 幸存者区放得下:放置在S0或S1,达到阈值的对象晋升老年代
- 幸存者区放不下:直接放到老年代
- 伊甸园依然放不下(超大对象):直接放到老年代
- 老年代放不下:触发Major GC
- 老年代依然放不下:OOM
Minor GC、Major GC、Full GC
GC的分类
部分收集(Partial GC):
Minor GC:只是新生代(Eden,S0,S1)的垃圾回收
Major GC:只是老年代的垃圾回收
- 目前,只有CMS GC会有单独收集老年代的行为
- 目前,很多时候Major GC会和Full GC混淆使用,需要具体区分是老年代回收还是整堆回收
Mixed GC:整个新生代以及部分老年代的垃圾回收
- 目前,只有G1 GC会有这种行为
整堆收集(Full GC):
- Full GC:整个Java堆和方法区的垃圾收集
Minor GC的触发机制
- Eden满了触发,Survivor满了不会引发GC
- Minor GC触发频率非常高,回收速度也很快
- Minor GC会引发STW(Stop The World),暂停其他用户的线程,等垃圾回收结束才会恢复
Major GC的触发条件
指发生在老年代的GC,对象从老年代消失时,我们说”Major GC“或”Full GC“发生了
出现Major GC,经常伴随至少一次的Minor GC
- 但并非绝对的,Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略
- 也即:老年代空间不足时,先尝试触发Minor GC,空间还是不足才触发Major GC
- 但并非绝对的,Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略
Major GC的速度比Minor GC慢10倍以上,STW时间更长
Major GC后内存还是不足,报OOM
Full GC的触发条件
- 调用
System.gc()
,系统建议执行Full GC,但不必然执行 - 老年代空间不足
- 方法区空间不足
由于暂停时间长,应当尽量避免Full GC / Major GC
GC举例与日志分析
1 | // -Xms9m -Xmx9m -XX:+PrintGCDetails |
1 | JDK17的结果 |
堆空间分代思想
为什么要把Java堆分代?
分代的唯一理由就是优化GC性能
经研究,70%-99%的对象是临时对象
- 将朝生夕死的对象进行回收,能腾出大量的空间
如果没有分代,GC需要对堆的所有区域进行扫描
总结–内存分配策略
- 如果对象在Eden出生并经过第一次Minor GC后仍然存活,且能被Survivor容纳,将被移动到Survivor空间中,年龄设为1。
- 对象在Survivor区中每熬过一次MinorGC,年龄就增加1。当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同),就会被晋升到老年代
- 晋升老年代的阀值,可以通过选项**-XX:MaxTenuringThreshold**来设置
针对不同年龄段的对象分配原则:
- 优点分配到Eden
- 大对象直接分配到老年代
- Eden都放不下了
- 长期存活的对象分配到老年代
- 年龄超过阈值的对象
- 动态年龄判断(特例)
- 如果Survivor区中相同年龄的对象大小的总和,大于Survivor空间的一半。年龄大于等于该年龄的对象可以直接进入老年代,无需达到阈值
- 避免S0和S1反复倒来倒去
- 空间分配担保
- -XX:HandlePromptFailure(后面参数设置部分有讲)
补充–为对象分配内存:TLAB
TLAB:Thread Local Allocation Buffer
为什么有TLAB:
- 堆区是线程共享区域
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
什么是TLAB:
- JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
- 多线程分配内存时,可以避免一系列线程安全问题,还能提升内存分配吞吐量,这种内存分配方式称之为快速分配策略
- 几乎所有OpenJDK衍生出来的JVM都提供了TLAB的设计
- 当一个线程TLAB存满时,可以使用公共(蓝色)区域
TLAB的一些说明:
- 不是所有的对象都能够在TLAB中成功分配内存,但JVM将TLAB作为内存分配的首选
-XX:+UseTLAB
设置是否开启TLAB空间(默认开启)- 默认仅占Eden空间的1%,可以通过选项
-XX:TLABWasteTargetPercent
设置百分比大小 - 如果在TLAB空间分配内存失败,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,直接在Eden空间中分配内存
总结:TLAB分配过程
小结–参数设置
官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
参数 | 说明 |
---|---|
-XX:+PrintFlagsInitial | 查看所有参数的默认值 |
-XX:+PrintFlagsFinal | 查看所有参数的最终值 |
-Xms | 初始堆空间大小(默认物理内存1/64) |
-Xmx | 最大堆空间大小(默认物理内存1/4) |
-Xmn | 新生代大小(初始和最大都是) |
-XX:NewRatio | 新生代大小的占比(默认值2,新生代占1/3) |
-XX:SurvivorRatio | Eden和S0/S1空间的比例(默认8,8:1:1) |
-XX:MaxTenuringThreshold | 新生代最大年龄 |
-XX:+PrintGCDetails | 输出详细的GC日志 |
-XX:+PrintGC 或 -verbose:gc | 输出简化的GC日志 |
-XX:HandlePromotionFailure | 是否设置空间分配担保 |
什么是空间分配担保
Minor GC之前,JVM会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
如果大于,此次GC是安全的
如果小于:
- 如果
HandlePromotionFailure=true
,继续检查老年代最大可用连续空间是否大于历次晋升到老年代的平均大小- 如果大于,则尝试进行一次Minor GC,这次GC仍然是有风险的
- 如果小于,则进行一次Full GC
- 如果
HandlePromotionFailure=false
,则进行一次Full GC
jdk7之后,这个参数已经失效了
现在:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC
也即默认为true
具体查看某个参数的指令:
jps
:查看当前运行的进程
jinfo -flag SurvivorRatio 进程id
:查看某个参数的值
逃逸分析
堆是分配对象唯一选择吗?在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
- 随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
- 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
- 此外,前面提到的基于OpenJDK深度定制的TaoBao VM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
什么是逃逸分析
- 将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少Java程序中,同步负载和内存堆分配压力的,跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
逃逸举例
- 没有发生逃逸,可以分配到栈上。方法执行结束,栈空间就被移除(无需GC)
1 | public void my_method() { |
- StringBuffer sb 发生了逃逸,不能在栈上分配
1 | public static StringBuffer createStringBuffer(String s1, String s2) { |
- 想要StringBuffer sb不发生逃逸,进行优化
1 | public static String createStringBuffer(String s1, String s2) { |
参数设置:
在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析
-XX:+DoEscapeAnalysis
:显式开启逃逸分析-XX:+PrintEscapeAnalysis
:查看逃逸分析的筛选结果
总结:能使用局部变量,就不要在方法外部定义
代码优化
栈上分配
1 | // -Xmx128m -Xms128m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails |
-XX:-DoEscapeAnalysis
关闭逃逸分析,进行了垃圾回收,速度较慢
1 | [GC (Allocation Failure) [PSYoungGen: 33280K->872K(38400K)] 33280K->880K(125952K), 0.0013246 secs] |
-XX:+DoEscapeAnalysis
开启逃逸分析,没有进行垃圾回收,速度快很多
1 | 花费的时间为: 2 ms |
同步省略
如果一个对象被发现只能从一个线程中被访问到,那么对于这个对象的操作可以不考虑同步
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程
如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
例如,下面的代码
1 | public void f() { |
但是hollis对象的生命周期只在方法中,并不会被其他线程所访问到。在JIT编译阶段会被优化掉,优化成:
1 | public void f() { |
通过反编译字节码可以看到,依然有加锁(monitorenter,monitorexit),同步省略操作是在解释运行时发生的
标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换
代码举例
1 | private static void alloc() { |
以上代码,经过标量替换后,就会变成
1 | private static void alloc() { |
Point经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。不再需要分配堆内存,大大减少堆内存的占用。
实际测试
-XX:+ElimilnateAllocations
:是否开启标量替换(默认打开)
1 | // -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations |
-XX:-ElimilnateAllocations
:关闭标量替换,进行了垃圾回收
1 | [GC (Allocation Failure) 25600K->944K(98304K), 0.0008014 secs] |
-XX:+ElimilnateAllocations
:开启标量替换,未进行垃圾回收
1 | # 花费的时间为: 3 ms |
逃逸分析并不成熟
关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象(no),这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
本章小结
年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象可能会被分配在TLAB上;如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC。当GC发生在老年代时则被称为Major GC或者Full GC。一般的,Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
方法区
栈-堆-方法区的交互关系
从线程共享与否来看
从代码角度来看
方法区的理解
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4
可以看作是,一块独立于Java堆的内存空间
方法区主要存放Class,堆主要存放实例化的对象
方法区在JVM启动的时候被创建,它的实际的物理内存和Java堆区一样,都可以是不连续的
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,JVM同样会抛出OOM
JDK8以前:
java.lang.OutofMemoryError:PermGen space
JDK8及以后:
java.lang.OutOfMemoryError:Metaspace
加载大量的第三方的jar包
Tomcat部署的工程过多(30~50个)
大量动态的生成反射类
关闭JVM就会释放这个区域的内存
Hotspot中方法区的演进
- JDK8之前,习惯上把方法区称为永久代。JDK8开始使用元空间取代了永久代
- 现在来看,使用永久代并不是好的做法,容易导致程序OOM(超过XX:MaxPermSize上限)
- 永久代/元空间,都是对JVM规范中方法区的实现。区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
设置方法区大小与OOM
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整
JDK8及以后(元空间)
- 元空间大小由
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定- 默认值依赖于平台,windows 64位下:
- MetaspaceSize约为21MB
- MaxMetaspaceSize值为-1,即没有限制
- 默认情况下,JVM会耗尽所有的可用系统内存。如果元数据区发生溢出,JVM会抛出异常
OutOfMemoryError:Metaspace
- 对一个64位的服务器端JVM来说,元空间初始大小一般为21MB。
- 一旦触及这个水位线,Full GC将触发并卸载没用的类。这个高水位线将被重置。
- 如果释放的空间不足,会适当提高该值
- 如果释放的空间过多,则适当降低改值
- 为避免频繁GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值
方法区OOM代码举例
方法一:借助CGLib使得方法区出现内存溢出
1 | // -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m |
方法二:使用二进制字节码来定义类
1 | // -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m |
如何解决OOM
这个属于调优的问题,这里先简单的说一下
要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
内存泄漏:大量的引用指向某些对象,但是这些对象以后不会使用了。但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收
内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
如果不存在内存泄漏(对象确实都还必须存活),那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大。从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
方法区的内部结构
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
- 它用于存储已被虚拟机加载的类型信息、常量、静态变量、JIT编译器编译后的代码缓存等
类型(class)信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储:
- 完整名称(包名.类名)
- 直接父类的完整有效名(interface或java.lang.Object都没有父类)
- 类型的修饰符(public,abstract,final的某个子类)
- 实现接口的有序列表
域(Field)信息
也即成员变量
- 所有域的相关信息
- 名称
- 类型
- 修饰符(public,private,protected,static,final,volatile,transient的某个子集)
- 域的声明顺序
方法(Method)信息
- 名称
- 返回类型(包括void,void.class)
- 参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码,操作数栈大小,局部变量表(abstract和native方法除外)
- 异常表(abstract和native方法除外)
- 记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
举例
1 | public class MethodInnerStrucTest extends Object implements Comparable<String>, Serializable { |
javap -v -p MethodInnerStrucTest.class > test.txt
- 反编译字节码文件,并输出值到文本文件中,便于查看。参数-p确保能查看private权限类型的字段或方法
1 | Classfile /C:/Users/xdy/Desktop/learn-jvm/target/classes/org/example/methodArea/MethodInnerStrucTest.class |
final static类变量
1 | class Order { |
静态类变量和类关联在一起,随着类的加载而加载(clinit赋值),被所有实例共享
全局常量:static final,在编译时就会被分配
1 | clinit时才会被赋值 |
运行时常量池
1 | 【#5】就是在引用常量池 |
方法区中,包含运行时常量池
字节码文件中,包含常量池
常量池
- 字节码文件中,包含常量池(Constant Pool Table),包含各种字面量、对类型的符号引用、对域的符号引用、对方法的符号引用
为什么需要常量池
- 如果不用常量池,就需要在所有地方全写一遍,造成臃肿
- 需要用到的结构信息记录在常量池中,并通过引用的方式,来加载、调用所需的结构(减少代码大小)
常量池中有什么
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
运行时常量池
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
运行时常量池就是常量池在程序运行时的称呼
JVM为每个已加载的类型(类或接口)都维护一个常量池
运行时常量池,相对于常量池的另一重要特征是:具备动态性(
String.intern
)运行时常量池中包含多种不同的常量,包括:
编译期就已经明确的数值字面量
运行期解析后才能够获得的方法或者字段引用(此时不再是常量池中的符号地址了,这里换为真实地址)
当创建类或接口的运行时常量池时,如果所需的内存超过了方法区所能提供的最大值,JVM会抛OutofMemoryError异常。
方法区使用举例
- 原始代码
1 | public class MethodAreaDemo { |
- 字节码文件(常量池部分)
1 | Constant pool: |
- 字节码文件(main函数部分)
1 | public static void main(java.lang.String[]); |
- 500放入操作数栈中
- 弹出操作数栈顶,保存到本地变量表中:位置1(因为main是静态方法,所以没有this)
- 100放入操作数栈中
- 弹出操作数栈顶元素,存入本地变量表中:位置2
- 读取本地变量1,压入操作数栈
- 读取本地变量2,压入操作数栈
- 栈顶元素运算,再放回栈顶
- 弹出操作数栈顶元素,存入本地变量表中:位置3
- 将50压入操作数栈
- 弹出操作数栈顶元素,存入本地变量表中:位置4
- 获取常量池中的变量,压入操作数栈中
#2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#25 = Class #31 // java/lang/System
#26 = NameAndType #32:#33 // out:Ljava/io/PrintStream;
- 将本地变量表中:位置3,位置4的元素放入操作数栈中
- 栈顶元素运算,再放回栈顶
- 调用方法,方法中的参数会从操作数栈中弹出,压入虚拟机栈;虚拟机会开始执行虚拟机栈最上面的栈帧
#3 = Methodref #27.#28 // java/io/PrintStream.println:(I)V
方法区的演进细节
JDK6
有永久代(permanent generation),静态变量存储在永久代上
方法区由永久代实现,使用 JVM 虚拟机内存(虚拟的内存)
JDK7
有永久代,但已经逐步 “去永久代”
字符串常量池,静态变量移除。保存在堆中
方法区由永久代实现,使用 JVM 虚拟机内存
JDK8
无永久代,类型信息、字段、方法、常量保存在本地内存的元空间
字符串常量池、静态变量仍然在堆中
方法区由元空间实现,使用物理机本地内存
永久代为什么要被元空间替代?
- Java8中移除了永久代。其中的元数据被移到了堆之外,元空间(MetaSpace)中
- 元空间使用物理内存,最大可用空间就是系统可用空间
- 改动原因:
- 永久代很难确定大小。在某些场景下,如果动态加载类过多,容易产生永久代的OOM、
- 永久代调优很困难
- 方法区的垃圾收集主要有两部分,常量池中:废弃的常量,不再用的类型
- 一般来说,这个区域的回收效果难令人满意,尤其是类型的卸载,条件相当苛刻
字符串常量池为什么调整位置
- JDK7中将StringTable放到了堆空间中
因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收。
而Full GC是老年代的空间不足、永久代不足时才会触发。
这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。
放到堆里,能及时回收内存。
静态变量放在哪里?
1 | /** |
对象实例(也就是这个
new byte[1024 * 1024 * 100]
)无论如何都存在堆空间静态变量(这个对象)在JDK6,JDK7,JDK8存放位置中有所变化
- JDK6:方法区中(永久代实现)
- JDK7:堆空间中
- JDK8:堆空间(元空间中)
方法区的垃圾回收
- 《Java虚拟机规范》对方法区的约束非常宽松,不要求JVM在方法区中实现GC。确实有未实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。方法区GC效果难令人满意,尤其是类型的卸载,条件相当苛刻。
- 方法区的GC主要回收两部分,常量池中:废弃的常量、不再使用的类型
常量的回收
方法区内常量池之中主要存放的两大类常量:字面量和符号引用
- 字面量:如文本字符串、被声明为final的常量值等
- 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
HotSpot虚拟机,对常量池的GC策略:常量没有被任何地方引用,就可以被回收
回收废弃常量比较简单
类的卸载
- 判断常量是否废弃很简单,判断类型是否不再使用很困难
- 该类所有实例(包括派生类)都被回收
- 加载该类的类加载器已被回收(这个条件除非特殊设计,不然很难触发)
- 该类的java.lang.Class对象没有在任何地方被引用(无法在任何地方反射访问此类)
- JVM“被允许”对无用的类进行回收(不是必然被回收),HotSpot虚拟机
-Xnoclassgc
:不对方法区进行垃圾回收-verbose:class
:输出JVM载入类的相关信息-XX:+TraceClassLoading
:同上,监控类的加载-XX:+TraceClassUnLoading
:监控类的卸载
- 在大量使用:反射、动态代理、CGLib等字节码框架;动态生成JSP和OSGi等,频繁自定义类加载器的场景中,通常都需要JVM具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
总结
栈帧中动态链接,指向常量池中当前方法的引用
面试题
- 百度
- 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
- 蚂蚁金服:
- Java8的内存分代改进
- JVM内存分哪几个区,每个区的作用是什么?
- 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
- 二面:Eden和survior的比例分配
- 小米:
- jvm内存分区,为什么要有新生代和老年代
- 字节跳动:
- 二面:Java的内存分区
- 二面:讲讲vm运行时数据库区
- 什么时候对象会进入老年代?
- 京东:
- JVM的内存结构,Eden和Survivor比例。
- JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和survivor。
- 天猫:
- 一面:Jvm内存模型以及分区,需要详细到每个区放什么。
- 一面:JVM的内存模型,Java8做了什么改
- 拼多多:
- JVM内存分哪几个区,每个区的作用是什么?
- 美团:
- java内存分配
- jvm的永久代中会发生垃圾回收吗?
- 一面:jvm内存分区,为什么要有新生代和老年代?
实例化
对象的实例化
对象创建的方式:
- new,最常见的方式
- 变形1:单例类中调用静态类方法,getInstance()
- 变形2:XxxBuilder/XxxFactory的静态方法
- Class的newInstance()方法,使用反射
- 必须空参,权限必须public
- Constructor的newInstance()方法,使用反射
- 可以带参数,权限没有要求
- 使用clone(),不调用任何构造器,当前类需要实现Cloneable接口
- 使用反序列化,从文件或网络中获取对象的二进制流
- 使用第三方库(Objenesis),动态生成对象
对象创建的步骤
- 判断对象对应的类是否:加载,链接,初始化
- 虚拟机遇到new指令,检查能否在元空间的常量池中定位到此类的符号引用
- 并检查此类是否已被加载、解析、初始化(类的元信息是否存在)
- 如果没有,在双亲委派机制下,使用当前类加载器,用包名+类名为key查找对应class文件。找不到文件抛出ClassNotFoundException异常。找到了则进行类加载
- 为对象分配内存
- 如果内存规整:指针碰撞(Bump The Point)
- 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了
- 如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式
- 如果内存不规整:空闲列表(Free List)
- 维护一个空闲列表,记录上哪些内存块是可用的
- 分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容
- 如果内存规整:指针碰撞(Bump The Point)
- 处理并发安全问题
- 采用CAS+失败重试,保证更新的原子性(乐观锁)
- 每个线程预先分配TLAB,通过设置
-XX:+UseTLAB
参数来设置
- 初始化分配到的空间(属性的默认初始化,零值)
- 设置对象的对象头
- 将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息,等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
- 执行init方法进行初始化
- 属性值初始化
- 代码块初始化
- 构造器初始化
对象的内存布局
对象头(Header)
运行时元数据
- 哈希值(HashCode):对象在堆空间中都有一个首地址值,引用根据这个地址指向堆中的对象,这就是哈希值起的作用
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
类型指针
- 指向元空间中,对象所属的类型
如果是数组对象,还会记录数组长度
实例数据(Instance Data)
父类定义的变量、本身定义的变量
规则:
- 父类变量,出现在子类变量之前
- 相同宽度的字段总是被分配在一起
- 如果CompactFields参数为true(默认true),子类窄变量,可能插入到父类变量的空隙
对齐填充(Padding)
没作用,仅是占位符
对象的访问定位
JVM如何让通过栈帧中的对象引用,访问到堆空间中的对象实例的呢?
对象访问的方式
- 使用直接指针
使用句柄访问(使用句柄池)
直接内存(了解)
JDK8引入元空间,就是使用的直接内存
- 不是虚拟运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 直接内存是在Java堆外的、直接向系统申请的内存区间
- 来源于NIO,通过存储在堆中的DirectByteBuffer操作Native内存
- 读写性能更高
- 出于性能考虑,读写频繁的场合考虑使用直接内存
- Java的NIO库允许Java程序使用直接内存,用于数据缓存
- 也可能导致
OutOfMemoryError: Direct buffer memory
异常- 大小受限于系统内存大小
- 缺点:
- 分配回收成本较高
- 不受JVM内存回收管理
- 也可以设置直接内存的最大值
- 可以通过
-XX:MaxDirectMemorySize=10m
设置 - 如果不指定,默认和堆最大值
-Xmx
一致
- 可以通过
简单使用
1 | public class BufferTest { |
性能测试
访问直接内存读写性能更高。使用JVM时,读取内存,应用需要从用户态切换到内核态。
读内存时:应用需要,先从用户态的虚拟机内存中读取数据,而此数据需要从内核态的物理内存上获取
写内存时:应用需要,先写到用户态的虚拟机内存空间上,然后copy到内核态的本地物理内存上
使用直接内存,则不会有用户态和内核态的copy,避免中间商赚差价,读写速度更快
1 | // 测试:将一个文件复制三次 |
- 使用JVM内存做Buffer
1 | // 总时间sum = 26416 |
- 使用直接内存地址做Buffer
1 | // 总时间sum = 11260 |
直接内存溢出实验
1 | // -Xmx20m -XX:MaxDirectMemorySize=10m |
1 | // -Xmx20m -XX:MaxDirectMemorySize=10m |
执行引擎
- 执行引擎包括:解释器、即时编译器、垃圾回收器
- 执行引擎的任务:将字节码指令翻译/解释为对应平台上的本地机器指令
执行引擎的工作流程
- 执行什么字节码指令:依赖PC寄存器
- 执行完成后,PC寄存器更新为下一条需要被执行的指令地址
- 执行过程中,执行引擎会通过:
- 局部变量表中的对象引用,准确定位存储在堆中的对象实例
- 对象头中的元数据指针,准确定位存储在方法区的对象类型信息
代码编译和执行的过程
- 黄色部分:编译生成字节码的过程,javac编译器(前端编译器)完成,与JVM无关
- 后面绿色,蓝色部分:解释执行与即时编译,JVM完成
- javac编译器(前端编译器)流程:
- java字节码执行由JVM执行引擎来完成,流程如下:
什么是解释器?什么是JIT编译器?
- 解释器:当JVM启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,将字节码“翻译”为本地机器指令执行
- JIT编译器:就是虚拟机将源代码一次性编译成机器语言,但并不是马上执行
为什么Java是半编译半解释语言?
- 现在JVM在执行时,通常会将解释执行与编译执行结合起来进行
- JIT编译器将字节码翻译成本地代码后,可以做缓存操作,存储在方法区的JIT代码缓存中(执行效率更高了),并且在翻译成本地代码的过程中可以做优化
解释器
为什么要有解释器:
- JVM设计者们的初衷:跨平台特性,避免由静态编译的方式生成本地机器指令,从而诞生了逐行解释的想法(不产生中间产品)
解释器的分类:
- Java发展历史中,出现了字节码解释器和模板解释器
- 字节码解释器:通过纯软件代码,模拟字节码的执行,效率低下
- 模板解释器:将每一条字节码与一个模板函数关联,模板函数中直接产生执行的机器码,大幅度提高了性能
- HotSpot VM中,解释器主要由Interpreter模块和Code模块构成
- Interpreter模块:实现解释器的核心功能
- Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
解释器的现状:
- Python、Perl、Ruby等高级语言也是使用解释器的
- 为了解决低效的问题,JVM平台支持JIT技术
- 避免函数被解释执行,将整个函数编译成机器码,效率大幅度提升
JIT编译器
- HotSpot VM是高性能虚拟机的代表作之一,它采用解释器与即时编译器并存的架构
比如:JRockit只部署在服务器上,已经砍掉了解释器
为什么还需要解释器:
- 看重启动时间的场景,需要两者并存的架构,来换取一个平衡
- 此模式下,解释器可以首先发挥作用,省去编译时间;随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,提高执行效率
- 在编译器进行激进优化不成立的时候,作为编译器的“逃生门”(后备方案)
阿里的案例:
- 机器在热机状态(运行了一段时间)可以承受的负载要大于冷机状态
- 程序员分批发布时,误分为两批发布。本来热机状态一半的机器可以勉强承载流量。但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在
编译器相关概念
- 前端编译器:把.java文件转变为.class
- JVM的后端运行期编译器(JIT编译器):把字节码转变为机器码
- 静态提前编译器(AOT编译器):直接把.java文件编译成本地机器码(后续发展的趋势)
典型的编译器:
- 前端编译器:Sun的javac、Eclipse JDT的增量式编译器(ECJ)
- JIT编译器:HotSpot VM的C1、C2编译器
- AOT编译器:GNU Compiler for the Java(GCJ)、Excelsior JET
热点代码及探测方式
在运行时,JIT编译器针对频繁被调用的热点代码做出优化,直接编译为机器指令,以提升性能
- 这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换(OSR编译,On StackReplacement)
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测
每个函数都建立两个不同类型的计数器
- 调用计数器:方法的调用次数
- 回边计数器:方法体中,循环体执行的次数
方法调用计数器:
统计方法调用的次数,达到阈值触发JIT编译
- 阈值可以用
-XX:CompileThreshold
来设定
- 阈值可以用
方法被调用时,会检查是否存在被JIT编译过的版本
- 存在,执行编译后的本地代码
- 不存在,计数器加一
- 超过阈值,向JIT编译器提交此方法的代码编译请求
- 未超过阈值,对字节码解释执行
存在热度衰减:超过一定的时间,如果调用次数不足以让它提交给JIT编译器编译,那此方法的调用计数器就会减少一半
- 可以用
-XX:-UseCounterDecay
关闭热度衰减(这样绝大多数方法都会被编译成本地方法) - 可以用
-XX:CounterHalfLifeTime
参数设置半衰期时间,单位是秒
- 可以用
回边计数器:
统计方法中循环体代码执行的次数,字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。建立回边计数器统计的目的就是为了触发OSR编译
HotSpot中的程序执行
设置程序执行的方式:
-Xint
:完全采用解释器模式执行-Xcomp
:完全采用JIT编译器模式执行。编译出问题,解释器会介入-Xmixed
:采用解释器+编译器混合的模式执行
-Xint : 6520ms -Xcomp : 950ms -Xmixed : 936ms
实验结论:只用解释器执行是真的慢
HotSpotVM JIT分类:
-client
:Java虚拟机运行在Client模式下,并使用C1编译器- C1编译器:对字节码进行简单可靠的优化,编译速度快
-server
:Java虚拟机运行在Server模式下,并使用C2编译器- C2编译器:进行较激进的优化,编译慢但执行效率高
CI和C2编译器不同的优化策略:
- C1编译器:
- 方法内联:将引用函数代码,编译到引用处
- 去虚拟化:对唯一实现的方法进行内联
- 冗余消除:在运行期间,把一些不会执行的代码折叠掉
- C2编译器,优化主要是在全局层面,逃逸分析是优化的基础;基于逃逸分析的优化有:
- 标量替换:用标量值代替聚合对象的属性值
- 栈上分配:未逃逸的对象,分配在栈上
- 同步消除:清除同步操作,通常指synchronized
逃逸分析只有在C2(server)模式下才会触发
分层编译策略:
不开启性能监控:程序解释执行可以触发C1编译,将字节码编译成机器码
开启性能监控:C2编译会根据性能监控信息进行激进优化
Java7之后,
-server
会默认开启分层编译策略,C1和C2协作来完成编译任务
Graal编译器:
- 自JDK10起,HotSpot又加入了一个全新的即时编译器:Graal编译器
- 编译效果短短几年时间就追平了C2编译器,未来可期(对应还出现了Graal虚拟机,是有可能替代Hotspot的虚拟机的)
- 目前,带着实验状态标签,需要使用开关参数去激活才能使用
-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler
AOT编译器:
JDK9引入了AOT编译器(静态提前编译器,Ahead of Time Compiler)
- AOT编译指的则是,在程序运行之前,将字节码转换为机器码
JDK9引入了实验性AOT编译工具jaotc。借助Graal编译器,将输入的Java类文件转换为机器码,存放至生成的动态共享库之中
.java
->.class
- (使用jaotc) ->.so
优点:
- 已经预编译成二进制库,可以直接执行
- 不必等待JIT编译器预热,减少Java”第一次运行慢“的不良体验
缺点:
- 破坏Java”一次编译,到处运行“,必须为每个硬件和系统编译对应的包
- 降低了Java链接过程中的动态性,加载的代码在编译器就必须全部知道
StringTable
String基本特性
- String被声明为final的,不可被继承
- String实现了Serializable接口,表示是支持序列化的
- String实现了Comparable接口,表示可以比大小
- JDK8之前定义
final char[]
用于存储数据,JDK9改为final byte[]
- String具有不可变性,无论是修改还是赋值,都需要重新指定内存区域来赋值
为什么改为byte[]:
- 每个char字符使用两个字节(16位),大量的拉丁字符只需要一个字节存储,浪费空间
- 之前使用UTF-16的char[]数组来存储,现在改为byte[]数组+编码标记字段来存储
- 如果是ISO-8859-1/Latin-1,只需要一个字节存
- 如果是其它字符集,比如UTF-8,仍然用两个字节存
底层结构
- String的String Pool(字符串常量池)是一个固定大小的HashTable。如果放进String Pool的String非常多,就会造成Hash冲突严重,导致链表会很长。而链表长了后,会导致调用
String.intern()
方法时性能下降 -XX:StringTablesize
可设置StringTable的长度- JDK6中长度是固定的,就是1009
- JDK7中默认长度60013,StringTablesize设置不做限制
- JDK8中默认长度60013,StringTablesize最小为1009
String内存分配
Java有8种基本类型+String类型,8种基本类型的常量池是系统协调的,而String的常量池比较特殊
- 双引号声明的字符串会直接存储在常量池种:
String info = "xuedongyun"
- 不是使用双引号声明的字符串,可以使用
intern()
方法
字符串常量池存储位置
JDK6之前,存储在永久代
JDK7,存储在堆中
JDK8,依然存储在堆中,永久代变成元空间
StringTable为什么要调整位置?
官方文档:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html#jdk7changes
- 为什么要调整位置?
- 永久代的默认空间大小较小
- 永久代垃圾回收频率较低,大量字符串无法及时回收。容易Full GC产生STW,或者OOM: PermGen Space
- 堆中空间足够大,字符串可被及时回收
- intern字符串在堆中分配,与其他被创建的对象一起分配,可能需要调整堆的大小
OOM实验:
1 | /** |
String基本操作
举例1:
完全相同的字符串字面量,必须指向同一个String类实例
1 | public class StringTest { |
程序启动时加载了2144个字符串常量
程序结束时,加载了2155个字符串常量(多了”1”到”10”,以及回车符)
举例2:
1 | class Memory { |
字符串拼接操作
- 常量与常量的拼接,结果在常量池,原理是编译期优化
- 拼接前后,只要有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
- 对拼接的结果调用
intern()
方法- 常量池中存在,返回字符串在常量池中的地址
- 常量池中不存在,在常量池中创建一份,再返回地址
1 | String s = "a" + "b"; |
1 | String s1 = "a"; |
改进空间:
- 在实际开发中,如果确定长度不高于highLevel,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);
- 避免频繁扩容,提升性能
intern()的使用
1 | public native String intern(); |
- intern是一个native方法,调用底层C的方法
- StringTable最初是空的,由String类私有地维护,调用intern方法时:
- 如果池中包含equals(object)方法确定相同的字符串:返回该字符串对象地址
- 否则:添加字符串对象到池中,返回该字符串对象地址
new String(“ab”)会创建几个对象?
- 两个对象,一个对象是new关键词在堆空间创建的;另一个对象是字符串常量池中的对象”ab”
1 | public class StringNewTest { |
1 | 0 new #2 <java/lang/String> # 在堆中创建了一个String对象 |
new String(“a”) + new String(“b”)会创建几个对象?
- 对象1:new StringBuilder()
- 对象2:new String(“a”)
- 对象3:常量池中的”a”(如果之前没有的话)
- 对象4:new String(“b”)
- 对象5:常量池中的”b”(如果之前没有的话)
- 对象6:new String(“ab”),由StringBuilder的toString()创建,但没有常量池中生成
1 | public class StringNewTest { |
1 | 0 new #2 <java/lang/StringBuilder> # StringBuilder对象 |
JDK7之后intern的变化:
1 | public class StringIntern { |
JDK7以后,由于StringTable放到堆空间中了。调用s3.intern()方法时,如果常量池中没有,为了节省空间,会在常量池中直接记录s3的地址
JDK7以后,不用重新创建并放入一个”11”对象了
性能测试:
直接new String
:程序需要维护大量存放在堆空间中的String实例,内存占用高
1 | arr[i] = new String(String.valueOf(data[i % data.length])); |
使用intern()
:直接指向常量池中的字符串,内存占用也更低
1 | arr[i] = new String(String.valueOf(data[i % data.length])).intern(); |
StringTable垃圾回收测试
1 | // -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails |
- Number of entries 和 Number of literals 明显没有 100000
- 年轻代发生了垃圾回收
G1中的String去重操作(了解)
String去重操作的背景:
- 注意:不是指字符串常量池的去重,常量池本身就没有重复的
- 背景:对很多Java程序做测试,结果:
- 堆存活数据中,String对象占了25%
- 堆存活数据中重复的String对象占了13.5%
- String对象平局长度45
- 堆上存在重复的String对象,是一种内存的浪费
String去重操作的实现:
- 垃圾收集器工作时,会访问堆上存活的对象。每一个对象,都会检查是否是候选要去重的String对象
- 如果是,把对象的引用插入到队列中等待处理。一个去重线程在后台运行,处理这个队列
- 使用一个HashTable来记录所有的被String对象使用的不重复的char数组。去重时会查这个HashTable,看堆上是否存在一摸一样的char数组
- 如果存在,String对象会被调整引用那个数组,释放对原来数组的引用(最终被垃圾回收器回收掉)
- 如果不存在,char数组会被插入到Hashtable,以后就可以共享这个数组了
命令行选项:
-XX:+UseStringDeduplication
:开启String去重,默认是不开启的,需要手动开启-XX:+PrintStringDeduplicationStatistics
:打印详细的去重统计信息-XX:StringDeduplicationAgeThreshold=threshold
:达到这个年龄的String对象被认为是去重的候选对象
垃圾回收概述
垃圾收集和内存动态分配,是Java区别于C++的最大差异
下面是一些大厂面试题
蚂蚁金服:
- 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下CMS和G1?
- JVM GC算法有哪些,目前的JDK版本采用什么回收算法?
- G1回收器讲下回收过程GC是什么?为什么要有GC?
- GC的两种判定方法?CMS收集器与G1收集器的特点
百度:
- 说一下GC算法,分代回收说下
- 垃圾收集策略和算法
天猫:
- JVM GC原理,JVM怎么回收内存
- CMS特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
滴滴:
- Java的垃圾回收器都有哪些,说下G1的应用场景,平时你是如何搭配使用垃圾回收器的
京东:
- 你知道哪几种垃圾收集器,各自的优缺点,重点讲下CMS和G1,
- 包括原理,流程,优缺点。垃圾回收算法的实现原理
阿里:
- 讲一讲垃圾回收算法。
- 什么情况下触发垃圾回收?
- 如何选择合适的垃圾收集算法?
- JVM有哪三种垃圾回收器?
字节跳动:
- 常见的垃圾回收器算法有哪些,各有什么优劣?
- System.gc()和Runtime.gc()会做什么事情?
- Java GC机制?GC Roots有哪些?
- Java对象的回收方式,回收算法。
- CMS和G1了解么,CMS解决什么问题,说一下回收的过程。
- CMS回收停顿了几次,为什么要停顿两次?
什么是垃圾
程序中,没有任何指针指向的对象。如果不及时清理,可能导致内存溢出。
为什么需要GC
- 如果不进行垃圾回收,内存迟早都会被消耗完
- 垃圾回收也可以清除内存里的碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
没有GC不能保证程序的正常进行,经常GC导致STW影响性能,所以才需尝试对GC进行优化
Java垃圾回收机制
官网介绍:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
- 垃圾回收可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收
- 频繁收集You区
- 较少收集Old区
- 基本不收集Perm区/元空间
垃圾回收算法
标记阶段
目的:判断对象是否存活
方法:引用计数算法,可达性算法
引用技术算法:
- 每个对象保存一个整型引用计数器,用于记录被引用的情况
- 任何对象引用了A,则A的计数器就加1;引用失效时,引用计数器就减1。计数器为0表示对象可回收
- 优点:
- 实现简单,效率高
- 缺点:
- 增加存储开销
- 需要更新计数器,增加时间开销
- 严重问题:无法处理循环引用
可以证明,Java没有使用这一类算法
可达性分析算法:
- 也称为根搜索算法、追踪性垃圾收集
- 有效解决循环引用问题
- 以根对象集合(GCRoots)为起始点,搜索对象是否可达,搜索路径称为引用链
- 只有能被访问的对象才是存活对象
GCRoots可以是哪些元素:
总结:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析
小技巧:一个指针保存了堆中的对象,但自己不在堆中,那它就是Root
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 比如StringTable里的引用
- 所有被同步锁synchronized持有的对象
- JVM内部的引用
- 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),系统类加载器
- 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
其他:根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
对象finalization机制
- Java语言提供了对象终止(finalization)机制,允许自定义对象销毁前的处理逻辑
- 垃圾回收对象前,总会先调用这个对象的finalization方法
- finalization方法允许在子类被重写,用于对象在被回收时释放资源
- 永远不要主动调用某个对象的finalization方法,应交由垃圾回收机制调用
对象的三种状态:
- 可触及:从根节点开始可达
- 可复活:对象的所有引用都被释放,但是对象可能在finalize中复活
- 不可触及的:对象finalize被调用,没有复活;不可触及的对象不可能被复活,因为finalize只会被调用一次
判断对象是否可回收:
- 从对象到GCRoot没有引用链条
- 判断是否有必要执行finalization方法
- 没有重写,或者已经被调用了:判定为不可触及
- 重写了,没执行。对象被插到F-Queue队列中。JVM创建的低优先级的Finalizer线程触发其执行
- GC会对F-Queue队列中的对象进行二次标记
- 如果对象与引用链上的任何对象建立了联系,会被移出“即将回收”集合
- 不然,变成不可触及的状态
JVisual VM查看Finalizer线程:
对象复活代码演示:
1 | public class CanReliveObj { |
1 | public static void main(String[] args) throws InterruptedException { |
第一次自救成功,第二次自救失败(finalize方法只会执行一次)
MAT进行GCRoots溯源
- MAT是Memory Analyzer的简称,是一款功能强大的Java堆内存分析器,用于查找内存泄露、查看内存消耗情况
- MAT是基于Eclipse开发的
- 下载地址:http://www.eclipse.org/mat/
在内存分析方面,MAT更好用一些
实验代码:
1 | public class GCRootsTest { |
命令行jmap获取dump文件:
1 | C:\Users\xdy>jps |
JVisualVM获取dump文件:
使用MAT打开文件:
MAT中划分GCRoot的方式不太一样,不过可以在Thread中找到main线程,可以看到里面有两个ArrayList和Date类型的GCRoot。如果执行完代码后,就会看到已经找不到他们两个了。
JProfiler进行GCRoots溯源
- 点击“标记当前值”,可以看到从此刻开始某类型的对象增多/减少的量,如果某个对象疯狂增多,肯定有问题
- 右键,“在堆遍历器中显示所选”,可以看到一些详细信息
- 最常用的是“引用中的“Incoming references”(从哪来的)和“Outcoming references”(到哪去的)
- 可以看到,此字符串是从System类的out静态类变量来的
JProfiler分析OOM
- 让OOM时生成dump文件
1 | //-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError |
- 生成了dump文件,可以直接打开
1 | java.lang.OutOfMemoryError: Java heap space |
- 可以从最大对象中看到,是ArrayList最大
- 也可以从线程中,看到是main线程出现的OOM异常,以及出问题的代码行数
清除阶段
标记清除算法:
当堆空间耗尽时STW时:
- 标记:从根节点遍历,标记所有被引用的对象
- 清除:对堆从头到尾进行线性的遍历,清除所有不可达对象
缺点:
- 效率低
- 需要停止整个应用
- 清理出来的空间不是连续的,产生碎片,需要维护一个空闲列表
何为清除:
- 不是真的置空,而是把地址保存在空闲地址列表里
- 如果内存规整
- 采用指针碰撞的方式,进行内存分配
- 如果内存不规整
- 虚拟机需要维护一个空闲列表
复制算法:
活着的内存空间分为两块,每次只使用其中一块
将存活的对象复制到未使用的内存块中,清空正在使用的内存块
新生代就用到了复制算法,Eden区和S0区存活的对象整体复制到S1区
优点:
- 没有标记清除的过程,简单高效
- 不会出现碎片问题
缺点:
- 需要两倍空间
- GC需要维护区域之间对象引用的关系,内存占用和时间开销都较大(参考:对象句柄访问)
应用场景:
- 垃圾对象多,存活对象少,复制算法的效率将会很高
标记压缩算法:
- 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
- 第二阶段将所有存活对象压到内存的一端,之后清理边界外的空间
- 老年代使用的垃圾回收策略
优点:
没有内存碎片问题
没有内存减半的问题
缺点:
- 效率低
- 需要调整对象的引用地址(HotSpot虚拟机采用的不是句柄池的方式,而是直接指针)
- 移动过程中需要STW
对比三种清除算法:
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
分代收集算法
为什么使用分代收集算法:
- 不同的对象的生命周期是不一样的,可以采取不同的收集方式,以便提高回收效率。一般是分为新生代和老年代
- 在Java程序运行的过程中,会产生大量的对象:
- 比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,生命周期比较长。
- 但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
几乎所有的GC都采用分代收集算法:
在HotSpot中:
年轻代(Young Gen)
区域小,对象生命周期短,存活率低,回收频繁。
适合使用复制算法,空间问题,通过HotSpot中的两个survivor的设计得到缓解。
老年代(Tenured Gen)
- 区域较大,对象生命周期长,存活率高,回收不频繁。
标记-清除,标记-整理,混合实现。
- 标记阶段(Mark)的开销,与存活对象的数量成正比。
- 清除阶段(Sweep)的开销,与管理区域的大小成正相关。
- 压缩阶段(Compact)的开销,与存活对象的数据成正比。
以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。
对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
增量收集算法
STW会导致所有线程都被挂起,等待一段时间
增量收集算法思想:
- 每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成
- 依然是传统标记-清除算法,但是允许GC以分阶段的方式完成标记、清除、复制等工作
优点:
- 减少了系统停顿的时间
缺点:
- 线程切换和上下文转换有消耗,造成系统整体吞吐量下降
分区算法
主要针对G1收集器来说
- 堆空间越大,一次GC时所需要的时间就越长
- 将大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,从而减少一次GC所产生的停顿
- 分代算法按照生命周期划分成两个部分,而分区算法将堆空间划分成连续的不同小区间
- 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间
垃圾回收相关概念说明
System.gc()的理解
System.gc()
或者Runtime.getRuntime().gc()
,会显示触发Full GC,同时对老年代和新生代进行回收- 但是,不能确保立即执行
- 一般情况下,无需手动调用
1 | public class SystemGCTest { |
一些例子分析:
1 | // -Xms256m -Xmx256m -XX:+PrintGCDetails |
localvarGC1()
:没回收
1 | 先进行了YoungGC,对象并没有被回收掉 |
localvarGC2()
:回收了
1 | 先进行了YoungGC,buffer对象直接被回收了 |
localvarGC3()
:没回收
1 | 先进行了YoungGC,对象并没有被回收掉 |
原因:从字节码可以看到,局部变量表槽数为2。但是实际去看发现只有一个槽位存储了this。GC时,buffer其实还占用着槽位,所以没有回收
localvarGC4()
:回收了
原因:新创建了局部变量
int value
,槽位复用,buffer无法再占用原本的槽位了
localvarGC5()
:回收了
1 | [GC (System.gc()) [PSYoungGen: 15516K->10744K(76288K)] |
内存溢出
内存溢出(OOM):没有空闲内存,且垃圾回收也无法提供更多内存
原因分析:
- JVM堆内存设置不够
- 通过参数
-Xms
、-Xmx
来调整
- 通过参数
- 代码创建了大量的大对象,且长时间不能被回收
- 潜台词:在抛出
OutOfMemoryError
之前,会触发GC,尽可能清理出空间 - 也不是一定会触发GC:如果分配一个超大对象,比堆的最大值都大,将直接抛出错误
- 潜台词:在抛出
内存泄露
- 对象不会被程序用到了,但GC又不能回收它们
- 广泛意义上的内存泄露:不需要的对象,但是生命周期很长
常见例子:
- 单例模式:单例模式的对象的生命周期和应用程序一样长,如果单例对象持有对外部对象的引用,这个外部对象将不能回收
- 外部资源未close():数据库连接,网络连接,io操作等资源,如果不手动close会导致无法回收
Stop The World
GC事件发生过程中,整个应用程序线程都会被暂停
- 枚举根节点(GC Roots),会导致所有Java执行线程停顿
- 系统冻结在某个时间点上
- 任何GC都会有STW
垃圾回收的并行与并发
GC中的并行:
- 并行:多条GC线程并行工作,但此时用户线程处于等待状态
- 如:ParNew、Parallel Scavenge、Parallel Old
- 串行:相较于并行的概念,GC单线程执行
GC中的并发:
- 用户线程与GC线程同时执行,GC执行时不会停止用户程序的运行
- 用户程序继续运行,垃圾回收线程运行于另一个CPU核心
- 如:CMS、G1
安全点与安全区域(了解)
安全点(Safepoint):
程序执行时,在特定位置才能停下来开始GC,这些位置称为安全点
安全点的选择很重要:
太少可能导致GC等待的时间太长,
太频繁可能导致运行时的性能问题
大部分指令的执行时间都非常短暂,通常会根据是否具有让程序长时间执行的特征为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等
安全区域(Safe Region):
- 安全点机制保证程序执行时,一小段时间就能遇到可进入GC的点。但是如果程序不执行呢?
- 列如:线程处于Sleep或Blocked状态
- 安全区域指,一段代码片段中,对象的引用状态不会变化,此区域任何位置GC都是安全的
安全区域的执行流程:
- 当线程运行到安全区时,首先标识已经进入了安全区。如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
- 当线程即将离开Safe Region时,会检查JVM是否已经完成根节点枚举(即GC Roots的枚举)。如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止
再谈引用
强引用
- 99%都是强引用,也是默认的引用类型
- 只要强引用的对象是可达的,JVM宁可OOM也不回收它
- 强引用是造成内存泄露的主要原因
软引用
内存不足即回收
- 内存足够,不会回收软引用可达的对象;内存不够,才会回收
- 将要OOM前,会将此类对象列入回收范围中二次回收,空间还不够才会OOM
- 构造软引用时,会指定一个引用队列,以跟踪对象的回收情况
- 软引用常用来实现缓存
1 | SoftReference<User> userSoftRef = new SoftReference<User>(new User(1, "songhk")); |
弱引用
发现即回收
- 在GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象
- 比起软引用,GC回收时无需检查是否需要回收,弱引用对象更容易、更快被GC回收
- GC线程通常优先级较低,有时弱引用还是会存在一段时间的
- 同样的,构造软引用时,会指定一个引用队列,以跟踪对象的回收情况
- 适合保存可有可无的缓存数据,内存不足缓存数据会被回收;内存充足时又可以存在相当长的时间
1 | Object obj = new Object(); // 声明强引用 |
虚引用
- 也称“幽灵引用”或“幻影引用”,是最弱的引用
- 如果对象仅持有虚引用,那么和没有引用几乎是一样的,随时都可能被GC回收
- 不能单独使用,也无法通过虚引用来获取被引用的对象,get()总是null
- 唯一目的在于跟踪垃圾回收过程,比如:能在这个对象被收集器回收时收到一个系统通知
- 虚引用必须和引用队列一起使用
- 虚引用在创建时必须提供一个引用队列作为参数
- 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况
1 | Object obj = new Object(); // 声明强引用 |
1 | public class ReferenceTest { |
终结器引用
用于实现对象的finalize()方法,也可以称为终结器引用
无需手动编码,其内部配合引用队列使用
在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时把他进行回收
垃圾回收器
垃圾回收器分类
按GC线程数分:
串行回收:只有一个CPU用于执行GC操作,会有STW
并行回收:多个CPU同时执行GC操作,会有STW
按工作模式分:
- 并发式:与应用程序线程交替工作,以减少停顿时间
- 独占式:一旦运行,就停止所有用户线程,直至垃圾回收结束
按碎片处理方式分:
- 压缩式:回收完成后,对存活对象进行压缩整理;分配空间时使用指针碰撞
- 非压缩式:不进行压缩;分配空间时使用空闲列表
按工作的区间:
- 年轻代垃圾回收器
- 老年代垃圾回收器
评估性能指标
吞吐量(throughout):
- 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间)
暂停时间(pause time):
- 执行垃圾回收时,程序工作线程被暂停的时间
吞吐量 VS 暂停时间
- 吞吐量越高越好,暂停时间越低越好
- 这两者矛盾
- 如果选择高吞吐量,则需要降低内存回收的执行频率
- 如果选择低暂停时间,则需要频繁执行内存回收
- 现在的标准:最大吞吐量优先情况下,降低暂停时间
不同垃圾回收器
- 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
- 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布·
- Parallel GC在JDK6之后成为HotSpot默认GC。
- 2012年,在JDK1.7u4版本中,G1可用。
- 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
- 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
- 2018年9月,JDK11发布。引入Epsilon 垃圾回收器,又被称为 “No-Op(无操作)“ 回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)
- 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC(Experimental)。
- 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
- 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macOS和Windows上的应用
7款经典的垃圾回收器:
- 串行回收器:Serial,Serial Old
- 并行回收器:ParNew,Parallel Scavenge,Parallel Old
- 并发回收器:CMS,G1
垃圾回收器的组合关系:
- 两个收集器之间有连线,表明可以搭配使用
- Serial Old作为CMS出现“Concurrent Mode Failure”失败的后备方案
- 红色虚线:JDK8声明为废弃;JDK9完全移除
- 绿色虚线:JDK14完全启用
- 青色虚线:JDK14删除CMS垃圾回收器
- 为什么有很多垃圾收集器?
- Java应用场景很多,移动端、服务端等等
- 没有完美的收集器,只有最适合场景的收集器
查看默认的垃圾收集器:
-XX:+PrintCommandLineFlags
:查看命令行相关参数(包含使用的垃圾收集器)jinfo -flag <相关垃圾回收器参数> <进程ID>
:使用命令行参数
- JDK8下,使用JVM参数查看:看到有
-XX:+UseParallelGC
,说明使用了 ParallelGC(默认和Parallel Old绑定使用)
1 | XX:InitialHeapSize=666046592 |
- JDK8下,使用命令行查看
1 | C:\Users\xdy>jps |
Serial回收器:串行回收
最简单最基本的垃圾回收器,JDK1.3之前新生代的唯一选择
HotSpot虚拟机中,使用
-XX:+UseSerialGC
参数可以指定年轻代和老年代都使用串行收集器- 新生代用Serial GC,老年代用Serial Old GC
Serial是HotSpot中,Client模式下,默认的新生代垃圾收集器
- 采用复制算法,串行回收,STW机制
Serial Old是HotSpot中,Client模式下,默认的老年代垃圾收集器
- 唯一的不同是使用标记-压缩算法
Serial Old在HotSpot中,Server模式下,有两个作用
- 与新生代的Parallel Scavenge配合使用
- 作为老年代CMS收集器的后备方案
这个垃圾收集器是一个“单线程”的:只使用一个线程完成垃圾收集工作,且垃圾收集的过程中必须停止其他工作线程(STW)
优势:
- 简单高效,没有额外线程开销
- 用户桌面应用中,可用内存一般不大,可以在较短时间完成垃圾收集,使用串行收集器是可以接受的
ParNew回收器:并行回收
- Serial收集器的多线程版本,用于年轻代
- 唯一的区别:并行回收
- 是很多JVM在server模式下,新生代的默认垃圾收集器
-XX:+UseParNewGC
使用ParNew收集器,仅年轻代使用并行收集器,不影响老年代-XX:ParallelGCThreads
限制线程数量,默认和CPU核心数相同
对于新生代,回收频繁,使用并行的方式高效
对于老年代,回收次数少,使用串行方式节省资源
Parallel Scavenge回收器:吞吐量优先
- Java8默认垃圾收集器
- 与ParNew收集器使用几乎一样的算法(并行回收,复制算法,STW)
- 它存在的意义:
- 目标:达到一个可控制的吞吐量(吞吐量优先)
- 自适应调节策略:动态调整内存分配,以达到最优的吞吐量
- JDK1.6时提供了Parrallel Old收集器,代替Serial Old收集器
- Parrallel Old采用标记-压缩算法,并行回收,STW机制
- 高吞吐量可以高效利用CPU时间,适合在后台运算而不需要太多交互的任务,例如服务器中
- Parallel和Parallel Old组合,在server模式下的性能很不错
相关参数:
-XX:+UseParallelGC
:手动指定年轻代使用Parallel GC-XX:+UseParallelOldGC
:手动指定老年代使用ParallelOld GC- Java8默认使用,以上两个参数会互相激活,一个开启都会开启
-XX:ParallelGCThreads
:年轻代并行收集器的线程数- 默认情况下,CPU数量小于等于8个,值等于CPU核心数;CPU数量大于8个,值等于3 + (5 * CPU_COUNT) / 8
-XX:MaxGCPauseMillis
:设置STW的最大时间,单位毫秒- 为了控制时间,,收集器在工作时会调整Java堆大小或者其他一些参数
- 该参数使用需谨慎
-XX:GCTimeRatio
:设置垃圾收集时间占总时间的比例,即目标吞吐量- 范围(0, 100),默认99
- 与前一个参数矛盾,STW时间越长,Ratio参数就越容易超
-XX:+UseAdaptiveSizePolicy
:设置开启自适应调节策略- 会自动调整:年轻代大小、Eden和Survivor比例、晋升老年代对象的年龄等参数。以达到堆大小、吞吐量、停顿时间之间的平衡
- 手动调优困难的场合,使用此模式,仅需指定虚拟机最大堆,目标吞吐量、停顿时间,让虚拟机自己完成工作
CMS回收器:低延迟
- CMS关注点:尽可能缩短用户停顿时间
- 低延时,适合与用户交互的程序,比如B/S系统的服务器
- 采用标记-清除算法,也会STW
- 是老年代收集器,新生代只能选择使用ParNew或者Serial
- G1出现之前,CMS使用还是很广泛的
CMS工作原理:
- 初始标记(Initial Mark):所有工作线程都会STW,主要任务仅仅是标记出GC Root能直接关联的对象,非常快
- 并发标记(Concurrent Mark):从能直接关联的对象开始遍历整个图,不需要停顿用户线程
- 重新标记(Remark):正并发期间,程序继续运行,导致的一部分对象标记变动,对此进行修正,会STW但是非常快
- 并发清除阶段(Concurrent Sweep):清除已经死亡的对象,由于不需要移动对象,所以可以和用户线程并发
CMS分析:
- 并非完全没有STW,而是尽可能减少STW
- 最耗时的并发标记、并发清除,都不需要暂停工作,整体的回收是低停顿的
- 由于垃圾收集阶段用户线程没有中断,所以CMS过程中需要保证有足够的内存
- CMS不能等到老年代完全被填满再收集,而是当堆内存使用达到阈值时,便开始进行回收
- 要是CMS运行期间内存无法满足程序需要,会出现“Concurrent Mode Failure”失败
- 后备方案:临时启用Serial old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了
- CMS采用标记-清除算法,使用空闲链表,不可避免地将会产生一些内存碎片
优缺点:
- 优点:
- 并发收集
- 低延迟
- 缺点:
- 会产生内存碎片
- CMS收集器对CPU资源敏感,因为收集期间占用一部分线程,导致程序变慢
- CMS收集器无法处理浮动垃圾,标记阶段如果产生新的对象,需要等下一次GC执行时才能回收
CMS参数配置:
-XX:+UseConcMarkSweepGC
:指定使用CMS收集器- 会自动将
-XX:+UseParNewGC
打开,也即:ParNew(Young区)+CMS(Old区)+Serial Old(Old区备选方案)
- 会自动将
-XX:CMSInitiatingOccupanyFraction
:设置使用率阈值,一旦达到阈值,便开始收集- JDK5之前默认68,JDK6以上默认92
- 如果内存增长缓慢,大的阈值可以降低CMS触发频率;如果内存增长很快,则应该降低这个阈值,避免触发Serial Old
-XX:+UseCMSCompactAtFullCollection
:指定在Full GC后对内存空间进行压缩整理,避免内存碎片,不过停顿时间会变长-XX:CMSFullGCsBeforeCompaction
:多少次Full GC,执行一次内存压缩-XX:ParallelCMSThreads
:设置CMS的线程数量- 默认启动线程数是:(ParallelGCThreads + 3) / 4。(ParallelGCThreads默认值是核心数)
前四种回收器小结:
- 最小化内存和并行开销,选择Serial GC
- 最大化吞吐量,选择Parallel GC
- 最小化中断时间,选择CMS GC
后续版本中CMS的变化:
- JDK9新特性:CMS被标记为Deprecate了
- JDK14新特性:删除CMS垃圾回收器
G1回收器:区域化分代式
为什么需要G1:
为了适应:不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量
目标:延迟可控情况下,获得尽可能高的吞吐量
为什么叫Garbage First:
- 把堆内存分割为很多不相关的区域(Region),物理上未必连续
- 使用不同Region来表示Eden,S0,S1,老年代等
- 有计划的避免全区域垃圾收集
- 跟踪各个区域垃圾堆积的价值大小(回收获得的空间大小,回收所需时间的经验值)
- 每次根据允许的收集时间,优先回收价值最大的Region
- 侧重点:回收垃圾最大量的区间,所以叫垃圾优先(Garbage First)
- JDK1.7正式启用,JDK9成为默认垃圾回收器,取代了CMS,以及Parallel+Parallel Old
- 在JDK8中,需要手动开启:
-XX:+UseG1GC
- 在JDK8中,需要手动开启:
G1优势:
- 并行与并发兼备
- 并行:G1在回收期间,可以有多个GC线程同时工作。用户线程STW
- 并发:G1拥有与用户线程交替执行的能力,部分工作可以和应用线程同时执行
- 分代收集
- 依然区分Eden,Survivor,老年代。但从堆的结构来看,不要求连续,也不再坚持固定大小和数量
- G1兼顾年轻代和老年代
- 空间整合
- CMS:标记-清除,若干次GC后进行一次碎片整理
- G1:内存回收以region为单位,Region之间是复制算法,整体是标记-压缩算法,能避免碎片化
- 可预测的停顿时间模型
- G1跟踪各Region垃圾堆积的价值(回收所得空间大小,回收所需时间,的经验值),在后台维护一个优先列表
- 根据允许的收集时间,优先回收价值最大的Region
G1缺点:
- 相较于CMS,G1还不具有压倒性的优势
- 比如:G1为垃圾收集产生的内存占用(Footprint),程序运行时的额外执行负载(overload)都要比CMS要高
- 小内存上CMS表现大概率优于G1,平衡点大约在6-8G之间
G1参数设置:
-XX:+UseG1GC
:指定使用G1垃圾收集器-XX:G1HeapRegionSize
:每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。-XX:MaxGCPauseMillis
:设置期望达到的最大GC停顿时间指标,不保证达到,默认200ms-XX:+ParallelGCThread
:设置STW时,GC线程数的值。最多设置为8-XX:ConcGCThreads
:设置并发标记的线程数。设置为ParallelGcThreads的1/4左右-XX:InitiatingHeapOccupancyPercent
:触发并发GC的占用率阈值。超过此值,就触发GC。默认45
G1收集器常见设置步骤:
- 开启G1
- 设置堆最大内存
- 设置最大停顿时间
G1的适用场景:
- 面向服务端应用(大内存,多处理器)
- 应用需求:低GC延时
- 用来替换JDK1.5中的CMS,在以下情况使用G1可能比CMS更好
- 超过50%的堆空间,都被活动数据占用
- 对象分配或年代提升变化很快
- GC停顿时间过长(长于0.5至1秒)
- HotSpot中,除了G1外,其他垃圾回收器均使用内置的JVM线程执行GC多线程。
- G1可以采用应用线程承担后台GC工作
- 即:JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收
分区Region思想
基本思想:
- 使用G1时,将堆划分为约2048个大小相同的独立Region块,每个块大约控制在1MB到32MB之间(2的n次幂)
-XX:G1HeapRegionSize
来设定
- 新生代,老年代不再物理隔离,而是一部分Region的集合
- Region可能属于E,S,O,H
- H是一种新的内存区域(Humongous),用于存放大对象
- H是一组连续的区域,如果超过0.5个Region就放到H
- 每个Region都是通过指针碰撞来分配空间的
- 每个Region都有两个名为TAMS(Top at Mask Start)的指针
- Region同样可以使用TLAB来保证并发性
设置H区的原因:
- 之前:大对象直接被分配到老年区,但如果是一个短期对象的话,会有负面影响
- 现在:G1直接寻找连续的H区来存储,为了找到连续区域,有时会不得不启动Full GC
- G1大多数行为,都把H区作为老年代的一部分
G1垃圾回收流程
主要包括三个环节:
- 年轻代GC(Young GC)
- 老年代并发标记(Concurrent Marking)
- 混合回收(Mixed GC)
- (如果需要)单线程、独占式、高强度的Full GC,作为一种失败的后备选项
回收流程:
- 当Eden区用尽,开始年轻代GC
- 并行独占式收集器
- 存活对象移动到Survivor区,或者老年区
- 当堆内存达到一定值(默认45%),开始老年代并发标记
- 标记完成后,开始混合回收过程
- 从老年区移动存活对象到空闲区间,空闲区间成为了老年代的一部分
- 不需要整个老年代被回收,只需要回收一部分Region
年轻代GC
- JVM启动时,G1先准备好Eden区,程序运行过程中不断创建对象到Eden区中;Eden区耗尽会启动一次Young GC
- Young GC只回收Eden和Survivor区
- 先STW,再创建回收集(需要被回收的Region的集合,Young GC的回收集包括年轻代的所有分段)
图片的意思:
- 回收完E和S区,存活对象复制到新的S区
- S区达到阈值可以晋升为O区
细致过程:
- 第一阶段,扫描根:
- 扫描GC Root,会连同RSet记录的外部引用一起,作为存活对象的入口
- 第二阶段,更新RSet
- 第三阶段,处理RSet:
- 识别被老年代对象指向的Eden中的对象
- 第四阶段,复制对象:
- E区和S区中存活的对象,复制到新S区中空的内存分段
- 年龄未达阈值,年龄加一,达到阈值会被复制到Old区中空的内存分段去
- 如果S区空间不够,E区的部分数据会直接晋升到Old区
- 第五阶段,处理引用:
- 处理Soft,Weak,Phantom,Final,JNI Weak 等引用
- 最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
备注:
- 对于应用程序的引用赋值语句 oldObject.field(这个是老年代)=object(这个是新生代),JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
- 为什么不在引用赋值语句处直接更新RSet?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
并发标记
初始标记:标记从根节点直接可达的对象
- 只扫描直接可达对象,节省时间
- 这个阶段是STW的
- 会触发一次Young GC
根区域扫描:
- 扫描S区直接可达的老年代对象,并标记被引用的对象
- 必须在Young GC之前完成,因为Young GC会使用复制算法对Survivor区进行GC
并发标记:
- 在整个堆中进行并发标记),可能会被Young GC中断
- 若区域对象都是垃圾,此区域会被立即回收
- 并发标记过程中,会计算每个区域的对象活性(存活对象的比例)
再次标记:
- 由于应用程序持续进行,需要修正上一次的标记结果
- 是STW的
- G1中采用了比CMS更快的原始快照算法:Snapshot-At-The-Beginning(SATB)
独占清理:
- 计算各个区域的存活对象和GC回收比例,并进行排序
- 识别可以混合回收的区域,为下阶段做铺垫
- 是STW的
并发清理阶段:
- 识别并清理完全空闲的区域
混合回收
当越来越多对象晋升到老年代时,为避免内存耗尽,会触发混合回收(Mixed GC)
除了回收整个Young Region,还会回收部分Old Region
Mixed GC不等于Full GC
混合回收的细节:
- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来
- 默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收
- 意思就是一个Region会被分为8个内存段
- 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段
- 和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段
- 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。
- 垃圾占内存分段比例越高的,越会被先回收
- 阈值会决定内存分段是否被回收。XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收
- 如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间
- 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
可选:Full GC
- G1的初衷就是要避免Full GC的出现。STW+单线程,性能非常差
- 要避免Full GC的发生,一旦发生Full GC,需要对JVM参数进行调整
- 堆内存太小,复制存活对象时空间不够,增大内存来解决
导致G1 Full GC的原因可能有两个:
- Evacuation的时候没有足够的to-space来存放晋升的对象;
- 并发处理过程完成之前空间耗尽。
G1优化建议
- 年轻代的大小
- 避免使用-Xmn或-XX:NewRatio等选项显式设置年轻代大小。默认使用可预测的暂停时间目标,让G1自己去调整
- 暂停时间目标不要太苛刻
- G1的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
- 评估G1吞吐量时,暂停时间目标不要太严苛。目标太严苛,表明你愿意承受更多的垃圾回收开销,这会影响吞吐量
Remembered Set(记忆集)
问题:一个Region中的对象,可能被任意其他Region中的对象引用,判断对象存活是否需要扫描整个Java堆?
- 其他分代收集器也有这个问题,回收新生代时需不需要扫描老年代?
解决方法:
- 所有分代收集器,JVM都会使用Remembered Set(记忆集)来避免全堆扫描
- 每个Region都有一个Remembered Set
- 每次Reference类型数据进行写操作时,都会产生一个Write Barrier暂时中断操作
- 检查将要写入的引用指向的对象,是否与该Reference类型数据在不同的Region
- 如果是其他收集器,那就检查老年代对象是否引用了新生代对象
- 如果不同,通过CardTable将相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
- 当垃圾回收时,在GC根节点范围内加入Remembered Set即可
垃圾回收器总结
GC发展阶段:
Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC
如何选择垃圾回收器:
- 调整堆的大小,让JVM自适应完成
- 如果内存小于100M,使用串行收集器
- 如果是单核单机程序,没有停顿时间的要求,串行收集器
- 如果是多CPU,需要高吞吐量,允许停顿时间超过1秒,选择并行或者JVM自己选择
- 如果是多CPU,追求低停顿时间,使用并发收集器
- 官方推荐G1,性能高
面试:
- 如何判断一个对象能不能回收
- 垃圾回收算法有哪些
- 垃圾回收工作流程
- 多多关注垃圾回收器这一章的各种参数
GC日志分析
内存分配与垃圾回收的参数列表:
-XX:+PrintGC :输出GC日志。类似:-verbose:gc
-XX:+PrintGCDetails :输出GC的详细日志
-XX:+PrintGCTimestamps :输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDatestamps :输出GC的时间戳(以日期的形式,如2013-05-04T21: 53: 59.234 +0800)
-XX:+PrintHeapAtGC :在进行GC的前后打印出堆的信息
-Xloggc:…/logs/gc.log :日志文件的输出路径
-verbose:gc
是一个JVM参数,只会显示总的GC堆的变化,如下:
GC,Full GC:GC的类型,GC只在新生代进行,Full GC包括永久代,新生代,老年代
Allocation Failure:GC发生的原因
80832K->19298K:堆在GC前的大小和GC后的大小
228840K:堆现在的大小
0.0084018 secs:GC持续的时间
PrintGCDetails
- GC,Full GC:同样是GC的类型
- Allocation Failure:GC发生的原因
- PSYoungGen:使用了Parallel Scavenge并行垃圾收集器,新生代前后的大小变化
- ParOldGen:使用了Parallel Old并行垃圾收集器,老年代前后的大小变化
- MetaSpace:元数据区GC前后大小的变化,JDK1.8引入了元数据区以替代永久代
- 0.02 secs:GC花费的时间
- Times:user-垃圾回收花费的所有CPU时间,sys-花费在等待系统调用或系统事件的时间,real-GC从开始到结束的时间,包括阔其他进程占用时间片的实际时间
PrintGCTimestamps
- GC,Full GC:GC的类型
- 收集器的名字
- **[DefNew]**:使用Serial收集器,默认新生代收集器
- **[ParNew]**:使用ParNew收集器
- **[PSYoungGen]**:使用Parallel scavenge收集器
- G1会显示garbage-first heap
- Allocation Failure:引起GC的原因(年轻代中没有足够的空间能够存储新的数据)
- [ PSYoungGen: 5986K->696K(8704K) ] 5986K->704K (9216K)
- 中括号内:年轻代,回收前大小,回收后大小,(年轻代总大小)
- 括号外:年轻代和老年代,回收前大小,回收后大小,(年轻代和老年代总大小)
- user代表用户态回收耗时,sys内核态回收耗时,real实际耗时
- 由于多核线程切换的原因,时间总和可能会超过real时间
- 标题: JVM系列:尚硅谷JVM笔记总和
- 作者: 布鸽不鸽
- 创建于 : 2024-04-10 20:34:10
- 更新于 : 2024-01-10 14:58:18
- 链接: https://xuedongyun.cn//post/58683/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。