chaohan
2021-07-29 10:37:48 9 举报AI智能生成
jvm
Java虚拟机
模版推荐
作者其他创作
大纲/内容
类加载子系统
类加载器与类的加载过程
类加载子系统作用<br>
类加载器子系统负责从文件系统或者网络中加载Class文件,Class文件在文件开头有特定的文件标识<br>
ClassLoader只负责Class文件的加载,至于它是否可以运行,则由ExecutionEngine决定<br>
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,<br>可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射
类加载器ClassLoader角色
Class File 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是<br>要加载到jvm当中来根据这个文件实例化出n个一模一样的实例
Class File加载到jvm中,被称为DNA元数据模板,放在方法区
在.calss文件 -> jvm -> 最终成为元数据模板,此过程就要一个运输工具(类加载器Class Loader),<br>扮演一个快递员的角色
类的加载过程
加载
通过一个类的全限定名获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
链接
验证
目的在于确保class文件的字节流中包含信息符合当前虚拟机的要求,保证被加载类的正确性,不会危害虚拟机自身安全
主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
准备
为类变量分配内存并且设置该类变量的默认初始值,即零值
这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化
这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是随着对象一起分配到Java堆中
解析
将常量池内的符号引用转换为直接引用的过程
事实上,解析操作往往伴随着jvm在执行完初始化之后再执行
符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件<br>格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、<br>CONSTANT_Fieldref_info、CONSTANT_Methodref_info等
初始化
初始化阶段就是执行类构造器方法<clinit>()的过程,此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来<br>
构造器方法指令按语句在源文件中出现的顺序执行<br>
<clinit>()不同于类的构造器
若该类具有父类,jvm会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
类加载器的分类<br>
jvm支持两种类型的类加载器,分为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
无论类加载器的类型如何划分,在程序中最常见的类加载器始终只有三个<br>
启动类加载器 Bootstrap ClassLoader
使用c/c++语言实现,嵌套在jvm内部
用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供jvm自身需要的类
并不继承自java.lang.ClassLoader,没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
处于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器 Extension ClassLoader
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为启动类加载器
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。<br>如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载<br>
应用程序类加载器 AppClassLoader
java语言编写,由sun.misc.Launcher$AppClassLoader实现
派生于ClassLoader类
父类加载器为扩展类加载器
它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器
双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,他是一种任务委派模式
工作原理
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
如果父类加载器还存在父类加载器,则进一步向上委托,依次递归,请求最终到达顶层的启动类加载器
如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
优势
避免类的重复加载
保护程序安全,防止核心API被随意篡改
其它
jvm中表示两个class对象是否为同一个类存在两个必要条件
类的完整类名必须一致,包括包名
加载这个类的ClassLoader必须相同
jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的
Java程序对类的使用方式分为:主动使用和被动使用
主动使用
创建类的实例
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射
初始化一个类的子类
Java虚拟机启动时被标明为启动类的类
JDK7开始提供的动态语言支持:<br>java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
被动使用
除了上面的七种情况,其它使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化
运行时数据区
PC寄存器
它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域
在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的<br>Java方法的jvm指令地址;或者,如果是在执行native方法,则是未指定值(undefined)<br>
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
虚拟机栈
虚拟机栈出现的背景
内存中的栈与堆
栈中可能出现的异常
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机栈将会抛出一个OutOfMemoryError异常
栈帧的内部结构
局部变量表
参数值存放总是从局部变量数组的index0开始,到数组长度-1的索引结束
局部变量表最基本的存储单元是Slot(变量槽)。32位以内的类型只占用一个Slot(包括returnAddress),64位的类型(long和double)占用两个Slot
jvm会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
如果需要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可
如果当前帧是由构造方法或者实例方法创建的,那么<b><font color="#0076b3">该对象引用this将会存放在index为0的Slot处</font></b>,其余的参数按照参数表顺序继续排列
<font color="#0076b3"><b>栈帧中的局部变量表中的槽位是可以重用的</b></font>,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用<br>过期局部变量的槽位,从而达到节省资源的目的
操作数栈(表达式栈)
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈就是jvm执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值
栈中任何一个元素都可以是任意的Java数据类型,<font color="#0076b3"><b>32位的类型占用一个栈单位深度,64位的类型占用两个栈单位深度</b></font>
<b><font color="#0076b3">如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中</font></b>,并更新PC寄存器中下一条需要执行的字节码指令
Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
动态链接(指向运行时常量池的方法引用)
每一个栈帧内部都包含一个指向<font color="#f384ae">运行时常量池</font>中<font color="#0076b3">该栈帧所属方法的引用</font>。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
在Java源文件被编译成字节码文件中,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用了另外的其它方法时,就是通过常量池中指向方法的符号引用来表示的,那么<font color="#0076b3"><b>动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用</b></font>。
方法返回地址(方法正常退出或者异常退出的定义)
存放调用该方法的pc寄存器的值
无论方法是正常退出还是异常退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息<br>
一些附加信息
栈顶缓存技术
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派次数和内存读写次数。<br>由于操作数是存储在内存中的,因此频繁地执行内存读写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将<b><font color="#0076b3">栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率</font></b>。
本地方法栈
线程私有的,允许被实现成固定或者是可动态扩展的内存大小
具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库
当某个线程调用一个本地方法时,它就进入了一个全新的并且不受虚拟机限制的世界。它和虚拟机拥有同样的权限
可以通过本地方法接口来访问虚拟机内部的运行时数据区
可以直接使用本地处理器中的寄存器
可以直接从本地内存的堆中分配任意数量的内存
并不是所有的jvm都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果jvm产品不打算支持native方法,也可以无需实现本地方法栈。在Hotspot jvm中,直接将本地方法栈和虚拟机栈合二为一
堆
存储在jvm中的Java对象可以被划分为两类:<br> 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速(新生代)<br> 另一类对象的生命周期却非常长,在某些极端的情况下还能够与jvm的生命周期保持一致(老年代)
对象分配过程
MinorGC、MajorGC、FullGC
MinorGC / YoungGC:只是新生代的垃圾收集。Eden区满的时候会触发,Survivor区满的时候不会触发<br>
MajorGC / OldGC:只是老年代的垃圾收集。出现了MajorGC,经常会伴随至少一次的MinorGC(但并非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
MixedGC:整个新生代以及部分老年代的垃圾收集,目前只有G1垃圾收集器会有这种行为
FullGC:整个Java堆和方法区的垃圾收集。FullGC触发条件:<br> 调用System.gc()时,系统建议执行FullGC,但是不必然执行<br> 老年代空间不足<br> 方法区空间不足<br> 通过MinorGC后进入老年代的平均大小大于老年代的可用内存<br> 由Eden区、Survivor0区向Survivor1区复制时,对象大小大于Survivor1区的可用内存,则把该对象转存到老年代,其老年代的可用内存小于该对象大小
TLAB(Thread Local Allocation Buffer)
从内存模型而不是垃圾收集的角度,对Eden区继续进行划分,jvm为每个线程分配了一个私有的缓存区域,它包含在Eden空间内。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还是能提升内存分配的吞吐量,因此可以将这种内存分配方式称为快速分配策略。OpenJDK衍生出来的JVM都提供了TLAB的设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但<b><font color="#0076b3">JVM确实是将TLAB作为内存分配的首选</font></b>。可通过 -XX:+UseTLAB 设置是否开启TLAB空间。默认情况下,TLAB空间的内存非常小,仅<b><font color="#0076b3">占有整个Eden区的1%</font></b>,可以通过 -XX:TLABWasteTargetPercent 设置TLAB空间所占用Eden空间的百分比大小。
若对象在TLAB空间分配内存失败,JVM就会尝试着<b><font color="#0076b3">通过加锁机制</font></b>确保数据操作的原子性,从而直接在Eden空间中分配内存
参数设置
-XX:+PrintFlagsInitial:查看所有参数的默认初始值
-XX:+PrintFlagsFinal:查看所有参数的最终值
-Xms:初始堆空间内存(默认为物理内存的1/64)
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xmn:设置新生代的大小(初始值及最大值)
-XX:NewRatio:设置老年代与新生代的比例
-XX:SurvivorRatio:设置新生代中Eden和S0/S1的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX:PrintGCDetails:输出详细的GC日志
-XX:HandlePromotionFailure:设置空间分配担保
堆是分配对象存储的唯一选择吗?
有一种特殊情况,那就是<b><font color="#0076b3">如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配</font></b>。这样就无需在堆上分配内存,也无须进行垃圾回收,这也是最常见的堆外存储技术。
此外,基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
方法区
jdk8及以后,元空间大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定<br>在Windows下,-XX:MetaspaceSize默认值是21M,-XX:MaxMetaspaceSize默认值是-1,即没有限制<br>
方法区存储已被虚拟机加载的<b><font color="#f15a23">类型信息、运行时常量池、静态变量、域信息、方法信息、即时编译器编译后的代码缓存</font></b>等
类型信息:对每个加载的类型(类class、接口interface、枚举enum、注解annotation),jvm必须在方法区中存储以下类型信息<br> 这个类型的完整有效名称(全名=包名.类名)<br> 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)<br> 这个类型的修饰符(public,abstract、final的某个子集)<br> 这个类型直接接口的一个有序列表
域(field)信息:jvm必须在方法区中保存类型的所有域的相关信息以及域的声明顺序<br>域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法信息:jvm必须保存所有方法的以下信息,同域信息一样包括声明顺序<br> 方法名称、返回类型(或void)、参数的数量和类型(按顺序)<br> 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集<br> 方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)<br> 异常表(abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量:静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。类变量被类的所有实例共享,即使没有类实例也可以访问它。<br>全局常量:static final:被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配
方法区内部包含了运行时常量池,字节码内部包含了常量池。常量池中的内容在类加载后存放到方法区的运行时常量池中。<br>一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表,里面包括各种字面量和对类型、域和方法的符号引用。<br>常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
方法区的演进细节<br>
Hotspot中方法区的变化
String Table为什么要调整?<br> jdk7中将String Table放到了堆空间中。因为永久代的回收效率很低,在full gc时才会触发,而full gc是老年代空间不足,永久代不足时才会触发,这就导致String Table回收效率不高,而开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆空间中,能及时回收内存。
方法区的垃圾收集
判定一个类型是否属于“不再被使用的类”的条件比较苛刻,需要同时满足如下三个条件:<br><ol><li> 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例</li><li><span style="font-size: inherit;"> 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则通常是很难达成的</span></li><li> 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法</li></ol>Java虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而不是和对象一样,没有引用了就必然会回收。<br>
常量池中主要存放两大类常量:字面量和符号引用。<br>字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。<br>符号引用则属于编译原理方面的概念,包括以下三类常量:<br><ol><li><span style="font-size: inherit;">类和接口的全限定名</span></li><li>字段的名称和描述符</li><li>方法的名称和描述符</li></ol>Hotspot虚拟机堆常量池的回收策略是很明确的,<b><font color="#f15a23">只要常量池中的常量没有被任何地方引用,就可以被回收</font></b><br>
对象的实例化内存布局与访问定位
对象实例化
创建对象的方式
new
Class的newInstance():反射的方式,只能条用空参的构造器,权限必须的public
Constructor的newInstance(xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求<br>
使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone()<br>
使用反序列化:从文件、网络中获取一个对象的二进制流
第三方库Objenesis<br>
创建对象的步骤
判断对象对应的类是否加载、链接、初始化
为对象分配内存
如果内存规整
指针碰撞
如果内存不规整
虚拟机需要维护一个表
空闲列表分配
说明
处理并发安全问题
采用CAS失败重试、区域加锁保证更新的原子性
每个线程预先分配一块TLAB
初始化分配到的空间
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
设置对象的对象头
执行init方法进行初始化
对象的内存布局
对象头(Header)
包含两部分
运行时元数据(Mark Word)
哈希值(HashCode)
GC分代年龄
锁状态标志
线程持有的锁
偏向线程ID
偏向时间戳
类型指针
指向类元数据InstanceKlass,确定该对象所属的类型
说明:如果是数组,还需要记录数组的长度
实例数据(Instance Data)
说明
它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)<br>
规则
相同宽度的字段总是被分配在一起
父类中定义的变量会出现在子类之前
如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙
对其填充(Padding)
不是必须的,也没有特别的含义,仅仅起到占位符的作用
小结
对象访问定位
图示
创建对象的目的是为了使用它
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
定位,通过栈上reference访问
对象访问方式
句柄访问
图示
好处
直接指针(Hotshot采用)
图示
好处
执行引擎
执行引擎的作用及工作过程
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表、以及其他辅助信息。<br>如果想要让一个Java程序运行起来,执行引擎的任务就是<b><font color="#f15a23">将字节码指令解释/编译为对应平台上的本地机器指令</font></b>才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎在执行过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
在方法的执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
Java代码编译和执行的过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
解释器
JIT(Just In Time Compiler)编译器
Hotspot JVM的执行方式
当虚拟机启动的时候,<b><font color="#f15a23">解释器可以首先发挥作用</font></b>,而不必等待即时编译器全部编译完再执行,这样可以<b><font color="#f15a23">省去许多不必要的编译时间</font></b>。<br>并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,<b><font color="#f15a23">将有价值的字节码编译为本地机器指令</font></b>,以换取更高的程序执行效率。
热点代码及探测方式
<font color="#0076b3" style=""><b>一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称为“热点代码”</b></font>,因此都可以通过JIT编译器编译为本地机器指令。<br>由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换,或简称为<font color="#f15a23"><b>OSR(On Stack Replacement)</b></font>
一个方法究竟<b><font color="#0076b3">要被调用多少次</font></b>,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,<br>JIT编译器才会将这些“热点代码”编译为本地机器指令,这里主要依靠<b><font color="#f15a23">热点探测功能</font></b>
HotSpot VM采用<b><font color="#f15a23">基于计数器的热点探测</font></b>,此方式会为每一个方法都建立两个不同类型的计数器,<br>分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
方法调用计数器用于统计方法的调用次数,它的默认值在Client模式下是1500次,在Server模式下是10000次,<br>超过这个值,就会触发JIT编译。可通过 -XX:CompileThreshold 设置。
回边计数器用于统计循环体执行的循环次数,在字节码中遇到控制流向后跳转的指令称为“回边”,建立回边统计计数器统计的目的就是为了触发OSR编译<br>
可以通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器还是完全采用即时编译器执行
-Xint 完全采用解释器模式执行程序
-Xcomp 完全采用即时编译器执行程序,如果即时编译出现问题,解释器会介入执行
-Xmixed 采用解释器+即时编译器的混合模式共同执行程序
垃圾回收
什么是垃圾?
是指<b><font color="#f44336">在运行程序中没有任何指针指向的对象</font></b>,这个对象就是需要被回收的垃圾。<br>
如果不及时对内存中的垃圾进行清理,这些垃圾对象所占的内存空间会一直保留到应用程序结束,<br>被保留的空间无法被其他对象使用,甚至可能导致内存溢出。
为什么需要GC
对于高级语言来说,一个基本的认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配<br>内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,<br>以便JVM将整理出的内存分配给新的对象
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。<br>而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试堆GC进行优化
GC算法
垃圾标记阶段
引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
对一个对象A,只要有任何一个对象引用了A,A的引用计数器就加1;当引用失效时,引用计数器就减1<br>只要A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
<span style="font-size: inherit;">缺点:<br><ol><li><span style="font-size: inherit;">需要单独的字段存储计数器,这样的做法增加了<b><font color="#1976d2">存储空间的开销</font></b></span></li><li>每次赋值都需要更新计数器,伴随着加法和减法操作,增加了<b><font color="#1976d2">时间开销</font></b></li><li><b><font color="#1976d2">无法处理循环引用</font></b>的情况,这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法</li></ol></span>
可达性分析算法
以根对象集合(GC Roots)为起始点,按照从上至下的方式<b><font color="#1976d2">搜索被根对象集合所连接的目标对象是否可达</font></b>
使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为<b><font color="#f44336">引用链(R</font></b>eference Chain)
如果目标对象没有任何引用链相连,则是不可达的,即意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
GC Roots包括以下几类元素
虚拟机栈中引用的对象,如线程被调用的方法中使用到的参数、局部变量等
本地方法栈中JNI引用的对象
方法区中类静态属性引用的对象,如Java类的引用类型静态变量
方法区中常量引用的对象,如字符串常量池里的引用
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointerEcxeption..),系统类加载器
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
使用可达性分析算法来判断内存是否可以回收,分析工作必须在一个能保证一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。<br>这也是导致GC进行时必须“Stop The World”的一个重要原因,即使号称(几乎)不会发生停顿的CMS收集器中,<b><font color="#f44336">枚举根节点时也是必须要停顿的</font></b>。
垃圾清除阶段
标记-清除算法
当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两项工作,标记和清除<br><ol><li>标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象</li><li>清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收</li></ol>
缺点:<br><ol><li><span style="font-size: inherit;">效率不算高</span></li><li>在进行GC的时候,需要停止整个应用程序,导致用户体验差</li><li>清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表</li></ol>
这里的清除不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
复制算法
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,<br>之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
优点:<br><ol><li>没有标记和清除过程,实现简单,运行高效</li><li>复制过去以后保证空间的连续性,不会出现“碎片”问题</li></ol>
缺点:<br><ol><li>需要两倍的空间</li><li>对于G1这种分拆称为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用还是时间开销也不小</li></ol>
标记-压缩算法
第一阶段和标记清除算法一样,从根节点开始标记所有被引用的对象<br>第二阶段将所有的存活对象压缩到内存的一端,按顺序排放<br>之后,清理边界外所有的空间
优点:<br><ol><li>消除了标记-清除算法当中,内存区域分散的缺点,需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可</li><li>消除了复制算法当中,内存减半的高额代价</li></ol>
缺点:<br><ol><li>从效率上来说,标记-压缩算法要低于其他算法</li><li>移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址</li><li>移动过程中,需要全程暂停用户线程</li></ol>
分代收集算法
年轻代
特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存货对象大小有关,因此很适用于年轻代的回收。<br>而复制算法内存利用率不高的问题,通过HotSpot中的两个survivor的设计得到缓解
老年代
特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-压缩的混合实现
标记阶段的开销与存活对象的数量成正比<br>清除阶段的开销与所管理区域的大小成正比<br>压缩阶段的开销与存活对象的数据成正比
分区算法<br>
一般来说,在相同条件下,堆空间越大,一次GC所需的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC产生的停顿时间,<br>将一块打的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所<br>产生的停顿。<br>
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆划分成连续的不同小区间。每个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间。
垃圾回收器
评估GC性能的指标
吞吐量
吞吐量就是CPU用于运行用户线程的时间与CPU总消耗时间的比值,即<br>吞吐量 = 用户线程运行时间 / (用户线程运行时间 + 垃圾收集时间)
这种情况下,应用程序能容忍较高的暂停时间,因此,<br>高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的
吞吐量优先,意味着在单位时间内,STW的时间最短
暂停时间
是指一个时间段内用户线程暂停,让GC线程执行的状态,例如:GC期间100毫秒<br>的暂停时间意味着在这100毫秒内没有用户线程是活动的
暂停时间优先,意味着尽可能让单次STW的时间最短
7款经典的垃圾收集器
串行回收器:Serial、Serial Old
并行回收器:ParNew、Parallel Scavenge、Parallel Old
并发回收器:CMS、G1
JDK8中红色虚线的搭配方案是废弃状态,JDK9中则是移除了红色虚线的搭配方案,JDK14中废弃了绿色虚线的搭配方案并且移除了CMS回收器
垃圾回收器细节
Serial
Serial收集器是最基本、历史最悠久的垃圾收集器。JDK1.3之前回收新生代的唯一选择,作为HotSpot中Client模式下的默认新生代垃圾收集器
<b><font color="#1976d2">采用复制算法、串行回收和STW机制的方式执行内存回收</font></b>
除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器,<b><font color="#1976d2">Serial Old也采用了串行回收和STW机制,<br>只不过内存回收算法使用的是标记-压缩算法</font></b>
Serial Old是运行在Client模式下默认的老年代垃圾回收器,其在Server模式下主要有两个用途:<br><ol><li>与新生代的Parallel Scavenge配合使用</li><li>作为老年代CMS收集器的后备垃圾收集方案</li></ol>
<span style="font-size: inherit;">此收集器“单线程”的意义不仅仅说明它</span><b style="font-size: inherit;"><font color="#1976d2">只会使用一个CPU或一条收集线程去完成垃圾收集工作</font></b><span style="font-size: inherit;">,</span><br>更重要的是它在进行垃圾收集时,<b><font color="#1976d2">必须暂停其他所有的工作线程</font></b>,直到它收集结束
在HotSpot虚拟机中,使用 -XX:+UseSerialGC 参数可以指定年轻代和老年代都是用串行收集器。<br>等价于 新生代用Serial GC,且老年代用Serial Old GC
ParNew
如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew则是Serial收集器的多线程版本。<br><b><font color="#1976d2">Par是Parallel的缩写,New表示只能处理新生代</font></b>
ParNew收集器除了采用<b><font color="#1976d2">并行回收</font></b>的方式执行内存回收外,与Serial收集器几乎没有任何区别。<br>其在年轻代中也是<b><font color="#1976d2">采用复制算法、STW的机制</font></b>
对于新生代,回收次数频繁,使用并行方式高效。对于老年代,回收次数少,使用串行方式节省资源(CPU并行需要切换线程,串行可以省去切换线程的资源)
ParNew收集器运行在多CPU的环境下,可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。<br>但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效,虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,<br>因此可以有效地避免多线程交互过程中产生的一些额外开销
可以使用 -XX:+UseParNewGC 指定使用ParNew收集器执行内存回收,它表示年轻代使用并行收集器,不影响老年代<br>-XX:ParallelGCThreads 设置线程数量,默认开启和CPU核数相同的线程数
Parallel Scavenge
同样采用复制算法、并行回收和STW机制
和ParNew不同,Parallel Scavenge的目标是达到一个可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器。<br>高吞吐量可以高效利地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
自适应调节策略也是Parallel Scavenge与ParNew的一个重要区别
Parallel Scavenge在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来替换老年代的Serial Old收集器<br>Parallel Old采用了标记-压缩算法,同样也是基于并行回收和STW机制
JDK1.8中默认使用的收集器
参数设置
-XX:+UseParallelGC 指定年轻代使用Parallel并行收集执行内存回收任务
-XX:+UseParallelOldGC 指定老年代使用并行回收收集器,分别适用于新生代和老年代。<br>上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
-XX:ParallelGCThreads 设置年轻代并行收集器的线程数
-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(STW时间),单位是毫秒。<br>为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器工作时会调整Java堆大小<br>或者其他一些参数
-XX:GCTimeRatio 垃圾收集时间占总时间的比例,用于衡量吞吐量的大小
-XX:+UseAdaptiveSizePolicy 设置收集器具有自适应调节策略<br>在这种模式下,年轻代的大小,Eden和Survivor的比例,晋升老年代的对象年龄等参数会被自动调整,<br>以达到在堆大小、吞吐量和停顿时间之间的平衡点
CMS
Concurrent-Mark-Sweep是HotSpot中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
CMS的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序。
采用标记-清除算法,也会STW
初始标记
此阶段中,所有用户线程都会短暂的STW,这个阶段的主要任务<b><font color="#f44336">仅仅只是标记出GC Roots能直接关联<br>到的对象</font></b>,一旦标记完成,就会恢复之前被暂停的所有用户线程,由于直接关联对象比较少,所以<b><font color="#f44336">速度<br>非常快</font></b>
并发标记
从GC Roots的<b><font color="#f44336">直接关联对象开始遍历整个对象图的过程</font></b>,这个过程<b><font color="#f44336">耗时较长</font></b>但是<b><font color="#f44336">不需要停顿用户线程</font></b>,<br>可以与用户线程一起并发运行
重新标记
由于在并发标记阶段,用户线程和垃圾收集线程同时运行或者交叉运行,因此,为了<b><font color="#f44336">修正并发标记期间,<br>因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录</font></b>,这个阶段的停顿时间通常会比初始<br>标记阶段稍长一些,但也远比并发标记阶段的时间短
并发清除
此阶段<font color="#f44336" style=""><b>清理删除掉标记阶段判断已经死亡的对象,释放内存空间</b></font>。由于不需要移动存活对象,所以这个阶段<br>也是可以和用户线程同时进行的
尽管CMS采用并发回收,但是在其<b><font color="#f44336">初始标记和重新标记这两个阶段中仍需STW暂停用户线程</font></b>,不过暂停时间不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要STW,只是尽可能的缩短暂停时间<br>由于<b><font color="#f44336">最耗费时间的并发标记与并发清除阶段都不需要STW,所以整体的回收是低停顿的</font></b><br>
由于在垃圾收集阶段用户线程没有中断,所以<b><font color="#f44336">在CMS回收过程中,还应该确保用户线程有足够的内存可用</font></b>。因此CMS不能像其他收集器一样等到老年代几乎完全被填满了再进行收集,而是<b><font color="#f44336">当堆内存使用率达到某一阈值时,便开始进行回收</font></b>,以确保应用程序再CMS工作过程中仍然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“<b><font color="#1976d2">Concurrent Mode Failure</font></b>”失败,这时虚拟机将启动后备预案:<b><font color="#f44336">临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了</font></b>
CMS采用标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会<b><font color="#1976d2">产生一些内存碎片</font></b>,CMS再为新对象分配内存空间时,将无法使用指针碰撞技术,而只能选择空闲列表执行内存分配
优点:并发收集、低延迟
弊端:<br><ol><li><b><font color="#1976d2">产生内存碎片</font></b>。导致并发清除后,可用的空间不足,在无法分配大对象的情况下,不得不提前触发Full GC</li><li><b><font color="#1976d2">对CPU资源非常敏感</font></b>。在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低</li><li><b><font color="#1976d2">无法处理浮动垃圾</font></b>。可能出现”Concurrent Mode Failure“失败而导致另一次Full GC。在<b><font color="#1976d2">并发标记阶段由于用户线程和垃圾收集线程是并发执行的,那么在并发标记阶段如果产生新的垃圾对象</font></b>,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被即时回收,只能在下一次执行GC时回收</li></ol>
参数设置
-XX:+UseConcMarkSweepGC 指定使用CMS收集器,开启后会自动将 -XX:+UseParNewGC打开,即使用ParNew + CMS + Serial Old的组合
-XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收<b><font color="#1976d2">JDK5及以前的版本默认值为68,JDK6及以上版本默认值为92</font></b><br>如果内存增长缓慢,则可以设置一个稍大的值,可以有效降低CMS的触发频率;反之,如果内存增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器
-XX:+UseCMSCompactAtFullCollection 用于指定在执行完Full GC后对内存空间进行压缩整理,以避免内存碎片产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了
-XX:CMSFullGCsBeforeCompaction 设置在执行多少次Full GC后对内存空间进行压缩整理
-XX:ParallelCMSThreads 设置CMS的线程数量,CMS默认启动的线程数是(ParallelGCThreads + 3)/ 4,ParallelGCThreads是年轻代并行收集器的线程数
G1
G1是一个并行回收器,它把内存分割为很多不相关的区域(物理上不连续)。使用不同的区域来表示Eden,S0,S1,Old等。G1有计划地避免在整个堆中进行全区域的垃圾收集。G1跟踪各个区域里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次<b><font color="#f44336">根据允许的收集时间,优先回收价值最大的区域</font></b>
G1是一款面向服务的应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。是JDK9以后的默认垃圾回收器,在JDK8中需要使用-XX:+UseG1GC来启用<br>
特点(优势)
并行与并发
并行性
G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,此时用户线程STW
并发性
G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行。因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集
G1会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但是从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量
将堆空间分成若干个区域,这些区域中包含了逻辑上的年轻代和老年代
同时兼顾年轻代和老年代,对比其他回收器,或者工作在年轻代,或者工作在老年代
空间整合
G1将内存划分为一个个的区域,内存的回收是以区域作为基本单位的,区域之间是复制算法,但整体上可以看作是标记-压缩算法。两种算法都可以避免内存碎片,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC,尤其是当堆空间非常大的适合,G1的优势更加明显
可预测的停顿时间模型
可以让使用者明确指定一个在长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
G1跟踪各个区域里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,<br><b><font color="#1976d2">每次根据允许的收集时间,优先回收价值最大的区域</font></b>。保证了G1收集器在有限的时间内可以<b><font color="#1976d2">获取尽可能高的收集效率</font></b>
参数设置
-XX:+UseG1GC 指定使用G1收集器
-XX:G1HeapRegionSize 设置每个区域的大小,值是2的幂,范围是1Mb到32Mb之间,目标是根据最小的堆大小划分出约2048个区域。默认是堆内存的1/2000
-XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间(JVM会尽力实现,但不保证达到)。默认值是200ms
-XX:ParallelGCThread 设置STW时GC线程数的值,最多设置为8
-XX:ConcGCThreads 设置并发标记的线程数,将n设置为并行垃圾回收线程数的1/4左右
-XX:InitiathingHeapOccupancyPercent 设置触发并发GC周期的堆占用阈值,超过此值,就触发GC。默认值是45
化整为零
E: eden S: survivor O: old H: humongous(主要用于存储大对象,如果超过1.5个区域,就放到H中)
垃圾回收过程
yong gc -> yong gc + concurrent mark -> mixed gc
当Eden去用尽时开始年轻代回收,年轻代收集阶段是一个并行的独占式收集器,在此期间,G1暂停所有用户线程,启动多线程执行年轻代<br>回收,然后从年轻代区间移动存活对象到survivor区或者老年区,也有可能是两个区间都会涉及
当堆内存使用达到一定值时(默认45%),开始老年代并发标记过程
标记完成马上开始混合回收过程。对于一个混合回收期,G1从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。<br>和年轻代不同,老年代的G1回收器和其他GC不同,<b><font color="#f44336">G1的老年代回收器不需要整个老年代被回收,一次只需扫描/回收一小部分老年代的区域<br>即可</font></b>。同时,这个老年代区域是和年轻代一起被回收的<br>
Remembered Set
一个对象被不同区域引用的问题
一个区域不可能是孤立的,一个区域中的对象可能被其他任意区域中对象引用,<br>判断对象存活时,是否需要扫描整个堆才能保证准确
在其他分代收集器中,也存在这样的问题(G1更突出)
回收新生代也不得不同时扫描老年代
这样会降低yong gc的效率
垃圾回收器总结
评论
0 条评论
下一页