JVM-内存及垃圾回收
类加载子系统
类加载子系统一
类加载子系统作用
- 负责从文件系统或者网络中加载 Class 文件,Class 文件开头有特定标识(cafebabe)
- Classloader 只负责 class 文件的加载,至于是否可运行,则有执行引擎决定
- 加载的类信息存放于称为方法区的内存空间,除了类信息,方法区还会存放运行时常量池信息,还可能包括字符串字面量和数字常量。(常量池运行时加载到内存中,即运行时常量池)
类加载器 ClassLoader 的角色
1.class file 存在与本地硬盘上。可以理解为设计师画在之上的模板,而最终这个模板在执行的时候是要加载到 JVM 当中来,根据这个文件实例化出 n 个一模一样的实例。
2.class file 加载带 JVM 中,被称为 DNA 元数据模块,放在方法区。
3.在 .class 文件 –> JVM –> 最终成为元数据模板,此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色。
类的加载过程
一.加载
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的**java.lang.Class **对象,作为方法区这个类的各种数据的访问入口。
/**
补充:加载 .Class 文件的方式 1.从本地系统中直接加载 2.通过网络获取,典型场景:Web Applet 3.从 zip 压缩包中读取,成为日后 jar、war 格式的基础 4.运行时计算生成,使用最多的是:动态代理技术 5.有其他文件生成,典型场景:JSP 应用 6.从专有数据库中提取 .class 文件,比较少见 7.从加密文件中获取,典型的防 Class 文件被反编译的保护措施
**/
二.链接
1.验证(Verify)
(1)目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
(2)主要包括四种验证:
文件格式验证:
A.CA FE BA BE
B.主次版本号
C.常量池的常量中是否有不被支持的常量类型。
D. 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
元数据验证:
A.对字节码描述的信息进行语义分析,保证描述符合 Java 规范
B.类是否有父类,除了 Object 之外,所有的类都应该有父类
C.类的父类是否继承了不允许被继承的类(被 final 修饰的类)
D.如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。
E.类的字段,方法是否与父类的产生矛盾。例如方法参数都一样,返回值不同
字节码验证:
A.通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。
B.对类的方法体,进行校验分析,保证在运行时不会做出危害虚拟机的行为
C.保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个 int 类型的数据,使用时却按照 long 类型加载到本地变量表中的情况。
D.保障任何跳转指令都不会跳转到方法体之外的字节码指令上。
符号引用验证:
A.通过字符串描述的全限定名是否能找到对应的类
B.符号引用中的类、字段、方法的可访问性是否可被当前类访问
2.准备(Prepare)
(1)为类变量分配内存并且设置该类变量的默认初始值,即零值。
(2)不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化
(3)不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象一
起分配到 Java 堆中-
3.解析(Resolve)
(1)将常量池内的符号引用转换为直接引用的过程
(2)解析操作往往会伴随着 JVM 在执行完初始化之后再执行
(3)符号引用就是一组符号来描述所应用的目标,符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中
(4)直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
(5)解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
三.初始化
1.初始化阶段就是执行类构造器方法
补充说明
一.加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。
二.解析阶段不一定,在某些情况下可以在初始化阶段之后再开始,为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)
三.Java 虚拟机规范严格规定了,有且只有六种情况,必须立即对类进行初始化
1、遇到 new,getstatic,putstatic 或 invokestatic 这四条字节码指令时。
使用 new 关键字实例化对象
读取或设置一个类型的静态字段(final 修饰已在编译期将结果放入常量池的静态字段除外)
调用一个类型的静态方法的时候
2、对类型进行反射调用,如果类型没有经过初始化,则需要触发初始化
3、初始化类的时候,发现父类没有初始化,则先触发父类初始化
4、虚拟机启动时,用户需要指定一个要执行的主类(包含 main 方法的那个类),虚拟机会初始化这个主类
5、只用 JDK7 中新加入的动态语言支持,如果一个 java.lang.invoke.MethodHandler 实例最后的解析结果为 REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法对应的类没有进行初始化,则先触发其初始化
6、当一个接口中定了 JDK8 新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
除了以上几种情况,其他使用类的方式被看做是对类的被动使用,都不会导致类的初始化
类加载子系统二
一.类加载器分类
1.引导类加载器(使用 C/C++编写) 2.自定义加载器
概念:所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器
类:
二.代码获取类加载器:
1.截图:
2.对于用户来说定义器来说,默认使用系统类加载器进行加载
3.JAVA 的核心类库,使用引导类加载器进行加载
三.启动类加载器(引导类加载器,Bootstrap,ClassLoader):
- C/C++语言实现,嵌套在 JVM 内部
- 用来加载 JAVA 的核心库(JAVA_HOME/jre/lib/rt.jar 、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类。
- 代码获取加载路径
- 并不继承自 java.lang.ClassLoader ,没有父加载器。
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
- 出于安全考虑,Boostrap 启动类加载器只加载包名为 Java、javax、sun 等开头的类。
四.扩展类加载器(java9:平台类加载器)
- JAVA 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现。
- 派生于 ClassLoader 类
- 父类加载器为启动类加载器
- 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/lib/est 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。
- 代码
五.应用程序类加载器(系统类加载器:AppClassLoader)
- 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()方法,及其获取字节码流的方式,使自定义类加载器编写更加简洁
七.关于 CLassLoader
- 是一个抽象类,除了启动类加载器,其他类加载器都继承自他
八.双亲委派机制
原理:Java 虚拟机对 Class 文件采用的是按需加载,而且加载 class 文件时,Java 虚拟机使用的是双亲委派模式,即把请求交由父类处理,它是异种任务委派模式。
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载。而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
- 如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
一.双亲委派机制优势
- 避免类的重复加载
九.沙箱安全机制
- 保证对 Java 核心源代码的保护
/**
补充:
- 在 JVM 中表示两个 class 对象,是否为同一个类存在两个必要条件
- 类的完整类名必须一致,包括包名
- 加载这个类的 ClassLoader 必须相同
- JVM 必须知道一个类型是由启动类加载器加载的,还是由用户类加载器加载的。如果是用户类加载器加载的,JVM 会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中。
**/
运行时数据区概述
⭐ 程序寄存器(PC 寄存器)
- 运行时数据区中唯一不会出现 OOM(OOM:out of memory)的区域,没有垃圾回收(GC)
- 当前线程所执行的字节码的行号指示器
- 为了线程切换后能恢复到正确的位置
- 每个线程有一个独立的程序计数器,线程之间互不影响。
- 如果线程执行的 Java 方法,则计数器记录正在执行的虚拟机字节码的指令的地址
- 如果正在执行的本地方法,这个计数器值则应为空。(undefined)
⭐ 虚拟机栈
一,内存中的栈和堆
- 栈是运行时的单位,而堆是存储的单位,栈解决程序如何执行,如何处理数据。堆解决的是数据存储问题,即数据怎么放,放在哪里。
二,基本内容
- Java 虚拟机栈,早起也叫 Java 栈,每个线程创建时都会创建一个虚拟机栈,内部保存一个个栈帧,对应着一次次的 Java 方法调用
- 线程私有的
- 生命周期
- 生命周期和线程的一致
- 作用
- 主管 Java 程序的运行,保存方法的局部变量(8 种基本数据类型,对象的引用地址),部分结果,并参与方法的调用和返回。
- 局部变量 vs 成员变量
- 基本数据类型 VS 引用类型变量(类,数组,接口)
- 主管 Java 程序的运行,保存方法的局部变量(8 种基本数据类型,对象的引用地址),部分结果,并参与方法的调用和返回。
三,优点
- 快速有效的存储方式,访问速度仅次于程序计数器
- JVM 直接对 JAVA 栈的操作只有两个
- 每个方法执行,伴随着进栈(入栈,压栈)
- 执行结束的出栈
- 栈不存在垃圾回收,但是存在 OOM
- Java 栈大小是动态或者固定不变的。如果是动态扩展,无法申请到足够内存 OOM,如果是固定,线程请求的栈容量超过固定值,则 StackOverflowError
- 使用-Xss (记忆:站着做一个小手术,栈 Xss),设置线程的最大栈空间
四,栈的存储单位
- 每个线程都有自己的栈,栈中的数据以栈帧格式存储
- 线程上正在执行的每个方法都各自对应一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各个数据信息
- 先进后出,后进先出
- 一条活动的线程中,一个时间点上,只会有一个活动的栈帧。只有当前正在执行的方法的栈顶栈帧是有效的,这个称为当前栈帧,对应方法是当前方法,对应类是当前类
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果方法中调用了其他方法,对应的新的栈帧会被创建出来,放在顶端,成为新的当前帧
五,栈运行原理
- 不同线程中包含的栈帧不允许存在相互引用。
- 当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的栈帧。
- Java 方法有两种返回方式
- 一种是正常的函数返回,使用 return 指令
- 另外一种是抛出异常,不管哪种方式,都会导致栈帧被弹出
六,栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表(Local variables)
定义
局部变量表也被称为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型
- 局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题
- 局部变量表容量大小是在编译期确定下来的
- 局部变量表存放编译期可知的各种基本数据类型(8 种),引用类型(reference),return address 类型
- 最基本的存储单元是 slot(变量槽)
- 32 位占用一个 slot,64 位类型(long 和 double)占用两个 slot
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
- 局部变量表中的变量只有在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
- 方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
关于 Slot 的理解
VM 虚拟机会为局部变量表中的每个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this,会存放在 index 为 0 的 slot 处,其余的参数表顺序继续排列
this 截图:
- 栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的
操作数栈(Operand Stack)
操作数栈(数组实现):
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再将结果压入栈
- 比如:执行复制、交换、求和等操作
如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令
Java 虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好
栈中,32bit 类型占用一个栈单位深度,64bit 类型占用两个栈单位深度
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
栈顶缓存技术:
- 由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理 CPU 的寄存器中,依此降低对内存的读写次数,提升执行引擎的执行效率
动态链接(或指向运行时常量池的方法引用)
指向运行时常量池的方法引用
- 每一个栈帧内部都包含一个指向运行时常量池中,该帧所属方法的引用
- 目的是为了支持当前方法的代码能够实现动态链接,比如 invokedynamic 指令
- 在 java 源文件被编译成字节码文件中时,所有的变量、方法引用都作为符号引用,保存在 class 文件的常量池中。
- 描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。
- 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
常量池、运行时常量池
- 常量池在字节码文件中,运行时常量池,在运行时的方法区中
方法的调用
一,链接与方法的绑定
- 静态链接
- 当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接
- 动态链接
- 如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接
- 方法的绑定
- 绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程。仅仅发生一次。
- 早期绑定
- 被调用的目标方法如果在编译期可知,且运行期保持不变
- 晚期绑定
- 被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
- Java 中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++中则使用关键字 virtual 来显式定义
- 如果在 java 程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字 final 来标记这个方法
二,虚方法和非虚方法
- 非虚方法
- 如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法,静态方法,私有方法,final 方法,实例构造器,父类方法都是非虚方法
- 其他方法称为虚方法
三,方法调用指令
- 普通调用指令
- invokestatic
- 调用静态方法,解析阶段确定唯一方法版本
- invokespecial
- 调用
方法,私有及父类方法,解析阶段确定唯一方法版本
- 调用
- invokevirtual
- 调用所有虚方法(除了 final 修饰的)
- invokeinterface
- 调用接口方法
- 其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(final 修饰的除外)称为虚方法
- invokestatic
- 动态调用指令 JDK1.7 新增
- invokedynamic
- 动态解析出需要调用的方法,然后执行
- 直到 Java8 的 Lambda 表达式的出现,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式
- invokedynamic
- 静态语言和动态语言
- 区别在于对类型的检查是编译器还是运行期,满足编译期就是静态类型语言,反之就是动态类型语言。
- Java 是静态类型语言,动态调用指令增加了动态语言的特性
四,方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做 C
- 如果在类型 C 中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回 java.lang.IllegalAccessError 异常
- 否则,按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常
五,虚方法表
- 面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM 采用在类的方法区建立一个虚方法表,使用索引表来代替查找
- 每个类都有一个虚方法表,表中存放着各个方法的实际入口
- 虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法也初始化完毕
方法返回地址及栈的面试题
一,方法返回地址
- 存放调用该方法的 pc 寄存器的值
- 方法的结束
- 正常执行完成
- 出现未处理异常,非正常退出
- 无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
- 异常退出的,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
- 返回指令包括
- ireturn 返回值是 boolean,byte,char,short,和 int 类型时使用
- lreturn
- dreturn
- areturn
- 引用类型
- 还有一个 return 指供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用
- 返回指令包括
- 本质上,方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置 PC 寄存器值等,让调用者方法继续执行下去。
- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
二,一些附加信息
- 允许携带与 Java 虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。不确定有,可选情况
三,面试题
⭐ 本地方法接口与本地方法栈
一,本地方法接口
- 什么是本地方法
- 简单讲,就是一个 Java 调用非 Java 代码的接口
- 为什么使用 native method
- 与 Java 环境外交互
- 例如与操作系统底层或硬件交换信息时的情况
- 例如启动一个线程
- 与 Java 环境外交互
二,本地方法栈
- Java 虚拟机栈管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
- 本地方法栈,也是线程私有的。
- 允许被实现成固定或者是可动态扩展的内存大小。
- 内存溢出情况和 Java 虚拟机栈相同
- 使用 C 语言实现
- 具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载到本地方法库
- 当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限。
- 并不是所有的 JVM 都支持本地方法,因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
- Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一
⭐ 堆
一,堆的核心概念
- 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域
- Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确认了。堆内存的大小是可调节的
- Java 虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(TLAB)
- “几乎”所有的对象实例都在这里分配内存
- 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,引用指向对象或者数组在堆中的位置
- 方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 堆是 GC 执行垃圾回收的重点区域
- 堆空间细分为:
- Java7 及之前
- 内存逻辑上分为:
- 新生区
- Eden 区(伊甸园区)
- Survivor 区(幸存者区)
- from
- to
- 谁空谁是 to
- 养老区
- 永久区
- 新生区
- 内存逻辑上分为:
- Java8 及之后
- 内存逻辑上分为:
- 新生区
- Eden 区
- Survivor 区
- from
- to
- 谁空谁是 to
- 养老区
- 元空间
- 新生区
- 内存逻辑上分为:
- 约定
- 新生区==新生代==年轻代
- 养老区==老年区==老年代
- 永久区==永久代
- Java7 及之前
二,设置堆内存大小与 OOM
- -Xms :小秘书表示堆空间的起始内存。
- -Xmx:小明星表示堆空间的最大内存
- 超过最大内存将抛出 OOM
- 通常将-Xms 和-Xmx 两个参数配置相同的值,其目的是为了能够在 java 垃圾会后清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能
- 代码:
jps 命令
- 查看当前程序运行的进程
jstat
- 查看 JVM 在 gc 时的统计信息
- jstat -gc 进程号
- 查看 JVM 在 gc 时的统计信息
三,年轻代与老年代
- -XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3
- ratio:比率比例的意思
- jinfo -flag NewRatio 进程号,查看参数设定值
- 在 HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是:8:1:1
- -XX:SurvivorRatio 调整这个空间比例
- Eden 与 Survivor 区的比例
- 实际是 6:1:1,因为有自适应机制
- -XX:-UseAdaptiveSizePolicy:-表示关闭自适应,实际没有用。直接用 Ratio 分配即可
- -XX:SurvivorRatio 调整这个空间比例
- 几乎所有的 Java 对象都是在 Eden 区被 new 出来的。
- Eden 放不了的大对象,直接进入老年代了。
- IBM 研究表明,新生代 80%的对象都是朝生夕死
- -Xmn:洗面奶,设置新生代最大内存大小,如果同时设置了新生代比例与此参数冲突,则以此参数为准。
四,图解对象分配一般过程
- 1、new 的对象先放在 Eden 区,此区有大小限制
- 2、当创建新对象,Eden 空间填满时,会触发 Minor GC,将 Eden 不再被其他对象引用的对象进行销毁。再加载新的对象放到 Eden 区
- 3、将 Eden 中剩余的对象移到幸存者 0 区
- 4、再次触发垃圾回收,此时上次幸存者下来的,放在幸存者 0 区的,如果没有回收,就会放到幸存者 1 区
- 5、再次经历垃圾回收,又会将幸存者重新放回幸存者 0 区,依次类推
- 6、可以设置一个次数,默认是 15 次,超过 15 次,则会将幸存者区幸存下来的转去老年区
- -XX:MaxTenuringThreshold=N 进行设置
总结:
- 针对幸存者 s0,s1 区的总结:复制之后有交换,谁空谁是 to
- 频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集
对象分配特殊过程:
- 触发 YGC,幸存者区就会进行回收,不会主动进行回收
- 超大对象 eden 放不下,就要看 Old 区大小是否可以放下
- old 区也放不下,需要 FullGC(MajorGC),这两 GC 概念还是有区别的。下面详解
MinorGC,MajorGC,FullGC
一,MinorGC,MajorGC,FullGC
- 针对 HotSpotVM 的实现
- GC 按照内存回收区域分为
- 部分收集
- 新生代收集
- MinorGC (YoungGC)
- 老年代收集
- MajorGC/oldGC
- 目前只有 CMS GC 会单独收集老年代的行为
- 很多时候 MajorGC 与 FullGC 混淆使用,具体分辨是老年代回收还是整堆回收
- MajorGC/oldGC
- 混合收集
- 收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
- 收集整个新生代以及部分老年代的垃圾收集
- 新生代收集
- 整堆收集
- 收集整个 Java 堆和方法区的垃圾收集
- 部分收集
- GC 按照内存回收区域分为
二,MinorGC 的触发条件
- 当年轻代空间不足时,就会触发 MinorGC,这里的年轻代指的是 Eden 代满,Survivor 满不会触发 GC。每次 MinorGC 会清理年轻代的内存
- 因为 Java 对象大多朝生夕灭,所以 MinorGC 非常频繁
- Minor 翻译,较小的,未成年的
- MinorGC 会引发 STW
三,老年代 GC(MajorGC/FullGC)触发条件
- 指发生在老年代的 GC,对象从老年代消失,我们说“MajorGC”“FullGC”发生了
- 出现了 MajorGC,经常会伴随至少一次 MinorGC
- 非绝对,在 Parallel Scavenge 收集器的收集策略里就直接进行 MajorGC 的策略选择过程
- 也就是老年代空间不足,会先尝试触发 MinorGC,如果之后空间还不足,则触发 MajorGC
- MajorGC 的速度比 MinorGC 慢 10 倍以上,STW 的时间更长
- 如果 MajorGC 后,内存还不足,就报 OOM 了
四,FullGC 的触发机制
- 1、调用 System.gc()时,系统建议执行 FullGC,但是不必然执行
- 2、老年代空间不足
- 3、方法区空间不足
- 4、通过 MinorGC 后进入老年代的平均大小,大于老年代的可用内存
- 5、由 Eden 区,Survivor 0 区向 Survivor 1 区复制时,对象的大小大于 ToSpace 可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
- FullGC 是开发或调优中尽量要避免的,这样暂停时间会短一些。
堆空间分代思想、内存分配策略
一,堆空间分代思想
二,内存分配策略
- 如果对象再 Eden 出生并经过第一次 MinorGC 后仍然存活,并且能被 Survivor 区容纳,则被移动到 Survivor 空间中,并将对象年龄设置为 1,对象再 Survivor 区每熬过一次 MinorGC,年龄就+1,当年龄增加到一定程度(默认为 15,不同 Jvm,GC 都所有不同)时,就会被晋升到老年代中
- -XX:MaxTenuringThreshold
- 优先分配到 Eden
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄分配
- 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄
三,空间分配担保
- -XX:HandlePromotionFailure
- 在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
- 如果大于,则此次 MinorGC 是安全的
- 如果小于,则查看-XX:HandlePromotionFailure 设置是否允许担保失败
- true
- 会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
- 大于,则尝试进行一次 MinorGC,但是这次 MinorGC 依然是有风险的
- 小于,则改为进行一次 FullGC
- false
- 则改为进行一次 FullGC
- true
- jdk6update24 之后,这个参数不会再影响到虚拟机的空间分配担保策略。
- 规则改为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行 MinorGC
- 否则进行 FullGC
四,为对象分配内存:TLAB
为什么有 TLAB(Thread Local Allocation Buffer)?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是 TLAB?
- 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
- 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。
TLAB 的再说明
- 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
- 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启 TLAB 空间。
- 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置 TLAB 空间所占用 Eden 空间的百分比大小。
- 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
五,小结:堆空间的参数设置
官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
如果大于,则此次 Minor GC 是安全的
如果小于,则虚拟机会查看-XX:HandlePromotionFailure 设置值是否允担保失败。
- 如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;
- 如果小于,则改为进行一次 Full GC。
- 如果 HandlePromotionFailure=false,则改为进行一次 Full Gc。
- 如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
在 JDK6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 openJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 FullGC。
堆补充
堆是分配对象的唯一选择么?
- 在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:
- 随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
- 在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配.。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
- 此外,前面提到的基于 OpenJDK 深度定制的 TaoBaoVM,其中创新的 GCIH(GC invisible heap)技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。
一,逃逸分析概述
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
参数设置:
在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析
如果使用的是较早的版本,开发人员则可以通过:
选项“-XX:+DoEscapeAnalysis”显式开启逃逸分析
通过选项“-XX:+PrintEscapeAnalysis”查看逃逸分析的筛选结果
结论:开发中能使用局部变量的,就不要使用在方法外定义。
二,逃逸分析代码优化
使用逃逸分析,编译器可以对代码做如下优化:
一、栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
二、同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
三、分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
1.栈上分配
JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
常见的栈上分配的场景:
- 在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
2.同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
3.标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
标量替换参数设置:
参数-XX:EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配到栈上。
上述代码在主函数中进行了 1 亿次 alloc。调用进行对象创建,由于 User 对象实例需要占据约 16 字节的空间,因此累计分配空间达到将近 1.5GB。如果堆空间小于这个值,就必然会发生 GC。使用如下参数运行上述代码:
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
三,逃逸分析小结:逃逸分析并不成熟
- 关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK1.6 才有实现,而且这项技术到如今也并不是十分成熟。
- 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
- 注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
- 目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
⭐ 方法区
从线程共享与否的角度来看:
栈、堆、方法区的交互关系
方法区的理解
一,方法区在哪里?
《Java 虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于 Java 堆的内存空间。
二,方法区的基本理解
- 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。
- 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者 java.lang.OutOfMemoryError: Metaspace
- 加载大量的第三方的 jar 包;Tomcat 部署的工程过多(30~50 个);大量动态的生成反射类
- 关闭 JVM 就会释放这个区域的内存。
三,HotSpot 中方法区的演进
在 jdk7 及以前,习惯上把方法区,称为永久代。jdk8 开始,使用元空间取代了永久代。
本质上,方法区和永久代并不等价。仅是对 hotspot 而言的。《Java 虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。
现在来看,当年使用永久代,不是好的 idea。导致 Java 程序更容易 OOM(超过-XX:MaxPermsize 上限)
而到了 JDK8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
永久代、元空间二者并不只是名字变了,内部结构也调整了
根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OOM 异常
设置方法区大小与 OOM
方法区的大小不必是固定的,JVM 可以根据应用的需要动态调整。
jdk7 及以前
- 通过来设置永久代初始分配空间。默认值是 20.75M-XX:Permsize
- 通过来设定永久代最大可分配空间。32 位机器默认是 64M,64 位机器模式是 82M-XX:MaxPermsize
- 当 JVM 加载的类信息容量超过了这个值,会报异常 OutOfMemoryError:PermGen space。
JDK8 以后
- 元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定
- 默认值依赖于平台。windows 下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1//即没有限制。
- 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
- -XX:MetaspaceSize:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize 值为 21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize 时,适当提高该值。如果释放空间过多,则适当降低该值。
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地 GC,建议将-XX:MetaspaceSize 设置为一个相对较高的值。
举例 1
举例 2
如何解决这些 OOM
- 要解决 OOM 异常或 heap space 的异常,一般的手段是首先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与 GCRoots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及 GCRoots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
方法区内部结构
一,方法区(Method Area)存储什么?
《深入理解 Java 虚拟机》书中对方法区(Method Area)存储内容描述如下:
“它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。”
二,方法区的内部结构
类型信 息
- 对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.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 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final 的类变量
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
补充说明:全局常量(static final)
- 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
三,运行时常量池 VS 常量池
- 方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- 要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。
- 要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
为什么需要常量池?
一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。
常量池中有什么?
击中常量池内存储的数据类型包括:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
运行时常量池
● 运行时常量池(Runtime Constant Pool)是方法区的一部分。
● 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
● 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
● JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
● 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
● 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。
● 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
● 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。
方法区使用举例
方法区的演进细节
- 首先明确:只有 Hotspot 才有永久代。BEA JRockit、IBMJ9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一
- Hotspot 中方法区的变化:
一,为什么永久代要被元空间替代?
JRockit 是和 HotSpot 融合后的结果,因为 JRockit 没有永久代,所以他们不需要配置永久代
随着 Java8 的到来,HotSpot VM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 oom。比如某个实际 Web 工 程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
对永久代进行调优是很困难的。
有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载)。 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
二,StringTable 为什么要调整位置?
jdk7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代的空间不足、永久代不足时才会触发。
这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
三,静态变量存放在那里?
方法区的垃圾回收
有些人认为方法区(如 Hotspot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 zGC 收集器就不支持类卸载)。
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
回收废弃常量与回收 Java 堆中的对象非常类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
方法区总结及面试题
⭐ 对象实例化及直接内存
对象实例化
面试题:
一,创建对象的方式
new:最常见的方式、Xxx 的静态方法,XxxBuilder/XxxFactory 的静态方法
Class 的 newInstance 方法:反射的方式,只能调用空参的构造器,权限必须是 public
Constructor 的 newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
使用 clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口,实现 clone()
使用序列化:从文件中、从网络中获取一个对象的二进制流
第三方库 Objenesis
二,创建对象的步骤
从执行步骤角度分析:
1.判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。
如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 key 进行查找对应的 .class 文件;
如果没有找到文件,则抛出 ClassNotFoundException 异常
如果找到,则进行类加载,并生成对应的 Class 对象
2.为对象分配内存
首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小
如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。
意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。
如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。
已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。
选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
3.处理并发问题
- 采用 CAS 失败重试、区域加锁保证更新的原子性
- 每个线程预先分配一块 TLAB:通过设置 -XX:+UseTLAB 参数来设定
4.初始化分配到的内存
- 所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
5.设置对象的对象头
- 将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。
6.执行 init 方法进行初始化
在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。
给对象属性赋值的操作
- 属性的默认初始化
- 显式初始化
- 代码块中初始化
- 构造器中初始化
对象实例化的过程
- 加载类元信息
- 为对象分配内存
- 处理并发问题
- 属性的默认初始化(零值初始化)
- 设置对象头信息
- 属性的显示初始化、代码块中初始化、构造器中初始化
对象内存布局
一,对象头(Header)
对象头包含了两部分,分别是运行时元数据(Mark Word)和类型指针。如果是数组,还需要记录数组的长度
运行时元数据
哈希值(HashCode)
GC 分代年龄
锁状态标志
线程持有的锁
偏向线程 ID
偏向时间戳
类型指针
指向类元数据 InstanceClass,确定该对象所属的类型。
二,实例数据(Instance Data)
它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
相同宽度的字段总是被分配在一起
父类中定义的变量会出现在子类之前
如果 CompactFields 参数为 true(默认为 true):子类的窄变量可能插入到父类变量的空隙
三,对齐填充(Padding)
小结:
对象的访问定位
JVM 是如何通过栈帧中的对象引用访问到其内部的对象实例呢?
一,句柄访问
reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改
二,直接指针(HotSpot 采用)
直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
直接内存(Direct Memory)
一,直接内存概述
不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。直接内存是在 Java 堆外的、直接向系统申请的内存区间。来源于 NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存。通常,访问直接内存的速度会优于 Java 堆,即读写性能高。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区
二,非直接缓存区
三,直接缓存区
使用 NIO 时,操作系统划出的直接缓存区可以被 java 代码直接访问,只有一份。NIO 适合对大文件的读写操作。
也可能导致 OutOfMemoryError 异常
由于直接内存在 Java 堆外,因此它的大小不会直接受限于-Xmx 指定的最大堆大小,但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。
分配回收成本较高
不受 JVM 内存回收管理
直接内存大小可以通过 MaxDirectMemorySize 设置。如果不指定,默认与堆的最大值-Xmx 参数值一致
⭐ 执行引擎
一,执行引擎概述
执行引擎属于 JVM 的下层,里面包括解释器、及时编译器、垃圾回收器
执行引擎是 Java 虚拟机核心的组成部分之一。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令.才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
二,执行引擎的工作流程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于 PC 寄存器。
- 每当执行完一项指令操作后,PC 寄存器就会更新下一条需要被执行的指令地址。
- 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
Java 代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
Java 代码编译是由 Java 源码编译器(前端编译器)来完成,流程图如下所示:
Java 字节码的执行是由 JVM 执行引擎(后端编译器)来完成,流程图 如下所示:
一,什么是解释器(Interpreter)?什么是 JIT 编译器?
解释器:当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
二,为什么 Java 是半编译半解释型语言?
JDK1.0 时代,将 Java 语言定位为“解释执行”还是比较准确的。再后来,Java 也发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
机器码、指令、汇编语言
一,机器码
各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
用它编写的程序一经输入计算机,CPU 直接读取运行,因此和其他语言编的程序相比,执行速度最快。
机器指令与 CPU 紧密相关,所以不同种类的 CPU 所对应的机器指令也就不同。
二,指令
由于机器码是有 0 和 1 组成的二进制序列,可读性实在太差,于是人们发明了指令。
指令就是把机器码中特定的 0 和 1 序列,简化成对应的指令(一般为英文简写,如 mov,inc 等),可读性稍好
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如 mov),对应的机器码也可能不同。
三,指令集
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。 如常见的
x86 指令集,对应的是 x86 架构的平台
ARM 指令集,对应的是 ARM 架构的平台
四,汇编语言
由于指令的可读性还是太差,于是人们又发明了汇编语言。
在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用<mark 地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
五,高级语言
为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码,如下面说的 C 和 C++
C、C++源程序执行过程
编译过程又可以分成两个阶段:编译和汇编。
编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。
六,字节码
字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode
解释器
JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
为什么 Java 源文件不直接翻译成 JVM,而是翻译成字节码文件?可能是因为直接翻译的代价是比较大的
一,解释器工作机制
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
当一条字节码指令被解释执行完成后,接着再根据 PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
二,解释器分类
在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
在 HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块构成。
Interpreter 模块:实现了解释器的核心功能
Code 模块:用于管理 HotSpot VM 在运行时生成的本地机器指令
三,现状
由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 Python、Perl、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C/C++程序员所调侃。
为了解决这个问题,JVM 平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
JIT 编译器
一,Java 代码的执行分类
• 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
• 第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。
问题来了
有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置 JIT 编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。
首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
所以: 尽管 JRockit VM 中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
二,HotSpot JVM 执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
三,概念解释
Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java 文件转变成.class 文件的过程;
也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。
还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把.java 文件编译成本地机器代码的过程。
• 前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
• JIT编译器:HotSpot VM的C1、C2编译器。
• AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
四,热点代码及探测技术
当然是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过 JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为 OSR(On Stack Replacement)编译。
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT 编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM 将会为每一个方法都建立 2 个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
• 方法调用计数器用于统计方法的调用次数
• 回边计数器则用于统计循环体执行的循环次数
方法调用计数器
这个计数器就用于统计方法被调用的次数,它的默认阀值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次。超过这个阈值,就会触发 JIT 编译。
这个阀值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。
当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
热点衰减
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
另外,可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。
五,HotSpotVM 可以设置程序执行方法
缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
• -Xint:完全采用解释器模式执行程序;
• -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
• -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。
六,HotSpotVM 中 JIT 分类
JIT 的编译器还分为了两种,分别是 C1 和 C2,在 HotSpot VM 中内嵌有两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler,但大多数情况下我们简称为 C1 编译器 和 C2 编译器。开发人员可以通过如下命令显式指定 Java 虚拟机在运行时到底使用哪一种即时编译器,如下所示:
• -client:指定Java虚拟机运行在Client模式下,并使用C1编译器;C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
• -server:指定Java虚拟机运行在server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。
分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发 C1 编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。
不过在 Java7 版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由 C1 编译器和 C2 编译器相互协作共同来执行编译任务。
C1 和 C2 编译器不同的优化策略
在不同的编译器上有不同的优化策略,C1 编译器上主要有方法内联、去虚拟化、冗余消除。
• 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
• 去虚拟化:对唯一的实现类进行内联
• 冗余消除:在运行期间把一些不会执行的代码折叠掉
C2 的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在 C2 上有如下几种优化:
• 标量替换:用标量值代替聚合对象的属性值
• 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
• 同步消除:清除同步操作,通常指synchronized
总结
一般来讲,JIT 编译出来的机器码性能比解释器高。C2 编译器启动时长比 C1 慢,系统稳定执行以后,C2 编译器执行速度远快于 C1 编译器
写到最后 1
- 自 JDK10 起,HotSpot 又加入了一个全新的及时编译器:Graal 编译器
- 编译效果短短几年时间就追评了 C2 编译器,未来可期
- 目前,带着实验状态标签,需要使用开关参数-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler 去激活才能使用
写到最后 2:AOT 编译器
jdk9 引入了 AOT 编译器(静态提前编译器,Ahead of Time Compiler)
Java 9 引入了实验性 AOT 编译工具 jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
最大的好处:Java 虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少 Java 应用给人带来“第一次运行慢” 的不良体验
缺点:
- 破坏了 java “ 一次编译,到处运行”的理念,必须为每个不同的硬件,OS 编译对应的发行包
- 降低了 Java 链接过程的动态性,加载的代码在编译器就必须全部已知。
- 还需要继续优化中,最初只支持 Linux X64 java base
StringTable
一, String 的基本特性
- String:字符串,使用一对””引起来表示
- String 声明为 final 的,不可被继承
- String 实现了 Serializable 接口:表示字符串是支持序列化的。
- String 实现了 Comparable 接口:表示 string 可以比较大小
- String 在 jdk8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK9 时改为 byte[]
二,String 在 jdk9 中存储结构变更
官网地址:JEP 254: Compact Strings (java.net)
动机
目前 String 类的实现将字符存储在一个 char 数组中,每个字符使用两个字节(16 位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含 Latin-1 字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。
说明
我们建议将 String 类的内部表示方法从 UTF-16 字符数组改为字节数组加编码标志域。新的 String 类将根据字符串的内容,以 ISO-8859-1/Latin-1(每个字符一个字节)或 UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。
与字符串相关的类,如 AbstractStringBuilder、StringBuilder 和 StringBuffer 将被更新以使用相同的表示方法,HotSpot VM 的内在字符串操作也是如此。
这纯粹是一个实现上的变化,对现有的公共接口没有变化。目前没有计划增加任何新的公共 API 或其他接口。
迄今为止所做的原型设计工作证实了内存占用的预期减少,GC 活动的大幅减少,以及在某些角落情况下的轻微性能倒退。
结论:String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间
三,String 的基本特性
String:代表不可变的字符序列。简称:不可变性。
当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
当调用 string 的 replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
通过字面量的方式(区别于 new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池是不会存储相同内容的字符串的
String 的 String Pool 是一个固定大小的 Hashtable,默认值大小长度是 1009。如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。
使用-XX:StringTablesize 可设置 StringTable 的长度
- 在 jdk6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize 设置没有要求
- 在 jdk7 中,StringTable 的长度默认值是 60013,StringTablesize 设置没有要求
- 在 JDK8 中,设置 StringTable 长度的话,1009 是可以设置的最小值
String 的内存分配
在 Java 语言中有 8 种基本数据类型和一种比较特殊的类型 String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个 Java 系统级别提供的缓存。8 种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种。
直接使用双引号声明出来的 String 对象会直接存储在常量池中。
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern()方法。这个后面重点谈
Java 6 及以前,字符串常量池存放在永久代
Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内
所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java 7 中使用 String.intern()。
Java8 元空间,字符串常量在堆
StringTable 为什么要调整?
官网地址:Java SE 7 Features and Enhancements (oracle.com)
简介:在 JDK 7 中,内部字符串不再分配在 Java 堆的永久代中,而是分配在 Java 堆的主要部分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主 Java 堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用 String.intern()方法的大型应用程序将看到更明显的差异。
String 的基本操作
Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 类实例。
字符串拼接操作
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
- 如果拼接的结果调用 intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
例子 1:
例子 2:
例子 3:
- 不使用 final 修饰,即为变量。如 s3 行的 s1 和 s2,会通过 new StringBuilder 进行拼接
- 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用
例子 4:
字节码
我们拿例 4 的字节码进行查看,可以发现 s1 + s2 实际上是 new 了一个 StringBuilder 对象,并使用了 append 方法将 s1 和 s2 添加进来,最后调用了 toString 方法赋给 s4
字符串拼接操作性能对比
本实验进行 5 万次循环,String 拼接方式的时间是 StringBuilder.append 方式的约 8000 倍,StringBuffer.append()方式的时间是 StringBuilder.append()方式的约 4 倍
可以看到,通过 StringBuilder 的 append 方式的速度,要比直接对 String 使用“+”拼接的方式快的不是一点半点
那么,在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用 StringBuilder 进行 append 操作
除此之外,还有那些操作能够帮助我们提高字符串方面的运行效率呢?
StringBuilder 空参构造器的初始化大小为 16。那么,如果提前知道需要拼接 String 的个数,就应该直接使用带参构造器指定 capacity,以减少扩容的次数(扩容的逻辑可以自行查看源代码)
intern()的使用
当调用 intern 方法时,如果池子里已经包含了一个与这个 String 对象相等的字符串,正如 equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个 String 对象被添加到池中,并返回这个 String 对象的引用。
由此可见,对于任何两个字符串 s 和 t,当且仅当 s.equals(t)为真时,s.intern() == t.intern()为真。
所有字面字符串和以字符串为值的常量表达式都是 interned。
返回一个与此字符串内容相同的字符串,但保证是来自一个唯一的字符串池。
一,intern 的使用:JDK6 vs JDK7/8
练习 1:
JDK6:
JDK7:
练习 2:
二,intern 的效率测试:空间角度
三,StringTable 的垃圾回收
四:G1 中的 String 去重操作
垃圾回收概述及算法
垃圾回收概述
一,垃圾回收概述
1,什么是垃圾?
垃圾收集,不是 Java 语言的伴生产物。早在 1960 年,第一门开始使用内存动态分配和垃圾收集技术的 Lisp 语言诞生。
关于垃圾收集有三个经典问题:
哪些内存需要回收?
什么时候回收?
如何回收?
垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
2,大厂面试题
3,什么是垃圾?
An object is considered garbage when it can no longer be reached from any pointer in the running program
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。
4,为什么需要 GC
想要学习 GC,首先需要理解为什么需要 GC?
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象。
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。
5,早期垃圾回收
6,Java 垃圾回收机制
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
- 没有垃圾回收器,java 也会和 cpp 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
oracle 官网关于垃圾回收的介绍 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
担忧
对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 outofMemoryError 时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
GC 主要关注的区域
- GC 主要关注于 方法区 和堆中的垃圾收集
垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。其中,Java 堆是垃圾收集器的工作重点
从次数上讲:
频繁收集 Young 区
较少收集 Old 区
基本不收集 Perm 区(元空间)
垃圾回收相关算法——标记阶段
对象存活判断
在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
那么在 JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
一, 标记阶段
方式一:引用计数算法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
循环引用
当 p 的指针断开的时候,内部的引用形成一个循环,这就是循环引用
小结
引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。
具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Python 如何解决循环引用?
- 手动解除:很好理解,就是在合适的时机,解除引用关系。 使用弱引用 weakref,weakref 是 Python 提供的标准库,旨在解决循环引用。
方式二:可达性分析算法
可达性分析算法(根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)
所谓”GCRoots”根集合就是一组必须活跃的引用。
基本思路
- 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
在 Java 语言中,GC Roots 包括以下几类元素:
- 虚拟机栈中引用的对象
- 比如:各个线程被调用的方法中使用到的参数、局部变量等。
- 本地方法栈内 JNI(通常说的本地方法)引用的对象
- 方法区中类静态属性引用的对象
- 比如:Java 类的引用类型静态变量
- 方法区中常量引用的对象
- 比如:字符串常量池(String Table)里的引用
- 所有被同步锁 synchronized 持有的对象
- Java 虚拟机内部的引用。
- 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
- 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(PartialGC)。
如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。
小技巧:由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。
注意
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
这点也是导致 GC 进行时必须“stop The World”的一个重要原因。
- 即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
对象的 finalization 机制
Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize()方法。
finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的 finalize()方法 I 应该交给垃圾回收机制调用。理由包括下面三点:
在 finalize()时可能会导致对象复活。
finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize()方法将没有执行机会。
一个糟糕的 finalize()会严重影响 Gc 的性能。
从功能上来说,finalize()方法与 C 中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 finalize()方法在本质上不同于 C 中的析构函数。
由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。
一,生存还是死亡?
如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
可触及的:从根节点开始,可以到达这个对象。
可复活的:对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize()只会被调用一次。
以上 3 种状态中,是由于 inalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
二,具体过程
判定一个对象 objA 是否可回收,至少要经历两次标记过程:
- 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行 finalize()方法
- 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
- 如果对象 objA 重写了 finalize()方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize()方法执行。
- finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次。
MAT 与 JProfiler 的 GC Roots 溯源
垃圾回收相关算法——清除阶段
标记清除
当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在 JVM 中比较常见的三种垃圾收集算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)
标记-清除算法(Mark-Sweep)
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。
一,执行过程
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除
标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。
清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收
二,缺点
- 标记清除算法的效率不算高
- 在进行 GC 的时候,需要停止整个应用程序,用户体验较差
- 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表
三,何为清除
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。
复制算法
复制(Copying)算法
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。
一,核心思想
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
二,优点
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
三,缺点
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小
四,特别的
如果系统中的垃圾对象很多,复制算法不会很理想,因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行
五,应用场景
在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
标记-压缩(整理)算法
标记-压缩(或标记-整理、Mark-Compact)算法
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。
1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
一,执行过程
- 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
二,指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)。
三,优点
- 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
四,缺点
- 从效率上来说,标记-整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
- 移动过程中,需要全程暂停用户应用程序。即:STW
小结:
分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的 GC 都采用分代手机算法执行垃圾回收的。
在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。
一,年轻代(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 以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代
增量收集算法、分区算法
增量收集算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。
一,基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
二,缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
一般来说,在相同条件下,堆空间越大,一次 Gc 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
垃圾回收相关概念
一,System.gc()的理解
在默认情况下,通过 system.gc()或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)
JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()
内存溢出与内存泄露与 STW
一,内存溢出(OOM)
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 ooM 的情况。
大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。
javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因有二:
- Java 虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms 、-Xmx 来调整。 - 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space”。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 ooM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace”。直接内存不足,也会导致 OOM。
这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等。
在 java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的
- 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。
二,内存泄漏(Memory Leak)
也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏。
但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 00M,也可以叫做宽泛意义上的“内存泄漏”。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。
注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
三,Stop The World
Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。
可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。
STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。
哪怕是 G1 也不能完全避免 Stop-the-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
开发中不要用 System.gc() 会导致 Stop-the-World 的发生。
垃圾回收的并行与并发
并发(Concurrent)
在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
并行(Parallel)
当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。
其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。
适合科学计算,后台处理等弱交互场景
并发 vs 并行
- 并发,指的是多个事情,在同一时间段内同时发生了。
- 并行,指的是多个事情,在同一时间点上同时发生了。
- 并发的多个任务之间是互相抢占资源的。
- 并行的多个任务之间是不互相抢占资源的。
- 只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。
- 否则,看似同时发生的事情,其实都是并发执行的。
垃圾回收的并发与并行
一,并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel Old;
二,串行(Serial)
相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动 JM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
三,并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;如:CMS、G1
安全点与安全区域
一,安全点
程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safepoint)”。
Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。
如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来呢?
抢先式中断:(目前没有虚拟机采用了)
- 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
主动式中断
- 设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)
二,安全区域(Safe Resion)
Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 Gc 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。
强软弱虚引用
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?
在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)这 4 种引用强度依次逐渐减弱。
除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
一,强引用(Strong Reference)——不回收
在 Java 程序中,最常见的引用类型是强引用(普通系统 99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。
当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。
强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 nu11,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成 Java 内存泄漏的主要原因之一。
二,软引用(Soft Reference)——内存不足即回收
- 软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
- 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。
- 类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
- 在 JDK1.2 版之后提供了 java.lang.ref.SoftReference 类来实现软引用
三,弱引用(Weak Reference)——发现即回收
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
在 JDK1.2 版之后提供了 WeakReference 类来实现弱引用
四,虚引用(Phantom Reference)——对象回收跟踪
也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get()方法取得对象时,总是 null
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。
在 JDK1.2 版之后提供了 PhantomReference 类来实现虚引用。
五,终结器引用
它用于实现对象的 finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。
在 GC 时,终结器引用入队。由 Finalizer 线程通过终结器引用找到被引用对象调用它的 finalize()方法,第二次 GC 时才回收被引用的对象