JVM 简介
Java:跨平台的语言
- Java文件编译成字节码文件
- 字节码文件可以运行在 Windows、Linux、Mac 等等不同操作系统的 JVM 上
JVM:跨语言的平台
-
Kotlin、Groovy、Scala等等语言编写的文件编译成字节码文件
-
不同语言生成的字节码文件可以运行在 JVM 上。
Java 平台上的多语言混合编程正在成为主流,通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。各个语言之间的交互不存在任何困难,就像使用自己的语言的原生 API 一样方便,因为它们最终都运行在一个虚拟机之上。推动 Java 虚拟机从"Java 语言虚拟机"向"多语言虚拟机"的方向发展。
Java 虚拟机不关心运行在其内部的程序到底是何种编程语言编写的,它只关心字节码文件。
Java 虚拟机拥有语言无关性,并不会单纯的与 Java 语言"终生绑定",只要其它编程语言的编译结果满足并包含 Java 虚拟机的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。
字节码
任何能在 JVM 平台上执行的字节码格式都是一样的。
不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的 JVM 上运行。
Java 虚拟机与 Java 语言没有必然的联系,它只与特定的二进制文件格式—— class 格式文件所关联。
虚拟机
一台虚拟的计算机。是一款软件,用来执行一系列虚拟计算机指令。大体上虚拟机可以分为:
- 系统虚拟机:完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。例如:VMware。
- 程序虚拟机:专门为执行单个计算机程序而设计。例如:Java虚拟机。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
Java虚拟机
一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。
Java 虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。
JVM 运行在操作系统之上,与硬件没有直接交互。
JVM 的整体结构
Java 代码执行流程
Java 源码文件 --> Javac(前端编译器)
–> 词法分析、语法分析、(语法/抽象语法树)、语义分析、(注解抽象语法树)、字节码生成器 -->class 字节码文件
–> Java 虚拟机 --> 类加载器、字节码校验器、解析执行/编译执行
JVM 的架构模型:基于栈的指令集结构
基于栈的指令集架构:
- 设计和实现更简单 适用于资源受限的系统
- 避开了寄存器的分配难题 使用零地址指令方式分配
- 指令集更小 编译器容易实现
- 不需要硬件支持 可移植性好 更好实现跨平台
基于寄存器的指令集架构:
- 指令集架构完全依赖硬件 可移植性差
- 性能优秀 执行更高效
- 花费更少的指令去完成一些操作
- 基于寄存器的指令集往往都以一地址、二地址和三地址指令为主
JVM 的生命周期
- 虚拟机的启动:通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成,这个类由虚拟机的具体实现指定。
- 虚拟机的执行:程序开始执行时 JVM 就开始运行,程序结束时就停止。执行一个所谓的 Java 的程序的时候,真正在执行的是一个 Java 虚拟机的进程。
- 虚拟机的退出:
- 程序正常结束退出
- 程序在执行过程中遇到了异常或错误导致退出
- 由于操作系统的错误导致退出
- 调用了 System 类中的 exit 方法
- JNI 规范来加载或卸载 Java 虚拟机
类加载子系统
类加载子系统负责从文件系统或者网络中加载 class 文件。class 文件在开头由特定的文件标识。
类加载子系统(ClassLoader)只负责加载 class 文件,至于它是否可以运行,由执行引擎(Execution Engine)决定。
加载的类信息存放于一块成为方法区的内存空间。除了类信息外,方法区中还会存放运行时常量池的信息,可能还包括字符串常量池和数字常量。
- 类加载的过程:
-
装载:
- 通过一个类的全限定类名获取此类的二进制字节流
- 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class 对象 作为方法区这个类的各种数据的访问入口
-
链接:
- 验证:确保 class 文件的字节流中包含信息符合当前虚拟机要求 保证加载类的正确性 不会危害虚拟机自身安全
- 准备:为类变量分配内存并初始化默认值
- 解析:将常量池内的符号引用转化为直接引用
-
初始化:执行类构造器方法
<cinit>()
。此方法不需要定义 是 javac 编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来。如果没有定义 static 变量,则没有该方法。构造器方法中指令按语句在源文件中出现的顺序执行。
- 类的加载器分类:
引导类加载器:Bootstrap ClassLoader
扩展类加载器:Extension ClassLoader
应用程序类加载器:AppClassLoader
用户自定义加载器:继承 ClassLoader 类
- 双亲委派机制:
Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 Class 对象。而且加载某个类的 class文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,是一种任务委派模式。
- 如果一个类加载器收到了类加载的请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
- 如果父类加载器还存在父类加载器,则进一步向上委托,依次递归,最终达到最顶层的启动类加载器。
- 如果父类加载器可以完成类的加载任务,就返回成功,倘若父类加载器无法完成此加载任务,子类加载器才会尝试自己去加载。
避免了类的重复加载。保护了程序的安全性 防止核心API被随意篡改。
在 JVM 中表示两个 class 对象是否为同一个类的两个必要条件:
- 类的全限定类名一致
- 类的加载器一致
JVM 会将类加载器的一个引用作为类型信息的一部分保存在方法区。
运行时数据区
Java 虚拟机定义了若干种程序运行时会用到的运行时数据区。其中一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁。另外一些则是与线程一一对应,这些线程对应的数据区域会随着线程的开始和结束而创建和销毁。
每个线程:独立包括 程序计数器、虚拟机栈、本地方法栈
每个线程共享:堆、方法区
程序计数器
PC寄存器用来存储指向下一条指令的地址 由执行引擎读取下一条指令。
执行引擎:操作栈结构、局部变量表、操作数栈、实现数据的存取、把字节码指令翻译成机器指令。
个线程都有自己的程序计数器 声明周期和线程保持一致。
任何时间一个线程都只有一个方法在执行 也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址 如果是native方法则是未指定的值(undifined)
使用PC寄存器存储字节码指令地址有什么作用:CPU 在不停的切换 切换回来后得知道执行到哪了
CPU 时间片:针对单核CPU 在一个时间段之内只能有一个线程在执行。
不存在 GC 和 ERROR。
虚拟机栈
每个线程在创建时都会创建一个虚拟机栈,其内部由一个一个栈帧组成,对应着一次次的方法调用。
Java 虚拟机栈的大小可以使固定不变的或者是动态的:
- 如果采用动态的 在尝试扩展的时候发现没有足够的内存会抛出 OutOfMemoryError。
- 如果采用固定不变的 在栈空间的容量不够使会抛出 StackOverflowError。
可以使用 -Xss 选项来设置线程的最大栈空间 栈的大小决定了函数调用的最大可达深度。
在一条活动线程中 一个时间点上 只会有一个活动的栈帧 即当前栈帧 对应 当前方法。
不同线程所包含的栈帧是不允许相互引用的。
Java 方法有两种返回函数的方式:一种是正常 return。一种是抛出异常。不管哪种方式 都会导致栈帧被弹出。
每个栈帧存储着:
-
局部变量表
定义为一个数字数组 主要存储方法参数和定义在方法内部的局部变量:
包括基本数据类型、对象引用、returnAddress类型
局部变量表所需的容量大小是在编译期定下来的 并保存在 maximum local variables 数据项中。在方法运行期间不会改变局部变量表的大小。
局部变量表最基本的存储单位是slot。32位的类型占用一个slot,64位的类型占用两个slot。
byte/short/char/boolean 在存储前会被转换为 int。
Long/Double 这种基本数据类型的包装类占用一个slot。
JVM会为局部变量表中的每一个slot都分配一个访问索引。如果当前帧是由实例方法或构造方法创建,则该对象引用this将会自动存放在index为0的slot处,其余参照参数表顺序继续排列。
栈帧中的局部变量表中的slot是可以重用的。如果一个局部变量过了其作用域,那么在其作用域之后申请的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
-
操作数栈
主要用于保存计算过程中的中间结果 同时作为计算过程中变量的临时存储空间。
当一个方法刚开始执行的时候 这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的深度用于存储数值,其最大的深度在编译期就定义好了,为 max stack 的值。
操作数栈作为栈 不能采用访问索引的方式来进行数据访问 而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话 会将其返回值放在操作数栈中 并更新PC寄存器。
-
动态链接
指向运行时常量池的方法引用。为了将符号引用转换为调用方法的直接引用。
为什么要常量池:提供一些符号和常量 便于指令的识别。
方法的调用:
静态链接:目标方法在编译时可知 运行期保持不变 此时从符号引用转换为直接引用称为静态链接。
动态链接:目标方法在编译器无法确定下来 只能在运行期将符号引用转为直接引用 转换过程具备动态性 所以称为动态链接。
绑定是一个字段、方法或类在符号引用被替换为直接引用的过程 这仅仅发生一次。
对应的方法绑定机制:早期绑定和晚期绑定。
对象的方法类型:虚方法和非虚方法。
非虚方法:静态方法、私有方法、final修饰的、构造器、父类方法。
虚拟机中提供了以下几条方法调用指令:
非虚方法:
invokestatic:调用静态方法
invokespecial:调用<init>
方法、私有及父类方法
invokevirtual:调用所有虚方法(除了final修饰的)
invokeinterface:调用接口方法
invokedynamic:lamda 表达式会出现虚方法表:在面向对象的编程中 会很频繁的使用到动态分配 如果在每次动态分配的过程中 都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。JVM 采用在类的方法区建立一个虚方法表(非虚方法不会在此表中),使用索引表来代替查找。
-
方法返回地址
存放调用该方法的PC寄存器的值。让调用者方法继续执行下去。
ireturn/lreturn/freturn/dreturn/areturn/return
-
一些附加信息
不存在GC 但存在ERROR。
本地方法栈
Java 虚拟机栈用于管理 Java 方法的调用。而本地方法栈用于管理本地方法的调用。
堆
堆的核心概述
- 一个 JVM 实例只存在一个堆空间 堆是 Java 内存管理的核心区域
- Java 堆区在 JVM 启动的时候被创建 其空间大小也确定了 是可以调节的
- 堆可以处于物理上不连续的内存 但在逻辑上应该被视为连续的
- 所有的线程共享 Java 堆 但是还可以划分线程私有的缓冲区 TABL(Thread Local Allocation Buffer)
- 几乎所有的对象实例以及数组都应当分配在堆上
- 在方法结束后 堆中的对象不会马上移除 仅仅在垃圾收集的时候才会移除
- 堆是 GC 执行垃圾回收的重点区域
- 堆的内存分为:
- JDK7 之前:新生代 + 老年代 + 永久代
- JDK8 及以后:新生代 + 老年代 + 元空间
设置堆内存大小与OOM
-
Java 堆区用于存储 Java 对象实例 那么堆的大小在 JVM 启动的时候就已经设定好了 可以通过
-Xms -Xmx
来设置-Xms 用于设置堆的初始内存 等价于 -XX:InitialHeapSize
-Xmx 用于设置堆的最大内存 等价于 -XX:MaxHeapSize
-
通常将 -Xms 和 -Xmx 设置成同一个大小 目的是为了能跟在 Java 垃圾回收机制清理完堆区后不需要重新进行分配堆区的大小 从而提高性能
-
默认情况下 初始内存为 物理内存的/64 最大内存为 物理内存的/4
// -Xms600m -Xmx600m 的情况下 long init = Runtime.getRuntime().totalMemory() / 1024 / 1024; System.out.println(init + "M"); // 575M //System.out.println(init * 64 / 1024 + "G"); long max = Runtime.getRuntime().maxMemory()/ 1024 / 1024; System.out.println(max + "M"); // 575M //System.out.println(max * 4 / 1024 + "G");
S0:25600kb = 25M
S1:25600kb = 25M
Eden:153600kb = 150M
Old:409600kb = 400M
S0 和 S1 只会用到一个 所以 400 + 150 + 25 = 上述代码里的 575M
-
存储在 JVM 中的 Java 对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象
- 一类是生命周期非常长的对象 在某些极端情况下还可能与 JVM 的生命周期保持一致
-
Java 堆区进一步划分为:
-
新生代(YongGen) = 伊甸园区(Eden) + 幸存者0区(Survivor0) + 幸存者1区(Survivor1)
有时也把幸存者0或1区叫为from区、to区
-
老年代(OldGen)
-
-
配置新生代和老年代在堆结构的占比:-XX:NewRatio=2
默认是1:2 即在 600M的堆中 新生代占200M 老年代占400M
-XX:NewRatio=4 时 堆空间区域划分如上
S0:15360kb = 15M
S1:15360kb = 15M
Eden:92160kb = 90M
新生代 = 15 + 15 + 90 = 120M
Old:491520kb = 480M
新生代占整个堆空间的 1/5
- 新生代的中 Eden/s0/s1 的比例默认为8:1:1 -XX:SurvivorRatio 调整比例
由于有自动适应的内存分配策略 所以默认不是严格的 8:1:1 需要显示设置一下
96:12:12 = 8:1:1
96+12+12 = 120
-
设置新生代最大内存大小 -Xmn
-Xms600m -Xmx600m -XX:NewRatio=4 -XX:SurvivorRatio=8 -Xmn200m
在 -XX:NewRatio 和 -Xmn 都设置的时候 都可以对 新生代的大小进行控制 生效的是 -Xmn
对象分配过程
-
new 的对象先放到伊甸园区
-
当伊甸园区满时 又要创建对象 会堆伊甸园区进行垃圾回收(Minor GC)
将伊甸园区不再被其它对象引用的对象进行销毁
将没有被销毁的对象移动到幸存者0区 并设置自己的年龄为1
加载新的对象到伊甸园区
当创建一个超大对象的时候(通常是一个数组或者字符串)如果伊甸园区发生 MinorGC 后还存放不下 会直接放到老年代
-
如果再次发生 Minor GC。上次在幸存者0区的对象如果这次还没有被销毁 则会放到幸存者1区 并把自己的年龄加1
如果幸存者0区已经满了的情况下 会把对象直接放到老年代 幸存者区满了不会发生GC
-
如果再次发生Minor GC。会把幸存者1区没有销毁的对象重新放回幸存者0区 并把自己的年龄加1
-
当对象的年龄大于默认值15的时候 会被放到老年代 可以通过 -XX:MaxTenuringThreshold=15 设置
动态年龄判断:如果幸存区中所有同龄的对象的大小的总和大于了幸存者区的一半 年龄大于或等于该年龄的对象会直接进入老年代 无需等到 MaxTenuringThreshold 的阈值
6. 当老年代的内存空间不足的时候 会发生 Major GC
6. 当 Major GC 之后仍然无法保存新的对象 就会发生 OOM
针对幸存者0和1区:复制之后有交换 谁空是谁to区
GC频繁发生在新生代 很少发生在老年代 几乎不发生在元空间
堆空间分代思想
分代的唯一理由就是优化GC的性能。如果没有分代思想 所有的对象都在一块。
GC的时候要找哪些对象没用就会对堆内的所有区域进行扫描
而百分之八十的对象都是朝生夕死的 如果分代 只对这些朝生夕死的对象进行扫描回收 就会腾出很多空间和时间
对象分配过程 TLAB
-
为什么有 TLAB (Thread Local Allocation Buffer)
- 堆区是线程共享的区域 任何线程都能访问到堆区中的共享数据
- 由于对象在 JVM 中创建非常频繁 因此在并发环境下 从堆中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址 需要使用加锁等机制 进而影响分配速度
-
什么是 TLAB
- 对 Eden 区进行划分。JVM 为每个线程分配了一个私有缓存区域。
- 多线程同时分配内存时 使用TLAB可以避免一系列线程安全问题 将这种内存分配方式称为 快速分配策略。
-
说明:
-
-XX:UseTLAB 设置是否开启 TLAB
-
默认情况下 TLAB 的空间占 Eden 区的百分之一
通过-XX:TLABWasteTargetPercent 调整大小
-
一旦 TLAB 的空间分配方式失败 JVM 就会尝试使用加锁机制 在Eden空间中分配内存
-
尽管不是所有对象实例都能够在 TLAB 中创建成功 但是 JVM 确实将 TLAB 作为内存分配的首选
-
堆空间参数设置
-XX:+PrintFlagsInitial:查看所有参数的初始值
-XX:+PrintFlagsFinal:查看所有参数的最终值
-Xms:设置堆的初始内存大小
-Xmx:设置堆的最大内存大小
-Xmn:设置新生代的内存大小
-XX:NewRatio:设置新生代和老年代的比例
-XX:SurvivorRatio:设置幸存者区和Eden区的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails:输出详细的GC处理日志
-XX:+PrintGC
-verbose:gc
-XX:HandlePromotionFailure:是否设置空间分配担保 JDK 7 之后默认打开
在发生 Minor GC 之前会检查
- 老年代的连续存储空间大于新生代的对象总大小 或者
- 老年代的连续存储空间大于历次晋升到老年代对象的平均大小
如果都不满足会进行 FullGC
堆是分配对象存储的唯一选择吗
是也不是。
随着 JIT 编译器的发展 与 逃逸分析技术 的逐步成熟。栈上分配、标量替换会进行一些微妙的变化。导致所有的对象都分配到堆上渐渐地变得不那么绝对了。
如果一个对象经过逃逸分析 并没有逃逸出方法的话 那么就可能被优化成栈上分配。这样就无需在堆上分配内存 也无需进行垃圾回收了。
逃逸分析:当一个对象在方法中被定义 对象只在方法内部使用 则认为没有发生逃逸。如果这个对象被外部方法引用则认为发生逃逸。
-
使用逃逸分析 编译器可以对代码做如下优化:
-
栈上分配:将堆分配转化为栈分配。
-
同步省略:一个对象被发现只能从一个线程访问 那么这个对象的操作可以不考虑同步。也叫做锁消除。
-
分离对象或标量替换:
标量:基本数据类型。
聚合量:对象。可以分解成其它标量和聚合量。
在 JIT 阶段 如果经过逃逸分析 发现一个对象不会被外界访问的话 就会把这个对象拆解成若干标量
-XX:+EliminateAllocations 开启标量替换 默认打开
-
-
逃逸分析自身也需要进行一系列复杂的分析。如果经过逃逸分析后 发现没有一个对象是逃逸的 那这个逃逸分析过程就白白浪费了。
逃逸分析目前来说并不成熟,Hotspot中并没有使用栈上分配,而是使用标量替换,所以堆依然是存储对象的唯一选择
方法区
尽管所有的方法区在逻辑上是属于堆的一部分 但一些简单的实现可能不会选择去进行垃圾收集或者压缩。
对于HotSpot JVM 而言 方法区还有一个别名叫做 Non-Heap 目的就是要和堆分开。
所以方法区看作是一块独立于 Java 堆的内存空间。
-
方法区与堆一样 是各个线程共享的
-
方法区在 JVM 启动的时候被创建 在 JVM 关闭的时候被释放。实际物理内存中的空间可以是不连续的。
-
方法区的大小可以固定大小或者扩展
-
方法区的大小决定了系统可以保存多少个类 如果系统定义了太多的类 导致方法区溢出 会抛出 OOM
-
JDK7 以前习惯把方法区叫做永久代。JDK8 以后元空间取代了永久代。
方法区是 Oracle 虚拟机规范定义的标准。永久代或者元空间是对方法区的具体落地实现。
设置方法区大小
对于一个64位的服务端 JVM 来说
最大支持的内存大小为 2^64Bytes
设置元空间初始大小 -XX:MetaspaceSize=
设置元空间最大大小 -XX:MaxMetaspaceSize=
> jinfo -flag MetaspaceSize 11972
> -XX:MetaspaceSize=21807104 ≈ 21M(20.796875M)
jinfo -flag MaxMetaspaceSize 11972
-XX:MaxMetaspaceSize=18446744073709486080=2^64-2^16
在 HotSpot 中最小的分配内存为 64Kb = 2^16Bytes
向下对最小内存分配单元进行对齐,使其为最小内存分配单元的倍数
默认的21M是初始的高水位 一旦内存空间使用率触及到这个水位线 就会发生 Full GC 卸载掉不再使用的类。然后这个高水位的值会被重置 新的高水位值取决于 GC 后释放了多少元空间 如果释放的元空间不足 那么在不超过 MaxMetaspaceSize 的情况下会适当提高 如果释放过多 会适当降低。
方法区的内部结构
类型信息:
- 这个类的完整有效名
- 这个类直接父类的完整有效名
- 这个类的修饰符
- 这个类的直接接口的有序列表
域信息(字段信息)
- 字段名称
- 字段类型
- 字段修饰符
方法信息
- 方法名称
- 方法返回值和类型
- 方法参数的数量和类型
- 方法的修饰符
- 方法的字节码
- 异常表
声明为 static 的变量会随着类的加载而加载 成为类数据在逻辑上的一部分
final static 会被声明为常量 在编译的时候就会被确定 不会执行
<cinit>
方法
运行时常量池
什么是常量池:常量池是字节码的一部分 用于存放编译期生成的各种字面量和对类型、域和方法的符号引用
为什么需要常量池:Java 字节码需要的数据支持通常非常多 不能直接存到字节码文件里 换一种方式 可以存到常量池 字节码只包含了指向常量池的引用 在动态链接的时候就会生成运行时常量池。
运行时常量池:
- JVM 为每个已加载的类型都维护一个常量池 池中的数据项和数组一样都通过索引访问
- 运行时常量池中包含多种不同的常量 包含编译期已经明确的数值字面量 也包含运行期解析后才能获得的方法或字段引用 运行时常量池中不再是符号引用 而转为真实的物理地址
- 运行时常量池具备动态性。可能和 class 文件中的常量池不一样。
JIT 代码缓存
方法区的演进细节
JDK6以前:有永久代 静态变量存放在永久代上
JDK7:有永久代 但已经逐步"去永久代" 字符串常量池、静态变量都保存在堆中
JDK8以后:没有永久代 类型信息、字段、方法、运行时常量池保存在元空间。字符串常量池和静态变量仍在堆
为什么永久代会变元空间替换:
- 随着 JDK8 的到来 HotSpot JVM 不再使用永久代了。类的元数据信息被保存到与堆不相连的本地内存空间,这个空间叫做元空间
- 由于类的元数据被保存到本地内存中 元空间最大可分配内存就是系统可用内存空间
- 这项改动的原因:
- 永久代的空间设置大小难以确定
- 永久代的调优困难 Full GC
- JRockit 和 HotSpot 合并。JRockit 不需要永久代。
为什么字符串常量池要调整到堆空间:
永久代的回收效率很低 在 Full GC 的时候才会触发。而字符串在开发中会大量的使用 回收效率低会导致永久代内存不足。放到堆里能及时回收。
方法区的垃圾回收
在 Java 虚拟机规范 中对方法区的约束是非常宽松的 提到过可以不对虚拟机的方法区进行垃圾回收。
一般来说这个区域的垃圾回收效果都不太令人满意 尤其是类型卸载 条件相当苛刻。
但是这部分区域的垃圾回收又是必要的。
方法区的垃圾回收主要分为两部分内容:常量池中废弃的常量 和 不再使用的类型。
常量池中废弃的常量:
- final的
- 文本字符串
- 符号引用:类和接口的全限定类名、字段的描述、方法的描述
只要常量池中的常量没有任何地方被引用就可以被回收
不再使用的类型:需要同时满足:
- 该类所有的实例都被回收。Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器被回收。
- 该类的Class对象没有任何地方使用。无法通过反射访问到该对象。
满足条件后仅仅是被允许回收
对象的实例化、内存布局与访问定位
创建对象的步骤:
-
判断对象对应的类是否 装载 链接 初始化。
虚拟机遇到一条 new 指令 先去检查这个指令的参数能否在元空间的常量池定位到一个类的符号引用
并检查这个符号引用代表的类是否已经被 装载、链接、初始化。如果没有 在双亲委派模式下 使用当前类加载器 ClassLoader + 包名 + 类名 为 Key 查找 .class 文件。如果没有找到文件则抛出 ClassNotFoundException 如果找到 则进行类的加载 并生成对应的 Class 对象。
-
为对象分配内存空间
首先计算对象占用的空间大小 接着在堆中分配一块内存给新对象
如果内存规整 发生指针碰撞
如果内存不规整 虚拟机需要维护一个列表 在空闲的列表中进行分配
-
处理对象分配的并发问题
CAS保证更新原子性 或者 TLAB
-
初始化分配到的空间
为所有属性设置默认值
-
设置对象的对象头
-
执行
<init>
方法进行初始化
对象的内存布局
对象头:
1. 运行时元数据:
1. 哈希值(地址)
2. GC分代年龄
3. 锁状态标志
4. 线程持有的锁
5. 偏向线程ID
6. 偏向时间戳
2. 类型指针:指向类元数据
如果是数组还要存储数组长度
实例数据:对象中的有效信息 对象本身的字段(包括从父类继承下来的字段)
对齐填充:不是必须的 没有含义 仅仅起到占位符的通
访问定位
两种方式:
-
句柄访问
-
直接指针(HotSpot采用)
本地方法接口
Java 调用非 Java 代码的接口。初衷是融合 C/C++ 程序。
native 可以与所有其它的 Java 标识符连用,abstract 除外。
执行引擎
概述
-
"虚拟机"是相对"物理机"的一个概念。这两种机器都有代码执行能力。物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。虚拟机的执行引擎则是有软件自己实现。因此可以不受物理条件的制约,能够执行不被硬件支持的指令集格式。
机器指令与CPU紧密相关 所以不同种类的CPU对应的机器指令也不同
机器指令的可读性太差 于是人们发明了指令 不同的硬件平台 同一种指令对应的机器码不同
不同硬件平台各自支持的指令是有差别的 所以针对不同的平台有对应的指令集
指令的可读性也不高 于是人们发明了汇编语言 (助记符、标号、地址符号)
高级语言比汇编语言更接近人的语言
但不论是高级语言还是汇编语言都需要将程序解释/编译成机器语言的指令码
-
JVM 的主要任务是负责装载字节码文件到其内部 但字节码本身并不能够运行在操作系统之上 因为字节码指令并不等价于机器指令 其内部只是一些能够被 JVM 识别的字节码指令 符号表 以及一些其它辅助信息。
-
执行引擎的主要任务就是 将字节码指令解释/编译成对应平台的机器指令。执行引擎 充当了将高级语言翻译为机器语言的译者。
字节码是一种中间状态。即高级语言翻译成机器语言的中间态。比机器语言更加的抽象。
为什么需要字节码:
- JVM 支持多语言 需要字节码作为同一的规范
- 字节码转换成机器语言会更快
- 字节码具有一定的安全保密作用
Java 代码的编译和执行过程
- 前端编译过程:源码程序->词法分析->单词流->语法分析->抽象语法树
javac 使用 java 源文件 生成 class 文件
- 解释器:指令流->解释器->解释执行
在 Java 虚拟机启动时会根据预定义的规范 对字节码指令采用逐行解释的方式执行 对每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行
当一条字节码指令被解释执行完成 接着再根据 PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作
在 Java 的发展历史里 一共有两套解释器:
- 字节码解释器:通过纯软件代码模拟字节码的执行 效率低下
- 模板解释器:每一个字节码和一个模板函数相关联 模板函数中直接产生这条字节码执行时的机器码
- JIT编译器:优化器->中间代码->生成器->目标代码
虚拟机将源代码直接编译成本地机器平台相关的机器语言
HotSpot VM 采用解释器和 JIT 编译器并存的架构:
解释器保证了程序启动后的响应时间,省去了编译时间,立即执行。
随着时间的推移 JIT 编译器就会发生作用 将越来越多的代码编译成本地代码 获得更高效的执行效率。
同时在 JIT 编译器激进优化不成立的时候 解释器会发生作用
JIT 编译器
是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令 则需要根据代码被调用的执行频率而定。关于那些被编译为本地代码的字节码 也称为热点代码。JIT编译器在运行时会针对那些频繁被调用的热点代码做出深度优化。编译方式发生在执行过程中 因此也被称为栈上替换 OSR(on stack replacement)
-
热点代码:一个被多次调用的方法 或 一个方法体内循环次数较多的循环体。
-
热点代码的判断条件:采用热点探测功能 基于计数器。
HotSpot VM 会为每一个方法都建立两个不同类型的计数器:
- 方法调用计数器:用于记录方法被调用的次数
- 回边计数器:用于记录循环体执行的次数
-
计数器的阈值默认为 10000;(64位操作系统 server模式下)
-
热度衰减
计数器的统计并不是方法被调用的绝对次数 而是一个相对的执行频率 即一段时间内方法被调用的次数。当超过一定时间限度 如果计数器仍达不到阈值 那这个计数器就会减少一半 这个过程称为方法调用计数器的热度衰减 而这个时间段就称为此方法的 半衰周期。关闭热度衰减:-XX:-UseCounterDecay。
设置半衰周期的时间:-XX:CounterHalfLifeTime
VM option ‘CounterHalfLifeTime’ is develop and is available only in debug version of VM.
-
HotSpot VM 设置编译模式
-Xint:完全采用解释器模式
-Xcomp:完全采用编译器模式
-Xmixed:采用解释器和编译器的混合模式
-
C1和C2编译器
C1编译器用于 Client VM 模式下 进行简单和可靠的优化 耗时短
C2编译器用户 Server VM 模式下 进行耗时较长的激进优化 优化后的代码执行效率高
-
关于 AOT
AOT(静态提前编译器 Ahead Of Time Compiler)
即使编译是在程序运行过程中 将字节码指令转化为可以直接在硬件上运行的机器指令
AOT 则是在程序运行之间 将字节码转化为机器指令
字符串常量池
-
字符串常量池中不会存储相同的字符串
-
StringTable 是一个固定大小的 HashTable。
-
-XX:StringTableSize 设置其大小 JDK8以后最小设置为 1009。默认值是60013。
-
-
String 的内存分配
- 直接用双引号声明的Stirng对象会直接存储在常量池中。
- 如果不是用双引号声明的String对象可以用intern()方法存储在常量池中。
- JDK6 字符串常量池在永久代。JDK7/JDK8 在堆空间。
-
字符串的拼接操作
- 常量与常量的拼接结果在常量池 会在编译器直接进行优化(final 声明的字符串也会)
- 拼接的字符串中只要有一个是变量就会存储在堆空间 会使用 StringBuilder 的 append 方法
- 拼接的结果调用 intern 方法 主动将常量池中还没有的字符串对象放入池中 并返回地址
-
intern 的使用
-
new String(“ab”) 会创建几个对象
两个对象:#3 里字符串常量池放一个对象 #4 String 的构造方法在堆里创建一个对象
-
new String(“a”) + new String(“b”) 会创建几个对象
- #3 new StringBuilder
- #5 ldc a
- #6 new String
- #8 ldc b
- #6 new String
- 注意toString在这里的处理:并没有 ldc ab。所以此处生成的字符串并没有放入常量池。只是放入了堆中。
-
JDK6 和 JDK7/8 使用 intern 的区别
在 JDK6 中:
- 如果池中有则不会放入。返回已有字符串的地址。
- 如果池中没有。则会把字符串复制一份放入池中 返回池中字符串的地址。
在 JDK 7/8中:
1. 如果池中有则不会放入。返回已有字符串的地址。 1. 如果池中没有。则会把**堆中字符串的引用**复制一份 放入池中 返回池中的地址。
-
G1 会对堆空间中重复的字符串进行去重操作。
-
垃圾回收
为什么需要垃圾回收
- 对于高级语言来说 如果不进行垃圾回收 内存迟早会被消耗完
- 除了释放没用的对象 垃圾回收也可能清理内存里的内存碎片 碎片整理将所占用的内存移到堆的一端 以便 JVM 将整理出的内存分配给新的对象
- 随着程序应对的业务越来越庞大、复杂、用户越来越多 没有GC就不能保证应用程序的正常运行 频繁的STW的GC又跟不上实际需求 所以才会不断地尝试对GC进行优化
Java 垃圾回收机制
- 自动内存管理 无需开发人员手动参与内存分配与回收 降低内存泄漏和内存溢出的风险 将程序员从繁重的内存管理中释放出来 更专注于业务开发 弱化了开发人员在内存溢出和内存泄露时的定位和解决问题的能力 必须对自动化的技术实施必要的监控和调节
- 垃圾回收可以对年轻代回收 老年代回收 甚至是全堆和方法区的回收:频繁收集年轻代 较少收集老年代 基本不动方法区(元空间)
垃圾回收相关算法
标记阶段的算法
在堆里放着几乎所有的 Java 对象实例 在GC执行垃圾回收之前 首先需要区分出内存中哪些是存活对象 哪些是已经死亡的对象 只有被标记为死亡的对象 GC才会在执行垃圾回收时 释放掉其占用的内存空间 这个区分的过程被称为垃圾标记阶段 一般使用如下两种算法:
-
引用计数算法
- 为每个对象保存一个引用计数器属性 用于记录对象被引用的情况
- 对于一个对象A 只要有任何一个对象引用了A 则A的引用计数器就加1 当引用失效时 引用计数器就减1 只要对象A的引用计数器的值为0 即表示对象A不可能再被使用 可以进行回收
- 优点:实现简单 垃圾对象便于辨识 判定效率高 回收没有延迟
- 缺点:
- 需要单独存储引用计数器属性 增加了存储空间的开销
- 每次赋值都需要更新计数器属性 增加了时间开销
- 无法处理循环引用的情况 这是一条致命的缺陷 导致 Java 没有使用这类算法
-
可达性分析算法
- 以跟对象集合(GC Roots)为起点 按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
- 内存中存活的对象都会被GC Roots 直接或间接的连接着 搜索所走过的路径被称为 引用链
- 如果对象没有任何引用链相连 就意味着对象已经死亡 可以标记为垃圾对象
- 只有被 GC Roots 直接或间接连接的对象才是存活对象
- GC Roots 包括以下几类元素:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁synchronized持有的对象
- 虚拟机内部的引用:基本数据类型的Class对象 异常对象 系统类加载器
- 本地代码缓存
- 除了上述固定的 GC Roots 外 根据垃圾收集器回收的内存区域不同 还可以有其他对象 临时性的加入 共同构成整个 GC Roots 集合
清除阶段的算法
当成功区分出内存中存活的对象和死亡对象后 GC接下来的任务就是执行垃圾回收 释放掉无用对象所占用的内存空间 以便有足够的内存空间为新的对象分配内存 目前在 JVM 中比较场景的三种垃圾收集算法如下:
-
标记-清除算法
当有效内存空间被耗尽时 则会停止程序(STW)然后进行两项工作:
- 标记:GC 从根节点开始遍历 标记所有被引用的对象 一般在对象头中记录为可达对象
- 清除:GC 从堆内存从头到尾进行线性遍历 如果发现某个对象的对象头中没有被标记为可达对象 则将其回收
缺点:效率不高 在GC时需要停止整个应用程序 清除后的内存空间是不连续的 需要维护空闲列表。
-
复制算法
将活着的内存空间分为两块 每次只使用其中一块 在垃圾回收时将正在使用的内存块中的存活对象复制到未被使用的内存块中 之后清除正在使用的内存块中的所有对象 交换两个内存的角色 最后完成垃圾回收
优点:没有标记和清除过程 实现简单 运行高效 复制过去的对象保证内存空间的连续性 不会出现碎片问题
缺点:需要两倍的内存空间 如果系统中的垃圾对象过少 导致每次复制的效率很低 所以使用复制算法需要存活对象的数量不会太大才行 适用于年轻代
-
标记-整理算法
标记:从根节点开始标记所有被引用的对象
整理:将所有的存活对象压缩到内存的一端 按顺序排放 之后清理边界外所有的内存空间
标记-整理 最终效果等同于 标记-清除 执行完成后再进行一次内存整理 此时无需维护空闲列表
优点:消除了内存碎片
缺点:效率比较低 移动对象的时候还需要调整引用的地址
分代收集策略
在前面所有的算法中 并没有一种算法能完全替代其它算法 它们都具有自己独特的优势和特点。分代收集策略应运而生。
分代收集策略 基于这样一个事实:不同对象的生命周期是不一样的 因此可以堆不同生命周期的对象采用不同的收集算法 以便提高回收效率。
增量收集策略
现有的算法 在垃圾回收过程中 应用程序将处于一种 STW 的状态。在 STW 的状态下应用线程都会被挂起 等待垃圾回收完成。应用程序线程如果被挂起过久 就会影响用户体验或系统稳定性 为了解决这个问题 导致增量收集策略诞生。
基本思想:垃圾收集只收集一小片区域的内存空间 接着切换到应用程序线程 依次反复 直到垃圾收集完成
缺点:线程切换和上下文转换的消耗 导致垃圾回收的总体成本上升 造成系统吞吐量的下降
分区收集策略
在相同条件下 堆空间越大 一次GC时所需要的时间就越长
基本思想:将整个堆空间划分成连续的不同小区间 每个小区间都独立使用 独立回收 可以控制一次回收多个小空间
finalization 机制
Java 提供了对象终止机制来允许开发人员在对象被销毁之前自定义处理逻辑
-
当垃圾回收此对象之前 总先调用这个对象的 finalize() 方法
-
finalize() 方法允许在子类中被重写 未被执行时 对象会被插入到 F-Queue 队列中 由一个虚拟机自动创建 低优先级的 Finalizer 线程触发其 finalize()方法执行的时间是没有保障的 完全由GC线程决定
-
由于 finalize() 方法的存在 虚拟机中的西一般处于三种状态:
- 可触及的:从根节点开始 可以到达这个对象
- 可复活的:对象的所有引用都被释放 但是对象可能在 finalize() 中复活
- 不可触及的:对象的 finalize() 被调用 并且没有复活。不可触及的对象不可能被复活 因为 finalize() 方法只会被调用一次
-
finalize() 方法是对象逃脱死亡的最后机会 稍后GC会对 F-Queue 对了中的对象进行二次标记 如果对象在 finalize() 方法中与引用链上任何一个对象建立了联系 那么在二次标记的时候 对象会被移出 即将回收 的集合。如果之后对象再次出现没有引用的情况 finalize() 方法不会被再次调用 对象会直接变为不可触及的状态
System.gc() 的理解
默认情况下 System.gc() 或者 Runtime.getRuntime().gc() 的调用 会显示的触发 Full GC。同时对老年代和新生代进行回收。但是无法保证垃圾收集器的调用。
内存溢出与内存泄露
内存溢出:没有空闲内存 并且垃圾收集器也无法提供更多内存。
内存泄露:对象不会再被程序用到了 但GC又不能回收他们 或者 一些对象的生命周期很长导致 OOM。
单例模式:单例模式的生命周期和应用程序一样长 所以单例程序中 如果持有对外部对象的引用的话 那么这个外部对象是不能被回收的 会导致内存泄露的产生。
一些提供close的资源未关闭导致内存泄露:数据库连接、网络连接、IO连接
Stop The World
- 在GC事件发生的过程中 会产生应用程序的停顿。整个应用程序都会被暂停 没有任何响应。
- 可达性分析算法中枚举根节点会导致 Java 执行线程停顿
- 分析工作必须在一个确保一致性的快照中进行
- 如果分析工作进行时关系在不断变化 则分析结果的准确性无法保证
- 可达性分析算法中枚举根节点会导致 Java 执行线程停顿
- STW 事件在哪款 GC 中都会发生
- STW 是 JVM 在后台自动发起的和自动完成的
- 开发中不要使用 System.gc() 会导致 STW 的发生
垃圾回收的并行与并发
串行:指单线程执行垃圾回收工作
并行:指多条垃圾收集线程并行工作 但此时用户线程仍处于等待状态
并发:指用户线程和垃圾回收线程同时执行 垃圾回收线程在执行时不会停顿用户线程
安全点和安全区域
程序执行时并非在所有地方都能停顿下来开始 GC 只有在特定的位置才能停顿下来开始GC 这些位置成为 安全点
安全点的选择:如果太少可能导致GC等待的时间太长 如果太多可能导致运行的性能问题。大部分指令的执行时间都非常短暂 通过会根据 是否具有让程序长时间执行的特征 为标准。如:选择一些执行时间较长的指令作为安全点。方法调用、循环跳转、异常跳转等。
如何检查所有线程都到最近的安全点:
- 抢先式中断:首先中断所有线程 如果还有线程不在安全点 就恢复线程 让线程跑到安全点。(目前没有虚拟机采用)
- 主动式中断:设置一个中断标志 各个线程运行到安全点的时候主动轮询这个标志 如果中断标志为真 则自己进行中断挂起
安全点 保证了程序执行时在不太长的时间内就会遇到可进入 GC 的安全点。到那时如果线程不执行的时候 例如线程处于 Sleep 或者 Blocked 状态 这时候线程无法响应 JVM 的中断请求 JVM 也不太可能等待线程被唤醒 对于这种情况 就需要安全区域来解决。
安全区域:指在一段代码片段中 对象的引用关系不会发生变化 在这个区域中的任何位置开始 GC 都是安全的。
对象引用
-
强引用:不回收
最传统的引用的定义 无论任何情况下 只要强引用关系还在 垃圾回收器就永远不会回收掉被引用的对象
-
软引用:内存不足即回收
在系统要发生内存溢出之前 将会把对象列入回收范围之中进行二次回收 如果这次回收后还没有足够的内存 才会抛出内存溢出异常
new SoftReference<>(obj)
-
弱引用:发现即回收
当垃圾收集器工作时 无论内存空间是否足够 都会回收掉被弱引用关联的对象
new WeekReference<>(obj)
-
虚引用:对象回收跟踪
完全不会对其生存时间构成影响 也无法通过虚引用来获得一个对象的实例 唯一目的就是在这个对象被收集器回收时收到一个系统通知
new PhantomReference<>(obj,phantomQueue)
-
终结器引用
垃圾回收器
垃圾收集器没有在规范中进行过多的规定 可以由不同的厂商 不同版本的 JVM 来实现 由于 JDK 的版本处于高速迭代过程中 因此 Java 发展至今已经衍生了众多 GC 版本
GC性能指标
吞吐量:运行用户代码的时间占总运行时间的比例
吞吐量优先:应用程序能容忍较高的暂停时间
垃圾收集开销:吞吐量的补数 垃圾收集占总运行时间的比例
暂停时间:执行垃圾收集时 程序的工作线程被暂停的时间
暂停时间优先:尽可能让单次 STW 的时间最短
收集频率:相对应用程序的执行 收集操作发生的频率
内存占用:Java 堆区占用的内存大小
快速:一个对象从诞生到被回收所经历的时间
高吞吐量和低延迟是一对相互竞争的目标:
吞吐量优先必然降低内存回收的执行频率 这就需要更长的GC时间来回收
低延迟就需要更频繁的执行内存回收 导致程序的吞吐量下降
现在的标准:在最大吞吐量的情况下 降低停顿时间
垃圾收集器发展史
- JDK1.3 Serial GC 第一款GC。ParNew 是 Serial GC 的多线程版本。
- JDK1.4 Parallel GC 和 CMS。
- JDK6 将 Parallel GC 设置为 HotSpot 的默认GC。
- JDK7 中 G1 可用。
- JDK9中 G1 变为默认收集器 以代替 CMS。
- JDK11中 引入 ZGC。
JDK9以后默认都是G1。JDK8默认是 Parallel GC 和 Parallel Old。
经典垃圾回收器:
串行回收器:Serial 、Serial Old
并行回收器:ParNew 、Parallel Scavenge、Parallel Old
并发回收器:CMS、G1
垃圾收集器与分代之间的关系:
新生代收集器:Serial 、ParNew 、Parallel Scavenge
老年代收集器:Serial Old、Parallel Old、CMS
整堆收集器:G1
如何查看默认的垃圾收集器:
- -XX:+PrintCommandLineFlags:查看命令行相关参数
- jinfo -flag 相关拉机器回收参数 进程ID
Serial 回收器:串行回收
- JDK1.3 之前回收新生代的唯一选择
- 作为 Client 模式下默认的新生代收集器
- 采用复制算法、串行回收、STW机制的方式执行内存回收
- Serial 提供执行老年代收集器 Serial Old。Serial Old 采用标记-压缩算法、串行回收。
ParNew 回收器:并行回收
- 是 Serial GC 的多线程版本。只能处理新生代。
- 采用复制算法、并行回收、STW机制的方式执行内存回收
- -XX:+UseParNewGC 手动开启 ParNew 回收器
- -XX:ParallelGCThreads 限制线程处理 默认开始和CPU数据相同的线程数
注意:在单核CPU的环境下 ParNew 收集器不比 Serial 收集器高效。CPU频繁的做任务切换也会产生一些额外的开销。
Parallel 回收器:吞吐量优先
- Parallel Scavenge:采用复制算法、并行回收、STW机制的方式执行内存回收
- 为什么 Parallel Scavenge 要出现:
- 达到可控制的吞吐量:高效的利用CPU时间 尽快完成运算任务 主要使用在不需要太多交互的后台运算 如:批量处理订单、工资支付、科学计算等
- 增加自适应调节策略
- JDK6提供:Parallel Old 用于收集老年代。采用标记-压缩算法
- -XX:+UseParallelGC 手动指定年轻代使用 Parallel 并行收集器
- -XX:+UseParallelOldGC 手动指定老年代使用 Parallel Old GC
- UseParallelGC 和 UseParallelOldGC 默认开启一个 另一个也会被开启 相互激活
- -XX:ParallelGCThreads 设置并行收集器的线程数 一般最好与CPU默认数量相等 当CPU小于8 默认等于CPU数量 当CPU大于8个 默认值为3+[(5*CPU数量)/8] => 16核的CPU 默认值为13
- -XX:MaxGCPauseMillis 设置收集器的最大停顿时间
- -XX:GCTimeRatio 垃圾收集时间占总时间的比例 默认99
- -XX:+UseAdaptiveSizePolicy 设置自适应调节策略
- 这种模式下年轻代的 Eden 和 Survivor 的比例 晋升老年代对象的年龄会自动调整 已达到堆大小、吞吐量、停顿时间的平衡点
CMS 回收器:低延迟
- JDK5 时 HotSpot 推出了一款在强交互中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器。 这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器 它第一次实现了让垃圾收集线程和用户线程同时工作。
- CMS的垃圾收集采用标记-清除算法
- CMS 作为老年代收集器 却无法与 Parallel Scavenge 配合 所以只能与 Serial 和 ParNew 配合使用
- 初始标记:所有工作线程都会因为STW出现短暂的暂停 仅仅标记出直接与 GC Roots 能直接关联到的对象
- 并发标记:从GC Roots 直接关联到的对象开始变量整个对象图 这个过程耗时较长 但是不需要暂停用户线程
- 重新标记:修正并发期间 因用户程序继续运作而导致标记变动的那一部分对象的标记记录 通常回避初始标记阶段稍微长一些 但远比并发标记时间短
- 并发清理:清理掉标记阶段已经死亡的对象 由于不需要移动存活对象 所以也可以与用户线程并发执行
在 CMS 回收过程中 应该确保应用程序的用户线程有足够的内存可用 因此 CMS 不能像其它收集器那样等到老年代几乎全部被填满了再进行收集 而是当堆内存使用率达到某一阈值时 便开始进行回收 要是CMS运行期间预留的内存无法满足程序需要就会出现 **Concurrent Mode Failure **这时虚拟机将启动后备预案 临时启用 Serial Old 来重新进行老年代的收集。
CMS 收集器的垃圾算法采用 标记-清除 不可避免的会产生一些内存碎片 只能够选择空闲列表执行内存分配
为什么 Concurrent-Mark-Sweep 不能换成 Concurrent-Mark-Compact:
当并发清除时 用户线程还在使用内存 要保证用户线程的资源不受影响。
缺点: 会产生垃圾碎片 对CPU资源非常敏感 无法处理浮动垃圾
浮动垃圾:在并发标记阶段如果产生新的垃圾 CMS无法立即对这些垃圾进行标记 只能在下一次执行GC的时候来释放这些内存空间
- -XX:+UseConcMarkSweepGC 手动开启 CMS。自动开启ParNewGC。
CMS在 JDK9 中被 Deprecate。在 JDK14 中被remove。
G1 回收器:区域划分代式
G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量 担起全功能收集器的重任与期望。
-
优势:
- 兼顾并行和并发
- 兼顾老年代和年轻代
- 空间整合 没有内存碎片:采用复制算法和标记-压缩
- 可预测的停顿时间模型:每次根据允许的收集时间 优先回收价值最大的区域 保证了 G1 收集器在有限的时间尽可能高的收集效率
-
为什么叫做 G1(Garbage First):垃圾优先
- G1 是一个并行收集器 把堆内存分割为很多不想关的区域(Region)物理上不连续的空间。
- G1 跟踪各个区域里垃圾堆积的价值大小 在后台维护一个优先列表 每次根据允许的收集时间 优先回收价值最大的区域
- 由于这种方式侧重点在于回收垃圾量最大的区间 所以名为垃圾优先
-
-XX:+UseG1GC 开启 G1收集器。在 JDK9中默认为G1。
-
-XX:G1HeapRegionSize 根据最小的 Java 堆大小划分出2048个区域 4M
-
-XX:MaxGCPauseMillis 最大GC停顿时间指标 默认 200ms
-
-XX:InitiatingHeapOccupancyPercent 堆空间占用比达到 45% 老年代并发标记
一个 Regin 可能属于 Eden/Survivor 或者 Old 内存区域。但是一个 Regin 只可能属于一个角色。G1收集器还增加了一个新的内存区域 叫做 Humongous 内存区域 主要用于存储大对象 超过 1.5个 Region 就放到H区。
为什么要设置 H区:对于堆中的大对象 默认会分配到老年代 但如果是一个短期存在的大对象 会对垃圾收集器造成负面影响。
- Remembered Set
- 避免全局扫描
- 每个 Region 都有一个对应的 Remembered Set 记录当前Region中哪些对象被外部引用指向
- 每次 Reference 类型数据写操作时 都会产生一个 Write Barrier 暂时中断操作
- 检查写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region
- 如果不同 通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 的 Remembered Set
- 当垃圾回收时 GC 根节点的枚举范围加入 Remembered Set 保证不进行全局扫描
G1 垃圾回收的环节:
-
年轻代GC
并行的独占式收集
- 根扫描:GC Roots 和 Remembered Set 记录 作为存活对象的入口。
- 更新 Remembered Set
- 处理 Remembered Set
- 复制对象
- 处理引用
-
年轻代GC + 老年代并发标记过程
- 初始标记:只标记和 GC Roots 直接关联的对象
- 根区域标记:标记 Survivor 区直接可达的老年代区域对象
- 并发标记:此时若发现区域对象中所有对象都是垃圾会立即回收
- 再次标记:修正上一次的标记结果
- 独占清理:计算各个区域存活对象和GC回收比例并排序 统计过程 不会清理
- 并发清理:识别并清理完全空闲的区域
-
混合回收(年轻代GC + 老年代GC)
越来越多的对象晋升到老年代的时候 为了避免堆内存耗尽 虚拟机会触发 Mixed GC。回收一部分老年代。
-
单线程、独占式、高强度的 FullGC 还是存在 它针对GC的失败提供了一种保护机制 即强力回收。触发原因:并发处理过程完成之前内存耗尽