JVM知识点导图
2021-06-22 00:59:17 24 举报
AI智能生成
JVM
作者其他创作
大纲/内容
JVM与Java体系结构
Java虚拟机整体架构祥图
JAVA文件 --> 字节码文件 --> JAVA虚拟机
说一下 JVM 的主要组成部分及其作用?
类加载子系统:加载字节码文件到运行数据区的方法区
执行引擎:执行字节码文件中的指令
本地方法接口:java调用其他语言的接口,与本地资源交互
运行时数据区:JVM的内存
执行流程
首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
Java程序运行机制
java代码--编译器-->字节码文件--类加载器-->方法区
堆:java.lang.Class对象-->封装方法区内数据结构
Java代码执行过程详图
JAVA文件-->字节码文件-->JAVA虚拟机
汇编语言、机器语言、高级语言关系
JAVA文件-->字节码文件-->JAVA虚拟机
JAVA特性
跨平台性
每个语言都需要转换成字节码文件,最后转换的字节码文件都能通过Java虚拟机进行运行和处理
随着Java7的正式发布,Java虚拟机的设计者们通过JSR-292规范基本实现在Java虚拟机平台上运行非Java语言编写的程序。
Java虚拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件
虚拟机:提供资源有限
系统虚拟机
VMware:完全对物理计算机的仿真
程序虚拟机
专门执行单个计算机程序,JVM:执行Java字节码指令
字节码包含:JVM指令集、符号集、辅助信息
Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
跨平台性,一次编译,到处运行
自动垃圾回收功能
自动内存管理
即时编译器JIT
JVM的架构模型
基于栈式(虚拟机栈)
优点
设计和实现简单,适用于资源受限的系统
简单易实现
避开了寄存器的分配难题:使用零地址指令方式分配
操作栈顶元素,不需要地址
零地址
指令流中大部分都是零地址指令,执行过程依赖操作栈,指令集更小,编译器容易实现
8位字节码,所以说指令集更小,但是完成一项操作花费的指令相对多。
指令集更小
不需要硬件支持,可移植性更好,更好实现跨平台
可移植性,跨平台
缺点
性能下降,实现同样的功能需要更多的指令,毕竟还要入栈出栈等操作
需要更多的指令操作
指令
地址、操作数
零地址只有操作数
一地址,一个操作数
二地址,一个操作数
基于寄存器式
优点
性能优秀,执行更高效
花费更少的指令去完成一项操作
缺点
不同平台CPU架构不同,指令集架构完全依赖硬件,可移植性差
典型应用是X86的二进制指令集,比如传统的PC以及安卓的Davlik虚拟机
16位字节码
大部分情况下,指令集往往以一地址指令,二地址指令和三地址指令为主。
javap 查看字节码
-v输出附加信息
-l输出行号和本地变量表
-p显示所有类和成员
-c对代码进行反汇编
JVM的生命周期
虚拟机的启动
通过引导类加载器bootstrap class loader创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。
虚拟机的执行
执行一个所谓的Java程序的时候,真正执行的是一个叫Java虚拟机的进程
虚拟机的退出
程序正常执行结束
执行过程遇到异常或错误而异常终止
操作系统错误导致Java虚拟机进程终止
Runtime类或System类的exit方法、runtime类的halt方法,并且Java安全管理器允许这次exit或halt操作
halt停止、停下、阻止
exit方法源码:static native void halt0(int status)
JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机退出的情况
类加载子系统
作用
负责从文件系统或者网络中加载Class文件,Class文件开头有特定文件标识
Classloader只负责class文件的加载,是否可以运行,由执行引擎决定
加载的类信息存到内存:方法区,除了类信息,方法区还会存放运行时常量池信息,还可能包括字符串字面量和数字常量
常量池运行时加载到内存中,即运行时常量池
类加载器加载字节码文件到内存
角色
截图
说一下类装载的执行过程?(5步)
加载:根据查找路径找到相应的 class 文件然后导入;
验证:检查加载的 class 文件的正确性;
准备:给类中的静态变量分配内存空间;
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
初始化:对静态变量和静态代码块执行初始化工作。
类的加载过程
加载
加载刚好是加载过程的一个阶段,二者意思不能混淆
通过一个类的全限定名获取定义此类的二进制字节流
本地系统获取
网络获取,Web Applet
zip压缩包获取,jar,war
运行时计算生成,动态代理
有其他文件生成,jsp
专有数据库提取.class文件,比较少见
加密文件中获取,防止Class文件被反编译的保护措施
将这个字节流所代表的的静态存储结果转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
链接
验证
目的
确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
四种验证
文件格式验证
开头:CA FE BA BE(魔数,Java虚拟机识别)
主次版本号
常量池的常量中是否有不被支持的常量类型
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
元数据验证
对字节码描述的信息进行语义分析,保证描述符合Java规范
类是否有父类,除了Object之外,所有的类都应该有父类
类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是 抽象类,是否实现了其父类或接口中要求实现的所有方法。
类的字段,方法是否与父类的产生矛盾。例如方法参数都一样,返回值不同
字节码验证
通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。
对类的方法体,进行校验分析,保证在运行时不会做出危害虚拟机的行为
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个int类型的数据,使用时却按照long类型加载到本地变量表中的情况。
保障任何跳转指令都不会跳转到方法体之外的字节码指令上。
符号引用验证
通过字符串描述的全限定名是否能找到对应的类
符号引用中的类、字段、方法的可访问性是否可被当前类访问
准备
为类变量(静态变量)分配内存,并且设置该类变量的初始值,即零值
零值
不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化
不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象一起分配到Java堆中
解析
将常量池内的符号引用转换为直接引用的过程(虚拟机栈,动态链接,解析)
事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
符号引用就是一组符号来描述引用的目标。符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中
直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄
解析动作主要针对类,或接口,字段,类方法,接口方法,方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
初始化
初始化阶段是执行类构造器方法<clinit>()的过程
此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
非法的前向引用问题
如果没有类变量和静态代码块,也不会有clinit
构造器方法中指令按照语句在源文中出现的顺序执行
<clinit>()不同于类的构造器(关联:构造器是虚拟机视角下的<init>())
若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
使用
卸载
补充说明:
解析阶段不一定,在某些情况下可以在初始化阶段之后再开始,为了支持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新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。
除了以上几种情况,其他使用类的方式被看做是对类的被动使用,都不会导致类的初始化
简述java类加载机制?
JVM把描述类的数据从Class文件加载到内存(方法区),并对数据进行校验,解析和初始化,最终形成可以被JVM直接使用的java类型。
描述一下JVM加载Class文件的原理机制
什么是类加载器,类加载器有哪些?
通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器
四种类加载器
扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。JVM的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类
系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
启动类加载器(Bootstrap ClassLoader):用来加载java核心类库,无法被java程序直接引用
用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现
类加载器分类
引导类加载器和自定义加载器
概念上,将所有派生于抽象类ClassLoader的类加载器都划分为自定义加载器
图示
类
代码样例,获取类加载器
截图
对于用户来说定义器来说,默认使用系统类加载器进行加载
Java的核心类库,使用引导类加载器进行加载
启动类加载器
C/C++语言实现,嵌套JVM内部
用来加载Java核心类库,rt.jar,resources.jar,sun.boot.class.path路径下的内容
代码获取加载路径
并不继承java.lang.ClassLoader,没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
出于安全考虑,Bootstrap启动类加载器只加载包名为java\javax\sun等开头的类
扩展类加载器
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为启动类加载器
从java.ext.dirs系统属性所指定的目录中加载类库,或从jre/lib/ext子目录下加载类库
代码
应用程序类加载器(系统类加载器) 父类加载器为扩展类加载器
Java语言编写,由sun.misc.Launcher$AppClassLoader实现
派生于ClassLoader类
负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
通过ClassLoader#getSystemClassLoader()方法可以后去到改类加载器
用户自定义类加载器
为什么要用自定义类加载器
隔离加载类
例如使中间件的Jar包与应用程序Jar包不冲突
修改类加载的方式
启动类加载器必须使用,其他可以根据需要自定义加载
扩展加载源
防止源码泄露
对字节码进行加密,自定义类加载器实现解密
实现步骤
继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器
1.2之前,继承并重写loadClass方法,
1.2之后,建议把自定义的类加载逻辑写在findClass()方法中
如果没有太过复杂的需求,可以直接继承URLClassLoader类,可以避免自己编写findClass()方法,及其获取字节码流的方式,使自定义类加载器编写更加简洁
关于ClassLoader
是一个抽象类,除了启动类加载器,其他类加载器都继承自他
什么是双亲委派模型?
双亲委派
原理
Java虚拟机对Class文件采用的是按需加载,而且加载class文件时,Java虚拟机使用的是双亲委派模式,即把请求交由父类处理,它是异种任务委派模式
截图
1、如果一个类加载器收到了类加载请求,它并不会自己先去加载。而是把这个请求委托给父类的加载器去执行
2、如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
3、如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
优势
避免类的重复加载
保护程序安全,防止核心API被篡改
沙箱安全机制
保证对Java核心源代码的保护
补充
在JVM中表示两个class对象,是否为同一个类存在两个必要条件
类的完整类名必须一致,包括包名
加载这个类的ClassLoader必须相同
JVM必须知道一个类型是由启动类加载器加载的,还是由用户类加载器加载的。如果是用户类加载器加载的,JVM会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中。
说一下JVM运行时数据区
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。
灰色为单独线程私有,红色为多个线程共享的
线程私有:程序计数器、虚拟机栈、本地方法栈
线程共享:堆,方法区
百度:说一下JVM内存模型?分哪几个区,每个区的作用是什么?
程序计数器:记录下一条字节码执行指令,实现分支循环跳转、异常处理、线程恢复等功能。
虚拟机栈:存储局部变量表、操作数栈、动态链接、方法返回地址等信息。Java方法调用
本地方法栈:本地方法调用
堆:所有线程共享,几乎所有对象实例都在堆中分配内存
方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
说一下堆和栈的区别
物理地址
堆:对象分配物理地址不连续,性能相对栈弱些。
GC考虑分配不连续,产生算法提升性能:标记-消除,复制,标记-压缩,分代
栈:先进后出,物理地址连续,性能相对堆好些。
内存分配
堆:在运行时分配,大小不固定
栈:在编译时分配,大小固定
存放内容
堆:对象的实例和数组,更关注:数据存储
栈:局部变量,操作数,动态链接,方法返回地址等信息,更关注:程序方法的执行
程序可见性
堆:所有线程共享,可见
栈:线程私有,只对线程可见,生命周期和线程相同
深拷贝与浅拷贝
浅拷贝:增加一个指针指向已有的内存地址
深拷贝:增加一个指针指向新开辟的一块内存空间
原内存发生变化,浅拷贝也随之变化;深拷贝则不会随之改变
静态变量-->方法区
静态对象-->堆
程序计数器(PC寄存器)
特点
运行时数据区中唯一不会出现OOM的区域,没有垃圾回收。
每个线程有一个私有的程序计数器,线程之间互不影响。
程序计数器会存储当前线程正在执行的Java方法的JVM指令地址
如果正在执行的本地方法,这个计数器值则应为空。(undefined)
面试题
使用PC寄存器存储字节码指令地址有什么用
因为线程是一个个的顺序执行流,CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
记录下一条字节码执行的指令,实现分支循环跳转、异常处理、线程恢复等功能。
PC寄存器为什么被设定为私有的?
CPU为每个线程分配时间片,多线程在一个特定的时间段内只会执行某一个线程的方法,CPU会不停地进行任务切换,线程需要中断、恢复
CPU,多线程,分配时间片,只执行一个线程,CPU任务切换,线程中断恢复
各个线程、PC寄存器记录、当前字节码指令地址,各个线程之间可进行独立计算,防止相互干扰。
虚拟机栈
基本内容
Java虚拟机栈,早起也叫Java栈,每个线程创建时都会创建一个虚拟机栈,内部保存一个个栈帧,对应着一次次的Java方法调用
生命周期和线程的一致
主管Java程序的运行,保存方法的局部变量(8种基本数据类型,对象的引用地址),部分结果,并参与方法的调用和返回。
局部变量 vs 成员变量
基本数据类型 VS 引用类型变量(类,数组,接口)
优点
快速有效的存储方式,访问速度仅次于程序计数器
JVM直接对JAVA栈的操作只有两个
每个方法执行,伴随着进栈(入栈,压栈)
执行结束的出栈
栈不存在垃圾回收,但是存在OOM、栈溢出
Java栈大小是动态或者固定不变的
动态扩展,无法申请到足够内存OOM
如果是固定,线程请求的栈容量超过固定值,则StackOverflowError
使用-Xss,MaxStackSize ,设置线程的最大栈空间
截图
栈的存储单位
每个线程都有自己的栈,栈中的数据以栈帧格式存储
线程上正在执行的每个方法都各自对应一个栈帧
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各个数据信息
先进后出,后进先出
一条活动的线程中,一个时间点上,只会有一个活动的栈帧。只有当前正在执行的方法的栈顶栈帧是有效的,这个称为当前栈帧,对应方法是当前方法,对应类是当前类
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
如果方法中调用了其他方法,对应的新的栈帧会被创建出来,放在顶端,成为新的当前帧
栈运行原理
不同线程中包含的栈帧不允许存在相互引用。
当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的栈帧。
Java方法有两种返回方式
一种是正常的函数返回,使用return指令
另外一种是抛出异常,不管哪种方式,都会导致栈帧被弹出
栈的内部结构
局部变量表
定义为一个数字数组,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及return address类型
局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题
局部变量表容量个数,容量大小(反编译查看locals的值)是在编译期确定下来的
局部变量表存放编译期可知的各种基本数据类型(8种),引用类型(reference),return address 类型
最基本的存储单元是slot
32位占用一个slot,64位类型(long和double)占用两个slot
局部变量表中的变量只有在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
关于Slot的理解
JVM虚拟机会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this,会存放在index为0的slot处,其余的参数表顺序继续排列
截图
this截图
栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的
静态变量与局部变量对比及小结
变量的分类
按照数据类型分
基本数据类型
引用数据类型
按照声明的位置
成员变量,在使用前经历过初始化过程
类变量
链接的准备阶段给类变量默认赋值,初始化阶段显示赋值,即静态代码块赋值
实例变量
随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值
局部变量
在使用前,必须进显式赋值,否则编译不通过
补充:
在栈帧中,与性能调优关系最密切的部分,就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈
截图
作用:计算过程中变量临时存储空间,保存计算过程的中间结果
当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好(反编译 Stack=2)
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度
栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度
如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令
Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈
i++和++i的区别
i++:先将 i 的值加载到操作数栈,再将 i 的值加 1
++i:先将 i 的值加 1,在将 i 的值加载到操作数栈
栈顶缓存技术
由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,依此降低对内存的读写次数,提升执行引擎的执行效率,指令更少,执行速度快
操作数栈顶元素缓存于寄存器
动态链接
指向运行时常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中,该帧所属方法的引用
包含引用目的:支持当前方法的代码实现动态链接,如invokedynamic指令
在java源文件被编译成字节码文件中时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中。
描述一个方法调用了其他方法:用常量池中指向方法的符号引用来表示。作用:将符号引用转换为调用方法的直接引用
常量池、运行时常量池
常量池在字节码文件中,运行时常量池,在运行时的方法区中
为什么要用常量池?
存储一份,供多个方法调用,需记录索引,节省空间
作用:提供符号、常量便于指令识别
方法返回地址
存放调用该方法的pc寄存器的值
方法的结束
正常执行完成
出现未处理异常,非正常退出
无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
异常退出的,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
返回指令包括
ireturn返回值是boolean,byte,char,short,和int类型时使用
lreturn
dreturn
areturn
引用类型
还有一个return指供声明为 void的方法、实例初始化方法、类和接口的初始化方法使用
本质上,方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
一些附加信息
允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。不确定有,可选情况
方法的调用
静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下将调用方的符号引用转为直接引用的过程称为静态链接
动态链接
如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接
方法的绑定
早期绑定(invokespecial )
被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程。仅仅发生一次。
晚期绑定(invokevirtual、invokeinterface )
被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
Java中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++中则使用关键字virtual来显式定义
如果在java程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字final来标记这个方法
虚方法和非虚方法
非虚方法
如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法
静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法
其他方法称为虚方法
方法调用指令
普通调用指令
invokestatic
调用静态方法,解析阶段确定唯一方法版本
invokespecial
调用<init>方法,私有及父类方法,解析阶段确定唯一方法版本
invokevirtual
调用所有虚方法
invokeinterface
调用接口方法
其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法
动态调用指令JDK1.7新增
invokedynamic
动态解析出需要调用的方法,然后执行
直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
截图
静态语言和动态语言
区别在于对类型的检查是编译器还是运行期,满足编译期就是静态类型语言,反之就是动态类型语言。
Java是静态类型语言,动态调用指令增加了动态语言的特性
方法重写的本质
找到操作数栈顶的第一个元素所执行的对象的实际类型,记做C
如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回java.lang.IllegalAccessError异常
否则,按照继承关系从下往上依次对C的各个父类进行上一步的搜索和验证过程。
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
虚方法表(存在方法区)
面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM采用在类的方法区建立一个虚方法表,使用索引表来代替查找
每个类都有一个虚方法表,表中存放着各个方法的实际入口
虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕
截图
本地方法接口
什么是本地方法
简单讲,就是一个Java调用非Java代码的接口
为什么使用native method
与Java环境外交互
例如与操作系统底层或硬件交换信息时的情况
例如启动一个线程
接口、交互
本地方法栈
Java虚拟机栈:Java方法调用,本地方法栈:本地方法调用
本地方法栈,线程私有。(key生成hash值为native方法)
允许被实现成固定或者是可动态扩展的内存大小。
内存溢出情况和Java虚拟机栈相同
使用C语言实现
具体做法是Native Method Stack 中登记native方法,在Execution Engine执行时加载到本地方法库
当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限。
并不是所有的JVM都支持本地方法,因为Java虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一
堆
Java内存管理的核心区域
一个JVM实例只存在一个堆内存
JVM启动就创建,空间大小确定,可调节,参数-Xms,-Xmx
Java堆区在JVM启动的时候即被创建,其空间大小也就确认了。堆内存的大小是可调节的
堆内存物理可不连续,逻辑要连续
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有线程共享Java堆,单个线程可以有TLAB
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(TLAB:thread local allocation buffer)
所有对象实例、数组应在运行时分配在堆上
《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)《Java虚拟机规范》中对JavaL堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
“几乎”所有的对象实例都在堆分配内存。有些对象可能栈上分配:逃逸分析,标量替换
栈帧:保存引用,引用指向对象或数组在堆中的位置
方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收的时候才会被移除。
eden区满,触发GC,进行垃圾回收
如果堆中对象马上被回收,用户线程会受到影响,stop the world
堆是GC执行垃圾回收的重点区域
堆空间细分为:
Java7及之前
内存逻辑上分为:
新生区Young Generation Space
Eden区
Survivor区
from/to 谁空谁是to
养老区Old/Tenure generation space
永久区Permanent
Java8及之后
内存逻辑上分为:
新生区
Eden区
Survivor区
from/to 谁空谁是to
养老区
元空间Meta Space
-XX:+PrintGCDetails 可开启打印查看方法区实现
设置堆内存的大小与OOM
-Xms9m :堆空间的起始内存。X执行 memory start
-Xmx9m:堆空间的最大内存。X执行 memory max 超过最大内存将抛出OOM
通常将-Xms和-Xmx两个参数配置相同的值
目的:在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下
初始内存大小
物理电脑内存大小/64
最大内存大小
物理电脑内存/4
jps命令 查看当前程序运行的进程
jstat 查看JVM在gc时的统计信息 jstat -gc 进程号
新生代与老年代
为什么要有新生代和老年代
分代的目的:优化GC的性能(不分代完全可以)
若不分代-->GC需要扫描整个堆空间,分代之后-->对具体某一区域进行适合的GC
不同代根据其特点进行不同的垃圾回收算法-->提高回收效率(分代收集算法)
新生代与老年代空间默认比例1:2
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
jinfo -flag NewRatio 进程号,查看参数设定值
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是:8:1:1
-XX:SurvivorRatio调整这个空间比例 Eden与Survivor区的比例
为什么新生代被划分为Eden和survivor?
如果没有survivor区,Eden区进行一次MinorGC,存活对象-->老年代--满-->MajorGC
MajorGC消耗时间更长,影响程序执行和响应速度。
survivor存在意义:增加进入老年代的筛选条件,减少送到老年代的对象,减少FullGC的次数。
为什么要设置两个survivor区
只有一个survivor区,在第一次Eden区满进行MinorGC,存活对象放到survivor区;第二次Eden区满MinorGC-->survivor区,会产生不连续的内存,无法存放更多的对象。
设置三个四个survivor区,则每个被分配的survivor空间相对较小,很快被填满。
设置两个survivor区,在MinorGC时可以将Eden区和S0存活的对象以连续存储的方式存入S1区。减少碎片化。(清除阶段的复制算法)
复制算法也是减少碎片化的过程(减少Eden区,减少survivor区)
几乎所有的Java对象都是在Eden区被new出来的。
Eden放不了的大对象,直接进入老年代了。
IBM研究表明,新生代80%的对象都是朝生夕死
-Xmn:设置新生代最大内存大小 memory new。
新生区的对象默认生命周期超过 15 ,就会去养老区养老
图解对象分配一般过程
1、new的对象先放Eden区,放得下直接放入(此区有大小限制 参数-Xmn 一般默认)
2、当创建新对象,Eden空间填满,会触发一次Minor GC/YGC,young,将Eden不再被其他对象引用的对象进行销毁。将Eden中未销毁的对象移到survive0区。survive0区每个对象都有一个年龄计数器,一次回收还存在的对象,年龄加1
3、如果Eden有空间,加载的新对象放到Eden区(超大对象放不下入老年代)
4、再次eden区满,触发垃圾回收,回收eden+survive0,幸存下来的放在survive1区,年龄加1
5、再垃圾回收,又会将幸存者重新放回survive0区,依次类推
6、超大对象放入老年代,老年代满或放不下,触发majorGC,再放不下,OOM
7、可以设置存活次数,默认15次,超过15次,对象将从年轻区步入老年区
-XX:MaxTenuringThreshold=N进行设置
总结:
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
新生代采用复制算法的目的:为了减少内碎片
频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集
对象分配特殊过程
触发YGC,幸存者区就会进行回收,不会主动进行回收
幸存区满了咋办?
特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作
如果Survivor区满了后,新对象可能直接晋升老年代
说一下 JVM 调优的工具?
Visual VM(实时监控 推荐~) Jprofiler (推荐~) Java Flight Recorder (实时监控)
jconsole
用于对 JVM 中的内存、线程和类等进行监控
jvisualvm
JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。
说一下 JVM 调优的工具?
分代收集思想 MinorGC,MajorGC,FullGC
针对HotSpotVM的实现
GC按照内存回收区域分为
部分收集,不是完整收集整个Java堆
新生代收集,MinorGC (YoungGC),新生代( Eden、S0/S1 )的垃圾收集
老年代收集,MajorGC/oldGC
目前只有CMS(concurrent mark sweep并行标记扫描) GC会单独收集老年代的行为
混合收集
收集整个新生代以及部分老年代的垃圾收集
目前只有G1 GC会有这种行为
很多时候MajorGC与FullGC混淆使用,具体分辨是老年代回收还是整堆回收
整堆收集收集,整个Java堆和方法区的垃圾收集
MinorGC的触发条件
Eden区满,触发MinorGC,Survivor区满不触发GC。每次MinorGC会清理年轻代(eden+survivor)的内存
因为Java对象大多朝生夕灭,所以MinorGC非常频繁
MinorGC会引发STW(拓展)
老年代GC(MajorGC/FullGC)触发条件
老年代空间不足,会触发MinorGC,空间还不足,触发MajorGC或FullGC。还不足,OOM
出现了MajorGC,经常会伴随至少一次MinorGC
非绝对,在Parallel Scavenge收集器的收集策略里就直接进行MajorGC的策略选择过程
MajorGC的速度比MinorGC慢10倍以上,STW的时间更长
FullGC的触发机制
1、调用System.gc()时,系统建议执行FullGC,但是不必然执行
2、老年代空间不足
3、方法区空间不足
4、通过MinorGC后进入老年代的平均大小,大于老年代的可用内存
5、由Eden区,Survivor 0区向Survivor 1区复制时,对象的大小大于ToSpace可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
FullGC是开发或调优中尽量要避免的,这样暂停时间会短一些。
Minor GC 针对于新生区,Major GC 针对于老年区,Full GC 针对于整个堆空间和方法区
CMS
G1
STW 暂停其它用户的线程,等待垃圾回收线程结束,用户线程才恢复运行
简述分代垃圾回收器是怎么工作的?
老年代:新生代=2:1
Eden区:from区:to区=8:1:1
Eden区满,进行GC,将Eden+from区存活的对象移动到to区
清空 Eden 和 From Survivor 分区,from/to调换名称
对象到to区后,对象头的GC分代年龄加一,到达年龄阈值后进入老年代;to区满eden区的对象可能直接进入老年代;大对象可能直接进入老年代
老年代达到一定阈值时,进行老年代的垃圾回收(标记-压缩算法)
堆空间分代思想(堆空间为什么分代)
可以不分代,分代目的:优化GC性能,避免对所有对象进行扫描,统一对新对象进行管理
GC第一大任务:内存分配(第二大任务:内存回收)
如果对象再Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor区容纳,则被移动到Survivor空间中,并将对象年龄设置为1,对象再Survivor区每熬过一次MinorGC,年龄就+1,当年龄增加到一定程度(默认为15,不同Jvm,GC都所有不同)时,就会被晋升到老年代中
-XX:MaxTenuringThreshold
优先分配到Eden
大对象直接分配到老年代
尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代
动态对象年龄分配
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄
空间分配担保
-XX:HandlePromotionFailure
在发生Minor GC之前,jvm会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
如果大于,则此次MinorGC是安全的
如果小于,则查看-XX:HandlePromotionFailure设置是否允许担保失败
true
会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
大于,则尝试进行一次MinorGC,但是这次MinorGC依然是有风险的
小于,则改为进行一次FullGC
false
则改为进行一次FullGC
jdk6update24之后,这个参数不会再影响到虚拟机的空间分配担保策略。
规则改为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行MinorGC
否则进行FullGC
为对象分配内存TLAB
Thread Local Allocation Buffer
堆区是线程共享区域,任何线程都可以访问到堆区的共享数据
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
为避免多个线程操作(指针碰撞方式分配内存)同一地址,需要使用加锁等机制,进而影响分配速度
TLAB
从内存模型而不是垃圾收集的角度,对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域,包含在Eden空间中
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们将这种内存分配方式成为快速分配策略
openjdk衍生出来的JVM都提供了TLAB的设计
补充
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实是将TLAB作为内存分配的首选
开发人员通过-XX:UseTLAB设置是否开启TLAB空间
默认情况下,TLAB空间内存非常小,仅占有整个Eden空间的1%,通过-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小
一旦对象在TLAB空间分配内存失败,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
HotSpot虚拟机对象探秘-->对象创建-->并发问题
面试题:堆是分配对象的唯一选择吗
随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术,将会导致一些微秒变化,所有对象分配到堆上渐渐变得不那么绝对了。
有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样无需堆上分配,也不需要垃圾回收了,也是最常见的堆外存储技术
逃逸分析,未逃逸,栈上分配
标量替换
TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,实现了将生命周期较长的Java对象从heap中移动heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
逃逸分析概述
对象引用范围分析,减少程序同步负载、内存堆分配压力
对象在方法中定义,对象只在方法内部使用,未逃逸
被外部方法引用,发生逃逸
判断:new的对象实体是否有可能在方法外被调用(csdn)
启示:开发中使用局部变量,替代在方法外定义。
逃逸分析:代码优化
栈上分配:调用栈内运行,线程结束,栈空间被回收,局部变量对象也被回收。无须进行垃圾回收。
同步省略(线程同步代价高,同步降低并发性和性能。又叫锁清除)
动态编译(解释运行)阶段,JIT编译器用,逃逸分析,来判断同步块所使用的锁对象,是否只能被一个线程访问
是,JIT编译器在编译阶段,会取消对这部分代码的同步。提高并发性和性能
标量替换(分离对象)
JIT编译器在编译阶段,经过逃逸分析,发现一个对象不会被外界访问,那么经过JIT优化,就会把这对象拆解成若干个成员变量来代替。
标量:无法再分解的更小的数据,如Java中原始数据类型,聚合量分解为标量
标量替换参数:-XX:EliminateAllocations,默认打开
常用的 JVM 调优的参数都有哪些?
-Xms2g
初始化堆大小为2g
-Xmx2g
最大堆内存为2g
-Xmn
设置新生区内存大小
-XX:NewRatio=2
设置新生代与老年代内存比例为1:2
-XX:SurviveRatio=8
设置eden区与survivor区内存比例为8:1:1
-XX:MaxTenuringThreshold
设置分代年龄阈值
-XX:+UseParNewGC
指定使用 ParNew + Serial Old 垃圾回收器组合
-XX:+UseParallelGC
指定年轻代使用Parallel scavenge+Parallel Old并行收集器执行内存回收任务
-XX:+UseParallelOldGC
默认jdk8开启。默认开启一个,另一个也会被开启。(互相激活)
-XX:+UseConcMarkSweepGC
开启该参数后会自动将-XX:+UseParNewGC打开。即:ParNew(Young区)+CMS(Old区)+Serial Old(Old区备选方案)的组合。
-XX:+PrintGC:开启打印 gc 信息
-XX:+PrintGCDetails:打印 gc 详细信息
方法区
运行时数据区的最后一部分
栈、堆、方法区交互关系
Person 类的 .class 信息存放在方法区中
person 变量存放在 Java 栈的局部变量表中
真正的 person 对象存放在 Java 堆中
在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的
方法区的理解
方法区定位
《Java虚拟机规范》:尽管所有方法区在逻辑上属于堆一部分,但一些简单实现,可能不会进行垃圾收集或进行压缩。
对于HotSpot,方法区又名:Non-Heap(非堆),目的:区分堆。
方法区看作是一块独立于Java堆的内存空间
方法区和堆的异同
方法区主要存放Class,堆中主要存放实例化对象(堆和方法区的不同点)
方法区/Java堆,各个线程共享内存区域(同)
方法区/Java堆,在JVM启动时被创建,物理内存空间可以不连续,逻辑空间要连续(同)
方法区/Java堆,可以选择固定大小或者可扩展(同)
方法区大小决定了系统可以保存多少个类,如果类定义太多,导致方法区溢出,JVM同样抛出内存溢出异常 OOM
java.lang.OutofMemoryError:PermGen space
java.lang.OutOfMemoryError:Metaspace
关闭JVM就会释放这个区域的内存(待)
举例说明方法区OOM
加载大量的第三方的jar包
Tomcat部署的工程过多(30~50个)
大量动态的生成反射类
HotSpot中方法区的演进
HostSpot可看作方法区永久代等价,本质不等价,《Java虚拟机规范》对如何实现方法区,不做统一要求。
在jdk7及以前,方法区-->永久代,jdk8开始,永久代-->元空间
元空间永久代,都是对JVM规范中方法区的实现。
元空间永久代区别:元空间不在虚拟机中设置内存,使用本地内存(堆外内存)
根据Jvm规范,如果方法区无法满足新的内存分配需求,将抛出OOM异常
永久代更容易导致Java程序OOM(超过-XX:MaxPermsize上限)
设置方法区大小与OOM
方法区大小可固定,jvm可根据应用需求动态调整
JDK7及之前设置永久代大小
-XX:PermSize 设置永久代初始分配空间
-XX:MaxPermSize 设置永久代最大可分配空间
JVM加载类信息容量超过设定值,会报异常OutofMemoryError:PermGen space
JDK8设置元空间大小
-XX:MetaspaceSize:设置初始元空间大小
64位服务端JVM,默认初始元数据区空间21M,初始的高水位线
触及水位线,FullGC触发并卸载没用类,高水位线会被重置。新高水位线值取决于GC后释放了多少元空间。
如果释放空间不足,在不超过最大设定值时,适当提高该值。
如果释放空间过多,则适当降低该值。
如果初始化高水位线设置过低,上述高水位线调整情况会发生很多次,FullGC多次调用。为避免频繁FullGC,建议将-XX:MetaspaceSize设置为一个相对较高值
-XX:MaxMetaspaceSize:-1(没有限制)
不指定大小,虚拟机耗尽所有系统可用内存,一样抛出异常OutOfMemoryError:Metaspace
如何解决OOM或heap space异常
一般手段:通过内存映像分析工具(如Eclipse Memory Analyzer),对dump出来的堆转存储快照分析,重点确认:内存中的对象是否是必要的。先分清:内存泄露,还是内存溢出
什么是内存泄漏?
大量引用指向某些对象,但是这些对象以后不会使用。这些对象还和GC ROOT有关联,所以也不会被回收
大量饮用-->对象,不去用又不能回收
若内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链,于是就能找到内存泄露对象时通过怎样的路径与GC Roots相关联,导致垃圾收集器无法自动回收他们。根据引用链信息,可以较准确的定位出泄露代码的位置
若内存泄漏,导致垃圾回收器无法自动回收原因
如果不存在内存泄露,或者说内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与物理机器内存对比是否还可以调大,从代码检查是否某些对象生命周期过长,持有状态时间过长,尝试减少程序运行时的内存耗用
若内存溢出,jvm参数-Xmx/-Xms 调大,减少程序运行时的内存耗用
方法区的内部结构
方法区存储什么
用于存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存
对于每个加载的类型(类Class,接口Interface,枚举Enum,注解annotation)JVM必须在方法区中存储以下类型信息
这个类型的修饰符(public,abstract,final的某个子集)
这个类的完整有效名称(全名=包名.类名)
这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
这个类型直接接口的一个有序列表
例:public class MethodInnerStrucTest extends Object implements Comparable<String>, Serializable
域(Field)信息
JVM必须在方法区中保存类型的所有域的相关信息,以及域的声明顺序
域的相关信息包括
域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
域类型
域名称
例:public int num = 10;
域信息特殊情况
类变量:non-final 类型
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,你也可以访问它(空指针调用不会异常)
证明不属于特定类实例,随着类的加载而加载
全局常量:static final 进行修饰
每个全局常量在编译阶段被分配。
反编译,查看字节码指令,可以发现 number 的值已写死在字节码文件中
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序
方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
方法的返回类型(包括 void 返回类型),void 在 Java 中对应的类为 void.class
方法名称
方法参数的数量和类型(按顺序)
方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
运行时常量池 VS 常量池
方法区,内部包含了运行时常量池
字节码文件,内部包含了常量池
运行时将常量池加载到方法区,就是运行时常量池
执行时,将常量池中的符号引用(字面量)转换为直接引用(真正的地址值)
加载类的信息在方法区,需要理解字节码文件
要弄清方法区的运行时常量池,需要理解字节码文件中的常量池
运行时常量池,相对于class文件常量池:具备动态性
常量池
字节码文件包含:类的版本信息、字段、方法以及接口等描述信息
还包含常量池表(Constant Pool Table),包括编译生成各个字面量和对类型、域和方法的符号引用
为什么要用常量池?
一个java源文件中的类、接口、编译后产生字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码里。
可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接会用到运行时常量池。
编译产生字节码文件需要大量数据支持,不能存在字节码文件中,存到常量池里,字节码包含指向常量池的引用
常量池有什么?
数量值
字符串值
类引用
字段引用
方法引用
常量池总结
常量池,可看做一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区一部分
常量池表(Constant Pool Table)是class字节码文件一部分,(用于存放编译生成各个字面量和对类型、域和方法的符号引用),这部分内容将在类加载后存放到方法区的运行时常量池中。
创建:在加载类和接口到虚拟机后,就会创建对应的运行时常量池
当创建类或接口的运行时常量池,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值。则JVM会抛出OOM异常
JVM为每个已加载的类和接口都维护一个运行常量池,池中的数据像数组项一样,通过索引访问
运行时常量池包含多种不同的常量,(包括编译期就已经明确的数值字面量,也包括到运行期解析后,才能够获得的方法或者字段引用。)此时不再是常量池中的符号地址,这里转换为真实地址。
运行时常量池,相对于class文件常量池:具备动态性
例如:String.intern可以将字符串也放入运行时常量池
常量池数量为N,则索引为1到N-1?
方法区使用举例
方法区的演进细节
首先明确,只有HotSpot才有永久代
HotSpot中方法区的变化
jdk1.6及之前,有永久代,静态变量存放在永久代上。使用 JVM 虚拟机内存
jdk1.7,有永久代,但已经逐步去永久代,字符串常量池,静态变量移除,保存在堆中。使用 JVM 虚拟机内存
阿里:Java8的内存分代改进(问题同:为什么被替代)
jdk7及之前:永久代和堆物理内存连续,与老年代一起进行垃圾回收
方法区是JVM的规范,永久代,元空间是方法区的实现
jdk8,取消永久代,使用元空间实现方法区(保存类型信息,字段,方法,常量) JVM内存-->本地内存。
永久代为什么要被元空间替代?
永久代设置空间大小很难确定
如果动态加载类过多,就容易产生OOM
会经常触发Full GC
设置-XX:PermSize,初始化分配一块连续的内存块
设置过大:内存浪费
设置过小:OOM
存储在本地内存,仅受本地内存限制。
-XX:MetaspaceSize
设置一样大,一般设置256M
-XX:MaxMetaspaceSize
达到-XX:MetaspaceSize-->触发FGC-->进行类型卸载,同时GC会对该值进行调整(可动态调整)
如果释放了大量的空间,就适当降低该值
如果释放了很少的空间,那么在不超过MaxMetaspaceSize,适当提高该值。
对永久代进行调优很困难
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了减少Full GC次数
有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏
字符串常量池 StringTable 为什么要调整位置?
JDK7中将StringTable从运行时常量池移到堆空间。Full GC执行永久代的垃圾回收,永久代回收效率低。Full GC触发条件:老年代空间不足、永久代空间不足
开发中会有大量字符串被创建,回收效率低,导致永久代内存不足。
移动到堆,提高回收效率
静态变量存放在哪里?
JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中
方法区的垃圾回收:常量池中废弃的常量和不再使用的类型。
常量池中废弃的常量
HotSpot对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以被回收
回收废弃常量与回收Java堆中对象非常类似
方法区内常量池中主要存放两大类常量:
字面量(常量):如文本字符串,被声明为final的常量值等
符号引用(编译原理)
类和接口的全限定名
字段的方法和描述符
方法的名称和描述符
方法区类的回收
不再使用的类型,需要同时满足三个条件
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收(难达成)
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足以上三个条件后,并不是和对象一样立即被回收,仅仅是被允许。
HotSpot虚拟机提供了-Xnoclassgc参数进行控制
在大量使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
HotSpot虚拟机对象探秘
对象的实例化
创建对象的方式
new
最常见的方式
变形:Xxx的静态方法
XxxBuilder/XxxFactory的静态方法
Class的newInstance
JDK9标记过时,反射的方式,只能调用空参的构造器,权限必须是public
Constructor的newInstance
反射的方式,可以调用空参,带参的构造器,权限没有要求。
使用clone
不调用任何构造器,当前类需要实现Cloneable接口,实现clone方法
使用反序列化
从文件、网络等获取一个对象的二进制流
第三方库Objenesis
创建对象的步骤
1、判断对象对应的类是否被常量池加载
当虚拟机遇到一条字节码new指令时。首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过。如果没有,在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常
2、(类加载通过后)为对象分配内存
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象
如果实例成员变量是引用变量,仅分配引用变量空间,即4个字节大小
如果Java堆内存中不规整,虚拟机就必须维护一个列表,记录哪些内存可用,哪些不可用。分配的时候在列表中找一个足够大的空间分配,然后更新列表。这种分配方式叫空闲列表(Free List)。-->标记-清除算法
假设Java 堆中内存是规整的,所有被使用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点指示器。那么内存分配就是指针指向空闲的方向,挪动一段与对象大小相等的距离。指针碰撞(Bump The Pointer)。-->标记-清除-压缩算法
选择哪种由Java堆是否规整决定,Java堆是否规整由所采用的的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
当使用Serial,ParNew等带有压缩整理过程的收集器,指针碰撞简单高效;
当使用CMS基于清除(Sweep)算法收集器时,只能采用空闲列表来分配内存;(CMS为了能在多数情况下分配内存更快,设计了一个Linear Allocatioin Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞方式分配)
3、处理并发安全问题
对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象A分配内存,指针还未修改,对象B又使用原来的指针分配内存。解决问题有两种可选方案:: CAS同步处理、本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
a、对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性。
b、把内存分配的动作按照线程划分到不同的空间中进行,每个线程在Java堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定。
4、初始化分配到的空间
内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,这个工作可以提前到TLAB分配时进行。
这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。
这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。
5、设置对象的对象头
接下来Java虚拟机还要对对象进行必要的设置,例如对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的HashCode会延后真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放到对象的对象头(Object Header)
6、执行init方法进行初始化
上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从Java程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class文件中init()方法)还未执行,所有字段都是默认的零值。new指令之后接着执行init方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来
对象的内存布局
对象头
包含两部分
这部分数据的长度在32位和64位的虚拟机(未开启指针压缩中)分别是32bit和64bit,【Mark Word】运行时元数据
哈希值
GC分代年龄
锁状态标志
线程持有的锁
偏向线程ID
偏向时间戳
对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,根据对象状态的不同,Markword可以复用自己的空间。
类型指针(Klass Word)
即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确认该对象属于哪个类的实例
说明:如果是数组,还需要记录数组的长度
实例数据
对象的实例数据部分,是对象的真正存储的有效信息,即我们在程序代码中定义的各种类型的字段内容,无论是父类继承下来,还是子类中定义的字段都要记录下来。
1、这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
2、分配策略参数-XX:FieldsAllocationStyle
3、HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
4、从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
5、在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
6、如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省空间。
2、分配策略参数-XX:FieldsAllocationStyle
3、HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
4、从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
5、在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
6、如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省空间。
对齐填充
仅起占位符作用
因为HotSpot虚拟机自动内存管理系统,要对对象的起始地址要求8字节的整数倍
对象头已经精心设计为8字节的整数倍,1倍或者2倍
对象实例数据部分若未对齐,需对齐填充
对象的访问定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式
直接指针:指向对象,代表一个对象在内存中的起始地址。
句柄:可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。
【使用句柄】
句柄池:Java堆,reference中存储:对象的句柄地址,句柄包含对象实例数据、对象类型数据
截图
【直接指针】
reference中存储:对象地址,如果访问对象本身,不需要多一次的间接访问的开销。
截图
两种方式各有优势:
使用句柄最大好处:reference中存放稳定句柄地址,在对象被移动时(垃圾收集)只改变句柄中实例数据指针,reference本身不改变。
使用指针最大好处:速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本。
HotSpot所采用
直接内存
不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域
直接内存是在java堆外的,直接向系统申请的内存区间
来源于NIO(non-blocking IO),通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会优于Java堆,即读写性能高
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
也可能导致OOM异常
直接内存在堆外,所以大小不受限于-Xmx指定的最大堆大小
但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
缺点
分配回收成本较高
不受JVM内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
执行引擎
执行引擎概述
执行引擎是Java虚拟机核心的组成部分之一
虚拟机的执行引擎由软件自行实现,物理机的执行引擎是操作系统层面上
能够执行不被硬件直接支持的指令格式
执行引擎的工作过程
1、执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
2、每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
3、当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
Java代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
为什么说Java是半编译半解释型语言
JVM在执行Java代码的时候,通常会将解释执行与编译执行二者结合起来进行
机器码,指令,汇编语言
机器码
各种采用二进制编码方式表示的指令,叫做机器指令码。机器语言。机器指令与CPU紧密相关,不同种类的CPU所对应的机器指令也就不同
指令
由于机器码由01组成,可读性太差。于是人们发明了指令
指令就是把机器码特定的0和1序列,简化成对应的指令,一般为英文编写如mov,inc等,可读性稍好
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同。所以不同的硬件平台的同一种指令,对应的机器码也可能不同
指令集
不同硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集
x86指令集,对应的x86架构的平台
ARM指令集,对应的是ARM架构的平台
汇编
由于指令的可读性太差,于是又有了汇编语言
汇编语言用助记符代替机器指令的操作码,用地址符号或标号,代替指令或操作数的地址。
汇编语言要翻译成机器指令码,计算机才能识别和执行
解释器
当Java虚拟机启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为赌赢平台的本地机器指令执行
解析器真正意义上所承担的角色就是一个运行时翻译者,将字节码文件中的内容翻译为对应的平台的本地机器指令执行
当一条字节码指令被解释执行完成后,接着在根据PC寄存器中的记录下一条需要被执行的字节码执行解释执行
古老的字节码解释器
现在普遍使用的模板解释器
模板解释器将每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码,提高解释器的性能
HotSpot中
Interpreter模块
实现了解释器的核心功能
Code模块
用于管理HotSpot在运行时生成的本地机器指令
JIT编译器
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
JVM平台支持一种叫做即时编译的技术,目的是避免解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可。使执行效率大幅提升
为什么两条腿走路?
首先程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行
编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后执行效率更高
对于服务端应用,启动时间并非关注重点,但是对于看重启动时间的应用场景,就需要找到一个平衡点。
当Java虚拟机启动时,解释器可以首先发挥作用,而不是等待即时编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率
概念解释
前端编译器
把.java文件转换为.class文件的过程
sun的Javac,
后端运行期编译器
把字节码转为机器码的过程
JIT编译器:hotSpot的C1,C2编译器
静态提前编译器
Ahead of Time Compliler AOT,直接把.java文件编译器本地机器代码的过程
GNU Compiler for the Java(GCJ)
什么时候选择JIT
热点代码及探测方式
需要根据代码被调用执行的频率而定,需要被编译为本地代码的字节码,也称之为热点代码。
JIT编译器会在运行时针对频繁调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令。以此提升Java程序的执行性能
一个被多次调用的方法,后者一个方法体内部循环次数较多的循环体,都可以被称之为热点代码
因此可以通过JIT编译器编译为本地机器指令,由于这种编译方法发生在方法的执行过程中,因此也被称之为栈上替换,OSR On Statck Replacement
一个方法调用都少次才能达到标准?这个依靠热点探测功能
hotspot采用的基于计数器的热点探测
方法调用计数器
统计方法调用次数
默认阈值,Client模式下是1500次,Server模式下是10000次
-XX:CompileThreshold
回边计数器
统计循环体执行的循环次数
截图
当一个方法被调用时,如果不存在已被编译过的版本,则将此方法的调用计数器+1,然后判断方法调用计数器与回边计数器之和,是否超过方法调用计数器的阈值。如果已经超过,会向即时编译器提交一个该方法的代码编译请求。
截图
热度衰减
当超过一定的时间限度,如果方法调用次数仍然不足以提交即时编译器编译,那么这个方法的调用计数器就会被减少一半。
-XX:UseCounterHalfLifeTime参数设置半衰周期的时间,单位是秒
hotspot可以设置程序执行的方式
-Xint:完全采用解释器模式执行
-Xcomp完全采用即时编译器模式执行,如果即时编译器出现问题,解释器会介入执行
-Xmixed采用解释器+即时编译器的混合模式共同执行
hotspot中JIT分类
内嵌两个JIT编译器
client
server
大多情况下简称C1,C2
-client:指定Java虚拟机在Client模式下,并使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度
方法内联
将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化
对唯一的实现类进行内联
冗余消除
在运行期把一些不会执行的代码折叠掉
-server:指定虚拟机在server模式下,并使用C2编译器
C2进行耗时较长的优化,以及激进优化,单优化后的代码执行效率更高
逃逸分析是优化的基础,基于逃逸分析在C2上有几种优化
标量替换
用标量值代替聚合对象的属性值
栈上分配
对于未逃逸的对象分配在栈而不是堆
同步消除
清除同步操作,通常指synchronized
最后
jdk10起,hotspot又引入了个全新的即时编译器Graal编译器
JDK9引入了AOT编译器
StringTable
StringTable为什么要调整
因为字符串放在永久代中回收效率很低。老年代或永久代空间不足才会触发fullGC。
开发中会创建大量字符串,回收效率低,导致永久代内存不足。JDK7放到堆中,能及时进行回收。
String的基本特性
字符串,用""引起来表示
String s1 = "mogublog" ; // 字面量的定义方式,声明在字符串常量池
String s2 = new String("moxi"); // new 对象的方式
声明为final的不可被继承的
实现了Serializable接口,表示支持序列化
实现了Comparable接口,表示可以比较大小
jdk8及以前,内部定义了final char[] value用于存储字符串数据
jdk9类型更改:private final char value[];-->private final byte[] value;
char数组每个字符占两个字节16位,String是堆空间的主要部分,大部分是拉丁字符,占一个字节,节省空间
String再也不用char[] 来存储,改成了byte [] 加上编码标记
StringBuffer,StringBuilder同样做了修改
String代表不可变的字符序列(不可变性)
当字符串重新赋值,需要重写指定内存区域赋值,不能使用原有的value进行赋值
当调用String的replace方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能对使用原有的value进行赋值
字符串常量池中不会存储相同的字符串
String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern()方法时性能会大幅下降。
使用-XX:StringTablesize可设置StringTable的长度
在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTablesize设置没有要求
在JDK7中,StringTable的长度默认值是60013,StringTablesize设置没有要求
在JDK8中,StringTable的长度默认值是60013,StringTable可以设置的最小值为1009
String的内存分配
Java语言中有8种基本数据类型和一种比较特殊的类型String,这些类型为了使他们再运行过程中速度更快,更节省内存,都提供了一种常量池的概念
String的常量池比较特殊,主要使用方法有两种
直接使用双引号,声明出来的String对象会直接存储在常量池中
如果不是双引号声明的String对象,可以使用String提供的intern()方法
jdk6及之前,字符串常量池存在永久代,与应用程序创建的其他对象一起分配。
jdk7中,字符串常量池调整到Java堆中
调优时仅需调整堆大小就可以
Jdk8中,元空间,字符串常量在堆
为什么要调整?
永久代默认情况下比较小,大量字符串容易导致OOM。
永久代垃圾回收频率低
堆中空间足够大,字符串可被及时回收
String的基本操作
Java语言规范要求完全相同的字符串字面量,应该包含同样的Unicode字符序列,包含同一份码点序列的常量,并且必须指向同一个String类实例
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
常量池中不存在相同内容的常量(hashtable)
只要有一个变量,拼接结果就在堆中(常量池以外的堆),变量的拼接原理是StringBuilder
+ :字符串拼接底层
Final修饰符
对比用+号拼接字符串和StringBuilder.append操作
拼接10万次,+号4000,append用了7毫秒,原因是+号每次循环创建一个StringBuilder,还要通过toString创建一个String对象
内存中由于创建了较多的对象,内存占用更大,如果需要GC需要花费额外的时间
改进空间:StringBuilder默认是16长度的char型数组,不够的时候会扩容,可以一次建一个比较大长度的数组。
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式(+)
原因 ① StringBuilder的append()的方式: 自始至终中只创建过一个StringBuilder的对象;使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
原因② 使用String的字符串拼接方式(+): 内存中由于创建了较多的StringBuilder和String对象,内存占用更大; 如果进行GC,需要花费额外的时间。
改进的空间: 在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
如何保证变量s指向的是字符串常量池中的数据呢?(两种方式)
String s = "shkstart";//字面量定义的方式
调用intern()
String s = new String("shkstart").intern();
String s = new StringBuilder("shkstart").toString().intern();
intern()方法(见上test2)
确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度,注意,这个值会被存放在字符串内部池。(String intern pool)
new String("ab")会创建几个对象?
一个对象是:new关键字在堆空间创建的
0 new #2 <java/lang/String>:在堆中创建了一个 String 对象
另一个对象是:字符串常量池中的对象"ab"。 字节码指令:ldc
4 ldc #3 <ab> :在字符串常量池中放入 “ab”(若之前字符串常量池中没有 “ab” )
new String(“a”) + new String(“b”) 会创建几个对象?
对象1,new StringBuilder() (有拼接操作)
0 new #2 <java/lang/StringBuilder> :拼接字符串会创建一个 StringBuilder 对象
对象2,new String("a")
7 new #4 <java/lang/String> :创建 String 对象,对应于 new String(“a”)
对象3,常量池中的"a"
11 ldc #5 <a> :在字符串常量池中放入 “a”(如果之前字符串常量池中没有 “a” )
对象4,new String("b")
19 new #4 <java/lang/String> :创建 String 对象,对应于 new String(“b”)
对象5,常量池中的"b"
23 ldc #8 <b> :在字符串常量池中放入 “b”(如果之前字符串常量池中没有 “b”)
对象6,StringBuilder的toString() new String("ab")
toString()的调用,在字符串常量池中,没有生成"ab"
31 invokevirtual #9 <java/lang/StringBuilder.toString> :调用 StringBuilder 的 toString() 方法,会生成一个 String 对象
面试题
截图(一定要在main方法中测试,@test方式,第二个为false)
JDK6
new String() 即在堆中
str.intern() 则把字符串放入常量池中
JDK7/8
new String() 即在堆中
str.intern() 则把字符串放入常量池中,出于节省空间的目的,如果 str 不存在于字符串常量池中,则将 str 在堆中的引用存储在字符串常量池中,没错,字符串常量池中存的是 str 在堆中的引用,所以 s3 == s4 为 true
截图
总结(对应下面的案例)
jdk1.6中,s.intern() 将这个字符串对象放入串池
如果串池中有,则并不会放入,返回已有串池中的对象的地址,
如果没有,会把对象复制一份,放入串池,并返回串池中的对象地址
jdk1.7起,s.intern() 将这个字符串对象尝试放入串池
如果串池中有,则并不会放入,返回已有的串池中的对象的地址
如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
案例1
案例2
案例3
intern()的效率测试
直接 new String :由于每个 String 对象都是 new 出来的,所以程序需要维护大量存放在堆空间中的 String 实例,程序内存占用也会变高
使用 intern() 方法:由于数组中字符串的引用都指向字符串常量池中的字符串,所以程序需要维护的 String 对象更少,内存占用也更低
对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern() 方法,就会很明显降低内存的大小。
StringTable的垃圾回收
-XX:+PrintStringTableStatistics
G1中String去重操作
背景:对许多Java应用,做的测试结果如下
堆存活数据集合里面String对象占了25%
堆存活数据集合里面重复的String对象有13.5%
String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存,一半String对象是重复的,str1.equals(str2)= true
G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样能避免内存浪费。
String 去重的的具体实现
当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
使用一个Hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个Hashtable,来看堆上是否已经存在一个一模一样的char数组。
如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
如果查找失败,char数组会被插入到Hashtable,这样以后的时候就可以共享这个数组。
垃圾回收概述
JVM学习路线
关于垃圾收集
Java 和 C++语言的区别,垃圾收集技术、内存动态分配
三个经典问题
哪些内存需要回收?
什么时候回收?
如何回收?
简述Java垃圾回收机制(基本原理)
在java中,我们不需要手动释放对象的内存,由JVM的垃圾回收线程自动对没有引用的对象进行回收
创建对象时,GC开始监控这个对象的地址、大小以及使用情况。
GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当GC确定一些对象为"不可达"时,GC就有责任回收这些内存空间
垃圾回收机制
分代复制垃圾回收
标记垃圾回收
增量垃圾回收
什么是Grabage?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
An object is considered garbage when it can no longer be reached from any pointer in the running program.
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
为什么需要进行Grabage Collection?
不进行垃圾回收,内存会很快消耗完。进行垃圾回收,释放内存空间。
碎片整理,令新对象获得连续的内存空间。
STW的GC无法满足实际需求,需要对GC进行优化。
垃圾回收器
优点 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题需要自己管理
无需手动分配回收内存,降低内存泄漏、内存溢出的风险
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专注于业务开发
担忧
对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutofMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
GC 的作用域
垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中,Java堆是垃圾收集器的工作重点
次数层面
频繁收集年轻代
较少收集老年代
基本不收集永久代(元空间 matespace)
GC主要关注于方法区和堆中的垃圾收集
垃圾回收相关算法
why对哪些对象进行垃圾回收-->死亡对象-->如何识别
怎么判断对象是否可以被回收?
标记阶段what:判断对象是否存活
堆存放:几乎所有的Java对象实例,GC之前:区分对象是否存活
GC执行只回收死亡对象,释放其所占用的内存空间。
标记死亡对象:当一个对象不再被存活的对象引用时
两种方式How:why引用计数算法、可达性分析算法
what引用计数算法
每个对象上保存整形的引用计数属性,有对象引用计数器加一,减少引用计数器减一。计数器为零时,进行垃圾回收
优点:实现简单、垃圾对象易识别、判定效率高、回收没有延迟性
缺点:循环引用问题 空间额外开销
代码测试证明java不是使用的引用计数算法
结果:垃圾回收日志:Eden 区占用率为1% ,说明进行了垃圾回收(未用:引用计数)
空间额外开销
标记阶段:可达性分析算法(根搜索算法、追踪性垃圾收集)
解决循环引用问题,防止内存溢出
分析思路
根对象集合为起点,向下搜索直接或间接连接的对象为存活对象
无法建立连接,即不可达对象标记为可回收对象
GC Roots可以是哪些元素?
虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数、局部变量等。
本地方法栈内JNI(通常说的本地方法)引用的对象 方法区中类静态属性引用的对象,比如:Java类的引用类型静态变量
方法区中常量引用的对象,比如:字符串常量池(StringTable)里的引用
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用。
基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),系统类加载器。
反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
GC Roots 的总结
总结一句话就是,除了堆空间外的一些结构,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。
如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
可达性分析算法的注意事项
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
一致性-->准确性
这点也是导致GC进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
枚举根节点停顿
在Java中,对象什么时候可以被垃圾回收
不可达的对象可以被垃圾回收器回收
JVM中的永久代中会发生垃圾回收吗
如果永久代满或超过设定阈值,会触发Full GC(堆+方法区)
查看垃圾收集器的输出信息,会发现永久代也是被回收的
避免Full GC:设置永久代大小,Java8:从永久代到元数据区(本地内存区)
对象的 finalization 机制
对象销毁前的回调函数:finalize()
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
finalize() 方法使用的注意事项
永远不要主动调用某个对象的finalize()方法应该交给垃圾回收机制调用。理由包括三点
在finalize()时可能会导致对象复活
finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收
一个糟糕的finalize()会严重影响GC的性能。
从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
由于finalize()方法的存在,虚拟机中的对象有三种可能状态
可触及:从根节点开始,可以到达这个对象。
可复活:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
finalize() 具体执行过程
判定一个对象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()方法只会被调用一次。
GC Roots 溯源
MAT(Memory Analyzer):一款功能强大的Java堆内存分析器,用于查找内存泄漏以及查看内存消耗情况。基于Eclipse开发的,是一款免费的性能分析工具
使用JVisualVM捕捉 heap dump
使用 JProfiler 进行 GC Roots 溯源
JProfiler 分析 OOM
XX: +HeapDumpOnOutOfMemoryError
大对象
可以看到具体线程 以及出现代码的行号
清除阶段:当成功区分出内存中存活对象和死亡对象后,GC接下来的任务是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法
标记-清除算法(Mark-Sweep)
复制算法(Copying)
标记-压缩算法(Mark-Compact)
说一下 JVM 有哪些垃圾回收算法?
标记-清除算法 Mark-Sweep
复制算法 Copying
标记-整理算法 标记-压缩算法 Mark-Compact
分代算法
清除阶段:标记-清除算法
执行过程
当堆中的有效内存空间(available memory)被耗尽时,就会停止整个程序(stop the world),然后进行两项工作,标记、清除
标记:Collector从引用根节点开始遍历,标记所有被引用的对象。
一般是在对象的Header中记录为可达对象。
清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
清除对象的方式
把需要清除的对象地址保存在空闲地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放并覆盖原有地址内容
如果内存规整
采用指针碰撞的方式进行内存分配
指针碰撞的说明
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)。
如果内存不规整
虚拟机需要维护一个空闲列表
采用空闲列表分配内存
标记-清除算法缺点
效率不高
在进行GC时,需要停止整个应用程序,用户体验差
空闲内存不连续,产生内碎片
需要维护一个空闲列表
清除阶段:复制算法
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
Eden区、from区、to区的复制算法
复制算法的优点
没有标记和清除过程,实现简单,运行高效
复制过去以后保证空间的连续性,不会出现“碎片”问题。
复制算法的缺点
需要两倍内存空间
对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,内存占用、时间开销都很大
综上:适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivor0和Survivor1区
清除阶段:标记-压缩算法(标记-整理 、Mark - Compact)
标记-清除算法:老年代,会产生大量内存碎片
复制算法针对:新生代,存活对象少,垃圾对象多。不适用于老年代,大量存活对象
标记-压缩算法的执行流程(标记-清除-压缩)
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存一端,按顺序排放。之后,清理边界外所有空间。
优点
消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
消除了复制算法当中,内存减半的高额代价。
缺点
从效率上来说,标记-整理算法要低于复制算法。
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
移动过程中,需要全程暂停用户应用程序。即:STW
对比三种清除阶段的算法(没有最好的算法,只有最合适的算法)
分代收集算法
年轻代(Young Gen)
区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
老年代(Tenured Gen)
区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
Mark阶段的开销与存活对象的数量成正比。
Sweep阶段的开销与所管理区域的大小成正相关。
Compact阶段的开销与存活对象的数据成正比。
Hotspot CMS 回收器
以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()
不可达对象回收行为
System.runFinalization(); 一定会执行
垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?
可以
手动执行System.gc()
通知GC运行,但Java语言规范并不保证GC一定会执行
内存溢出(OutofMemoryError)
应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度
大多数情况下,GC年轻代老年代的垃圾回收-->内存不足-->独占式Full GC操作-->回收大量内存,供应用程序继续使用。
Javadoc中对OOM的解释:没有空闲内存,并且垃圾收集器也无法提供更多内存。
Java虚拟机的堆内存设置不够。
可能存在内存泄漏问题;也有可能是堆的大小设计不合理,比如要处理比较可观的数据量,但没有显式指定JVM堆大小或指定数值偏小
调整参数-Xms 、-Xmx
代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
对于老版本Oracle JDK,因为永久代大小有限,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型时,永久代出现OutOfMemoryError
运行时存在大量动态类型生成的场合:类似intern字符串缓存占用太多空间,也会导致OOM问题
异常信息,会标记出永久代相关:“java.lang.OutOfMemoryError:PermGen space"
元数据区,本地内存,异常信息:“java.lang.OutofMemoryError:Metaspace"。直接内存不足,也会导致OOM。
腾讯TEG实习
永久代是在哪个版本去掉的?MetaSpace 是什么时候出现的?MetaSpace 大小一般建议设置为多少?它有可能会爆吗?
一般设置256M
如果物理机有 11G 内存,堆设置了 10G,MetaSpace 设置了 128M,它有可能发生 OOM 吗?什么情况会发生?
对内存泄漏有了解吗?
内存泄漏
不再被使用的对象或变量一直占据在内存中,但GC又不能回收
内存泄漏,程序不会立刻崩溃,耗尽内存,最终出现OutofMemory异常,导致程序崩溃。
存储空间:虚拟内存大小,取决于磁盘交换区设定的大小
导致内存泄露的原因
长生命周期对象持有短生命周期对象的引用
内存泄露举例
左边的图:Java使用可达性分析算法,最上面的数据不可达,需要被回收。
右边的图:后期一些短生命周期对象不用,应断开引用,但长生命周期对象持有引用,导致短生命周期对象无法回收。
单例模式
单例的生命周期和应用程序一样长,所以在单例程序中,若持有对外部对象的引用,那么这个外部对象不能被回收,导致内存泄漏。
一些提供close()的资源未关闭导致内存泄漏
数据库连接 dataSourse.getConnection(),网络连接socket和io连接必须手动close,否则不能被回收。
Stop the World
GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。为什么需要停顿所有 Java 执行线程?
分析工作必须在一个能确保一致性的快照中进行
一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
被STW中断的应用程序线程会在完成GC之后恢复,频繁中断令用户体验差,需要减少STW的发生。
注意事项
STW事件和采用哪款GC无关,所有的GC都有这个事件。
哪怕是G1也不能完全避免Stop-the-world情况发生,只能说回收效率越来越高,尽可能地缩短了暂停时间。
STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
开发中不要用System.gc() ,会导致Stop-the-World的发生。
垃圾回收的并行与并发
并发
在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行
并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换
由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行
并行
当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)
决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行
并发与并行的对比
并发,指的是多个事情,在同一时间段内同时发生。
并行,指的是多个事情,在同一时间点上同时发生。
并发的多个任务之间是互相抢占资源的。并行的多个任务之间不互相抢占资源。
只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。
垃圾回收的并行与串行
并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
串行(Serial)
相较于并行概念,单线程执行。
如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收(单线程)
垃圾回收的并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
比如用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
典型垃圾回收器:CMS、G1
安全点与安全区域
安全点(Safepoint)
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint)”。
Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。
大部分指令执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
安全点的中断实现方式
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)
抢占式中断:首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安
全点。
全点。
安全区域(Safe Region)
Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?
例如线程处于Sleep状态或Blocked 状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。
对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
安全区域的执行流程
当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;
既偏门又高频面试题:强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到。如图,显示了这3种引用类型对应的类。Reference子类中只有终结器引用是包内可见,其他3种引用类型均为public,可以在应用程序中直接使用
Java 中都有哪些引用类型?
强引用(StrongReference):发生 gc 的时候不会被回收
最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
软引用(SoftReference):内存不足即回收
用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。注意,这里的第一次回收是不可达的对象
软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
垃圾回收器在某个时刻决定回收软可达的对象时,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。
概括:内存足够:不会回收软引用可达对象。内存不够:会回收软引用可达对象
弱引用(WeakReference):发现即回收
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收。
面试题:你开发中使用过WeakHashMap吗?
WeakHashMap用来存储图片信息,可以在内存不足时,及时回收,避免OOM
虚引用(PhantomReference):对象回收跟踪
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null ,即通过虚引用无法获取到我们的数据
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。
案例
第一次尝试获取虚引用的值,发现无法获取,这是因为虚引用是无法直接获取对象的值,然后进行第一次GC,因为会调用finalize方法,将对象复活了,所以对象没有被回收
但是调用第二次GC操作的时候,因为finalize方法只能执行一次,所以就触发了GC操作,将对象回收了,同时将会触发第二个操作就是将待回收的对象存入到引用队列中。
终结器引用
用于实现对象的finalize() 方法
无需手动编码,其内部配合引用队列使用
在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时才回收被引用的对象
垃圾回收器
垃圾回收器分类
按线程数分(垃圾回收线程数),可以分为串行垃圾回收器和并行垃圾回收器
串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。
适用场景
在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器
按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
独占式垃圾回收器(Stop the World)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片,分配对象空间使用指针碰撞
非压缩式的垃圾回收器不进行这步操作,分配对象空间使用空闲列表
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
评估 GC 的性能指标
吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
吞吐量优先,意味着在单位时间内,STW的时间最短
高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快
因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
若吞吐量低,50%用于执行程序,50%用于垃圾回收,程序执行会很久
暂停时间:是指一个时间段内应用程序线程暂停,让GC线程执行的状态
GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的
暂停时间优先,意味着尽可能让单次STW的时间最短
低暂停时间(低延迟)较好,因为从最终用户的角度来看,不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有较低的暂停时间是非常重要的,特别是对于一个交互式应用程序。
如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。现在标准:在最大吞吐量优先的情况下,降低停顿时间
内存占用:Java堆区所占的内存大小。
垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
收集频率:相对于应用程序的执行,收集操作发生的频率。
快速:一个对象从诞生到被回收所经历的时间。
说一下 JVM 有哪些垃圾回收器?
7种经典的垃圾收集器
串行回收器:Serial、Serial old
并行回收器:ParNew、Parallel Scavenge、Parallel old
并发回收器:CMS、G1
分代
如何选择垃圾回收器
针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
如何查看默认垃圾收集器
-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID
串行垃圾回收器
Serial GC(年轻代 Client模式):复制算法、串行回收、"Stop-the-World"机制
Serial Old GC(老年代 Client模式):标记-压缩算法、串行回收和"Stop the World"机制
Server模式下
与新生代的Parallel Scavenge配合使用
作为老年代CMS收集器的后备垃圾收集方案
特点
它只会使用一个CPU或一条收集线程去完成垃圾收集工作
限定单个CPU环境:省去切换线程的开销
在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(STW)
-XX:+UseSerialGC:指定年轻代和老年代都使用串行收集器
ParNew 回收器:年轻代(Parallel New)复制、并行、STW
区别Serial GC:是否并行
新生代:回收次数频繁,使用并行方式高效
老年代:回收次数少,使用串行方式节省资源(切换线程)
适用范围
ParNew收集器运行在多CPU的环境下,可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
单CPU:Serial GC
设置 ParNew 垃圾回收器
-XX:+UseParNewGC:指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代
-XX:ParallelGCThreads:限制线程数量,默认开启和CPU相同线程数
Parallel Scavenge 回收器:吞吐量优先、复制并行STW、server模式、Java8默认
区别ParNew
Parallel Scavenge收集器的目标:达到一个可控制的吞吐量
自适应调节策略
适用范围
高吞吐量可以高效率利用CPU时间,尽快完成程序的运算任务,主要适用于后台运算而不需要太多交互任务。因此,常见在服务器环境中使用。例如,执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel Old GC:标记-压缩、并行、STW、Java8默认
Parallel Scavenge 回收器参数设置
-XX:+UseParallelGC :指定年轻代使用Parallel并行收集器执行内存回收任务
-XX:+UseParallelOldGC:指定老年代使用并行回收收集器
默认jdk8开启。默认开启一个,另一个也会被开启。(互相激活)
-XX:ParallelGCThreads:设置年轻代并行收集器的线程数
默认情况下,当CPU数量小于8个,ParallelGCThreads的值等于CPU数量
当CPU数量大于8个,ParallelGCThreads的值等于3+[5*CPU_Count]/8]
避免过多的线程数影响垃圾收集性能
-XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间(即STW的时间)单位:毫秒
为了尽可能地把停顿时间控制在XX:MaxGCPauseMillis 以内,收集器在工作时会调整Java堆大小或者其他一些参数
对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。该参数使用需谨慎
-XX:GCTimeRatio:垃圾收集时间占总时间的比例,用于衡量吞吐量的大小
取值范围(0, 100)。默认值99,也就是垃圾回收时间占比不超过1。等于 1 / (N+1)
与前一个-XX:MaxGCPauseMillis参数有一定矛盾性
STW暂停时间越长,Radio参数就容易超过设定的比例
-XX:+UseAdaptiveSizePolicy :设置Parallel Scavenge收集器具有自适应调节策略
在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,来达到在堆大小、吞吐量和停顿时间之间的平衡点。
在手动调优比较困难的场合,可以直接使用这种自适应方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMillis),让虚拟机自己完成调优工作
详细介绍一下 CMS 垃圾回收器?
CMS 回收器(Concurrent-Mark-Sweep):标记-清除、并发、低延迟(STW)、老年代、客户端
第一次实现了让垃圾收集线程与用户线程同时工作
匹配:ParNew、Serial GC 无法匹配:Parallel Scavenge
CMS 工作原理
初始标记(Initial-Mark)阶段:STW
程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
并发标记(Concurrent-Mark)阶段
从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
重新标记(Remark)阶段:STW
由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,并且也会导致“Stop-the-World”的发生,但也远比并发标记阶段的时间短
并发清除(Concurrent-Sweep)阶段
此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
CMS 特点与弊端分析
最耗时:并发标记、并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的
由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用-->堆内存使用率达到某一阈值时,便开始进行回收
CMS运行期间预留的内存无法满足程序需求:出现“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial old收集器来重新进行老年代的垃圾收集,这样停顿时间更长
并发清楚阶段:标记-清除算法、内存碎片、×指针碰撞技术、空闲列表执行内存分配
未用标签-压缩算法原因:
并发清除时,用Compact整理内存,原来的用户线程使用的内存无法使用
需要 STW
缺点总结
内存碎片-->分配大对象-->提前触发 Full GC
占用一部分线程-->导致应用程序变慢,总吞吐量降低 对CPU资源比较敏感
无法处理浮动垃圾-->Concurrent Mode Failure-->Full GC
在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
CMS 参数配置
-XX:+UseConcMarkSweepGC:手动指定使用CMS收集器执行内存回收任务。
JDK 9 中 标记废弃 开启该参数后会自动将-XX:+UseParNewGC打开。即:ParNew(Young区)+CMS(Old区)+Serial Old(Old区备选方案)的组合。
-XX:CMSInitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。
反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
-XX:+UseCMSCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
-XX:CMSFullGCsBeforeCompaction:设置执行多少次Full GC后对内存空间进行压缩整理
-XX:ParallelCMSThreads:设置CMS的线程数 CPU 个数加3除4
如何选择垃圾回收器?
最小化地使用内存和并行开销:Serial GC
最大化应用程序的吞吐量:Parallel Scavenge GC
最小化GC的中断或停顿时间:CMS GC
G1 回收器:区域化分代式(Garbage-First)复制算法、并行并发兼具、面向服务端 JDK 9默认使用
目标:在延迟可控的情况下获得尽可能高的吞吐量
并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集
区分年轻代和老年代,年轻代依然有Eden区和Survivor区
但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是物理连续的,也不再坚持固定大小和固定数量。
可预测的停顿时间模型
设定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率
G1 回收器的缺点
用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
G1 参数配置
-XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务
-XX:G1HeapRegionSize:设置每个Region的大小
值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000
-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标,默认值:200ms
-XX:+ParallelGCThread:设置STW工作线程数。最多设置为8
-XX:ConcGCThreads:设置并发标记的线程数
将n设置为并行垃圾回收线程数(ParallelGcThreads)的1/4左右
-XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值
超过此值,触发GC。默认值:45
G1 收集器的常见操作步骤
G1的设计原则:简化JVM性能调优。开发人员只需要简单的三步即可完成调优
第一步:开启G1垃圾收集器
第二步:设置堆的最大内存
第三步:设置最大的停顿时间
G1收集器的适用场景
面向服务端应用,针对具有大内存、多处理器的机器
要低GC延迟,并具有大堆的应用程序提供解决方案
在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)
用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好
超过50%的Java堆被活动数据占用
对象分配频率或年代提升频率变化很大
GC停顿时间过长(长于0.5至1秒)
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器均使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程
分区 Region:化整为零
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过
XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
一个Region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个Region只可能属于一个角色。图中的E表示该Region属于Eden内存区域,S表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过0.5个Region,就放到H。
设置 H 的原因
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。
为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。
如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
Region的内部结构
每个Region都是通过指针碰撞来分配空间
每个Region都有TLAB,提高对象分配的效率
G1 垃圾回收流程
G1 GC的垃圾回收过程主要包括如下三个环节
年轻代GC(Young GC)
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
YGC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程
第一阶段,扫描根
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等
根引用连同RSet记录的外部引用作为扫描存活对象的入口
第二阶段,更新RSet
处理dirty card queue(见备注)中的card,更新RSet。
此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
第三阶段,处理RSet
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
第四阶段,复制对象
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象
如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。
如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
第五阶段,处理引用
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
备注
对于应用程序的引用赋值语句 oldObject.field=new Object(),JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。
在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
老年代并发标记过程(Concurrent Marking)
初始标记阶段
标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
正是由于该阶段时STW的,所以我们只扫描根节点可达的对象,以节省时间
根区域扫描(Root Region Scanning)
G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象
这一过程必须在Young GC之前完成,因Young GC会使用复制算法对Survivor区进行GC
并发标记(Concurrent Marking)
在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断
在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收
同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)
重新标记(Remark)
由于应用程序持续进行,需要修正上一次的标记结果。STW
G1中采用了比CMS更快的初始快照算法:Snapshot-At-The-Beginning(SATB)
独占清理(cleanup,STW)
计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域
为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
并发清理阶段
识别并清理完全空闲的区域
混合回收过程(Mixed GC)
当越来越多的对象晋升到老年代Old Region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region
这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC
混合回收细节
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算出来
默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收
XX:G1MixedGCLiveThresholdPercent,默认65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但回收到的内存却很少
(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
顺时针,Young GC --> Young GC+Concurrent Marking --> Mixed GC,进行垃圾回收
大致的回收流程
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。
在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。标记完成马上开始混合回收过程。
对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。
和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
G1 回收器垃圾回收过程:Remembered Set(记忆集)
存在问题
一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出,因为G1主要针对大堆)
回收新生代也不得不同时扫描老年代?这样的话会降低Minor GC的效率
解决方法
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
每个Region都有一个对应的Remembered Set
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏
总结
在回收 Region 时,为了不进行全堆的扫描,引入了 Remembered Set
Remembered Set 记录了当前 Region 中的对象被哪个对象引用了
这样在进行 Region 复制时,就不要扫描整个堆,只需要去 Remembered Set 里面找到引用了当前 Region 的对象
Region 复制完毕后,修改 Remembered Set 中对象的引用即可
G1 的注意事项
G1 回收可选的过程四:Full GC
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长
要避免Full GC的发生,一旦发生Full GC,需要对JVM参数进行调整。什么时候会发生Ful1GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存来解决
导致G1 Full GC的原因可能有两个
回收Evacuation的时候没有足够的to-space来存放晋升的对象
并发处理过程完成之前空间耗尽
G1 回收器的补充
从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中
另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案
G1 回收器的优化建议
年轻代大小
避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
固定年轻代的大小会覆盖暂停时间目标
暂停时间目标不要太过严苛
G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量
新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?
新生代回收器:Serial、ParNew、Parallel Scavenge
采用:复制算法,优点:效率高,缺点:内存利用率低(to区永远是空);适用生命周期短的对象收集
老年代回收器:Serial Old、Parallel Old、CMS
采用:标记-清除-压缩算法
整堆回收器:G1
垃圾回收器总结
7 种垃圾回收器的比较
怎么选择垃圾回收器?
优先调整堆的大小让JVM自适应完成
如果内存小于100M,使用串行收集器
如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
官方推荐G1,性能高。现在互联网的项目,基本都是使用G1
调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
GC 日志分析
GC 日志参数设置:通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略
内存分配与垃圾回收的参数列表
-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:日志文件的输出路径
./ 表示当前目录,在 IDEA中程序运行的当前目录是工程的根目录,而不是模块的根目录
日志分析的补充说明
“[GC"和”[Full GC"说明了这次垃圾收集的停顿类型,如果有"Full"则说明GC发生了STW
使用Serial收集器在新生代的名字是Default New Generation,因此显示的是"[DefNew"
使用ParNew收集器在新生代的名字会变成"[ParNew",意思是"Parallel New Generation"
使用Parallel scavenge收集器在新生代的名字是”[PSYoungGen"
老年代的收集和新生代道理一样,名字也是收集器决定的
使用G1收集器的话,会显示为"garbage-first heap"
Allocation Failure表明本次引起GC的原因是在年轻代中没有足够的空间能够存储新的数据
[ PSYoungGen: 5986K->696K(8704K) ] 5986K->704K (9216K)
中括号内:GC回收前年轻代大小,回收后大小,(年轻代总大小)
括号外:GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
user代表用户态回收耗时,sys内核态回收耗时,real实际耗时。由于多核线程切换的原因,时间总和可能会超过real时间
Young GC 图例
Full GC 图例
日志分析工具
GCViewer
GCEasy
面试
对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项
较通用、基础性的部分
垃圾收集的算法有哪些?
如何判断一个对象是否可以回收?
垃圾收集器工作的基本流程
垃圾回收器各种常用参数
0 条评论
下一页