虚拟机通常分为两类:系统虚拟机和程序虚机。其中,系统虚拟机是指完全对物理计算机的仿真,而程序虚拟机是指专门为执行单个计算机程序而设计的的。我们的java虚拟机就是典型的程序虚拟机。
java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。
具体来说,两种架构的区别是:
基于栈式架构的特点:
设计和实现更简单,适用于资源受限的系统
避开了寄存器的分配难题,使用零地址指令方式分配
指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集小,编译器容易实现。
不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点:
典型的应用是×86的二进制指令集
指令集架构则完全依赖硬件,可移植性差
花费更少的指令完成一些操作
性能优秀,执行更高效
虚拟机的启动
通过引导类加载器BootStrap Class Loader 来创建一个初始类(initial class)来完成
虚拟机的执行
当程序开始执行的时候,java虚拟机就开始执行
当程序执行结束的时候,java虚拟机执行结束(停止运行)
注意:执行java程序的时候,其实真正的是执行一个java虚拟机进程的过程
虚拟机退出的情况:
1)程序的正常执行结束
2)程序在执行过程中遇到了异常或者是错误而异常(错误)终止
3)由于操作系统出现了错误而导致了JVM进程终止
4)某线程调用了Runtime类/System类的exit方法或者Runtime类的halt方法,并且java安全管理器允许这次exit和halt操作
5)JNI规范中用JNI API加载和卸载了Java虚拟机时,java虚拟机退出
注意以下几点:
1.class文件在文件的开头有特定的文件标识
2.ClassLoader只负责class文件的加载,至于它是否可以运行,由Execution Engine(执行引擎)来决定
3.加载的类信息存放在于一块叫做方法区的内存空间里面
类的加载过程:
1.加载(Loading)过程:生成Class实例
过程 |
---|
1.通过一个类的全限定名获取定义此类的二进制字节流 |
2.将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构 |
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口 |
加载class文件的方式有:
1)从本地系统直接加载
2)网络获取
3)ZIP压缩包获取
4)动态代理技术获取
5)其他技术获取;例如从专有的数据库获取,从加密文件中获取等等
2.Linking过程:
2.1 验证(Verify)
确保class文件的字节流中包含信息是否符合虚拟机的规划要求(CAFE BABY)开头,保证被加载的类的正确性;
主要包括:文件格式验证,元数据验证,字节码验证,符号引用的验证等等;
2.2准备(Prepare)
为类变量(即静态变量)分配内存并且设置该类变量的默认初始化值,也就是我们所说的零值;
注意:这里不包含用final修饰的类变量值:因为final修饰的常量在编译的时候已经分配好了,准备阶段会进行显式的初始化.;同时,这里也不会为实例变量进行分配初始化,类变量会分配到方法区中,而实例变量是会随着对象一起分配到Java堆中的.
2.3.解析(Resove)
将常量池中的符号引用转换成直接引用的过程
其中,符号引用指的是一组符号锁引用的目标.符号引用的字面量形式明确规定在Class文件格式中;
而直接引用就是直接指向目标的的指针,相对偏移量或者一个间接定位到目标的句柄.
3.初始化阶段(Initalization)
初始化阶段就是执行类构造器的clinit()方法
此方法无需进行定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中语句合并而来的(言外之意就是如果该类没有定义静态变量和静态代码块的话,此方法将不会在jvm类加载中执行)
如果该类具有父类,JVM会保证子类的clinit()方法执行前,父类的clinit()方法已经执行完毕;
虚拟机必须保证一个类的clinit方法在多线程下被同步加锁.-----只会加载一次! ! !
JVM支持两种类型的类加载器:
引导类加载器(BootStrap CLassLoader)
自定义类加载器(User-Defined CLassLoader)
注意:此处将所有的派生于抽象类CLassLoader类加载器都划分为自定义类加载器,其中扩展类加载器(Extension ClassLoader)和系统类加载器(System CLassLoader)都属于CLassLoader类加载器,故他们都属于自定义类加载器.
具体分析三种类加载器:
1.启动类加载器(引导类加载器,BootStrap ClassLoader)
这个类加载器是使用C/C++语言实现的,嵌套在JVM的内部
它是用来加载Java的核心库(JAVA_HOME/jre/rt.jar,resoures.jar或者sun.boot.class.path路径下的内容),用来提供JVM自身需要的类;
它并不继承java.lang.CLassLoader,没有父类加载器
加载扩展类和应用类加载器,并制定为他们的父类加载器;
出于安全考虑,BootStrap启动类加载器只加载包名为java,javax.sun等开头的类
代码测试:
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
输出打印:
sun.misc.Launcher$AppClassLoader@18b4aac2 //系统类加载器
sun.misc.Launcher$ExtClassLoader@1b6d3586 //扩展类加载器
null //引导类加载器:因为不在jvm中定义,底层是c/c++实现,则返回null
sun.misc.Launcher$AppClassLoader@18b4aac2//用户自定义类使用系统类加载器加载
null//String类时用引导类直接加载的
2.扩展类加载器(Extension CLassLoader)
java语言编写,由sun.misc.Launcher$ExtClassLoader实现;
派生于ClassLoader类;
父类加载器为启动类加载器(引导类加载器);
从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库.如果用户创建的JAR放在此目录下,也会自动使用扩展类加载器加载;
3.系统类加载器(System CLassLoader,或者称为应用类加载器)
java语言编写,由sun.misc.LauncherAppClassLoader实现;
父类为扩展类加载器
.它只负责加载环境变量classpath或者系统属性java.class.path指定路径下 的类库;
该类加载是程序默认的类加载器,一般来说,Java应用的类都是由它来完成加载的;
通过ClassLoader.getSystemClassLoader()方法可以获得该类加载器;
为什么要自定义类加载器呢?
隔离加载类;修改类加载的方式;扩展加载源;防止源码的泄露
获取ClassLoader的途径:
面试题:双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存中生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式.
双亲委派机制的优点:
1.避免类的重复加载
2.保护程序的安全性,防止核心API随意被篡改
沙箱安全机制:保证对Java信息源代码的保护
何为沙箱安全机制?即如果我们自己建立和源代码相同的包,例如java/lang/String.Class,在我们去使用类加载器去加载此类时,为了防止你自定义的类对源码的破坏,所以他默认不是使用你的String类的本身的系统加载器去加载它,而是选择率先使用引导类加载器去加载,而引导类在加载的过程中会先去加载JDK自带的文件(rt.jar包中的java/lang/String.class),而不是你自己定义的String.class,报错信息会提示没有main方法 ,就是因为加载的是rt.jar包下的String类,这样就可以做到保证对java核心源代码的保护,这既是沙箱保护机制.
1.JVM判断两个class对象是否为同一个类存在两个必要条件:
1)类的完整类名必须一致(包括包名)
2)加载类的ClassLoader必须相同
2.若是一个类型由用户加载器(系统加载器)加载 那么JVM会将这个类加载器的一个引用类型信息的一部分保存到方法区中.
3.类的主动使用和被动使用
主动使用情况:① 创建类的实例 ②访问某个类或者接口的静态变量 ③调用类的静态方法 ④反射⑤初始化一个类的子类 ⑥Java虚拟机启动时被表明启动类的类等等
被动使用:若类不会导致类的初始化,则为被动使用
运行时数据区(Runtime Dat Area):即为JVM的内存空间,当然,不同的JVM虚拟机对于运行时数据区的划分会有部分的差别.
详细图如下所示(阿里出品,JDK8及以后版本):
每一个JVM只有一个Runtime实例,即运行时环境;
对于线程(即一个程序的运行单元,进程的组成单元)来说.JVM允许一个应用程序有多个线程并行(基于多核CPU或者CPU多核心来说)地执行;
HotSpot VM中,每一个线程都与操作系统的的本地线程直接映射,即当一个java线程准备好执行以后,此时操作系统的一个本地线程也同时创建,其中,操作系统负责所有的线程的安排调度到任何一个可用的cpu上,一旦本地的线程初始化成功,它就会调用java线程中的run()方法.而当Java线程执行终止时,本地线程也会进行回收.
Java默认底层是基于HotSpot VM实现的,其中:
程序计数器:用来储存指向下一条指令的地址,也就是即将要执行的指令代码,由执行引擎来读取下一条指令.
程序计数器是一块很小的内存空间,但也是运行速度最快的存储区域
JVM规范中,每一个线程中都有属于自己的程序计数器,它是线程私有的,其生命周期和线程的生命周期保持一致
任何时候,一个线程中都只有一个方法在执行,即当前方法,程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,如果执行native方法(本地方法),则是未指定值.
注意以下几点:
1.它是程序控制流的指示器,分支,跳转,异常处理,线程恢复等基础功能都需要依赖程序计数器来完成.
2.字节码解释器工作时就是通过程序计数器来选取下一条需要执行的字节码指令.
3.它是唯一一个在Java虚拟机规范中没有规定任何OOM(OutOfMemoryError),即内存溢出情况的区域
1.为什么使用程序计数器来记录当前线程的执行地址?
解析:cpu需要不断进行各个线程之间的切换操作,当从一个线程从到另一个线程切换回来的时候,需要知道接下来从哪里开始继续执行,就是接下来执行指令的地址位置,而JVM的字节码解释器就需要通过不断地改变PC计数器的值来明确下一条应该执行什么样的字节码指令了.
2.PC计数器为什么要设置为线程私有的?
第一种回答:多线程在一个特定的时间段只会执行其中的某一个线程的方法,cpu会不停的进行任务的切换,这样必然会导致经常发生线程的中断和恢复工作,那么让其可以做到没有丝毫问题,就需要在每一个线程中设置自己的pc计数器,来记录各个线程自己正在执行的当前字节码指令地址,这样一来各个线程之间可以进行独立的计算,从而不会出现相互干扰的情况.
第二种回答:由于cpu时间片轮限制(时间片即是cpu分配给各个程序的时间,每个线程被分配的一个时间段,称作为它的时间片),而当众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器的一个内核,只会执行某一个线程中一条指令,这样会导致经常性的中断或者恢复,如何可以保证其分毫不差,则可以在每个线程创建以后,都会创建自己的程序计数器和栈帧,其程序计数器在各个线程中是相对独立,互不影响的.
由于跨平台的设计,导致java的指令是基于栈设计(实现与各个操作系统的低耦合性,从而实现跨平台).
优点:跨平台,指令集小,编译器容易实现
缺点:性能下降,实现同样的功能需要更多的指令
对比Java内存区中的栈和堆:
栈 | 堆 |
---|---|
解决的是程序的运行问题,即程序如何执行,或者说如何处理数据 | 解决的是数据的存储问题,即数据怎么放,放在哪里 |
Java虚拟机栈定义:
Java Virtual Machine Stack:每个线程在创建时,都会伴随着创建一个虚拟机栈,其内部保存着一个个的栈帧(Stack Frame),对应着一次次的Java方法的调用.即可以理解为栈的内部是由一个个的栈帧组成的,而一个java方法对应着一个栈帧.
它是线程所私有的.
生命周期和线程一致
作用:主管Java程序的运行,它保存方法的局部变量(即8种基本数据类型,如果是对象类型,只会保存对象的引用地址),部分结果,并参与方法的调用和返回
Java虚拟机栈的优点:
1.栈是一个快速有效的分配存储方式,访问速度仅次于程序计数器;
2.JVM直接对Java栈的操作只有两个:
每个方法调用的时候,伴随着进栈(入栈,压栈)操作
执行结束后,进行出栈操作
3.对于栈来说,不存在垃圾回收(GC)
面试题:开发中Java栈中经常出现的异常有哪些?
在Java虚拟机栈中,可能出现的异常有以下这些:
即栈会出现StackOverflowError和OutofMemoryErrory异常
每个线程都有自己的虚拟机栈,栈中数据都是以栈帧(Stack Frame)的格式存在的;
在这个线程上正在执行的每个方法都各自对应着一个栈帧;
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息;
栈运行的原理
注意以下几点:
1.不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧;
2.如果当前方法调用了其他方法,方法返回之际,当前栈帧会传入此方法的执行结果,给前一个栈帧,接着虚拟机栈会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧;
3.Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另一种是抛出异常;不管是哪种方式,都会导致栈帧被弹出.
局部变量表也被称为局部变量数组或者本地变量表
定义为一个数字数组,主要是因为其用来存储方法参数和定义在方法体内的局部变量,这些数据包括了各类的基本数据类型,对象引用,以及returnAddress类型;
由于局部变量表是建立在线程的栈上,是线程私有数据,因此不存在数据安全问题;
局部变量表所需的容量大小是在编译期就确定下来的,并保存在方法的Code属性的maxmum local variables数据项中.在方法运行期间不会改变局部变量表的大小
同时,请注意以下几点:
1.方法嵌套调用的次数由栈的大小决定,一般来说,栈越大,方法嵌套调用的次数就越多.
2.局部变量表中的变量只有在当前方法调用中有效.在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束后,随着方法的栈帧的销毁,局部变量表也会随之销毁.
参数值的存放总是在局部变量数组的index0开始的,到数组长度-1的索引结束.
局部变量表,最基本的单位就是Slot(变量槽)
局部变量表中存放在编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量;
在局部变量表里,32位以内的类型只会占用1个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot:
byte,short,char在存储前被转换成int,boolean也会被转换成int,0表示false,非0表示true;
long和double则占据了连个slot
因此静态方法中无法使用this变量的原因:局部变量表中没有存在this引用
注意:Slot的可重复利用性
栈帧中的sot槽是可以重复利用的,如果一个局部变量过了作用域,那么在其作用域之后,申明一个新的局部变量会复用过期的局部变量的槽位.从而达到了节省资源的目的.
public void test(){
{
int a = 0;
System.out.println(a);
}
double b = 5;//此时b在局部变量表中会复用a的槽位,只不过会多增加一个
}
细节知识点1:类变量表有两次初始化的机会:第一次是在"准备阶段",执行系统的初始化,对类变量设置零值,另一次则是在"初始化"阶段,赋予程序员在代码中定义的初始值;但是局部变量不存在系统初始化的过程,这也意味着一旦定义局部变量表,则必须人为地进行初始化,否则无法正常使用.
public void test02(){
int i; //未人为初始化赋值,下面打印会出错
System.out.println(i);
}
**细节知识点2:**局部变量表中的变量是重要的垃圾回收根节点(GC Roots),只要被局部变量表直接或者间接引用的对象都不会被垃圾收集器回收!
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈;
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或者提取数据,即入栈(push)/出栈(pop)
注意:
1.如果被调用的方法带有方法值,其返回值将会被压入当前栈帧的操作数栈中,并更新pc寄存器下一条需要执行的字节码指令;
2.操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证.
3.Java虚拟机栈的解释引擎是基于栈的执行引擎,其中的栈就是操作数栈.
由于操作数栈是存储在内存中,因此频繁地进行内存的读与写的操作必然会影响执行的速度,为了解决这个问题,HotSpot VM使用了一种栈顶缓存技术:将栈顶元素全部缓存到物理cpu的寄存器中,以此降低对内存的读/写次数,从而提升执行引擎的执行效率.
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用.包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)
可以说:动态链接的作用就是将这些运行时常量池的符号引用转换成调用方法的直接引用
面试题:为什么存在常量池?
常量池的作用就是为了提供一些符号和常量,一方面便于指令的识别,也节省了内存的使用,若是直接将这些变量存入,将会大量占用内存空间.
在JVM中,将符号引用转换成调用方法的直接引用与方法的绑定机制有关
Java中任何一个方法都具备虚方法的特征
非虚方法:
如果方法在编译期就确定了具体的调用版本,且在运行时不可变,则这样的方法称为非虚方法;
例如:静态方法.私有方法,final方法,实例的构造器方法,父类方法;
虚方法
即是与非虚方法相反编译期不能确定了具体的调用版本,且运行时可能会发生变化
JVM中提供的方法调用指令:
方法名 | 作用 |
---|---|
invokeStatic | 调用静态方法(唯一确定) |
invokeVirtual | 调用init()方法,私有方法及父类方法等 |
invokeVirtual | 调用所有虚方法 |
invokeinterface | 调用接口方法 |
invokedynamic | 动态解析出需要调用的方法,然后执行 |
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持用户确定版本;
方法的返回地址:用来存放调用该方法的pc计数器的值
一个方法的结束,有两种方式:
1.正常执行返回:执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
2.出现异常信息,非正常返回:如果这个异常没有在该方法中进行处理,也就是本方法的异常表没有检测到任何匹配的异常处理器,就会导致方法退出,简称为异常完成出口
但是,不论是哪种方式退出,在方法退出后都返回到该方法调用的位置.方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址.而通过异常退出的,返回地址是要通过异常表来确定的,栈帧一般不会保存这部分信息的.
注意:正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
本地方法(Native Method),就是一个java代码调用非java代码的接口:该方法的实现是由非java语言实现的,比如C/C++;
在定义一个native method时,并不提供实现体,因为其实现体是由非java语言在外面实现的;
本地接口的作用:是融合不同的编程语言为Java所用,.它的初衷是融合C/C++程序;
注意:
标有native标识符可以和所有的其他标识符连用,但是abstract除外;
Java使用虽然十分的简便,但是有些层次的任务用Java实现并不是很容易,或者效率不高,此时我们想到使用本地方法;例如:与Java环境外进行交互;与操作系统进行交互等等.
目前除了一些和硬件有关的应用以为,已经使用的很少了
Java虚拟机栈是用于管理Java方法的调用,而本地方法则是用来管理本地方法的调用
本地方法栈:也是线程所私有的
1.本地方法是使用C语言编写的;
2.允许被实现成固定或者可动态扩展的内存大小:
如果线程请求分配的栈容量超过了本地方法栈允许的最大容量,Java虚拟机将会抛出StackOverflawError异常;
如果本地方法栈可以动态扩展,并且在尝试扩展时,无法申请到足够的内存,或者在创建新的线程时没有足够的内存区创建对应的本地方法栈,那么Java虚拟机将会抛出OutOfMemoryError异常;
3.本地方法栈的具体实现:Native Method Stack登记native 方法,在Execution Engine执行时加载本地方法库;
4.当某一个线程调用本地方法时,它就进入了一个全新的并且不受虚拟机控制限制的世界.它和虚拟机拥有同样的权限.
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
它甚至可以直接使用本地处理器中的寄存器;
直接从本地内存中的堆中分配任意数量的内存
5.并不是所有的JVM都支持本地方法.因为Java虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方法.数据结构等等.
注意:在HotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
Java堆区在JVM启动的时候即被创建,其空间大小也被确定下来.是JVM管理的最大一块内存空间
堆的大小是可以进行调节的:Java虚拟机规范规定,堆可以处于物理不连续的内存空间,但是逻辑上它应该视为连续的.
所有的线程共享Java堆,在这里还可以划分出线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
"几乎"所有实例对象都会在堆上分配内存;
数组和对象可能永远不会存储在栈上,仅仅是在垃圾收集的时候才会被移除;
堆是GC执行垃圾回收的重点区域;
Java栈,堆,方法区在对象实例存储的示意图如下所示:
现代垃圾收集器大部分基于分代收集理论设计,堆空间在不同的JDK版本的细分如下图所示:
则在JDK7的整体运行时数据区(包括详细堆空间分布)如下图所示:
指令 | 内容 |
---|---|
-Xms | 表示设置堆区的起始内存 |
-Xmx | 表示设置堆区的最大内存 |
一旦堆中的内存超过了"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常;
通常会将-Xms和-Xmx两个参数配置相同的值,其目的在于:为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
默认情况下:
初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存 / 4
存储在JVM中的java对象可以分为两类:
一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
另一类对象的生命周期非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
Java堆区进一步细分,可以分为年轻代(YoungGen)和老年代(OldGen),其中年轻代又可以划分为Eden空间,Survivor0空间和Survivor1空间(有时也被称作from区和to区),如下图所示:
配置新生代与老年代在堆结构中的占比:
默认情况下:-XX:NewRatio = 2,表示新生代占1,老年代占2,新生代占整个堆的1/3;
可以修改:利用-XX:NewRatio = n:表示新生代占1,老年代占n,新生代占老年代的1/n
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例为8:1:1
可以利用-XX:SurvivorRatio = n来调整这个空间比例
几乎所有的Java对象都是在Eden区被new出来的
绝大部分的Java对象的销毁是在新生代中进行的,数据表明,新生代中80%的对象都是"朝生夕死"的
可以使用"-Xmn"来设置新生代最大内存大小
对象分配过程步骤:
步骤 |
---|
1.new的对象先放伊甸园区,此区有大小限制; |
2.当伊甸园区的空间被填满以后,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(GC),将伊甸园区的不再被其他对象所引用的对象进行销毁.再加载新的对象到伊甸园区 |
3.然后将伊甸园中剩下的存活的对象移动到0区 |
4.如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的如果还没有被回收,此时全部移动到幸存者1区 |
5.如果再次经历垃圾回收,此时重新放回幸存者0区,接着再去幸存者1区,如此反复 |
6.当幸存者区中的对象的年龄到达阈值(默认15),会在垃圾回收时进入老年代区 |
7.在老年代区中,当老年代区被填满时,会触发GC:MajorGC,进行老年代的垃圾回收 |
8.若老年代区执行;垃圾回收还是发现无法进行对象的保存,就会产生OOM异常 |
总结:
1.针对幸存者0区,1区来说:复制之后有交换,谁空谁是to
2.关于垃圾回收:频繁在年轻代收集,很少在老年代收集,机会不再永久代/元空间收集
JVM在进行GC的时候,并非每次都是对上面三个内存(新生代,老年代,方法区)一起进行回收的,大部分时候回收都是指的新生代(年轻代)
针对HotSpot VM的实现,它里面的GC按照回收区域又分为了两个大类型:一个是部分收集(Partial GC).一个是整堆收集(Full GC)
部分收集:不是完整收集整个Java堆的垃圾收集,其中又分为
年轻代收集(Minor GC/Young GC):只是对年轻代进行垃圾收集
老年代收集(Major GC/Old GC): 只是对老年代进行垃圾收集
混合收集(Mixed GC):收集真个新生代以及部分老年代的垃圾收集
PS:目前只有G1 GC会有此种功法!
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
年轻代GC触发机制
1.当年轻代空间不足时,就会触发Minor GC,这里的年轻代指的是Eden区满,Survivor区满不会触发GC
2.因为Java对象大多都是朝生夕死的特性,所以Minor GC会比较地频繁,一般回收速度比较快.
3.Minor GC会触发STW(Stop The World,即让整个用户线程停止运行的状态),暂停其他用户的线程,等到垃圾回收结束,用户线程才会恢复运行.
老年代GC触发机制
1.指的是在老年代的GC,对象从老年代中消失
2.出现Major GC,经常会伴随至少一次的Minor GC(但也不是绝对的)
也就是说,在老年代空间不足的时候,会首先考虑触发Minor GC,如果之后空间还是不足,再触发Major GC
3.Major GC的速度一般比Minor GC慢十倍以上,STW的时间也会更长
4.如果Minor GC之后,内存空间还是不足,则报OOM
Full GC触发机制
1.调用System.gc()时,系统建议执行Full GC,但是不必然执行
2.老年代空间不足
3.方法区空间不足
4.通过Minor GC后进入老年代的平均大小大于老年代的可用内存
5.由Eden区,Survivor0区向Survivor1区复制时,对象大小大于To Space可用大小,则将该对象转存老年代,且老年代的可用内存小于该对象大小
PS:Full GC是开发中尽量避免的,这样暂停的时间会短很多
面试题: 为什么需要将Java堆进行分代处理?不分代就无法正常工作了?
经研究表明:不同对象的生命周期是不同的,其中70%~99%的对象时临时对象;
其中对堆进行划分:
年轻代:由Eden区,两块大小相同的Survivor区(to总为空)构成
老年代:存放年轻代中经历多次GC仍然存活的对象
其实,不分代程序能够正常运行的,分代的原因就是为了优化GC的性能,从而提高程序的运行性能.如果不进行分代操作,那么进行GC是会对堆区的所有区域进扫描,而很多对象其实都是“朝生夕死”的,当每次GC都对所有堆区的对象进行扫描回收,将提高GC的时间和降低其效率。但是我们如果分为新生代和老年代,其中新生代存放生命周期较短的对象,即“朝生夕死”或者刚新创建的一些对象,老年代存放生命周期比较长的对象,这样一来进行GC的时候,会先把生命周期较短的临时变量进行回收,不用扫描整个堆区,而是有针对性的进行扫描回收,这样可以节省很多时间和提高回收的效率,大大地提高了jvm的性能。
如果对象在Eden区出生到经历第一次MInor GC后仍然存活,并且能被Survivor所容纳的话,将会被移动到Survivor空间中,并且设置age = 1,对象在Survivor区每熬过一次Minor GC,age就会自动加1,当它增加到一定的程度(default = 15)时,就会晋升老年代中;
老年代的年龄阈值设置:-XX:MaxTenuringThreshold来设置
分配策略:
1.优先分配到Eden
2.大对象直接分配到老年代中:尽量避免程序中出现过多的大对象(还"朝生夕死")
3.长期存活的对象分配到老年代
4.动态对象年龄判断
如果Survivor区中相同年龄的对象 > Survivor空间的一半,年龄大于或者等于该对象可以直接进入老年代,无需等到达到阈值以后
为什么会存在TLAB(Thread Local Allocation Buffer)?
1.堆区是所有线程的共享区域,任何线程都可以访问到堆区中的共享数据
2.由于对象实例的创建在JVM中非常地频繁,因此在并发环境下从堆区中划分出内存空间是线程不安全的
3.为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响了分配的速度
从而引出了TLAB:线程私有的缓冲区
什么是TLAB?
1.从内存模型而不是垃圾收集器的角度来说,对Eden区继续进行划分,JVM为每个线分配了一个私有缓存区域,它包含在Eden空间内
2.多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存的分配的吞吐量,因此我们可以将这种内存分配方式叫做快速分配策略
基于TLAB的对象分配过程图如下所示:
参数说明网址
https://docs.oracle.com/en/java/javase/11/tools/tools-and-command-reference.html
测试堆空间常用的jvm参数:
-XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
-XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
具体查看某个参数的指令: jps:查看当前运行中的进程
jinfo -flag SurvivorRatio 进程id
javap -v -p Xxx.class
-Xms:初始堆空间内存 (默认为物理内存的1/64)
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xmn:设置新生代的大小。(初始值及最大值)
-XX: NewRatio:配置新生代与老年代在堆结构的占比
-XX: SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-XX: MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX: +PrintGCDetails:输出详细的GC处理日志
打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
-XX: HandlePromotionFailure:是否设置空间分配担保
空间分配担保:
逃逸分析概论:
注意:
1.判断是否逃逸分析:new的对象是否有可能在方法外被调用
2.开发中能够使用局部变量就不要轻易使用在方法外进行定义
逃逸分析之代码优化
其中:
1.栈上分配的场景:分别给成员变量赋值,方法返回值.,实例引用传递等等
2.同步消除----锁消除(如果一个对象被发现只能从一个线程中被访问到,那么对于这个对象来说,可以不考虑同步)
代码演示
public void test(){
Object user = new Object();
synchronized (user){
System.out.println(user);
}
}
//以上代码进行同步消除等价于以下代码:
public void test(){
Object user = new Object();
System.out.println(user);
}
3.分离对象或标量替换(HotSpot使用)
代码演示
public Class Point {
int x;
int y;
}
public void test(){
Point point = new Point(1,2);
System.out.println(point.getX() + point.getY());
}
//等价于
public void test(){
//Point point = new Point(1,2);
//System.out.println(point.getX() + point.getY());
int x = 1;
int y = 2;
System.out.println(x + y);
}
首先我们再来回顾一下运行时数据区的结构图
从线程共享与否的角度来看:
栈,堆,方法区的交互关系如下所示:
Java虚拟机规范中:尽管所有的方法区在逻辑上是属于堆的一部分,但是一些简单的实现可能不会选择去进行垃圾收集器或者进行压缩,但是对于HotSpot而言,方法区还有一个别名(Non-Heap堆),目的就是要和堆分离开;
所以:方法区可以看做是一块独立于Java堆的内存空间
1.方法区(Method Area)与Java堆一样,是各个线程共享的内存区域;
2.方法区在JVM启动的时候即被创建,并且它的实际物理内存空间中和Java堆区一样都可以是不连续的;
3.方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
4.方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致了方法区溢出,虚拟机会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace
5.关闭JVM就会释放这个区域的内存
HotSpot中方法区的演进
在jdk7即之前,习惯性把方法区,称为永久代,jdk8开始,使用元空间取代了永久代
从本质上来说,方法区和永久代来说并不是等价的关系;因为只有HotSpot有永久代的概念,像BEA JRokit/IBM J9不存在永久代的概念
JDK8由于Oracle收购了JRockit以后,将HotSpot和JRockit进行了整合,移除了永久代(Permant Generation),引进了元空间(MetaSpace),以下是jdk7和jdk8的jvm内存结构图:
元空间的本质和永久代是类似的:都是对JVM规范中方法区的实现.不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用的本地内存.
根据java虚拟机规范规定:如果方法区无法满足新的内存的分配需求,将会抛出OOM异常!
jdk版本 | 设置 |
---|---|
jdk7及以前 | 通过-XX:PermSize来设置永久代初始分配空间.默认值是20.75M ;-XX:MaxPermSize来设定永久代最大可分配空间 ;当JVM加载的类信息容量超过了这个值,会报异常:OutOfMemoryError:PerGenspace |
jdk8及以后 | 元数据区大小和最大可分配空间可以使用参数-XX:Metaspace和-XX:MaxMetespaceSize来指定,默认windows下-XX:Metaspace为21M,-XX:MaxMetespaceSize为-1,表示没有任何限制;与永久代不同的是如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存,如果元数据区发生溢出,则会一样抛出异常:OutOfMemory:Metaspace |
java源代码加载JVM的过程:重点观察方法区
那么方法区中到底存储了什么东西呢?
其中我们来具体分析一下上面方法区中包含的信息:
对每个加载的类型(类Class,接口Interface,枚举enum,注解annotation),JVM必须在方法区中存储以下类型信息:
1.这个类型的完整有效名称(全名=包名.类名)
2.这个类型直接父类的完整有效名(对于interface或者java.lang.Object来说,没有父类)
3.这个类型的修饰符(public,abstract,final的某个子集)
4.这个类型直接接口的一个有序列表
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息包括:
域名称,域类型,域修饰符(public,private,protected,static,final,volatile,tansient的某个子集)
JVM必须保存所有方法的以下信息,同域信息一样包括声明的顺序:
1.方法的名称
2.方法的返回类型(或者void)
3.方法参数的数量和类型(按照顺序)
4.方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
5.方法的字节码(bytecodes,操作数栈,局部变量表即大小(abstract和native方法除外))
6.异常表(abstract和native方法除外)
每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
PS:补充两个概念:non-final的类变量和final的类变量
non-final的类变量:
1.静态变量和类变量关联在一起,随着类的加载而加载,他们成为了类数据在逻辑上的一部分
2.类变量(即静态变量)被类的所有实例共享,即使没有类实例时你也可以访问它
final的类变量
被声明为final的类变量的处理方法则不同,每个全局变量在编译的时候就已经分配好了
在方法区中,内部包括运行时常量池,而字节码文件中包括常量池
对于一个有效的字节码文件来说,里面除了包含类的版本信息,字段,方法以及接口等描述信息以外,还包含一项信息,那就是常量池(Constant Pool Table),包括各种字面量和对类型.域和方法的符号引用,如下图所示:
那为什么字节码文件中需要常量池?
一个java源文件中的类,接口,编译后产生一个字节码文件.而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码文件中去,换成另一种方式,可以存到常量池中去,这个字节码包含了指向常量池的引用.在前面栈帧的一个组成部分—动态链接—就会使用到这个常量池;
那么常量池里面又有什么东西呢?
1.数量值
2.字符串值
3.类引用
4.字段引用
5.方法引用
总结一下:
常量池可以看做是一张表,虚拟机指令根据这张常量表找到了要执行的类名,方法名,参数类型,字面量等类型;
接下来,我们再来讲解以下运行时常量池:
1.运行时常量池(Runtime Constant Pool)是方法区的一部分;
2.常量池是Class文件的一部分,用来存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中;
3.运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池
4.JVM为每一个已加载的类型(类和接口)都维护这一个常量池.池中的数据项向数组项一样,是通过索引进行访问的;
5.运行时常量池中包含了多种不同的常量,包括编译期就已经确定下来的数值字面量,也包括到运行时期解析后才能够得到的方法或者字段引用等,此时不再是常量池中的符号引用了,这里换成了真实的地址.
运行时常量池相对于class文件常量池的另一个重要特征:具备动态性
String.intern()方法的使用----后面会重点讲解
6.运行时常量池类似传统编程中的符号表(Symbol table),但是它所包含的数据比符号表要更加的丰富
7.当创建类或者接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区提供的最大值,则会抛OutOfMemoryError异常.
首先,需要明确:只有HotSpot才会有永久代!
HotSpot中方法区的变化:
下面是jdk不同版本对于方法区的不同演进过程图:
JDK6及之前的版本的方法区:
JDK7版本的方法区:
JDK8及以后的方法区:
面试题:永久代为什么要被元空间替换?
回答:由于永久代存在着很多的弊端:
1.为永久代设置空间大小是很困难的;某些场景下,如果动态加载的类过多,容易产生Perm区的OOM。而元空间和永久代最大的区别在于:元空间并不在虚拟机中,而是使用的本地的内存,因此,默认情况下,元空间的大小仅仅受到本地内存的限制。
2.对永久代进行性能的调优是很困难的;如果采用永久代,则内存不足并不可少会触发Full GC,但这样会造成应用程序线程的停顿时间过长,对于性能是有很大的弊端的,而且对于Full GC,我们并不能进行多大程度的性能调优,而采用元空间,直接使用的就是本地的内存,则很大程度上的避免Full GC的发生。
面试题: StringTable为什么要被调整?
静态变量放在哪里?
静态引用的对象实体始终都存在堆空间中 !
常量池的垃圾回收:
类的垃圾回收:
对象的实例化图:
序号 | 方式 |
---|---|
方式1 | new(或者调用某些类(XxxFactory,XxxBuilder等等)的静态方法) |
方式2 | Class的newInstance() jdk1.8及之前反射的方式:不过只能调用空参构造器,权限必须是public |
方式3 | Constructor的newInstance() jdk1.9以后的反射方式:可以调用空参,带参的构造器,权限没有要求 |
方式4 | clone(),不调用任何构造器,当前类需要实现Cloneable接口,实现clone方法 |
方式5 | 使用反序列化,从文件中,从网络中获取一个对象的二进制流 |
方式6 | 第三方库Objensls |
步骤 | 内容 |
---|---|
第一步 | 判断对象对应的类是否加载,连接,初始化?虚拟机遇到一条new指令,首先检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化了(即判断类元信息是否存在?)如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key的方式进行查找对应的class文件,如果没有找到,则会抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成相对应的Class类对象 |
第二步 | 为对象分配内存:如果内存时规整的,则以指针碰撞的方式进行分配;如果内存不规整,虚拟机需要维护一个列表,利用空闲列表分配;选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能决定 |
第三步 | 处理并发安全问题:采用CAS配上失败重试保证更新的原子性,每一个线程预先分配一块TLAB |
第四步 | 初始化分配到的空间:所有属性设置默认值,保证对象实例字段在不进行赋值的情况下能够直接使用 |
第五步 | 设置对象的对象头 |
第六步 | 执行init()方法进行初始化 |
JVM是如何通过栈帧上的对象引用访问到其内部的对象实例的? — 通过栈上的reference访问
对象访问的方式主要有两种:
1.句柄访问
2.直接指针(HotSpot采用此种方式)
1.不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域
2.直接内存时Java堆外的,直接向系统申请的内存区间
3.来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
4.通常,访问直接内存的速度会优于Java堆,即读写性能高
因此,处于性能的考虑,读写频繁地场合可能会考虑使用直接内存
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
对比直接缓冲区和非直接缓冲区读写文件的过程:
注意:
1.直接内存也可能导致OutOfMemoryError异常;
2.由于直接内存在Java堆外,因此它的大小直接受到-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存.
3.直接内存的缺点
分配回收成本较高
不受JVM内存回收管理
4.直接内存大小可以通过MaxDirectMemorySize设置
5.如果不指定,默认与堆的最大值-Xmx参数值一致
1.执行引擎是Java虚拟机核心的组成部分之一
2.虚拟机是相对于"物理机"的概念,这两个机器都有代码执行,其区别是物理机的执行引擎是直接建立在处理器,缓存,指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件实现的,因为可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不受硬件直接支持的指令集格式.
3.JVM的主要任务是负责装载字节码到内部,而执行引擎的任务则是将字节码解释/编译为对应平台的本地机器指令(即机器码).
执行引擎的工作过程:
第一步:执行引擎在执行的过程中究竟需要执行什么样的的字节码指令完全依赖于pc计数器;
第二步:每当执行完一项指令操作以后,pc寄存器就会更新下一条需要被执行的指令地址;
第三步:当然方法在执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
大部分的代码转换成物理机的目标代码或者虚拟机能执行的指令集之前,需要经历的流程如下图所示:
Java代码编译是由Java源码编译器来完成,流程图如下:
Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:
那么,什么是解释器(Interpreter),什么是JIT编译器?
解释器:当Java虚拟机启动时会根据预定义的规划对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"解释"为对应平台的本地机器指令来执行;
JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
面试题:为什么说Java是半编译半解释型语言?
因为现在的Java虚拟机在执行java代码的时候,通常都会将解释执行和编译执行二者结合起来进行,其中的编译器即为我们的JIT即是编译器.
解释器:即将字节码中的内容"翻译"成对应平台的本地机器指令执行
发展历程:从字节码解释器------------->模板解释器
其中字节码解释器执行时通过纯软件代码模拟字节码的执行,效率比较低
而模板解释器则是将每一条字节码和一个模板函数相关联,模板函数中直接产生这个字节码的机器码,从而很大程度上提高了解释器的性能
在HotSpot VM中,解释器主要有Interpreter模块和Code模块构成
Interpreter模块:实现了解释器的核心功能
Code模块:用来管理HotSpot VM运行时生成的本地机器指令
上面的解释器是通过拿到源代码编译成的字节码文件,将其逐行解释转换成机器码执行;
而这里的JIT即时编译器则是编译执行(直接编译成机器码)
HotSpot VM 采用则是解释器和即时编译器并存的架构;在Java虚拟机运行时,解释器和及时编译器能够相互协作,各自取长补短,尽力选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间.
总结:
1.一般来说,JIT即时编译出来的机器码性能比解释器要高
2.C2编译器启动时长比C1编译器慢,但是,当系统稳定执行以后,C2编译器的执行速度远远快于C1编译器
面试题: 既然HotSpot VM已经内置了即时编译器JIT,为什么还需要再使用解释器"拖累"程序的执行性能呢?
首先,抛出三个垃圾回收的经典面试题:
哪些内存需要回收?
什么时候回收?
如何回收?
1.什么是垃圾?
垃圾是指在程序运行中没有任何指针指向的对象,这些对象就是需要被回收的垃圾,如果不及时对内存中的这些"垃圾"进行清理,那么垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间将无法被其他对象使用,甚至可能导致内存溢出.
2.为什么要需要GC?
3.Java 的垃圾回收机制
java的垃圾回收机制:自动内存管理机制
优点:降低内存泄露和内存溢出的风险,让java开发人员更专注于业务的开发
缺点:弱化了java程序员在内存溢出定位和解决的能力
Java堆是垃圾收集器的工作重点:频繁收集年轻代,较少收集老年代,基本不动Perm区/元空间
在堆中存放着几乎所有的java对象实例,在GC执行垃圾回收之前,首先需要区分内存中哪些对象是存活对象,哪些对象是已经死亡的对象.只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉所占用的内存空间.
那么在JVM中究竟如何标记一个死亡对象呢,简单来说,当一个对象已经不再被任何存活着的对象继续引用时,我们可以宣判其已经死亡.
判断对象存活一般有两种方式:
1.引用计数算法
2.可达性分析算法
该算法实现较为简单:对每个对象保存一个整型的引用计数器属性,用来记录对象被引用的情况
对于一个对象A,只要任何对象引用了A,则A的引用计数器就会加1,当引用失效时,引用计数器就会减1.只要对象A的引用计数器的值为0,则表示对象A不可能再被使用,可进行回收.
该算法的优点:实现简单,垃圾对象便于识别;判定效率高,回收没有延迟性
缺点:
1.它需要单独的字段存储计数器,这样的做法会增加存储空间的开销
2.每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
3.引用计数器有一个严重的问题,即无法处理循环引用的问题.这是一个致命的缺陷,导致Java的垃圾回收器没有使用该算法
上面提到循环引用的问题:这里举例说明:
上面若突然将P指向null,此时p原先指向的对象的引用计数器的值会变成1,没有减到0,此时由于p指向了null,导致原先p指向的对象无法被判定是死亡的,同样造成了内存泄露的问题.
总结:
1.引用计数算法,是很多语言的资源回收选择,例如Phython
2.Java并没有选择引用计数算法,是因为其存在一个基本的难题,也就是很难处理循环引用的问题
3.Python是如何解决循环引用问题的?
1.手动解除:很好理解,就是在合适的时机,解除引用关系
2.使用弱引用Weakref,Weakref是Python提供的标准库,旨在解除循环引用
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法解决了引用计数算法的循环引用的问题
Java选择了该算法作为垃圾标记的默认算法
首先我们需要引入一个概念------GC Roots(根集合就是一组必须活跃的引用)
基本思路:
1.可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
2.使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或者间接连接着,搜索所走过的路径称为引用链(Reference Chain)
3.如果目标对象没有任何引用链相连,则认为是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
4.在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象
在Java语言中,GC Roots包括以下元素:
注意::
1.如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行.
2.上面的要求也是导致GC进行时必须"Stop The World"的一个重要原因
PS:即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾收集器发现没有一个引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法;
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库连接等等;
永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用!理由如下所示:
1.在finalize()时可能会导致对象复活
2.finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生,则finalize()方法将没有执行机会;
3.一个糟糕的finalize()会严重影响GC的性能
由于finalize()方法的存在,导致虚拟机的对象一般处于三种可能的状态:
则判定一个对象是否可以被回收的具体过程如下所示:
当成功地分出内存中存活对象和死亡对象以后,GC接下来的任务就是执行垃圾回收了,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存.
目前在JVM中比较常见的三种垃圾算法是标记-清除算法(Mark-Sweep),复制算法(CopIng),标记-压缩算法(Mark-Compact)
标记-清除(Mark-Sweep)算法
执行过程:
当堆中的有效空间(available memory)被耗尽的时候,就会停止整个程序(STW),然后进行两项工作,第一项是标记,第二项是清除;
标记:Collector从引用根节点开始遍历,标记所有被引用的对象.一般是在对象的Header中记录为可达对象.
清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在Header中没有标记为可达对象,则将其回收.
标记-清除算法的缺点:
1.效率不算高
2.在进行GC的时候,需要停止整个应用程序,导致用户体验差
3.这种方式清理出来的空闲内存是不连续的,产生内存碎片.需要维护一个空闲列表
注意:何为清除?
这里所谓的清除并不是真正的置空,而是把需要清除的对象地址保存在空闲的地址列表里.下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放.
为了解决标记-清除算法在垃圾收集效率方面的缺陷,复制算法(Copying)应运而生;
复制算法思想:
将活着的内存空间分为两块,每次只使用其中的一块,在垃圾收集时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾的回收;
复制算法的特点
优点:
1.没有标记和清除过程,实现简单,运行高效
2.复制过去以后保证空间的连续性,不会出现"碎片"问题
缺点 :
1.此算法需要两倍的内存空间
2.对于G1这种拆分成大量region的GC来说,复制而不是移动,意味着GC需要维护region之间对象的引用关系,不管是内存占用还是时间开销也不小
注意:
如果系统的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行(例如:新生代的垃圾回收,新生代中的Survivor0和Survivor1区的垃圾回收思想)
背景:
复制算法虽然高效,但是必须建立在存活对象少,垃圾对象多的前提之下,这种情况在新生代比较适合.但是对于老年代来说,大部分对象都是存活的对象,则不再适用于复制算法;
而标记-清除算法虽然可以应用于老年代的垃圾收集,但是这种算法执行效率过低,并且执行完垃圾收集以后会产生内存碎片,故垃圾-压缩(Mark-Compact)算法由此诞生.
标记-压缩算法执行过程:
第一阶段:和标记-清除算法发一样,Collector从引用根节点开始遍历,标记所有被引用的对象.一般是在对象的Header中记录为可达对象.
第二阶段:将所有存活的对象压缩到内存的一端,按照顺序进行排放
之后,清理边界外所有的空间
标记-压缩算法的最终效果等同于标记-清除算法执行完成以后,再进行一次内存碎片的整理工作,因此,也可以把它称为标记-清除-压缩算法(Mark-Sweep-Compact)
对于标记-清除算法来说:标记清除算法是一种非移动式的回收算法,标记压缩 是移动式的.是否移动回收后的存活对象是一项优缺点并存的风险决策.
可以看到:标记的存活对象将会被整理,按照内存地址一次进行排列,而未被标记的内存会被清理掉,如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然要方便很多.
引入知识点:指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式进行分布,即已用和未用的内存都各在一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式叫做指针碰撞(Bump the Pointer)
标记-压缩算法的特点
优点:
1.消除了标记-清除算法当中,内存区域分撒的缺点,我们需要给新对象分配内存时,只需要持有一个内存的起始地址即可;
2.消除了复制算法当中,内存减半的高额代价
缺点:
1.从效率上来说,标记-整理算法要低于复制算法
2.移动对象时,如果该对象被其他对象引用,则还需要调整引用的地址
3.移动过程中,需要全称暂停用户的应用程序,即STW
总结:
对比上面的三种清除的算法:
目前几乎所有的垃圾收集器都是采用分代收集(Generational Collecting)算法执行垃圾收集的;
上述的算法中,在垃圾收集过程中,应用软件将处于一种Stop the World的状态,在Stop the World状态下,应用程序的所有线程都会挂起,暂停一切正常工作,等待垃圾回收的完成.如果垃圾回收的时间过长,应用程序会被挂起很久,将严重影响用户体验.
此时提出一种增量收集算法(Incremental Collecting)
基本思想:
如果一次性将所有的垃圾进行收集,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序交替执行,每一次,垃圾收集值收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,知道垃圾收集完成.
总的来说,增量收集算法的基础仍然是传统的标记-清除和复制算法.增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方法完成标记,清理或者复制工作.
增量收集算法的缺点:
使用这种方式,由于在垃圾收集过程中,间断性还执行了应用程序代码,所以能减少系统的停顿时间,但是,由于线程切换和上下文转换的销毁,会使得垃圾回收的总成本上升,造成系统的吞吐量的下降;
一般来说,在相同的条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长,为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿.
分区算法:将按照对象的生命周期长短划分为两个部分,分区算法将真个堆空间分为连续的不同的小区间.每个小区间都独立使用,这种算法的好处是可以控制一次回收多少个小区间:其中,G1 GC就采用这种分区算法思想.
如下代码所示:我们进行System.gc()时,按照前面是说的,一般如果进行垃圾回收之前,会先调用finalize()方法,这里我们重写finalize()方法,里面打印一段语句,当执行以后:会发现并不是每次都会打印我们重写的语句,说明System.gc()时并不能确定马上执行GC处理;
内存溢出:OutofMemoryError
一般上面所说的没有空闲内存指的就是没有空闲的堆内存,而导致Java虚拟机堆内存不足的原因主要有两点:
这里面隐含的一层意思,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间.
例如: 在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等
在java.nio.BIts.reserveMemeory()方法中,我们可以清楚的看到System.sc()会被调用,以清理空间.
当然,也不是在任何情况下垃圾收集器都会被触发的
比如:我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError异常.
并发(Concurrent):
并行(Parallel):
并发VS并行:
而对于垃圾回收来说:
并行(Parallel):指的是多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态.
比如:ParNew,Parallel Scavenge,Parallel Old
串行(Serial):单线程执行:如果内存不够,暂停程序,启动垃圾收集器进行垃圾回收.等垃圾收集完毕,再启动程序的线程.
并发(Concurrent):指的是用户线程与垃圾收集器线程同时执行(但不一定是并行的,可能交替执行)垃圾收集线程在执行时不会停顿用户程序的运行.
安全点(SafePoint):
程序执行时并非在所有的地方都能停顿下来进行GC,只有在特点的位置才能停顿下来进行GC,这些位置被称为"安全点";
Safe Point的选择很重要,如果太少可能会导致GC等待的时间太长,如果太频繁可能会导致运行时的性能问题.大部分的指令的执行时间都是非常地短暂的,通常会根据"是否让程序长时间执行的特征"为标准.比如:选择一次执行时间较长的指令作为Safe Point,例如方法的调用,循环跳转,异常跳转等等.
那么如何在GC发生时,检查所有的线程都跑到了最近安全点停顿下来了呢?
方式1:抢先式中断(目前不使用了)
首先中断所有线程,如果还有线程不在安全点,让线程跑到安全点
方式2:主动式中断
设置一个中断标志,各个线程运行到Safe Point的时候主要轮询这个标志,如果中断标志为真,则将自己进行中断挂起
安全区域(Safe Region):
安全区域指的是一段代码中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的.
实际执行过程:
1.当线程运行到Safe Region的代码时,首先标识已经进入Safe Region,如果这段时间进行GC.JVM会忽略标识为Safe Region状态的线程
2.当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续执行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止.
面试题: 强引用,软引用,弱引用,虚引用有什么区别?具体的使用场景是什么?
注意:可以通过java.lang.ref.SoftReference类来实现软引用
注意:可以通过java.lang.ref.WeakReference类来实现弱引用
弱引用对象和软引用对象最大的不同在于,在GC进行回收之前,需要通过算法检查是否回收软引用对象;而对于弱引用对象,GC总是进行垃圾回收的.弱引用对象将更容易,更快地被GC回收.
1.按照线程数来进行划分,可以分为串行垃圾回收器和并行垃圾回收器
串行垃圾回收器 | 并行垃圾回收器 |
---|---|
同一时间段只允许一个cpu用于执行垃圾回收操作,此时垃圾线程被暂停,直至垃圾收集工作结束 | 并行收集可以运用多个cpu同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收和串行回收一样,采用独占式,使用了STW机制 |
2.按照工作模式进行划分,可以分为并发式垃圾回收器和独占式垃圾回收器;
并发式垃圾回收器与应用程序交替工作,以尽可能减少应用程序的停顿时间;
独占式垃圾回收器(STW)一旦运行,就需要停止应用程序中的所有用户线程,直到垃圾回收过程完全结束为止;
3.按照碎片处理方式划分:可以分为压缩式垃圾回收器和非压缩式垃圾回收器
压缩式垃圾回收器会在回收完成以后,对存活对象进行压缩整理,消除回收后的碎片;
非压缩式的垃圾回收器不进行碎片的压缩整理工作
4.按照工作的内存区间划分:可以分为年轻代垃圾回收器和老年代垃圾回收器
性能指标 | 内容 |
---|---|
吞吐量 | 指的是运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间) |
暂停时间 | 执行垃圾收集时,程序的工作线程被暂停的时间 |
垃圾收集开销 | 吞吐量的补数,垃圾收集所用时间占总运行时间的比例 |
收集频率 | 相对于应用程序的执行,收集操作发生的频率 |
内存的占用 | Java堆区所占的内存大小 |
快速 | 一个对象从诞生到被回收所经历的时间 |
其中,吞吐量,暂停时间和内存占用称为评估GC性能的重要三点 !,后随着硬件的发展,内存占用慢慢得到了解决,其中由于硬件性能的提升有助于降低垃圾收集器运行时对应用程序的影响,即提高了吞吐量,而内存的扩大,对延迟反而带来了负面效果.
吞吐量(Throughput)
暂停时间(Pause Time)
吞吐量VS暂停时间
目前GC追求最大性能标准则是:在最大吞吐量优先的情况下,降低停顿时间.
垃圾收集器的发展史:
其中:
上面的7款垃圾收集器与垃圾分代直接的关系图如下所示:
同时,垃圾收集器在年轻代和老年代的组合使用关系随着jdk版本的演进发生着一些变化:
1.两个收集器之间有连线,表明他们可以配合使用:
Serial GC/Serial Old GC
Serial GC/CMS
ParNew GC/Serial Old GC
ParNew GC/CMS
Parallel Scavenge GC/Serial Old GC
Parallel Scavenge GC/Parallel Old GC
G1 GC
2.其中,Serial Old GC 作为CMS 出现"Concurrent Mode Failure"失败的后备方案.
3.(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial Old + CMS,ParNew + Serial Old这两个组合声明为废弃,同时,在JDK9中完全取消了这两个组合的支持(即移除)
4.(绿色虚线)JDK14中,弃用了Parallel Scavenge和Serial Old的组合
5.(青色虚线)JDK14中,删除了CMS垃圾回收器
如何查看默认的垃圾收集器?
-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID
1.Serial收集器是最基本,历史最悠久的垃圾收集器了.JDK1.3之前回收新生代唯一的选择;
2.Serial GC作为HotSpot中Client模式下的默认新生代垃圾回收器
3.Serial GC 采用的是复制算法,串行回收和"Stop The World"机制的方式执行垃圾内存回收的
4.除了年轻代之外Serial 收集器还提供了用于执行老年代垃圾收集的Serial Old GC,Serial Old GC收集器同样也采用了串行回收和"Stop The World"机制,只不过内存回收算法使用的是标记-压缩算法.
注意:
1.Serial Old是运行在Client模式下的默认的老年代的垃圾收集器
2.Serial Old在Server模式下主要有两个用途:一是与新生代的ParallelScavenge配合使用,二是作为老年代CMS收集器的后备垃圾收集方案
通过上图该垃圾回收器的工作流程可以发现:
这个垃圾收集器是一个单线程收集器,但是它的"单线程"的意义并不仅仅说明他只会使用一个CPU或者一条收集线程去完成垃圾收集 工作,更重要的是在他进行垃圾收集的时候,必须暂停其他所有的工作线程,直到他收集结束(Stop The World).
Serial GC的特点:
ParNew GC:即是Serial GC的多线程版本
ParNew收集器除了采用并行回收的方式进行内存垃圾回收以外,与Serial几乎没有其他区别:同样采用复制算法,"Stop The World"机制;
ParNew是很多JVM运行在Server模式下年轻代的默认垃圾收集器
ParNew GC的工作流程如下所示:
对于新生代来说,回收次数频繁,则使用并行方式高效
对于老年代,回收次数少,则使用串行方式节省资源
HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样采用了复制算法,并行回收,“Stop The World"机制”;
那么是否Parallel收集器的出现是多此一举呢?
1.和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,它也被称作吞吐量优先的垃圾收集器;
2.自适应调节策略也是一个重要的区别;
高吞吐量则是可以高效地利用cpu时间,尽快的完成程序的运算任务,主要适合后台运算而不需要太多交互的任务.因此,常见在服务器环境中使用.例如,那么执行批量处理,订单处理,工资支付,科学计算的应用程序;
Parallel 收集器在JDK1.6时提供了执行老年代收集的Parallel Old 收集器,用来替代老年代的Serial Old 收集器
Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop The World"机制;
Paralel Scavenge GC/Parallel Old GC工作流程入下图所示:
注意:
1.在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错;
2.在Java8(目前开发热流版本)中,默认的垃圾收集器即是Parallel GC;
Parallel GC的相关参数配置:
!
但是,不幸的是,CMS作为老年代的收集器,却无法与新生代收集器Parallel Scavenge 配合工作,所以在JDK1.5中使用CMS来收集老年代时,年轻代只能选择ParNew或者Serial收集器中的一个.
在 G1出现之前,CMS使用非常广泛;
CMS GC执行垃圾回收的过程:
CMS的垃圾收集过程可以分为四个主要的阶段:
步骤 | 具体内容 |
---|---|
初始标记阶段 | 在此阶段,程序的所有的工作线程会因为“Stop-The-World”机制而出现短暂的暂停(此时停顿第一次),这个阶段的主要任务仅仅只是标记出GC Roots能关联到的对象。一旦标记完成,就会恢复之前被暂停的所有的应用线程。由于直接关联的对象比较地小(因为此时在老年代中进行垃圾回收,能被GC Roots标记到的对象很少),所以这里的速度会很快!! |
并发标记阶段 | 从GC Roots的直接关联对象开始遍历整个对象图,这个过程耗时比较长,但是不需要停顿用户线程,可以与垃圾收集收集线程一起并发运行 |
重新标记阶段 | 由于在并发标记阶段中,程序的工作线程一一直在运行过程中 ,可能会由于和垃圾的收集线程交叉运行导致之前的一些标记会发生一些变动,故进行标记的修正,这个过程比初始标记略长(STW,此时停顿第二次),但比并发标记的时间要短很多 |
并发清除阶段 | 此阶段清理删除掉标记阶段已被判断为已经死亡的对象,并释放内存空间,由于不需要移动内存对象的位置,则此阶段也是可以和用户线程同时并发执行的 |
CMS GC的特点:
优点:
1.并发收集垃圾
2.低延迟性
缺点:
1.因为采用的是标记-清除算法,所以会产生内存的碎片;
2.CMS收集器对cpu资源非常地敏感;在并发阶段,它虽然不会导致用户的停顿,但是会占用一部分的线程而导致应用程序变慢,总的吞吐量会降低
3.CMS无法清除浮动垃圾;可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发阶段如何产生了一些新的垃圾对象,此时CMS将无法对这些垃圾进行标记,这些垃圾对象也被称为浮动垃圾,最终导致这些新产生的垃圾对象没有被及时的回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
G1收集器(Garbage First): JDK7中引入的垃圾回收器,是目前收集器技术发展的最前沿成果之一;
取名为G1收集器的原因:G1收集器将堆内存分割成很多不相关的区域(region),其物理上是不连续的,使用不同的region来表示Eden区,S0区,S1区老年代等等;G1收集器会跟踪region里面垃圾堆积的价值大小(根据回收所获得的空间大小以及回收所需的时间的经验值等),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。故由于这种方法侧重于回收垃圾的最大量的区间,所以我们取名为G1 GC。
优点:
1.并发和并行性:并行性表现在G1在回收期间,可以由多个GC线程同时工作,有效的利用多核计算能力,此时用户线程STW;并发性表现在G1拥有和应用程序交替执行的能力,部分工作能够和应用程序同时执行。
2.分代收集:它会区分老年代和新生代,年轻代依旧还是Eden区和Survivor区,但是它并不要整个Eden区,年轻代或者老年代都是连续的,也不坚持固定大小和固定的数量。将堆空间分为了若干个区域,这些区域中包含了逻辑上的年轻代和老年代。但是有一点,它和其他的垃圾收集器都不相同,即它同时可以兼顾年轻代和老年代的垃圾回收。
3.空间的整合:G1将内存划分为一个个的region,内存的回收都是以region为单位进行的,region之间使用的是复制算法,但是整体上使用的是标记-压缩算法,两种算法都是可以避免内存碎片的产生。这种特性有利于程序的长时间的运行,分为大对象时不会因为无法找到连续内存空间而提前触发下一次Full GC。尤其是当java的堆内存非常大的时候,G1的优势会非常地明显。
4.可预测的停顿时间模型:即软实时,G1除了追求低停顿以外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段中,消耗在垃圾回收的时间不超过N毫秒。
缺点:
1.垃圾收集时产生的内存占用相较于CMS而言,是要高于它的;
2.程序在运行时额外执行负载(OverLoad)也要高于CMS产生的OverLoad
G1 GC的分区(Region)思想:
其中:多出了一个H区(Humongous区):
对于堆中的大对象,默认直接会被分配到老年代中,但是如果让是一个短期存在的大对象,就会对垃圾收集器造成负面影响.为了解决这个问题,G1划分了一个Humongous区,它专门用来存放大对象,如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储.为了能够找到连续的H区,有的时候不得不启动Full GC,G1的大多数行为都是把H区作为老年代的一部分来看待的.
G1收集的三个环节:
1.年轻代GC(YoungGC/MinorGC): jvm启动的时候,G1会先准备好Eden区,程序在运行过程中,会不断地产生对象到Eden区中,当Eden区的空间耗尽的时候 ,G1会启动一次年轻代垃圾回收过程(YoungGC/MinorGC):具体回收过程如下所示:
第一步 | G1会停止应用程序的执行(Stop The World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区的所有的内存分段 |
---|---|
第二步 | 扫描根:根指的就是GC Roots,根和Rset记录的外部引用作为扫描存活对象的入口 |
第三步 | 更新Rset:处理dirty card queue中的card,更新Rset,此阶段结束时,Rset可以准确的反应老年代对所在的内存分段中对象的引用 |
第四步 | 处理Rset:识别被老年代指向的Eden区的对象,这些被指向的Eden中的对象被认为是存活的对象 |
第五步 | 复制对象:遍历对象树,Eden区中内存段中存活的对象会被复制到Survivor区的空白的内存分段(底层即是复制算法),Survivor区中存活的对象如果年龄未达阈值,则年龄加1,达到阈值则被复制到Old区的空的内存分段,如果Survivor区中内存空间不足。则可以将Eden区中的部分数据直接晋升老年代空间 |
第六步 | 处理引用:处理soft,Weak,Phantom,final等引用,最终使得Eden区变空。GC停止工作,因为目标内存中在复制过程中是连续存储的,没有碎片产生,所以复制过程中,可以达到内存整理的效果 |
注意:上面提到Rset:Remembered Set解释一下其来源和含义
Remembered Set: 因为我们说堆空间被分割成了若干个region,但是每一个region并不是相互独立的,很多时候,一个region里的对象有可能被其他的region里的对象所引用,则我们在扫描过程中寻找存活的对象和已经死亡的对象时,是否需要扫描整个java堆呢??为了避免全局扫描整个堆区耗费大量的时间,此时引进Rset:即给每个Region分配对应的一个Rset,每次Reference类型数据写操作时,都会产生一个Write Barrier暂时的中断操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region,如果不同,则将相关的引用信息记录到引用指向的对象的Region对应的Rset中,当进行垃圾收集的时候,在GC根节点的枚举范围加入Rset,就可以保证不进行局部扫描,也不会存在遗漏。
2.并发标记过程:
步骤 | 具体内容 |
---|---|
第一步 | 初始化标记扫描:标记从根节点可以直接可达的对象(STW) |
第二步 | 根区域扫描:G1GC扫描Survivor区可以直接可达的老年代的区域对象,并标记被引用的对象,这一过程必须在YoungGC之前完成 |
第三步 | 并发标记:在整个堆中进行并发标记(和应用程序并发执行),若发现区域对象中的所有对象都是垃圾,则这个区域会被立即回收,同时,标记过程中,会计算每个区域中的对象活性(即区域中存活对象的比例) |
第四步 | 再次标记:由于应用程序持续进行,需要进一步修正上一次的标记结果(STW),此阶段中G1采用比CMS更快的初始快照算法 |
第五步 | 独占清理:计算各个区域中存活的对象的存活情况和GC回收比例,并进行排序,识别可以混合回收的区域(STW) |
第六步 | 并发清理阶段:识别并清理完全空闲的区域 |
3.混合回收
当越来越多的对象晋升成老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法除了回收真个Young区,还会回收一部分的Old区。其中,混合回收的回收集包括1/8的老年代内存片段,Eden区片段,Survivor区内存片段。混合回收的算法和年轻代回收的算法完全一致,只是回收集多了老年代的内存片段。
4.Full GC
G1的初衷就是避免Full GC的出现,但是如果上述方式不能正常工作,则G1会停止应用程序的执行(Stop The World),使用单线程的内存回收算法进行垃圾回收,性能会比较差,应用停顿时间比较长;
对比7中经典的垃圾回收器,如下图所示:
令人震惊的ZGC!!!未来指日可待!!!
回答:jvm内存分区分为五个部分:堆区,java虚拟机栈区,本地方法栈,方法区以及程序计数器;
内存结构图如下所示:
其中:
程序计数器: 用来存储指向下一条指令的地址,也就是即将执行的指令代码的,并由执行引擎来读取下一条指令
虚拟机栈: 主管java程序的运行,它保存的是方法的局部变量,部分结果,并参与方法的调用和返回。可以这样说,栈解决的是程序的运行问题,即程序如何运行,或者说如何处理数据。
本地方法栈: 用来管理本地方法的调用,当某一个线程调用一个本地方法时(别的语言实现的),会进入本地方法栈中处理本地方法的数据,进行本地方法的调用和返回。每个线程创建时,就会创建一个java虚拟机栈,其内部由一个个的栈帧组成,对应着一次次方法的调用。
堆空间: 堆解决的是数据存储的问题,即数据放在哪里,怎么存放。java堆区在jvm启动时即被创建,是java内存管理的核心区域,几乎所有的对象实例以及数组都存放在堆空间中。
方法区: 方法区用来存储已经被虚拟机加载的类型信息,常量,静态变量,及时编译器(JIT)编译后的代码缓存,域信息,方法信息等(针对JDK1.8及以后版本来说)
回答:经研究表明,不同对象的生命周期是不相同的,但是70%~99%的对象属于临时对象,将堆区分为新生代和老年代的目的在于优化GC(垃圾回收)的性能,如果不进行分代操作,那么进行GC是会对堆区的所有区域进扫描,而很多对象其实都是“朝生夕死”的,当每次GC都对所有堆区的对象进行扫描回收,将提高GC的时间和降低其效率。但是我们如果分为新生代和老年代,其中新生代存放生命周期较短的对象,即“朝生夕死”或者刚新创建的一些对象,老年代存放生命周期比较长的对象,这样一来进行GC的时候,会先把生命周期较短的临时变量进行回收,不用扫描整个堆区,而是有针对性的进行扫描回收,这样可以节省很多时间和提高回收的效率,大大地提高了jvm的性能。
回答:
第一种情况:当对象进入Eden区后经过MinorGC后仍然存活,并且被Survivor区容纳,在接下来的MinorGC中每次都能存活,age依次累加到老年代的阈值(默认为15)后,将其晋升至老年代。
第二种情况:当对象准备从Eden区进入Survivor区中时,发现Survivor无法存放该对象,则进行晋升老年代区
第三种情况:大对象直接分配进入老年代。
第四种情况:动态对象年龄判断:如果Survivor区中的相同年龄的对象的所有对象大于了Survivor空间的一半,年龄大于或者等该对象年龄的对象可以直接进入老年代,无需等到阈值后进入。
JDK8由于Oracle收购了JRockit以后,将HotSpot和JRockit进行了整合,移除了永久代(Permant Generation),引进了元空间(MetaSpace),以下是jdk7和jdk8的jvm内存结构图:
而对于jvm,主要的变动就在于方法区内存的改变,此处列出JDK6及之前版本,JDK7和JDK8三种不同版本的改变:
图示如下所示:
JDK6及之前的版本的方法区:
JDK7版本的方法区:
JDK8的方法区:
回答:永久代、元空间是会发生垃圾回收的!首先我们说一下在方法区中,java虚拟机规范中对方法区的约束是比较宽松的,提到可以不要求虚拟机在方法区进行垃圾回收。但是这部分区域的回收是很有必要的,其中方法区的垃圾回收主要包括两个方面:常量池中的废弃的常量和再使用的类型。
常量池中主要存放的是:字面量和符号的引用。其中字面量比较接近java语言层次的常量的概念,例如:文本字符串,被声明final的常量值等等,而符号的引用主要包括以下三类常量:
1.类和接口的全限定名
2.字段的名称和描述符
3.方法的名称和描述符
HotSpot虚拟机对于常量池中回收策略很明确:只要常量池中的常量没有被任何地方引用,就可以被回收!!
但是判断一个类型是否属于“不再被使用的类”的条件十分的苛刻!!
首先需要满足一下三个条件
1.该类的所有的实例都已经被回收,也就是java堆中不存在该类以及其任何派生子类的实例;
2.加载该类到的类加载器已经被回收
3.该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法;
但是,java虚拟机被允许的对满足上面满足上述三中情况的无用类进行回收,仅仅是允许而已。
回答:由于永久代存在着很多的弊端:
1.为永久代设置空间大小是很困难的;某些场景下,如果动态加载的类过多,容易产生Perm区的OOM。而元空间和永久代最大的区别在于:元空间并不在虚拟机中,而是使用的本地的内存,因此,默认情况下,元空间的大小仅仅受到本地内存的限制。
2.对永久代进行性能的调优是很困难的;如果采用永久代,则内存不足并不可少会触发Full GC,但这样会造成应用程序线程的停顿时间过长,对于性能是有很大的弊端的,而且对于Full GC,我们并不能进行多大程度的性能调优,而采用元空间,直接使用的就是本地的内存,则很大程度上的避免Full GC的发生。
回答:
什么内存需要进行GC?
在一个程序运行过程中,如果一个对象,没有任何指针指向它,则说明该对象成为一种垃圾,该对象所占的内存即为需要进行GC的内存。
什么时候进行GC?什么时候会触发垃圾回收?
一般来说,垃圾回收是针对堆区进行的,而堆区主要是针对新生代和老年代的垃圾的回收,当新生代的内存被填满时,会触发新生代(年轻代)垃圾回收器进行垃圾回收,而当新生代中持续存在的对象到达阈值年龄以后或者大对象新生代无法直接存下的时候,会直接存放到老年代中,而老年代的内存空间被填满后,会触发老年代垃圾回收器进行垃圾的回收。如果老年代回收器进行垃圾回收以后,内存依旧不足,则此时会触发FullGC,如果进行FullGC以后还是内存不足,此时就会报OutOfMemoryError(OOM)-----内存溢出异常。
如何进行GC?
回答:
jvm的GC的算法分为两个阶段:垃圾标记阶段和垃圾清除阶段
垃圾标记阶段的算法:主要有引用计数算法和可达性分析算法(或者称为根搜索算法,追踪性垃圾收集算法);
垃圾清除阶段的算法:主要有标记-清除算法,复制算法,标记-压缩(整理)算法;
其中目前jdk8中主要标记阶段使用的是可达性分析算法,而清除阶段,因为jdk8默认的垃圾回收器是ParalleGC,而其内部采用的是复制算法(进行新生代的垃圾回收),与其组合搭配使用的Serial Old GC和Parallel Old GC 采用标记-压缩算法。
回答:
GC:Garbage Collection(垃圾回收)
在我们的程序运行过程中,会产生许多的垃圾对象(即在一个程序运行过程中,如果一个对象,没有任何指针指向它,则说明该对象成为一种垃圾),如果不及时进对内存中的垃圾进行清理,那么垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的内存空间就无法被其他的对象所使用,甚至会导致内存的泄露。
回答:
对象是否死亡的2种判定方法:引用计数和可达性分析(又称引用链),即所谓的两种垃圾的标记算法
1.引用计数
每个对象被创建的时候,jvm会为每一个对象的对象头保存一个引用计数引用,来记录该对象被引用的情况:当该对象被任意对象引用时,计数器+1;引用失效,计数器 -1;GC时会回收计数器为0的对象。注意:该方式无法解决对象互相循环引用的情况,java垃圾回收器没有采用判定方式
2.引用链(可达性分析)
程序把所有的引用看作图(类似树结构的图),选定一个对象作为GC Root根节点,从该节点开始寻找对应的引用节点并标记,找到这个节点之后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点认为是不可达的无用节点,会被回收。
该方式可以解决循环引用的问题;
GC Roots:指的是一组必须活跃的引用
通常情况下,包括以下几种:
1.虚拟机栈中被引用的对象;比如各个线程中使用到的参数,局部变量等等
2.本地方法栈中的JNI引用的对象
3.方法区中类静态属性引用的对象
4.方法区中字符串常量池中的对象
5.所有被同步锁synchronized持有的对象
6.java虚拟机的内部引用
总之,是处于堆外的活跃的对象引用
但是,注意,有的时候,当用户选择的 垃圾回收以及垃圾回收的内存区域不同的时候,我们还可以选择其他的对象作为“临时性GC Roots”加入,其中,我们说对新生代进行垃圾回收时,此时的老年代活跃的对象可以作为GC Roots集合的一份子。
回答:
CMS收集器(Concurrent-Mark-Sweep) :是jdk1.5的时候提出的,它是HotSpot虚拟机中第一款真正意义上的并发垃圾收集器,它第一次实现了让垃圾收集线程和用户线程同时运行。CMS是一款老年代的收集器,与其新生代搭配使用的垃圾收集器是ParNewGC和Serial GC的一个。当CMS运行期间发生预留的内存无法满足程序的需要,此时会出现“Concurrent Mode Failure”失败,此时,虚拟机会启动后备方案,即启用serial Old GC重新进行老年代的垃圾回收。
版本变动:其中在JDK8版本中将CMS-Serial GC的组合声明为弃用状态,在JDK9中移除。在JDK14中,删除了CMS垃圾回收器。采用的是标记-清除算法。
CMS的垃圾收集过程可以分为四个主要的阶段:
步骤 | 具体内容 |
---|---|
初始标记阶段 | 在此阶段,程序的所有的工作线程会因为“Stop-The-World”机制而出现短暂的暂停(此时停顿第一次),这个阶段的主要任务仅仅只是标记出GC Roots能关联到的对象。一旦标记完成,就会恢复之前被暂停的所有的应用线程。由于直接关联的对象比较地小(因为此时在老年代中进行垃圾回收,能被GC Roots标记到的对象很少),所以这里的速度会很快!! |
并发标记阶段 | 从GC Roots的直接关联对象开始遍历整个对象图,这个过程耗时比较长,但是不需要停顿用户线程,可以与垃圾收集收集线程一起并发运行 |
重新标记阶段 | 由于在并发标记阶段中,程序的工作线程一一直在运行过程中 ,可能会由于和垃圾的收集线程交叉运行导致之前的一些标记会发生一些变动,故进行标记的修正,这个过程比初始标记略长(STW,此时停顿第二次),但比并发标记的时间要短很多 |
并发清除阶段 | 此阶段清理删除掉标记阶段已被判断为已经死亡的对象,并释放内存空间,由于不需要移动内存对象的位置,则此阶段也是可以和用户线程同时并发执行的 |
CMS GC的特点:
优点:
1.并发收集垃圾
2.低延迟性
缺点:
1.因为采用的是标记-清除算法,所以会产生内存的碎片;
2.CMS收集器对cpu资源非常地敏感;在并发阶段,它虽然不会导致用户的停顿,但是会占用一部分的线程而导致应用程序变慢,总的吞吐量会降低
3.CMS无法清除浮动垃圾;可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发阶段如何产生了一些新的垃圾对象,此时CMS将无法对这些垃圾进行标记,这些垃圾对象也被称为浮动垃圾,最终导致这些新产生的垃圾对象没有被及时的回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
G1收集器(Garbage First): JDK7中引入的垃圾回收器,是目前收集器技术发展的最前沿成果之一;
取名为G1收集器的原因:G1收集器将堆内存分割成很多不相关的区域(region),其物理上是不连续的,使用不同的region来表示Eden区,S0区,S1区老年代等等;G1收集器会跟踪region里面垃圾堆积的价值大小(根据回收所获得的空间大小以及回收所需的时间的经验值等),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。故由于这种方法侧重于回收垃圾的最大量的区间,所以我们取名为G1 GC。
优点:
1.并发和并行性:并行性表现在G1在回收期间,可以由多个GC线程同时工作,有效的利用多核计算能力,此时用户线程STW;并发性表现在G1拥有和应用程序交替执行的能力,部分工作能够和应用程序同时执行。
2.分代收集:它会区分老年代和新生代,年轻代依旧还是Eden区和Survivor区,但是它并不要整个Eden区,年轻代或者老年代都是连续的,也不坚持固定大小和固定的数量。将堆空间分为了若干个区域,这些区域中包含了逻辑上的年轻代和老年代。但是有一点,它和其他的垃圾收集器都不相同,即它同时可以兼顾年轻代和老年代的垃圾回收。
3.空间的整合:G1将内存划分为一个个的region,内存的回收都是以region为单位进行的,region之间使用的是复制算法,但是整体上使用的是标记-压缩算法,两种算法都是可以避免内存碎片的产生。这种特性有利于程序的长时间的运行,分为大对象时不会因为无法找到连续内存空间而提前触发下一次Full GC。尤其是当java的堆内存非常大的时候,G1的优势会非常地明显。
4.可预测的停顿时间模型:即软实时,G1除了追求低停顿以外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段中,消耗在垃圾回收的时间不超过N毫秒。
缺点:
1.垃圾收集时产生的内存占用相较于CMS而言,是要高于它的;
2.程序在运行时额外执行负载(OverLoad)也要高于CMS产生的OverLoad
G1收集的三个环节:
1.年轻代GC(YoungGC/MinorGC): jvm启动的时候,G1会先准备好Eden区,程序在运行过程中,会不断地产生对象到Eden区中,当Eden区的空间耗尽的时候 ,G1会启动一次年轻代垃圾回收过程(YoungGC/MinorGC):具体回收过程如下所示:
第一步 | G1会停止应用程序的执行(Stop The World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区的所有的内存分段 |
---|---|
第二步 | 扫描根:根指的就是GC Roots,根和Rset记录的外部引用作为扫描存活对象的入口 |
第三步 | 更新Rset:处理dirty card queue中的card,更新Rset,此阶段结束时,Rset可以准确的反应老年代对所在的内存分段中对象的引用 |
第四步 | 处理Rset:识别被老年代指向的Eden区的对象,这些被指向的Eden中的对象被认为是存活的对象 |
第五步 | 复制对象:遍历对象树,Eden区中内存段中存活的对象会被复制到Survivor区的空白的内存分段(底层即是复制算法),Survivor区中存活的对象如果年龄未达阈值,则年龄加1,达到阈值则被复制到Old区的空的内存分段,如果Survivor区中内存空间不足。则可以将Eden区中的部分数据直接晋升老年代空间 |
第六步 | 处理引用:处理soft,Weak,Phantom,final等引用,最终使得Eden区变空。GC停止工作,因为目标内存中在复制过程中是连续存储的,没有碎片产生,所以复制过程中,可以达到内存整理的效果 |
注意:上面提到Rset:Remembered Set解释一下其来源和含义
Remembered Set: 因为我们说堆空间被分割成了若干个region,但是每一个region并不是相互独立的,很多时候,一个region里的对象有可能被其他的region里的对象所引用,则我们在扫描过程中寻找存活的对象和已经死亡的对象时,是否需要扫描整个java堆呢??为了避免全局扫描整个堆区耗费大量的时间,此时引进Rset:即给每个Region分配对应的一个Rset,每次Reference类型数据写操作时,都会产生一个Write Barrier暂时的中断操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region,如果不同,则将相关的引用信息记录到引用指向的对象的Region对应的Rset中,当进行垃圾收集的时候,在GC根节点的枚举范围加入Rset,就可以保证不进行局部扫描,也不会存在遗漏。
2.并发标记过程:
步骤 | 具体内容 |
---|---|
第一步 | 初始化标记扫描:标记从根节点可以直接可达的对象(STW) |
第二步 | 根区域扫描:G1GC扫描Survivor区可以直接可达的老年代的区域对象,并标记被引用的对象,这一过程必须在YoungGC之前完成 |
第三步 | 并发标记:在整个堆中进行并发标记(和应用程序并发执行),若发现区域对象中的所有对象都是垃圾,则这个区域会被立即回收,同时,标记过程中,会计算每个区域中的对象活性(即区域中存活对象的比例) |
第四步 | 再次标记:由于应用程序持续进行,需要进一步修正上一次的标记结果(STW),此阶段中G1采用比CMS更快的初始快照算法 |
第五步 | 独占清理:计算各个区域中存活的对象的存活情况和GC回收比例,并进行排序,识别可以混合回收的区域(STW) |
第六步 | 并发清理阶段:识别并清理完全空闲的区域 |
3.混合回收
当越来越多的对象晋升成老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法除了回收真个Young区,还会回收一部分的Old区。其中,混合回收的回收集包括1/8的老年代内存片段,Eden区片段,Survivor区内存片段。混合回收的算法和年轻代回收的算法完全一致,只是回收集多了老年代的内存片段。
4.Full GC
G1的初衷就是避免Full GC的出现,但是如果上述方式不能正常工作,则G1会停止应用程序的执行(Stop The World),使用单线程的内存回收算法进行垃圾回收,性能会比较差,应用停顿时间比较长;
回答:目前几乎所有的GC都是采用分代回收的,即Generational Collecting算法执行垃圾回收的。
jvm中使用的HotSpot基于分代的概念,GC所使用的的内存回收算法必须要结合年轻代和老年代各自的特点:
分区 | 分区的特点 |
---|---|
年轻代/新生代 | 区域相对于老年代较小,对象的生命周期较短,存活率低,回收频繁。这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活的对象大小有关,因此很适用于年轻代的垃圾回收。而复制算法内存利用率不高的问题,可以通过HotSpot中的两个Survivor区来进行缓解 |
老年代 | 老年代的区域很大,对象生命周期很长,存活率高,回收不及年轻代频繁。这种情况下存在大量存活率高的对象,复制算法就明显变得不合适。一般情况下我们采用标记-清除或者标记-整理的算法的混合实现 |
如此一来,我们针对堆空间的分代(新生代和老年代)分别进行垃圾的回收,可以大面积提高垃圾回收的效率,减少不必要的停顿时间(Stop-The-World)。
常见的垃圾回收算法有标记-清除算法,复制算法,标记-压缩(整理)算法,下面列表比较一下这三种算法的优缺点
垃圾回收算法 | 工作流程 | 优点 | 缺点 |
---|---|---|---|
标记-清除算法(Mark-Sweep) | 当堆中的有效空间被耗尽时,会停顿整个程序(STW),进行标记(即从引用节点开始遍历,标记所有被引用的对象),然后对堆内存进行一次线性的遍历,对于对象头中没有标记可达的对象,进行回收。 | 简单直接 | 效率不高;进行GC的时候回暂停整个应用程序,用户的体验感不好;这种清理方式清理出来的内存空间不是连续的,产生内存碎片,则需要维护一个空闲列表。 |
复制算法(Copying) | 为了解决上面标记-清除算法产生的内存碎片应运而生,将活着的内存空间分为两块,每次只使用其中一块空间,在垃圾回收时,在垃圾回收时将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中所有对象,交换两个内存的角色,最后完成垃圾回收。 | 没有标记和清除过程,实现简单,运行高效;复制过去以后可以保证空间的连续性,不会出现碎片问题 | 此算法需要使用内存的两倍的空间,对内存空间的需求量比较大;对于G1这种分区的GC,复制而不是移动,意味着GC需要维护region之间的对象的引用关系,不管是内存的占用还是时间开销方面。 |
标记-压缩算法(Mark-Compact) | 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象;然后第二阶段将所有存活的对象压缩到内存的一=端,按照顺序进行排放,之后再清理边界外的所有的空间。 | 消除了标记-清除算法中内存区域分散的缺点,我们需要给新对象分配内存时,jvm只需要持有一个内存的起始地址即可;消除了复制算法当中,内存减半的高额代价。 | 效率上来说低于复制算法;移动对象时,如果对象被其他对象引用,需要调整引用的地址;移动过程中,需要暂停用户的应用程序(STW) |
选择哪种垃圾算法,还是要针对不同的应用场景来说,当我们对新生代、年轻代进行垃圾回收时,可以优先考虑复制算法,因为新生代中大部分的对象都是生命周期很短的,朝生夕死的对象,使用复制算法可以提高回收的效率,同时由于存活的需要复制的对象很少,则大大地发挥了此算法的优势。而对于老年代这种生命周期较长的区域,我们则优先考虑标记-清除和标记-压缩(整理)的结合。