1 前章

1.1 JVM的架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。

具体来说:这两种架构之间的区别:

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统

  • 避开了寄存器的分配难题:使用零地址指令方式分配

  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现

  • 不需要硬件支持,可移植性好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机

  • 指令集架构则完全依赖硬件,可移植性差

  • 性能优秀和执行更高效

  • 花费更少的指令去完成一项操作

  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

基于栈式架构实现计算流程,指令更多

这是Java代码

这是反编译之后的代码

基于寄存器的计算流程

1.2 常见的JVM

Sun Classic VM

  • 1996年Java1.0版本时,Sun公司发布的一款虚拟机,是世界上第一款商用Java虚拟机,JDK1.4时完全被淘汰

  • 这款虚拟机内部只提供解释器

  • 如果使用JIT编译器,需要进行外挂。一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。二者不能配合工作

  • 现在hotspot内置了此虚拟机

Exact VM

  • 为了解决上一个虚拟机问题,jdk1.2时,sun提供了此虚拟机

  • Exact Memory Management:准确式内存管理

也可以叫Non-Conservative/Accurate Memory Management

虚拟机可以知道内存中某个位置的数据具体是什么类型

  • 具备现代高性能虚拟机的雏形

热点探测

编译器与解释器混合工作模式

  • 只是Solaris平台短暂使用,其他平台上还是classic vm

英雄气短,终被Hotspot虚拟机替换

Sun公司的 HotSpot VM

  • HotSpot历史

最初由一家名为“Longview Technologies”的小公司设计

1997年,此公司被Sun收购;2009年,Sun公司被甲骨文收购

  • JDK1.3时,HotSpot VM成为默认虚拟机

目前HotSpot占有绝对的市场地位

JDK6、JDK8、JDK11、JDK17默认使用的虚拟机都是HotSpot

Sun/Oracle JDK和OpenJDK的默认虚拟机

  • 从服务器、桌面到移动端、嵌入式都有应用

  • 名称中的HotSpot指的就是它的热点代码探测技术

通过计数器找到最具编译价值代码,触发即时编译或栈上替换

通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

BEA的 JRockit

  • 专注于服务端应用

它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行

  • 大量的行业基准测试显示,JRockit JVM是世界上最快的JVM

使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达50%)

  • 优势:全面的Java运行时解决方案组合

JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要

MissionControl服务套件,它是一组以极低的开销来监控、管理和分析生产环境中环境中的应用程序的工具

  • 2008年,BEA被Oracle收购

  • Oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合方式是在HotSpot的基础上,移植JRockit的优秀特性

IBM的J9

  • 全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9

  • 市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM

  • 广泛用于IBM的各种Java产品

  • 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机(猜测配合IBM的产品很快,不配合IBM产品不一定)

  • 2017年左右,IBM发布了开源J9 VM,命名OpenJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9

2 中章

2.1 类加载器及类加载过程

2.1.1 总体流程

  • 类加载器子系统负责从文件系统或网络中加载class文件,class文件在文件开头有特定的文件标识

  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定

  • 加载的类信息存放于一块称为方法区(JDK8之后永久代改成了元空间)的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射)

类加载器ClassLoader角色

1、class file存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的的实例。

2、class file加载到JVM中,被称为DNA元数据模板,放在方法区

3、在.class文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色

类的加载阶段

加载:

1、通过一个类的全限定名获取定义此类的二进制字节流

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

链接:

验证(Verify):

  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全

  • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符合引用验证

准备(Prepare):

  • 为类变量分配内存并且设置该类变量的默认初始值,即零值

  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化

  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java

解析(Resolve):

  • 将常量池内的符号引用转换为直接引用的过程

  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等

初始化:

  • 初始化阶段就是执行类构造器方法<clinit>()的过程

  • 此方法不需要定义。是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕

  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

tips:其实初始化阶段,就是对一些静态变量进行赋值,执行静态代码块(此过程会加锁)

2.1.2 类加载器的分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)自定义类加载器(User-Defined ClassLoader)

  • 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

  • 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示

在jdk1.9以后,扩展类加载器(ExtClassLoader)被平台类加载器(PlatformClassLoader)替代,URLClassLoader也被BuiltinClassLoader替代

package classloading;

/**
 * @author lmq
 * @version 1.0
 * @datetime 2025/4/17 21:37
 **/
public class ClassLoaderTest {

    public static void main(String[] args) {

        // 系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.err.println(systemClassLoader);// jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b

        // 获取其上层,平台类加载器(Java10之前是扩展类加载器)
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.err.println(extClassLoader);// jdk.internal.loader.ClassLoaders$PlatformClassLoader@776ec8df

        // 获取其上层:获取不到引导类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.err.println(bootstrapClassLoader);// null

        // 对于用户自定义类来说:默认使用系统类加载器加载
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.err.println(classLoader);// jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b

        // String类使用引导类加载器进行加载  --> Java核心类库都是使用引导类加载器加载
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.err.println(classLoader1);// null

    }
    
}

