JVM位置
JVM是运行在操作系统上的,与硬件没有直接的交互
整体结构
Java代码执行流程
架构模型
基于栈式架构:
设计和实现更简单,适用于资源受限的系统;
避开了寄存器的分配难题:使用零地址指令方式分配。
指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器式架构:
总结:
生命周期
虚拟机的启动
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
虚拟机的执行
一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。程序开始执行时他才运行,程序结束时他就停止。执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。
虚拟机的退出
类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识。
classLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine(执行引擎)决定。
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
过程
加载.class文件的方式
案例1
案例2
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加教器都划分为自定义类加载器。
对于用户自定义类来说:默认使用系统类加载器进行加载!
Java的核心内库都是引导类加载器进行加载的!
引导类加载器跟ClassLoader没任何关系,它是c语言编写。
引导类加载器
扩展类加载器
应用程序类加载器(系统类加载器)
为什么需要用户自定义加载器
用户自定义类加载器实现步骤
介绍
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理
沙箱安全机制
创建一个包java.lang,在里面自定义一个类String,那么这个类的全限定名就是java.lang.String,然后在测试类中new一个String,请问这个String,是自定义的还是java核心api中的?
答案:核心api中的String
原因:自定义类String,如果一运行,根据双亲委派机制,会首先由系统类加载器将其传递到扩展类加载器,然后再传递到引导类加载器,引导类加载器对java开头的都会进行加载,但是加载的还是api中的String,并不会加载自定义类的,这样可以保证对java核心源代码的保护,这就是沙箱保护机制!
例子
我们想要将SPI接口加载到内存,于是一直委派到引导类加载器进行加载,然后里面接口的实现类也需要加载,但实现类的加载被反向委派到系统类加载器加载,具体由当前的线程上下文加载器(一种系统类加载器)进行加载。
双亲委派机制的优点
几个概念
在JVM中表示两个class对象是否为同一个类存在两个必要条件:
换句话说,在JVM中,即使这两个类对象(class对象)来源同一个class文件,被同一个虚拟机所加载,但只要加载它们的classLoader实例对象不同,那么这两个类对象也是不相等的。
类的主动使用和被动使用
主动使用:
(方法区和堆是有垃圾回收的)
几个概念
概述
例子
class A {
public static void main(String[] args){
int i = 20;
int j = 20;
int k = i + j;
String s = "abc";
Sytem.out.println(i);
Sytem.out.println(k);
}
}
两个问题
使用PC寄存器存储字节码指令地址有什么用呢?
答:因为CPU需要不停地切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行,即需要明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有?
答:我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样 必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互千扰的情况。
概述
举例
public class A{
public static void main(String[] args){
A test = new A();
test.B();
}
public void B(){
int i = 10;
int j = 20;
C();
}
public void C(){
int k = 30;
int m = 40;
}
}
概念
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个stackoverflowError异常。
如果Java虚拟机栈可以勤态扩展,并且在尝试扩展的时候无法中请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
我们可以使用参数**「-Xss+具体大小」**来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
栈中存储什么?
运行原理
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”、“后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法,定义这个方法的类就是当前类 。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
介绍
(下面LocalariableTable表详解)
介绍
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
局部变量表,最基本的存储单元是slot(变量槽)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照声明顺序被复制到局部变量表中的每一个slot上
如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将套存放在index为0的slot处,其余的参数按照参数表顺序继续排列。(由于这一条的存在,因此在实际编码中,静态方法中是不可能用this的)
public static void A(){
int count = 10;
System.out.println(count);
//因为this变量不存在当前方法的局部变量表中
System.out.println(this.count) // this会爆红
}
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
类变量和局部变量的区别
public class A {
public static int a;
public static void main(String[] args) {
int b;
// System.out.println(a); // 0
System.out.println(b); // 报错
}
}
介绍
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称之为表达式栈。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值。
栈中的任何一个元素都是可以任意的Java数据类型。
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
工作流程
public class A {
public static void main(String[] args) {
byte i = 15;
int j = 8;
int k = i + j;
}
}
介绍
前面提过,基于栈式架构的虚拟机所使用的零地址指气更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
介绍
静态绑定和动态绑定
虚方法和非虚方法
invokedynamic指令的使用
JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现『动态类型语言』支持而做的一种改进。
但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
// Java中,由于直接申明了变量info为String类型,所以我们知道值qwe是String类型的,这就是静态类型语言的特征
String info = "qwe"
// JS中,var只能申明变量,但不具备申明变量类型的功能,我们只有在看到值的时候,才知道它的类型。
var info = "qwe"
var info = 10
/*
Python中,同样只能在看到值的时候,才知道它的类型
这种判断变量值的类型信息,就是动态类型语言
/*
info = 130.5
info = "name"
方法重写的本质
概念
存放调用该方法的pc寄存器的值。
一个方法的结束,有两种方式:
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
当一个方法开始执行后,只有两种方式可以退出这个方法:
介绍
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
面试题
举例栈溢出的情况?
答:OOM
调整栈大小,就能保证不出现溢出吗?
答:不能,如果在大代码量的情况下,例如递归调用,只能说推迟出现溢出的时间,但也不能绝对避免
分配的栈内存越大越好吗?
答:栈内存越大,使得OOM的概率越低,但是挤占其它栈的空间
垃圾回收是否会涉及到虚拟机栈?
答:不会
方法中定义的局部变量是否安全?
答:不一定,如果局部变量的生成和销毁都在方法内部,则是安全的,如果不是,在多线程的情况下就不一定安全。
本地方法接口的理解
为什么要使用本地方法?
与Java环境外交互:
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
与操作系统交互:
JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
sun’s Java:
sun的解释器是用c实现的,这使得它能像一些普通的c一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用c实现的,并被植入JVM内部,在windows 95的平台上,这个本地方法最终将调用win32 setPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库提供,然后被JVM调用。
现状:
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用socket通信,也可以使用web service等等,不多做介绍。
本地方法栈
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
本地方法是使c语言实现的。
它的具体做法是Native Method stack中登记native方法,在Execution Engine执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和成拟机拥有同样的权限。
不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆内存的大小是可以调节的。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区( ThreadLocal Allocation Buffer,TLAB)。
实例
查看设置的参数
举例
概念
存储在JVM中的Java对象可以被划分为两类:
Java堆区进一步细分的话,可以划分为年轻代和老年代
其中年轻代又可以划分为Eden空间、Survivor0空间和survivor1空间(有时也叫做from区和to区)
配置新生代与老年代在堆结构的占比:-XX:NewRatio(开发一般不调)。
在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1,当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。比如一XX:SurvivorRatio=8
几乎所有的Java对象都是在Eden区被new出来的。
绝大部分的Java对象的销毁都在新生代进行了,IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
可以使用选项”-xmn"设置新生代最大内存大小,这个参数一般使用默认值就可以了。
-XX:-UseAdaptiveSizePolicy:关闭自适应的内存分配策略
-Xmn:设置新生代的空间大小(如果与-XX:NewRatio=2一起使用,会优先-Xmn的值设置,但一般不使用这种方式设置)
介绍
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑Gc执行完内存回收后是否会在内存空间中产生内存碎片。
过程
总结
对象分配特殊情况
注意:在Eden区进行YGC操作的时候,Servivor区也会被动进行YGC操作
常用调优工具
介绍
JVM在进行GC时,并非每次都对上面三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收的都是指新生代。
针对HotSpotVM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集,一种是整堆收集
部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
目前,只有CMS、GC会有单独收集老年代的行为,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
目前,只有G1 GC会有这种行为
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
分代式GC触发条件
年轻代GC(Minor GC)触发机制:
老年代GC(Major GC / Full GC)触发机制:
Full GC触发机制(后面细讲):
说明:full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。
举例
问题
为什么需要把Java堆分代?不分代就不能正常工作了吗?
分配策略
针对不同年龄段的对象分配原则如下所示:
优先分配到Eden
大对象直接分配到老年代,尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代
动态对象年龄判断:如果survivor区中相同年龄的所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等至MaxTenuringThreshold中要求的年龄。
空间分配担保:-xx:HandlePromotionFailure
为什么会有TLAB
什么是TLAB
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量 ,因此我们可以将这种内存分配方式称之为快速分配策略。
据我所知所有openJDK衍生出来的JVM都提供了TLAB的设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项“-xx:UseTLAB”设置是否开启TLAB空间分比大小,默认情况下是开启的。
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
-XX:+PrintFlagsInitial :查看所有的参数的默认初始值
-XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms:初始堆空间内存(默认为物理内存的1/64)
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xmn:设置新生代的大小(初始值及最大值)
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中Eden和s0/s1空间的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails:输出详细的GC处理日志
打印gc简要信息:
-XX:HandlePromotionFailure:是否设置空间分配担保
堆是分配对象存储的唯一选择吗?
逃逸分析概述
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从渐决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
参数设置
结论
开发中能使用局部变量的,就不要使用在方法外定义
介绍
使用逃逸分析,编译器可以对代码做如下优化:
栈上分配
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:
分离对象或标量替换
标量是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
以上代码,经过标量替换后,就会变成:
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。标量替换为栈上分配提供了很好的基础。
标量替换参数设置:参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
逃逸分析不成熟的根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
方法区在哪里?
《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾回收或进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区看作是一块独立于Java堆的内存空间。
基本理解
HotSpot中方法区的演进
设置方法区大小的参数
OOM:PermGen和OOM:MetaSpace举例和解决
方法区存储什么?
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息:对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
域信息:
方法信息:JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
non-funal的类变量:
全局常量:static final: 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了,这跟static修饰的静态变量是不一样,后者是在类初始化阶段。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。
为什么需要常量池?
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍,比如以下的代码:
public class SimpleClass{
public void sayHello(){
System.out.println("hello");
}
}
虽然只有194字节,但是里面却使用了string、system、Printstream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!这里就需要常量池了!
常量池中有什么?
小结
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
public class MethodAreaDemo{
public static void main(String[] args){
int x = 500;
int y = 100;
int a = x/y;
int b = 50;
System.out.println(a + b);
}
}
首先明确:只有HotSpot才有永久代。BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《ava虚拟机规范》管束,并不要求统一。
HotSpot中方法区的变化:
JDK版本 | 变化 |
---|---|
JDK1.6及之前 | 有永久代(permanent generation),静态变量存放在永久代上 |
JDK1.7 | 有永久代,但己经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
JDK1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆 |
为什么要用元空间代替永久代?
随着Java8 的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace ) 。由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。这项改动是很有必要的,原因有:
为永久代设置空间大小是很难确定的:
在某些场景下,如果动态加载类过多,容易产生Perm区的ooM。比如某个实际web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
对永久代进行调优是很困难的
StringTable(字符串常量池)为什么要调整位置?
JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在FULL GC的时候才会触发。而FULL GC是老年代的空间不足、永久代不足时才会触发。这就导致stringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
静态变量放在哪?
public class StaticFieldTest{
private static byte[] arr = new byte[1024*1024*100]; //100MB
public static void main(String[] args){
System.out.println(StaticFieldTest.arr)
}
}
无论是JDK1.6、JDK1.7、JDK1.8,静态变量引用的对象实体都存放在堆空间!至于静态变量本身,则是依据JDK版本来定,1.6及之前,全部是放在永久代,而1.7及之后,全部是存放在堆!
介绍
有些人认为方法区〈如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java超拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型 卸载的收集器存在(如JDK 11时期的zGC收集器就不支持类卸载)。
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的Hotspot虚拟机对此区域未完全回收而导致内存泄漏。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
Hotspot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
回收废弃常量与回收Java堆中的对象非常类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+Traceclass-Loading、-XX:+TraceclassUnLoading查看类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
创建对象的方式:
判断对象对应的类是否加载、链接、初始化:
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class 文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象
为对象分配内存(首先计算对象占用空间大小[接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。):
如果内存规整:指针碰撞!
如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer )来为对象分配内存。
意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有
compact(整理)过程的收集器时,使用指针碰撞。
如果内存不规整,虚拟机需要维护一个列表,空闲列表分配!
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。
意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表(Free List ) "。
说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
处理并发安全问题:
初始化分配到的空间:所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
设置对象的对象头:将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调里举的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
Java虚拟机是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
定位,通过栈上reference访问!
对象访问的方式
概述
概述
直接内存也可能导致OutOfMemoryError异常:Direct buffer memory
由于直接内存在Java堆外,因此它的大小不会直接受限于-xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
缺点
直接内存大小可以通过MaxDirectMemorysize设置
如果不指定,默认与堆的最大值-xmx参数值一致
半编译半解释性
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤。
为什么叫半编译半解释性语言?
让执行引擎在解释执行字节码文件让cpu操作系统执行的时候,既可以使用解释器,又可以使用编译器
编译器和解释器
机器码
指令和指令集
汇编语言
高级语言
C、C++源程序执行过程
编译过程又可以分成两个阶段:编译和汇编。
字节码
工作机制
分类
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
现状
Java代码的执行分类
内置了JIT编译器,为什么还需要再使用解释器来“拖累”程序的执行性能呢?
首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。 所以: 尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
HotSpot JVM执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。 在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和IT动态编译,导致机器启动之后,当前l/2发布成功的服务器马上全部宕机, 此故障说明了JIT 的存在。—阿里团队
概念解释
Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程;
也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。
还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。
热点代码
探测方式
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter) 。
方法调用计数器:
这个计数器就用于统计方法被调用的次数,它的默认阈值在 client模式下是156次,在 Server模式下是10000 次。超过这个阈值,就会触发JIT编译。
这个阈值可以通过虚拟机参数-xx:CompileThreshold来人为设定。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
热度衰减:
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-X:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
另外,可以使用-X:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
回边计数器:
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。
设置执行方式
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
JIT分类
在HotSpot VM中内嵌有两个JIT编译器,分别为client Compiler和server Compiler,但大多数情况下我们简称为c1编译器和c2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:
C1、C2的不同优化策略
为什么要将char型数组改为byte?
为了节省空间,因为堆中大部分的String对象存储的都是一些拉丁文字符,都只占一个字符,那另外一个字符就存在浪费,因此改成了byte[]加上编码标记,节约空间。
举例
public void test(){
String s1 = "abc";
String s2 = "abc";
s2 += "def";
System.out.println(s2); // abcdef
System.out.println(s1); // abc
}
public void test2(){
String s1 = "abc";
String s2 = s1.replace('a','m');
System.out.println(s1); // abc
System.out.println(s2); // mbc
}
public static void main(String[] args){
System.out.println(); //Debug发现,这条语句之前有2293个字符串
System.out.println("1"); //Debug发现,这条语句之前有2294个字符串
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10"); //Debug发现,这条语句之前有2303个字符串
System.out.println("1"); //Debug发现,这条语句之前有2304个字符串
System.out.println("2"); //Debug发现,这条语句之前有2304个字符串
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10"); //Debug发现,这条语句之前有2304个字符串
}
结论:
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
Class Memory{
public static void main(String[] args){
int i = 1;
Object obj = new Object();
Memory mem = new Memory();
mem.foo(obj);
}
private void foo(Object param) {
String str = param.toString();
System.out.println(str);
}
}
性质
几个例子
public void test1(){
String s1 = "a" + "b"; //常量的拼接都放在了字符串常量池
String s2 = "ab"; //由于常量池中不会存在相同内容的常量,因此s2和s1均引用一个“ab” 因此 == 的结果是 true
System.out.println(s1 == s2); // true
System.out.println(s1.equals(s2)); // true
}
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop" ;
String s4 = "javaEE” + "hadoo
String s5 = s1 + "hadoop" ;
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4); // true
// 只要拼接符号的前后出现了变量,结果都是变量,相当于在堆中新new String(),具体的字符串内容为拼接的结果。
System.out.println(s3 == s5); // false
System.out.println(s3 == s6); // false
System.out.println(s3 == s7); // false
System.out.println(s5 == s6); // false
System.out.println(s5 == s7); // false
System.out.println(s6 == s7); // false
// intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;如果字符串常量池不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回此对象的地址。
String s8 = s6.intern();
System.out.println(s3 ==s8); // true
}
底层细节
public void test(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // false
}
如上的s1+s2
的执行细节:
结论
字符串拼接操作不一定使用的是StringBuilder,如果拼接富豪左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder方式。
public void test(){
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4) // true
}
针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用
拼接操作与append操作效率对比
public void method1(int highLevel){
String src = "";
for(int i =0;i<highLevel;i++){
src = src + "a"; // 每次循环都会创建一个StingBuilder、String
}
}
public void method2(int highLevel){
StringBuilder src = new StringBuilder();
for(int i = 0; i < highLevel; i ++){
src.append("a");
}
}
通过StringBuilder的append()方式添加字符串的效率远高于使用String的字符串拼接方式。
优势
:
StringBuilder的append()方式:自始至终只创建过一个StringBuilder的对象
String字符串拼接方式:创建多个StringBuilder和String对象
String字符串拼接方式:内存中由于创建了较多的StringBuilder和String对象,内存占用更大,如果进行GC,需要花费更多的时间。
改进空间
:
在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器:
StringBuilder s = new StringBuilder(highLevel); // new char[highLevel]
如果不是用双引号声明的string对象,可以使用string提供的intern()
:
intern()
会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。比如: string myInfo = new String (“T love atguigu” ).intern() ;
也就是说,如果在任意字符串上调用string.intern(),那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true: ( “a” + “b” + “c” ) .intern () == “abc”
通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池( string Intern Pool) 。
如何保证变量s指向的是字符串常量池中的数据呢?
String s = "qwe"; // 字面量定义的方式
String s = new String("qwe").intern(); // 调用intern()
String s = new String("qwe").toString().intern(); // 调用intern()
new String()创建了几个对象?
public void test(){
// 方式1
String str = new String("ab");
// 方式2
String str = new String("a")+new String("b");
}
方式1:会创建2个对象
String类实例对象
"ab"
方式2:会创建6个对象
new StringBuilder()(拼接底层创建)
new String(“a”)的实例对象
常量池中的"a"
new String(“b”)的实例对象
常量池中的"b"
在StringBuilder中的toString()里new String()
2种方式的异同
第2种方式中,最后调用了StringBuilder的toString():
下面是toString()方法的字节码文件:
在里面没有出现ldc “ab”!即在字符串常量池中不存在"ab"
面试难题
public void test(){
String s = new String("1");
s.intern(); // 调用此方法前,字符串常量池中已经存在了"1"
String s2 = "1";
System,out.println(s == s2); // jdk6、7、8:false
// s3变量记录的地址为:new String("11") 执行完这一行代码,常量池中是不存在“11”的!
String s3 = new String("1") + new String("1");
// 在字符串常量池中生成“11”:
// jdk6: 创建了一个新的对象 "11",也就有新的地址 因此jdk6的时候,结果为false
// jdk7、8: 并没有创建“11”,而是创建一个指向堆空间new String("1") + new String("1")的地 址,因此结果为true
s3.intern();
// s4变量记录的地址:使用的是上一行代码执行时,在常量池中生成的“11”的地址
String s4 = "11";
System.out.println(s3 == s4); // jdk6: false jdk7、8: true
}
public void test(){
String s3 = new String("1") + new String("1");
String s4 = "11";
String s5 = s3.intern();
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true
}
intern()的使用!
StringTable自带垃圾
背景
对许多Java应用(有大的也有小的)做的测试得出以下结界:
G1的String去重操作步骤
在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代码:
MibBridge *pBridge = new cmBaseGroupBridge ( ) ;/如果注册失败,使用Delete释放该对象所占内存区域if(pBridge->Register ( kDestroy) != NO_ERROR)
delete pBridge ;
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
在有了垃圾回收机制后,上述代码块极有可能变成这样:
MibBridge *pBridge = new cmBaseGroupBridge ( ) ;
pBridge->Register (kDestroy) ;
现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题让你头疼不已。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
oracle官网关于垃圾回收的介绍:
https : / / docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力
。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的 瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节
。
对象存活判断
需要区分出内存中哪些是存活对象,哪些是已经死亡的对象
。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
。引用计数算法
和可达性分析算法
。引用计数算法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况
。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
。
缺点:
引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Python如何解决循环引用?
基本思路
虚拟机栈中引用的对象
,比如:各个线程被调用的方法中使用到的参数、局部变量等。本地方法栈内JNI(通常说的本地方法)引用的对象
方法区中类静态属性引用的对象
,比如: Java类的引用类型静态变量方法区中常量引用的对象
,比如:字符串常量池(string Table)里的引用所有被同步锁synchronized持有的对象Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法
。
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放
。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的finalize(方法,应该交给垃圾回收机制调用理由包括下面三点:
从功能上来说,finalize ()方法与c++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于c++中的析构函数。
由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。
如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己
,如果这样那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下
可触及的:从根节点开始,可以到达这个对象。 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。 不可触及的:对象的finalize ()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
。
以上三种状态中,是由于finalize()方法的存在,进行的区分,只有在对象不可触及时才可以被回收。
具体过程
判定一个对象objA是否可回收,至少要经历2次标记过程:
finalize()方法是对象逃脱死亡的最后机会
,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。MAT
标记-清除算法
背景:
标记–清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记所有被引用的对象。一般是在对象的Header中记录为可达对象
。缺点:
背景:
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CALISP Garbage collector Algorithm Using serial Secondary storage ) ”。M.L.Minsky在该论文中描述的算法被人们称为复制(copying)算法,它也被M.L.Minsky 本人成功地引入到了Lisp语言的一个实现版本中。
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优缺点:
优点:
缺点:
特别的,如果系统中的垃圾对象很多,复制算法不会很理想,因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。 标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM 的设计者需要在此基础之上进行改进。 标记-压缩(Mark - Compact)算法由此诞生。 1970 年前后,G. L. steele ,c. J. Chene和D.s. wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
执行过程
区别
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-SweeCompact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
缺点:
介绍
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代妆集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的session对象、线程、socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:string对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational collecting)算法执行垃圾回收的。
在Hotspot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
年轻代(Yonng Gen) 年轻代特点:区城相对老年代较小,对象生命周期短、存活率低,回收频繁。 这种情况复制算法的回收整理,速度是最快杓。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。市复制算法内存利用率不高杓问题,通过hotspot中的两个survivor的设计得到缓解。
老年代(Tenured Gen) 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。 Mark阶段的开销与存活对象的数量成正比。 Sweep阶段的开销与所管理区域的大小成正相关。
compact阶段的开销与存活对象的数据成正比。
增量收集算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种 stop the world 的状态。在stop the world状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental collecting)算法的诞生。
基本思想: 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
结论
注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
在默认情况下,通过system.gc ()或者Runtime. getRuntime ( ).gc ( )的调用,会显式触发Full GC
,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
然而system.gc ( )调用附带一个免责声明,无法保证对垃圾收集器的调用。
JVM实现者可以通过system.gc ()调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了
。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用system.gc ( ) 。
public class A{
public static void main(String[] args){
new A();
System.gc(); // 与Runtime.getRuntime().gc()效果一致,但运行程序后发现未输出下面方法语句,说明此行代码只是提醒JVM的垃圾回收器执行GC,但是不确定是否马上执行GC
System.runFinalization(); // 强制调用使用引用的对象的finalize()方法
}
@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("SystemGCTest 重写了 finalize()")
}
}
public class A(){
public void test1(){
byte[] buffer = new byte[10 * 1024 * 1024]; // 10M
System.gc() // 不会回收buffer
}
public void test2(){
byte[] buffer = new byte[10 * 1024 * 1024]; // 10M
buffer = null;
System.gc() // 回收buffer
}
public void test3(){
{
byte[] buffer = new byte[10 * 1024 * 1024]; // 10M
}
System.gc() // 不会回收buffer,仍占据局部变量表下标为2的位置,因此回收不了
}
public void test4(){
{
byte[] buffer = new byte[10 * 1024 * 1024]; // 10M
}
int value = 10;
System.gc() // 回收buffer,在出了{}的局部作用域后,buffer仍占据局部变量表下标为2的位置,但此时value会替换buffer,因此buffer可被回收。
}
public void test5(){
test1();
System.gc(); // 回收buffer
}
}
内存溢出(OOM)
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现ooM的情况。
大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用。
javadoc中对outofMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:
Java虚拟机的堆内存设置不够
。 比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定VM堆大小或者指定数值偏小。我们可以通过参数-Xms,-Xmx来调整。
代码中创建了大量大对象,并且长时间不能被垃圾回收器收集(存在被引用)
对于老版本的oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现outofMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:“java.lang.outofMemoryError: PermGen space"
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的ooM有所改观,出现OOM,异常信息则变成了:“java.lang.OutOfMemoryError:Metaspace”。直接内存不足,也会导致OOM。
这里面隐含着一层意思是,在抛出outOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等;
在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError。.
内存泄漏
只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏
。单例模式
: 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。一些提供close的资源未关闭导致内存泄漏
: 数据库连接( dataSourse.getConnection( )),网络连接(socket)和io连接必须手动close,否则是不能被回收的。停顿产生时整个应用程序线程都会被暂停,没有任何响应
,有点像卡死的感觉,这个停顿称为STW。并发
并行
当系统有一个以上CPU
时,当一个cPu执行一个进程时,另一个CPU可以执行另一个进程两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
其实决定并行的因素不是CPU的数量,而是CPU的核心数量
,配如一个CPU多个核也可以并行。
适合科学计算,后台处理等弱交互场景
二者对比:
垃圾回收的并发与并行
并发和并行,在谈论垃圾回收器的上下文语境中,他们可以解释如下:
并行:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如ParNew、Parallel scavenge、Parallel old
串行:
并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的执行。
安全点
安全区域
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始Gc都是安全的
。我们也可以把 safe Region看做是被扩展了的Safepoint。再谈引用
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
既偏门又非常高频的面试题:强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱
。
除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用:
强引用(StrongReference)
:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“object obj=new object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。软引用(SoftReference)
:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常弱引用(weakReference)
:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。虚引用(PhantomReference)
:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。强引用
软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue) 。
类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
软引用特点:
在JDK 1.2版之后提供了java.lang.ref.SoftReference类来实现软引用
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 销毁强引用
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用
在DK 1.2版之后提供了java.lang.ref.weakReference类来实现弱引用。
object obj = new object(); //声明强引用
weakReferencecobject> wr = new weakReference<0bject>(obj);
obj = null; //销毁强引用
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收
。弱引用对象更容易、更快被GC回收。
也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是nu1l。
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过柱。比如:能在这个对象被收集器回收时收到一个系统通知。
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。 在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
在jdk1.2版之后提供了PhantomReference类来实现虚引用
object obj = new object();
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<object> pf = new PhantomReference<object>(obj,phantomQueue);
obj = null;
垃圾回收器概述
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的Gc版本。
从不同角度分析垃圾收集器,可以将GC分为不同的类型。
按线程数
分,可以分为串行垃圾回收器和并行垃圾回收器。
串行回收指的是在同一时间段内只允许有一个cPuU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。在诸如单cPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的client模式下的JVM中;在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
和串行回收相反,并行收集可以运用多个cPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“stop-the-world”机制。
按照工作模式
分,可以分为并发式垃圾回收器和独占式垃圾回收器。
并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
按碎片处理方式
分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。再分配对象空间使用:指针碰撞
非压缩式的垃圾回收器不进行这步操作。在分配对象空间使用:空闲列表
按工作的内存区间
分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
评估GC的性能指标
吞吐量
:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)暂停时间
:执行垃圾收集时,程序的工作线程被暂停的时间。内存占用
:Java堆区所占的内存大小。吞吐量
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞 吐量就是99%。
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 +0.2 = 0.4
暂停时间
吞吐量 VS 暂停时间
高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
低暂停时间(低延迟)较好因为从最终用户的角度来看不管是Gc还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。
不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率
,但是这样会导致GC需要更长的暂停时间来执行内存回收。相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收
,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
在设计(或使用)Gc算法时,我们必须确定我们的目标:一个Gc算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
现在标准:在最大吞吐量优先的情况下,降低停顿时间
。
垃圾回收器发展史
有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage collection,对应的产品我们称为Garbage Collector.
七款经典垃圾收集器
串行回收器:Serial、Serial old
并行回收器:ParNew、Parallel scavenge、Parallel old
并发回收器:CMS、G1
垃圾回收器的组合关系
我们选择的只是对具体应用最合适的收集器
。如何查看默认的垃圾回收器
Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。
serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器。
serial 收集器采用复制算法、串行回收和"stop-the-world"机制的方式执行内存回收
。
除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的serial old收集器。Serial old收集器同样也采用了串行回收和"stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。 Serial old是运行在client模式下默认的老年代的垃圾回收器 Serial old在server模式下主要有两个用途:
这个收集器是一个单线程的收集器,但它的“单线程”的意义并个仅仪说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The world)。
优势:简单而高效
(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。 运行在Client模式下的虚拟机是个不错的选择。
在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生,使用串行回收器是可以接受的。
在HotSpot虚拟机中,使用-XX:+UseSerialGC
参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用Serial Gc,且老年代用Serial Old GC
总结
如果说serial cc是年轻代中的单线程垃圾收集器,那么ParNew收集器则是serial收集器的多线程版本。
Par是Parallel的缩写,New:只能处理的是新生代
ParNew收集器除了采用并行回收
的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法
、"Stop-the-world"
机制。
ParNew是很多JVM运行在server模式下新生代的默认垃圾收集器。
对于新生代,回收次数频繁,使用并行方式高效。 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?
因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作
在程序中,开发人员可以通过选项"-XX:+UseParNewGc"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
-XX: ParallelGCThreads
:限制线程数量,默认开启和cPü数据相同的线程数。
复制算法、并行回收和"Stopthe world"机制
。可控制的吞吐量
,它也被称为吞吐量优先的垃圾收集器。适合在后台运算而不需要太多交互的任务
。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序
。标记-压缩算法,但同样也是基于并行回收和 "Stop-the-World" 机制
。在JDK 1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中弟一款真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程同时工作
。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短
,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS的垃圾收集算法采用标记-清除
算法,并且也会"stop-the-world"
不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel scavenge 配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者serial收集器中的一个。
在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。
工作原理
CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象
。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
。从GC Roots的直接关联对象开始遍历整个对象图的过程
,这个过程耗时较长但是不需要停顿用户线程
,可以与垃圾收集线程一起并发运行。因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。清理删除掉标记阶段判断的已经死亡的对象,释放内存空间
。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的特点与弊端分析
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中
仍然需要执行“Stop-the-World”机制暂停程序中的工作线样,个过省停的间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-the-wor1d”,只是尽可能地缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的
。
另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用
。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收
,以确保应用程序在CMS工作过程中依然有足够的空间文持产用程序运仃。安定CNS行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用serial old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
优缺点:
优点:并发收集、低延迟
缺点:
会产生内存碎片
,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。CMS收集器对CPU资源非常敏感
。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。CMS收集器无法处理浮动垃圾
。可能出现“concurrent Mode Failure"失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对家,CMS将无宏对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
答案其实很简答,因为当并发清除的时候,用compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“stop the world”这种场景下使用
CMS垃圾收集器可以设置的参数
-XX: +UseConcMarkSweepcc
手动指定使用cMS收集器执行内存回收任务。开启该参数后会自动将-XX:+UseParNewGc打开。即: ParNew ( Young区用)+CMS(old区用)+serial old的组合。-XX:CMSInitiatingOccupanyFraction
设置堆内存使用率的阙值,一旦达到该阈值,便开始进行回收。
因此通过该选项便可以有效降低Full GC的执行次数
。-XX:+UseCMSCompactAtFullCollection
用于指定在执行完FullGC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX:CMSFullGCsBeforeCompaction
设置在执行多少次Full cC后对内存空间进行压缩整理。-XX: ParallelCMSThreads
设置CMS的线程数量。CMS 默认启动的线程数是(ParallelGCThreads+3)/4, ParallelGCThreads 是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。小结
背景
既然我们已经有了前面几个强大的GC,为什么还要发布Garbage rirst (G1) GC?
原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多
,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1 (Garbage-First)垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量
,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望
。
为什么名字叫做Garbage First(G1)呢
G1回收器
主要针对配备多核CPU及大容量内存的机器
,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。G1回收器特点
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
并行与并发
分代收集
G1依然属于分代型垃圾回收器
,它会区分年轻代和老年代,年轻代依然有Eden区和survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
。兼顾年轻代和老年代
。对比其他回收器,或者工作在年轻代,或者工作在老年代;空间整合
Region之间是复制算法
,但整体上实际可看作是标记-压缩(Mark-Compact)算法
,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当Java堆非常大的时候,G1的优势更加明显。可预测的停顿时间模型
(即:软实时 soft real-time ):这是G1 相对于CMS 的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
每次根据允许的收集时间,优先回收价值最大的Region
。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率
。G1的缺点
G1的参数设置
-XX: +Usec1cc
手动指定使用G1收集器执行内存回收任务。-XX: G1HeapRegionsize
设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms-XX: Paralle1GCThread
设置STW工作线程数的值。最多设置为8-XX:ConcGcThreads
设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGcThreads)的1/4左右。-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。G1回收器的常见操作步骤
G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
第一步:开启G1垃圾收集器
第二步:设置堆的最大内存
第三步:设置最大的停顿时间
G1中提供了三种垃圾回收模式: YoungGC、Mixed GC和Full Gc,在不同的条件下被触发。
G1回收器的适用场景
Region的使用
-XX:G1HeapRegionsize
设定。所有的Region大小相同,且在JVM生命周期内不会被改变
。G1垃圾回收器的主要回收环节
G1 Gc的垃圾回收过程主要包括如下三个环节:
回收过程
Remember Set
一个对象被不同区域引用的问题 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确? 在其他的分代收集器,也存在这样的问题(而G1更突出)回收新生代也不得不同时扫描老年代? 这样的话会降低Minor GC的效率;
解决方式: 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:每个Region都有一个对应的Remembered set;每次Reference类型数据写操作时,都会产生一个write Barrier暂时中断操作;然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过cardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。
垃圾回收过程1:年轻代GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
年轻代垃圾回收只会回收Eden区和survivor区
。
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停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。垃圾回收过程2:并发标记过程
初始标记阶段
:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。根区域扫描
: G1 GC扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。并发标记
:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。再次标记
:由于应用程序持续进行,需要修正上一次的标记结果是STW的。G1中采用了比cMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。独占清理
:计算各个区域的存活对象和Gc回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集并发清理阶段
:识别并清理完全空闲的区域。垃圾回收过程3:混合回收
当越来越多的对象晋升到老年代oldregion时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed Gc,该算法并不是一个o1dGc,除了回收整个Young Region,还会回收一部分的old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些o1dRegion进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed Gc并不是Fu1l GC。
垃圾回收可选过程4:Full GC
G1垃圾回收的优化建议
从oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
不同厂商、不同版本的虚拟机实现差别很大。HotSpot虚拟机在DK7/8后所有收集器及组合(连线),如下图:(更新到了JDK14)
如何选择垃圾回收器?
现在互联网的项目,基本都是使用G1
。内存分配与垃圾回收的参数列表
-XX:+PrintcC
输出GC日志。类似: -verbose:gc-XX:+PrintcCDetails
输出GC的详细日志-XX:+PrintGCrimeStamps
输出GC的时间戳(以基准时间的形式)-XX:+PrintGCDateStamps
输出GC时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)-XX:+PrintHeapAtGC
在进行GC的前后打印出堆的信息-Xloggc: ../ logs/gc.log
日志文件的输出路径例子
打开GC日志:
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDateStamps
信息如下:
2019-09-24T22∶15:24.518+0800:3.287:[GC (Allocation railure)[PSYoungGen: 136162K->5113K (136192K)] 141425K->17632K(222208K),0.0248249 secs] [Times:
user=0.o5
sys=o.00,real=0.03 secs]
2019-09-24T22:15:25.559+0800: 4.329:[GC (Metadata Gc Threshold)[PsYoungGen:97578K->10068K(274944K)] 110096K->22658K(360960K),0.0094071 secs]
[Times: user=o.oo
sys=0.00, real=0.01 secs]
2019-09-24T22:15:25.569+0800:4.338: [Full GC(Metadata cc Threshold)[PSYoungGen:10068K->OK(274944K)][ParOldGen: 12590K->13564K(56320K)] 22658K->13564K(331264K),[Metaspace: 20590K->20590K(1067008K)],0.0494875 secs] [Times: user=0.17 sys=0.02,real=0.05 secs]
打开GC日志
-verbose:gc
这个总会显示总的GC堆的变化,如下:
[GC (Allocation Failure)80832K->19298K(227840K), 0.0084018 secs]
[GC(Metadata Gc Threshold) 109499K->21465K(228352K),0.0184066 secs]
[ Full GC (Metadata Gc Threshold)21465K->16716K(201728K),0.0619261 secs]
参数解析
日志补充说明:
垃圾回收数据分析
堆空间数据分析
日志分析工具的使用
如果想把GC日志存到文件的话,是以下面这个参数
-Xloggc: /path/to/gc.log
可以用一些工具去分析这些gc日志。常用的日志分析工具有:GCviewer、GCEalpy、GCHisto、GCLogvi er、Hpjmeter、garbagecat等。
在Serverless等新的应用场景下,Serial Gc找到了新的舞台
。JDK11新特性
JDK12新特性
现在G1回收器已成为默认回收器好几年了。我们还看到了引入了两个新的收集器:zGc(JDK11出现)和shenandoah(open JDK12))。特点:低停顿时间!
JDK12:Shenandoah GC
open JDK12的shenandoah cc:低停顿时间的GC(实验性)
Shenandoah,无疑是众多GC中最孤独的一个。是第一款是由oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排锭、比如号称openJDK和oracleJDK没有区别的oracle公司仍拒绝在oracleJDK12中支持Shenandoah。shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研宽项目PauseressGc的实现,旨在针对JVM上的内存回收实现低停顿的需求
。在2014年贡献给0penJDK。
Red Hat研发shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200 MB 还是 200 GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内
。不过实际使用性能将取凄于实际工作堆的大小和工作负载。
这是RedHat在2016年发表的论文数据,测试内容是使用ES对200GB的维基百科数据进行索引。从结果看:
总结:
ZGC
ZGC与shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟
。
《深入理解Java虚拟机》一书中这样定义zGC: zGc收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色揩针和内存多重映射等技术来实现可并发的标记-压缩算法
的,以低延迟为首要目标
的一款垃圾收集器。
ZGC的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配-并发重映射
等。
ZGC几乎在所有地方并发执行的,除了初始标记的是STw的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
其他产商的垃圾回收器