文章目录
JVM概述及类加载子系统
一、概述
1.1. 虚拟机
虚拟机就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体可分为系统虚拟机和程序虚拟机
- 系统虚拟机:Visual Box、VMware……
- 程序虚拟机:用于执行单个计算机程序,在Java虚拟机中执行的指令称为Java字节码指令
1.2. Java虚拟机
1.2.1 概述
- Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成
- JVM平台的各种语言可以共享Java虚拟机带来的跨平台、优秀的垃圾回收器,以及可靠的即时编译器
- Java技术的核心是Java虚拟机,因为所有Java程序都运行在Java虚拟机内部
1.2.2 作用
Java虚拟机是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译对应平台上的机器指令执行。
二、JVM的整体结构
三、JVM架构模型
Java编译器输入的指令流基本是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构
- 基于栈式架构的特点:
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器的分配难题:使用零地址指令方式分配
- 指令流中的指令大部分是零地址指令,其执行过程依赖于栈
- 不需要硬件支持,可移植性好
- 基于寄存器架构的特点
- 典型应用是x86的二进制指令集
- 指令集架构完全依赖硬件,可移植性差
- 性能优秀和执行更高效
- 花费更少的指令完成一项操作
四、JVM生命周期
4.1. 虚拟机的启动
Java虚拟机的启动时通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成,这个类是由虚拟机的具体实现指定的
4.2. 虚拟机的执行
- 运行中的Java虚拟机有明确的任务:执行Java程序
- 程序开始执行时它会运行,程序结束时它停止
- 执行一个Java程序时,真正执行的是一个Java虚拟机的进程
4.3. 虚拟机的退出
- 程序正常结束
- 程序在执行过程中遇到了异常或错误而异常退出
- 由于操作系统错误而导致Java虚拟机进程终止
- 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法
- 使用JNI Invocation API来加载或卸载Java虚拟机
五、类加载过程
5.1. 加载
- 通过一个类的权限定名获取定义此类的二进制字节流
- 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表整个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
5.2. 链接
5.2.1 验证(Verify)
- 确保Class文件的字节流总包含信息符合当前虚拟机要求,保证被加载类的正确性
- 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
5.2.2 准备(Prepare)
- 为变量分配内存并且设置该类变量的默认初始化值,即零值
- 不包含用final修饰的static,因为final在编译的时候就会分配,准备阶段显式初始化
- 不会为实例变量分配初始化,类变量会分配在方法区,而实例变量是会随这个对象一起分配到Java堆中
5.2.3 解析
- 将常量池内的符号引用转换为直接引用的过程
- 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
- 符号应用就是一组符号来描述所应用的目标
- 解析动作主要针对类或接口 、字段、类方法、接口方法、方法类型等
3. 初始化
- 初始化阶段就是执行类构造器方法<clinit>()的过程
- 此方法不需要定义,是Javac编译器自动收集类中所有类变量的赋值动作和静态代码块中语句合并而来
- 构造器方法中指令按语句在源文件中出现顺序执行
- <clinit>()不同于类的构造器。(构造器是虚拟机视角下的<init>())
- 若类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
- 虚拟机必须保证一个类的<clinit>()方法在多线程下被加锁
六、类加载器分类
-
JVM支持两种类型的类加载器,分别是引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
-
自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
-
Java的核心类库使用引导类加载器进行加载
6.1. 启动类加载器
也称为引导类加载器,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等开头的类
6.2. 扩展类加载器
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
- 派生出ClassLoader类
- 父类加载器为启动类加载器
- 从Java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库
6.3. 应用程序类加载器
应用程序类加载器(系统类加载器,AppClassLoader)
-
Java语言编写,由sun.misc.Launcher$AppClassLoader实现
-
派生于ClassLoader类
-
父类加载器为扩展加载器
-
负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
-
该类加载是程序中默认的类加载器,一般情况Java应用的类都是由它完成加载
-
通过ClassLoader#getSystemLoader()方法可以获取该类加载器
七、双亲委派机制
7.1 概述
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
7.2 工作原理
- 如果有一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,一次递归,请求最终将到达顶层的启动类加载器
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此类加载任务,子类加载器才会尝试自己去加载
7.3 优势
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
运行时数据区
一、概述
-
内存式非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存的申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。
-
Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则与线程一一对应,与线程对应的数据区域会随着线程的开始和结束而创建和销毁
-
灰色为单独线程私有,红色为对个线程共享:
- 每个线程:程序计数器、栈、本地栈
- 线程共享:堆、堆外内存
二、程序计数器
2.1 概述
-
PC寄存器用来存储指向下一条指令的地址,也即将要执行 的指令代码。由执行引擎读取下一条指令
-
它是一小块内存空间,可以忽略不计
-
在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期和线程的声明周期保持一致
-
任何时间一个线程只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,或者是在执行native方法,则是未指定值(undefined)。
2.2 面试问题
- 使用PC寄存器存储字节码地址的作用是什么?
- CPU需要不停的切换各个线程,这时候切换回来以后就需要知道接着从哪里开始继续执行
- JVM的字节码解释器需要通过改变PC寄存器的值来明确下一跳应该执行什么样的字节码指令
- PC寄存器为什么会被设定为线程私有?
- 多线程在特定的时间段只会执行其中的某个线程的方法,CPU需要不停的切换,为了能够准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程都分配一个PC寄存器,这样各个线程就可以进行独立的计算,从而不会互相干扰
三、虚拟机栈
3.1 概述
- Java虚拟机栈(Java Virtual Machine Stack),早起也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次次的Java方法调用
- 生命周期和线程一致
- 主管Java程序的运行,它保存方法的局部变量(8中基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回
- JVM对Java栈的操作只有进栈和出栈
3.2 面试问题
- 栈中可能出现的异常
- Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
- 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出StackOverflowError异常
- 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常
3.3 栈的存储结构
3.3.1 栈帧
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 线程执行的每个方法都各自对应一个栈帧(Stack Frame)
- 栈帧是一个内存区块,是一个数据集
- 在一个线程中,一个时间点只有一个活动的栈帧
3.3.2 栈帧的内部结构
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)(执行运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常或异常退出的定义)
- 附加信息
-
局部变量表:
- 局部变量数组或本地变量表
- 定义了一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型
- 局部变量表建立在线程栈上,是线程私有数据,因此不存在数据安全问题
- 局部变量表所需的大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。
- 局部变量表的基本单位是Slot(变量槽)
-
成员变量 vs 局部变量
- 类变量有两次初始化机会,分别是"准备阶段",执行系统初始化,对类变量设置零值,另一次则是在"初始化"阶段,赋予程序员在代码中定义的初始值
- 局部变量不存在系统初始化的过程,这意味着 一旦定义了局部变量则必须人为初始化
-
操作数栈(Operand Stack)
- 操作数栈在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)
- 操作数栈主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈是JVM执行引擎的一个工作区,当一个方法开始执行的时候一个新的栈帧也会随之被创建出来,这个方法的操作数栈为空
- 被调用的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
-
动态链接
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)
- 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。
-
方法的调用:
- 将符号引用转换为调用方法的直接引用于方法的绑定机制有关
- 静态链接:当一个字节码文件装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接
- 动态链接:被调用的方法在编译期无法确定下来,也就是只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称为动态链接
-
方法返回地址
- 存放调用该方法的PC寄存器的值
- 一个方法的结束有两种方式:正常执行完成,出现未处理的异常
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般会保存这部分信息
四、本地方法栈
4.1 本地方法接口
- 一个Native Method就是Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现
- 标识符Native可以与所有其他的java标识符连用,但是abstract除外
4.2 本地方法栈
- Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
- 本地方法栈也是线程私有
- 它的具体做法是Native Method Stack中登记Native方法,在Execution Engine执行时加载本地方法库
五、堆
5.1 概述
- 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
- Java堆区在JVM启动的时候即被创建,其空间大小也就确定。是JVM管理的最大一块内存空间
- 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
- 所有的对象实例以及数组都应当在运行时分配在堆上
- 数组和对象可能永远都不会存储在栈上,因为栈中保存引用,这个引用指向对象或者数组在堆中的位置
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
- 堆是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
5.2 内存细分
5.3 年轻代和老年代
- 存储在JVM中的Java对象可以被划分为两类:
- 一类是声明周期较短的瞬时对象,这类对象的创建和消亡非常迅速
- 另一类是生命周期较长,在某些极端情况下还可以和JVM的声明周期保持一致
- Java堆区进一步细分可分为年轻代和老年代
- 年轻代可分为Eden空间、Survivor0空间和Survivor1空间(from区、to区)
- 在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1
- 开发人员可以通过选项"-XX:SurvivorRatio"调整这个空间比例
- 几乎所有的Java对象都是在Eden区被new出来的
- 绝大多数Java对象的销毁都在新生代进行
- 可以使用选项"-Xmn"设置新生代最大内存大小
5.4 对象分配过程
- 上图红色代表被回收的对象,绿色代表没有被回收的对象,箭头方向表示对象在不同区域的移动方向
- 当Eden区满时触发YoungGC,Survivor区满不会触发YoungGC
- 当Eden区满时触发YoungGC会将Eden区和Survivor区一起回收
5.5 对象分配特殊情况
5.6 Minor GC、Major GC、Full GC
- JVM在进行GC时,并非每次都对三个内存(新生代、老年代、方法区)一起回收的,大部分时候回收都指新生代
- 针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大类:部分手机(Partial GC),一种是整堆收集(Full GC)
- 部分收集:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾回收
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前只有CMS GC会有单独收集老年代的行为
- 很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
- 混淆收集(Mixed GC):收集整个新生代以及部分老年代的垃圾
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾
5.6.1 年轻代GC触发机制
- 当年轻代空间不足时就会触发Minor GC,这里年轻代满指的是Eden代满,Survivor满不会引发GC
- Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,回收速度比较快
- Minor GC会引发STW,暂停其他用户线程,等待垃圾回收结束,用户线程才恢复运行
5.6.2 老年代GC触发机制
- 发生在老年代的GC,对象从老年代消失时则发生了"Major GC"或"Full GC"
- 出现Major GC,经常会伴随至少一次的Minor GC(但非绝对,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
- 老年代空间不足时会先触发Minor GC。如果之后空间还不足则触发Major GC
- Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
- 如果Major GC后内存不足则报OOM
5.6.3 Full GC触发机制
- 调用System.gc()时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,切老年代的可用空间小于该对象大小
5.7 内存分配策略
- 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,并将年龄设为1。对象在Survivor区没经过一次Minor GC年龄就会增减1,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代。对象晋升老年代的阈值,可通过选项**-XX:MaxTenuringThreshold**来设置
- 优先分配到Eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代中
- 动态对象年龄判断:
- 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无序等到MaxTenuringThreshold中要求的年龄
5.8 对象分配内存:TLAB
Thread Local Allocation Buffer
- 堆区域是线程共享区域,任何线程都可以访问到对区域中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 避免多个线程操作同一个地址,需要使用加锁机制,进而会影响程序执行效率
5.8.1 TLAB概述
-
JVM为每个线程分配了一个私有的缓存区域,它包含在Eden空间内
-
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此可以将这种内存分配方式称为快速分配策略
-
不是多有对象示例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
-
在程序中,开发人员可以通过选项"-XX:UseTLAB"设置是否开启TLAB空间
-
默认情况下TLAB的内存空间非常小,仅占整个Eden空间的1%,我们可以通过选项"-XX:TLABWasteTargetPercent"设置TLAB空间所占用Eden空间的百分比
-
一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
5.9 堆空间的参数设置
- -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
- -XX:+PrintFlagsFinal:查看所有的参数的最终值
- -Xms:初始堆空间内存(默认为物理内存的1/64)
- -Xmx:最大堆空间内存(默认为物理内存的1/4)
- -Xmn:设置新生代的大小
- -XX:NewRatio:配置新生代(1/3)与老年代(2/3)在堆结构的占比
- -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
- -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
- -XX:+PringGCDetails:输出详细的GC处理日志
- 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
- -XX:HandlePromotionFailure“是否设置空间分配担保
六、方法区
6.1 栈、堆、方法区的交互关系
- 从线程共享与否角度分析
6.2 方法区的理解
- 方法区是独立于堆的内存空间
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
- 方法区在JVM启动时被创建,并且它在实际的物理内存空间中和Java堆区一样都可以是不连续的
- 方法区的大小和堆空间一样,可以选择固定大小或者可扩展
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMamoryError:PermGen space或者java.lang.OutOfMemoryError: Metaspace
- 关闭JVM就会释放这个区域的内存
6.3 设置方法区大小与OOM
- jdk7及以前:
- 通过-XX:PermSize来设置永久代初始分配空间。默认值20.75M
- -XX:MaxPermSize来设定永久代最大分配空间。32位机器默认64M,64位机器默认82M
- 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space
- jdk8以后:
- 元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize设定,替代上述原有的两个参数
- 默认值依赖于平台。Windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值 是-1,没有限制
- 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一定会抛出异常OutOfMemoryError:Metaspace
- -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说其默认的-XX:MetaspaceSize值为21MB。这是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类,然后这个高水位线将会被重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提供该值。如果释放空间过多,则适当降低该值
- 如果初始化的高水位线设置过低,上述高水位调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁GC,建议将-XX:MetaspaceSize设置为一个相对较高的值
6.4 方法区内部结构
- 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等
6.4.1 类型信息
- 每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储一下类型信息:
- 这个类型的完整有效名称(全名=包名.类型)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- 这个类型的修饰符(public、abstract、final的某个子集)
- 这个类型直接接口的一个有序列表
6.4.2 域(field)信息
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
- 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)
6.4.3 方法(Method)信息
- 方法名称
- 方法的返回值类型(或void)
- 方法参数的数量和类型
- 方法的修饰符(public、private、protected、static、final、synchronize、native、abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native除外)
- 异常表,每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移子集、被捕获的异常类的常量池索引
6.5 运行时常量池
6.5.1 常量池
- 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用
- 常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
6.5.2 运行时常量池
-
运行时常量量池(Runtime Constant Pool)是方法区的一部分
-
常量池表(Constant Pool Table)是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池
-
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池
-
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,通过索引访问
-
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能获得的方法或者字段引用。
-
运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富
-
当创建类或接口的运行时常量池是,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutOfMemory异常
6.6 方法区的演进(HotSpot)
6.6.1 图示
6.7 方法区垃圾收集
-
方法区的垃圾收集主要回收常量池中废弃的常量和不再使用的类型
-
方法区常量池只要存放两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
-
HotSpot虚拟机对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以回收
-
回收废弃常量与回收Java堆中的对象非常类似
-
方法区回收类信息条件比较苛刻,此处不多赘述
七、对象
7.1 对象实例化
7.1.1 创建对象的方式
- new
- 反射方式
- 使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
- 使用反序列化:从文件中、从网络中获取一个对象的二进制流
- 第三方库Objenesis
7.1.2 创建对象步骤
- 判断对象对应的类是否加载、链接、初始化
- 虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。如果没有,那么在双亲委派机制下,使用当前类加载器以ClassLoader+报名+类名为Key进行查找对用的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到则进行类加载,并生成对应的Class类对象
- 为对象分配内存
- 计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员是引用变量,仅分配引用变量空间即可,即4个字节
- 处理并发安全问题
- 初始化分配到空间:默认初始化值
- 设置对象对象头
- 将对象所属类、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中
- 执行init方法进行初始化
- 初始化成员变量,执行实例化代码,调用类的构造器,并把堆内对象的首地址赋值给引用变量。
7.2 对象的内存分布
7.3 对象访问定位
- 对象访问定位有两种方式:句柄访问和直接指针
- HotSpot采用直接指针方式
八、直接内存
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 直接内存是在Java堆外的、直接向系统申请的内存区域
- 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
- 访问直接内存的速度会优于Java堆,即读写性能高
执行引擎与垃圾回收
一、概述
-
执行引擎是Java虚拟机核心组成之一
-
"虚拟机"是一个相对于"物理机"的概念,这两种机器都有代码执行能力,其区别是物理机执行引擎是直接建立在处理器、缓存、指令集和操作系统上面的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
-
JVM只要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只有一些能被JVM所识别的字节码指令、符号表,以及其他辅助信息
-
想要让Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令。即JVM中的执行引擎将高级语言翻译为机器语言
二、工作过程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
- 当然方法区在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
三、Java代码编译执行过程
- Java是半解释半编译型语言
- 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"翻译"对应平台的本地机器指令执行
- JIT(Just In Time Compiler)编译器:虚拟机将源代码直接编译成和本地机器平台相关的机器语言
3.1 解释器
- 将字节码文件中的内容"翻译"为对应平台的本地机器指令执行
- 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一天需要被执行的字节码指令执行解释操作
3.2 JIT编译器
-
为了提高执行效率,会使用即时编译技术将方法编译成机器码后执行
-
HotSpot VM是目前市面上高性能虚拟机的代表之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间
-
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为"热点代码",因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换,或简称OSP(On Stack Replacement)编译
-
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测
四、String Table
4.1 概述
- 字符串常量池不会存储相同的字符串
- String的String Pool是一个固定大小的HashTable,默认长度是1009.如果放进String Pool的String的非常多,就会造成Hash冲突严重,从而导致链表会边长,而链表长了之后直接会造成的影响就是当调用String.intern时性能会大幅下降
- 使用-XX:StringTableSize可设置StringTable的长度
- 在jdk6中StringTable是固定的,就是长度1009,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求
- 在jdk7和jdk8中,StringTable的长度默认值为60013,jdk8中1009是可设置的最小值
4.2 String的内存分配
-
常量池类似于Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调,String类型的常量池比较特殊,主要使用两种方法:
- 直接使用双引号声明的对象会直接存储在常量池
- 不使用双引号声明的String对象,可以使用String提供的intern()方法。
-
jdk8,字符串常量池在Java堆中
4.3 字符串拼接
- 常量与常量的拼接结果在常量池,原理是编译器优化
- 常量池中不会存在相同内容的常量
- 只要其中有一个变量,结果就在堆中。变量拼接的原理是StringBuilder
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
public class StringTest {
@Test
public void test(){
String s1="JavaEE";
String s2="Hadoop";
String s3="JavaEEHadoop";
String s4="JavaEE"+"Hadoop";//编译期优化
//如果拼接符前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:JavaEEHadoop
String s5=s1+"Hadoop";
String s6="JavaEE"+s2;
String s7=s1+s2;
System.out.println(s3==s4);//true
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(s6==s7);//false
//intern():判断字符串常量池中是否存在JavaHadoop值,如果存在,则返回常量池中JavaHadoop的地址,
//如果字符串常量池中不存在JavaHadoop,则在常量池中加载一份JavaHadoop,并返回此对象的地址
String s8=s6.intern();
System.out.println(s3==s8);//true
}
@Test
public void test1() {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
//s1+s2执行细节
//① StringBuilder s=new StringBuilder();
//② s.append("a");
//③ s.append("b");
//④ s.toString() --> 约等于new String("ab")
System.out.println(s3 == s4);//false
}
@Test
public void test2() {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
//s1和s2使用final修饰,即拼接符号两边都是字符串常量,则仍然使用编译器优化
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
}
}
4.4 intern()方法
- 如果不是用双引号声明的String对象,可以使用String提供的intern()方法:intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中
- 如果在任意字符串上调用String.intern()方法,那个其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。
public class StringTest {
/**
* 对象1:new StringBuilder()
* 对象2:new String("a")
* 对象3:常量池中的对象"a"
* 对象4:new String("b")
* 对象5:常量池中的对象"b"
*
* StringBuilder的toString方法
* 对象6:new String("ab")
*
* @return void
* @Param [args]
**/
public static void main(String[] args) {
String str = new String("a") + new String("b");
}
}
public class StringIntern {
public static void main(String[] args) {
String s = new String("1");
s.intern();//调用此方法,字符串常量池中已经存在"1"
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false
String s3 = new String("1") + new String("1");//s3的地址为new String("11")
//执行完上述代码,字符串常量池中并不存在"11"
s3.intern();//字符串常量池中生成"11"。
//jdk6:创建了一个新的对象,即会有一个新的地址
//jdk7/8:此时常量池中并没有创建"11",而是创建了一个执行堆空间new String("11")的地址
String s4 = "11";//s4变量记录的地址:使用的是上一次代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}
}
五、垃圾回收
5.1 概述
- 垃圾收集机制是Java语言的招牌能力,极大的提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过长时间的发展,Java的垃圾收集机制仍然不断演进,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战
- 垃圾指在运行程序中没有任何指针指向的对象,这个对象就需要被回收的垃圾
- 如果不及时进行垃圾回收,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出
- 除了释放没用的对象,垃圾回收也可以清楚内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出来的内存分配给新的对象
- Java堆是垃圾收集器的工作重点
5.2 相关算法
5.2.1 垃圾标记阶段
- 概述
- 在堆中存放几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活的对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们称为垃圾标记阶段
- 当一个对象已经不再被任何存活对象继续引用时,就可以宣布该对象已经死亡
- 判断对象存活的两种方式引用计数算法和可达性分析算法
- 引用计数算法(Reference Counting)
- 对每个对象保存一个整型的引用计数器属性
- 对于一个对象,只要有任何一个对象应用了它,则它的引用计数器就加1;当引用失效时,引用计数器就减1。只要该对象的引用计数器的值为0,即表示该对象不可能再被使用,则可以被回收
- 优点:实现简单,垃圾对象便于辨识;判断效率高,回收没有延迟性
- 缺点:无法处理循环引用的情况
- Java没有采用该算法
- 可达性分析算法
- 追踪性垃圾收集
- 以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被跟对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中存活的对象都会被根对象集合直接或间接连接着,搜索所经过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
- 可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
- GC Roots包括的几类元素:各个线程被调用的方法中使用到的参数、局部变量;本地方法栈内JNI引用的对象;方法区中类静态属性引用的对象;方法区中常量应用的对象例如:字符串常量池(String Table)里的引用;所有被同步锁synchronize持有的对象;Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器
- 判定一个对象ObjA是否可回收
- 如果对象ObjA到GC Roots没有引用链,则进行一次标记
- 进行筛选,判断此对象是否有必要执行finalize()方法
- 如果对象ObjA没有重写finalize()方法。或者finalize()方法已经被虚拟机条用过,则虚拟机视为"没有必要执行",ObjA判定为不可触及
- 如果对象ObjA重写了finalize()方法,且还未执行过,那么ObjA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalize线程触发其finalize()方法执行
- finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果ObjA在finalize()方法中与引用链上的任何一个对象建立联系,那个第二次标记时,ObjA会被移出"即将回收"集合。之后,对象会再次出现没有引用存在的情况,在这种情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,即一个对象finalize方法只会被调用一次
5.2.2 垃圾清除阶段
- 常见的算法:
- 标记——清除算法(Mark-Sweep)
- 复制算法(Copying)
- 标记——压缩算法(Mark-Compact)
- 标记–清除算法(Mark-Sweep)
- 当堆空间中的有效内存空间被耗尽时,就会停止整个程序(Stop the world),然后进行两项工作,第一项是标记,第二项是清除
- 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象
- 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在Header中没有标记为可达对象,则将其回收
- 复制算法
- 将活着的内存空间分为两块,每次使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中所有对象,交换两个内存角色,最后完成垃圾回收
- 优点:实现简单,运行高效;复制过去以后保证空间的连续性,不会出现"碎片"问题
- 缺点:需要两倍的内存空间
- 标记–压缩
- 第一阶段进行标记,标记所有被引用的对象
- 第二阶段将所有存活的对象压缩到内存的一端,按顺序排放
5.3 分代收集算法
-
目前几乎所有的GC都采用分代收集(Generational Collecting)算法执行垃圾回收
-
年轻代(Young Gen)
- 年轻代的特点:区域相对较小,对象生命周期短、存活率低、回收频繁
- 这种情况采用复制算法效率较高
-
老年代(Tenured Gen)
- 老年代特点:区域较大,对象声明周期长、存活率高、回收不及年轻代频繁
- 一般采用标记–清除与标记–整理的混合实现
5.4 增量收集算法
-
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那个就可以让垃圾收集线程和应用线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复执行
-
增量收集算法的基础仍是传统的标记–清除和复制算法。增量收集算法通过对线程冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理和复制工作
-
缺点:使用这种方式进行垃圾回收,由于垃圾回收的过程中,间断性地执行了应用程序代码,所以能减少系统的停顿时间。但是因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
5.5 分区算法
- 将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区域,而不是整个堆空间,从而减少一次GC所产生的停顿
- 分代算法将按照对象的生命周期长短划分为两部分,分区算法将整个堆空间划分为连续的不同小区间
- 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间
5.6 相关概念
5.6.1 System.gc()的理解
- 默认情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用,会显示触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存
- 无法保证对垃圾收集器的调用
- JVM实现着可以通过System.gc()调用来决定JVM的GC行为。一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则太过于麻烦
5.6.2 内存泄漏
- 对象不会再被程序用到,但是GC又不能回收他们,则称为内存泄漏
- 内存泄漏不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会逐渐被蚕食,最终导致OutOfMemory
- 此处存储空间不是物理内存,而是指虚拟内存
5.7 垃圾回收器
5.7.1 GC分类与性能指标
- 概述
- 垃圾收集器没有在规范中进行过多的规定,可以由不同厂商、不同版本的JVM实现
- 分类
- 按线程,可分为串行垃圾回收期和并行垃圾回收期
- 按工作模式分,可以分为并发式和独占式垃圾收集器
- 按碎片处理方式分,可以分为压缩式和非压缩式垃圾收集器
- 按工作区间分,可分为年轻代和老年代垃圾收集器
- 评估GC的性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 内存占用:Java堆区所占的内存大小
5.7.2 经典的垃圾收集器
- 串行回收器:Serial、Serial Old
- 并行回收器:ParNew、Parallel、Scavenge、Parallel Old
- 并发回收器:CMS、G1
5.7.3 收集器与垃圾分代之间的关系
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial Old、Parallel Old、CMS
- 整堆收集器:G1
5.7.4 垃圾收集器的组合关系
- 两个收集器之间有连线,表明他们可以搭配使用
- Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案
- (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃,并在JDK 9中完全取消了这些组合的支持
- (绿色虚线)JDK 14中,弃用Parallel Scavenge和Serial Old GC组合
- (青色虚线)JDK 14中:删除CMS垃圾回收器
5.8 Serial回收器
-
Serial收集器是最基本、历史最悠久的垃圾收集器。JDK 1.3之前回收新生代唯一选择
-
Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器
-
Serial收集器采用复制算法、串行回收
-
Serial收集器提供用于老年代的垃圾收集Serial Old收集器。Serial Old收集器使用标记–压缩算法
-
它是单线程的收集器,它进行垃圾回收时,会暂停其他所有的工作线程,直到它收集结束
5.9 ParNew回收器
- 并行回收,ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也采用复制算法
- ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器
5.10 Parallel Scavenge回收器
- Parallel Scavenge回收器采用复制算法、并行回收和"Stop the World"机制
- 和ParNew不同的是,Parallel Scavenge回收器的目标是达到一个**可控制吞吐量(Throughput)。它也被称为吞吐量优先的垃圾收集器
- 高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,只要适合在后台运算而不需要太多交互任务
- Parallel收集器在JDK 1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来替代老年代的Serial Old收集器
- Parallel Old收集器采用标记–压缩算法,但同样也基于并行回收和"Stop the World"机制
5.11 CMS回收器
5.11.1 概述
- JDK 1.5,HotSpot推出了一款在强交互应用中几乎可以认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
- CMS收集器尽可能的缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序
- CMS垃圾收集器采用标记–清除算法,并且也会"Stop the World"
5.11.2 工作原理
CMS整个过程比之前的收集器更复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段、并发清除阶段
- 初始标记阶段:程序中所有的工作线程将会因为"Stop the World"机制而表现出短暂的暂停,这个阶段的主要任务仅仅是标记处GC Roots能直接关联的对象,一旦标记完成之后就会恢复之前暂停的所有应用线程,此阶段速度非常快
- 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的对象,这个过程耗时较长但是不停止用户线程,可以与垃圾收集线程一起并发运行
- 重新标记(Remark)阶段:由于在并发标记阶段只给你,程序在工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
- 并发清楚(Concurrent-Sweep)阶段:此阶段清理删除标记阶段的已经死亡的对象,释放内存空间
5.12 G1回收器
5.12.1 概述
- 由于应用程序所对应的业务越来越庞大、复杂、用户越来越多,没有GC就不能保证程序的正常运行,而经常造成STW的GC又跟不上实际的需求,所以才会不断的尝试对GC进行优化。G1(Garbage First)垃圾回收器是在Java7 update 4之后引入的一个新的垃圾收集器,是当前收集器技术发展的前沿成果之一
- G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量
5.12.2 特点
- 并行和并发
- 并行:G1在垃圾回收期间,可以有多个GC线程同时工作,有效利用多核计算能力
- 并发:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时知心,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
- 分代收集
- G1依然属于分代型垃圾收集器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量
- 将堆空间分为若干区域(Region),这些区域中包含了逻辑上的年轻代和老年代
- 它同时兼顾年轻代和老年代
- 空间整合
- G1将内存划分为一个个Region。内存的回收是以region作为基本单位。Region之间是复制算法,但整体上实际看做是标记-压缩算法。两种算法都可以避免内存碎片化。
- 可预测的停顿模型
- G1追求低停顿,还能建立可预测的停顿时间模型,能让使用者明确是定在一个长度为M毫秒的时间片内,消耗的垃圾收集上的时间不得超过N毫秒
- 由于分区的原因,G1可以只选部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好控制
- G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证G1收集器在有限的时间内可以获取尽可能的收集效率
- -XX:+UseG1GC:手动指定使用G1收集器执行内存回收任务
5.12.3 主要的三个环节
- 年轻代GC(Young GC)
- 老年代并发标记过程(Concurrent Marking)
- 混合回收(Mixed GC)
5.12.4 Remember Set
- 无论G1还是其他分代收集器,JVM都是使用Remember Set来避免全局扫描
- 每个Region都有一个对应的Remember Set
- 每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作
- 然后检查将要写入的引用指向的对象是否和该Reference类型数据在同一个Region
- 如果不同,则通过CardTable把相关引用信息记录到引用指向对象的所在Region对象的Remembered Set
- 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描
5.12.5 G1回收器垃圾回收过程
-
年轻代GC
- JVM启动时,G1先准备好Eden区,程序在运行时不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收
- 年轻代垃圾回收只会回收Eden区和Survivor区
- YGC时,G1停止应用程序的执行,G1创建回收集(Collection Set),回收集指需要被回收的内存分段的集合
-
并发标记
- 初始标记阶段:标记根节点直接可达的对象。这个阶段是STW,并且会触发一次年轻代GC
- 根区域扫描:G1 GC扫描Survivor区直接可达老年代的区域对象,并标记被引用的对象。这一过程必须在young GC之前完成
- 并发标记:在整个堆中进行标记,此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。
- 再次标记:由于应用程序持续进行,需要修正上一次的标记结果,STW,G1采用了比CSM更快的初始快照算法:snapshot-at-the-beginning(SATB)
- 独占清理:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域
- 并发清理:识别并清理完全空闲的区域
-
混合回收
- 当越来越多的对象晋升到老年代时,为了避免堆内存耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。
- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收
- 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden去内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程
- 由于老年代中的内存分段默认分为8次回收,G1会优先回收垃圾多的内存分段。垃圾占内容分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThreadsholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间
- 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少
- 当越来越多的对象晋升到老年代时,为了避免堆内存耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。
-
可选过程:Full GC
- G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长
- 要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决
- 导致G1Full GC的原因可能有两个:
- Evacuation的时候没有足够的to-space来存放晋升的对象
- 并发处理过程完成之前空间耗尽
5.13 总结
垃圾收集器 | 分类 | 作用位置 | 使用算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制 | 响应速度优先 | 适用于单CPU环境下的client模式 |
ParNew | 并行 | 新生代 | 复制 | 响应速度优先 | 多CPU环境下Server模式与CMS配合使用 |
Parallel | 并行 | 新生代 | 复制 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
Serial Old | 串行 | 老年代 | 标记-压缩 | 响应速度优先 | 适用于单CPU环境下的Client模式 |
Parallel Old | 并行 | 老年代 | 标记-压缩 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
CMS | 并发 | 老年代 | 标记-清楚 | 响应速度优先 | 适用于互联网或B/S业务 |
G1 | 并发、并行 | 新生代、老年代 | 标记-压缩、复制 | 响应速度优先 | 面向服务端应用 |