虚拟机自带的加载器

JDK9之后,sun.misc.Launcher的实现就放到jdk.internal.loader.ClassLoaders了

  • 启动类加载器(引导类加载器,Bootstrap ClassLoader)

    • 这个类加载使用C/C++语言实现的,嵌套在JVM内部

    • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

    • 并不继承java.lang.ClassLoader,没有父加载器

    • 加载扩展类(JDK9之后替换成平台类加载器)和应用程序类加载器,并指定为他们的父类加载器

    • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类(后面版本的JDK肯定会有更多的包名前缀)

  • 扩展类加载器(Extension ClassLoader)

    • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现

    • 派生于ClassLoader类

    • 父类加载器为启动类加载器

    • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的jar放在此目录下,也会自动由扩展类加载器加载

  • 应用程序类加载器(系统类加载器,AppClassLoader)

    • java语言编写,由sun.misc.Launcher$AppClassLoader实现

    • 派生于ClassLoader类

    • 父类加载器为扩展类加载器

    • 它负责加载环境变量classpath或系统属性 java.class.path指定路径下的类库

    • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载

    • 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

引导类加载器能够加载的包路径

2.1.3 双亲委派机制

工作原理

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行

  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器

  • 如果父类加载器可以完成类加载任务,就成功返回,若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

优势

  • 避免类的重复加载

  • 保护程序安全,防止核心API被随意篡改

类的主动使用与被动使用

  • 在JVM中,表示两个class对象是否为同一个类存在两个必要条件:

    • 类的完整类名必须一致,包括包名

    • 加载这个类的classLoader(指classLoader实例对象)必须相同

  • 换句话说,在JVM中,即使这两个类对象(class对象)来源同一个class文件,被同一个虚拟机所加载,但只要加载它们的classLoader实例对象不同,那么这两个类对象也是不相等的

  • JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的

Java程序对类的使用方式分为:主动使用和被动使用

  • 主动使用,又分为七种情况:

    • 创建类的实例

    • 访问某个类或接口的静态变量,或者对该静态变量赋值

    • 调用类的静态方法

    • 反射

    • 初始化一个类的子类

    • Java虚拟机启动时被标明为启动类的类

    • JDK7开始提供的动态语言支持:

java.lang.invoke.MethodHandle实例的解析结果

REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

  • 除了以上七种情况,其它使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化

2.2 运行时数据区

2.2.1 总体结构

下图是运行时数据区的总体结构概览图。其中本地方法栈,程序计数器,虚拟机栈都是随着线程的创建而创建的,生命周期跟随线程的(每个线程独有的)。而堆和元数据区是随着JVM的启动而启动,生命周期跟随进程的(所有线程共用的)

JVM中的线程说明

  • 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行

  • 在HotSpot JVM里,每个线程都与操作系统的本地线程直接映射。

    • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收

  • 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法

  • 如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main的main线程以及所有这个main线程自己创建的线程

  • 这些主要的后台系统线程在HotSpot JVM里主要是以下几个:

    • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销

    • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行

    • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持

    • 编译线程:这种线程在运行时会将字节码编译城本地代码

    • 信号调度线程:这种线程接受信号并发送给JVM,在它内部通过调用适当的方法进行处理

2.2.2 程序计数器(PC寄存器)

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令

  • 它是一块很小的内存空间(只记录了下一条指令的地址),几乎可以忽略不记。也是运行速度最快的存储区域

  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致

  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域(内存溢出)

程序计数器示例

2.2.3 虚拟机栈

2.2.3.1 虚拟机栈主要特点

Java虚拟机栈是什么?

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。

是线程私有的

生命周期

生命周期和线程一致

作用

主管Java程序的运行,它保存方法的局部变量(基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回

栈的特点(优点)

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)

    • 执行结束后的出栈工作

  • 对于栈来说不存在垃圾回收问题

2.2.3.2 栈中可能出现的异常

  • Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

    • 如果采用固定大小的Java虚拟机栈,那每个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常

    • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常

启动Java程序时,设置-Xss参数可以改变栈的大小

具体的其它参数设置可以参考oracle官网

https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html

2.2.3.3 栈的存储结构和运行原理

2.2.3.3.1 概述
  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在

  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)

  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出” / “后进先出” 原则

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Fram),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作

  • 如果在该方法中调用了其它方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前帧

栈帧的内部结构

  • 局部变量表(Local Variables)

  • 操作数栈(Operand Stack)(或表达式栈)

  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)

  • 方法返回地址(Return Address)(或方法正常退出或异常退出的定义)

  • 一些附加信息

2.2.3.3.2 局部变量表(local variables)
  • 局部变量表也称为局部变量数组或本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题(线程安全)

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的

  • 方法嵌套调用次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁


操作数栈、方法返回地址、动态链接、附加信息之类的,以后再看


2.2.3.4 本地方法栈

什么是本地方法?

简单来说,一个Native Method就是一个Java调用非Java代码的接口(一般是c或者c++来实现)。


其它相关本地方法接口内容,以后再看


Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区

  • 它甚至可以直接使用本地处理器中的寄存器

  • 直接从本地内存的堆中分配任意数量的内存

2.2.4 堆

2.2.4.1 核心概述

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。(堆内存的大小是可以调节的)

  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)

  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。

  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆,是GC执行垃圾回收的重点区域

内存细分

Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

堆空间大小的设置

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项“-Xmx”和“Xms”来进行设置

    • “-Xms”用于表示堆区的起始内存 “-Xmx”用于表示堆区的最大内存

  • 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常

  • 通常会将“-Xms”和“-Xmx”两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

  • 默认情况下,初始内存大小:物理电脑内存大小 / 64 最大内存大小:物理电脑内存大小 / 4

2.2.4.2 年轻代和老年代

  • 存储在JVM中的Java对象可以被划分为两类:

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速

    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致

  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

  • 其中年轻代又可以划分为Eden(伊甸园)空间、Survivor0(幸存者0)空间和Survivor1(幸存者1)空间(有时也叫做from区、to区)

参数配置:

  • 配置新生代与老年代在堆结构的占比

    • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

    • 若修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

  • 在HotSpot中,Eden空间和另外两个Survivor空间默认所占的比例是8:1:1

  • 开发人员可通过选项“-XX:SurvivorRatio”调整这个空间比例。比如-XX:SurvivorRatio=8

    • -XX:-UseAdaptiveSizePolicy 可以关闭自适应的内存分配策略

  • 几乎所有的Java对象都是在Eden区被new出来的

  • 绝大部分的Java对象的销毁都在新生代进行了

    • IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”

  • 可以使用选项“-Xmn”设置新生代最大内存大小

    • 这个参数一般使用默认值就可以了

2.2.4.3 对象分配过程

1、new的对象先放伊甸园区。此区有大小限制

2、当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收,将伊甸园区中的不再被其它对象所引用的对象进行销毁。再加载新的对象放到伊甸园区

3、然后将伊甸园中的剩余对象移动到幸存者区0区

4、如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区

5、如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区

6、啥时候能去养老区呢?可以设置次数。默认是15次

可以设置参数:-XX:MaxTenuringThreshold=<N>进行设置

7、在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理

8、若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常

总结:

针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to

关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集

常用调优工具

JDK命令行

Eclipse:Memory Analyzer Tool

Jconsole

VisualVM

Jprofiler

Java Flight Recorder

GCViewer

GC Easy

2.2.4.4 Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域(新生代、老年代;元空间)一起回收的,大部分时候回收的都是新生代

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC

  • 部分收集:不是完整收集整个Java堆的垃圾回收。其中又分为:

    • 新生代收集(Minor GC / Young GC):只是新生代(Eden、S0、S1)的垃圾回收

    • 老年代收集(Major GC / Old GC):只是老年代的垃圾回收

      • 目前,只有CMS GC会有单独收集老年代的行为

      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收

    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集

      • 目前,只有G1 GC会有这种行为

  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

年轻代GC(Minor GC)触发机制:

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次Minor GC会清理年轻代的内存)

  • 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解

  • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

老年代GC(Major GC / Full GC)触发机制:

  • 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了

  • 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parellel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)

    • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC

  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长

  • 如果Major GC后,内存还不足,就报OOM了

Full GC触发机制:

触发Full GC执行的情况有如下五种:

1、调用System.gc()时,系统建议执行Full GC,但是不必然执行

2、老年代空间不足

3、方法区空间不足

4、通过Minor GC后进入老年代的平均大小大于老年代的可用内存

5、由Eden区、survivor0区向survivor1区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

Full GC是开发或调优中尽量要避免的。这样暂停时间会短一些

2.2.4.5 堆空间的分代思想

分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一起,GC的时候就会对堆的所有区域进行扫描。而且很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来

2.2.4.6 内存分配策略

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden

  • 大对象直接分配到老年代

    • 尽量避免程序中出现过多的大对象

  • 长期存活的对象分配到老年代

  • 动态对象年龄判断

    • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

  • 空间分配担保

    • -XX:HandlePromotionFailure

2.2.4.7 TLAB(Thread Local Allocation Buffer)

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据

  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内

  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选

在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间

默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWastetargetPercent”设置TLAB空间所占用Eden空间的百分比大小

一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

对象分配过程:TLAB