JVM基础知识
JVM概述
- Java 虚拟机是一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的字节码也未必由 Java 语言编译而成(只要其他程序语言编译的字节码文件能够遵循 JVM 规范,就可以在 JVM 上运行)。
- JVM 平台的各种语言可以共享 Java 虚拟机带来的跨平台性、优秀的垃圾回收器,以及可靠的即时编译器。
- Java 技术的核心就是 Java 虚拟机,因为所有的 Java 程序都是运行在 Java 虚拟机内部。
- JVM 是运行在计算机操作系统之上的,与硬件没有直接的交互。
作用
Java 虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释编译为对应平台上的机器指令执行。每一种指令,Java 虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
特点
- 一次编译,到处运行。
- 自动内存管理。
- 自动垃圾回收功能。
整体结构
由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。
基于栈的指令优点是跨平台,指令集更小,编译器容易实现,缺点是性能比寄存器差,实现同样的功能需要更多的指令。
详细结构
Java代码的执行流程
JVM生命周期
虚拟机的启动
Java 虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始化类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
虚拟机的执行
一个运行中的 Java 虚拟机有着一个清晰的任务:执行 Java 程序。
程序开始执行时他才运行,程序结束时他就停止。
执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机的进程。
虚拟机的退出
有如下的几种情况:
- 程序正常执行结束。
- 程序在执行过程种遇到了异常或错误而异常终止。
- 由于操作系统出现错误而导致 Java 虚拟机进程终止。
- 某线程调用 Runtime 类或 System 类的 exit() 方法,或 Runtime 类的 halt() 方法,并且 Java 安全管理器也允许这次 exit 或 halt 操作。
- 除此之外,JNI(Java Native Interface)规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机的退出情况。
Class Loader SubSystem
- 类加载器子系统负责从文件系统或者网络中加载 class 文件,class 文件在文件开头有特定的文件标识。
- ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 ExecutionEngine 决定。
- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 Class 文件中常量池部分的内存映射)
类的加载过程
加载
- 通过一个类的全限定名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
链接
验证(Verify)
- 目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备(Prepare)
- 为类变量分配内存并且设置该类变量的默认初始值,即零值。
- 这里不包括用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化。
- 这里不会为实例变量分配初始值,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
解析(Resolve)
- 将常量池内的符号引用转换为直接引用的过程。
- 事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info、CONSTANT_Filedref_info、CONSTANT_Methodref_ref 等。
初始化
- 初始化阶段就是执行构造方法 clinit() 的过程。clinit() 不同于类的构造器。(关联:类的构造器是虚拟机视角下的 init())
- 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
- 构造器方法中指令按语句在源文件中出现的顺序执行。
- 若该类具有父类,JVM 会保证子类的 clinit() 执行前,父类的 clinit() 已经执行完毕。
- 虚拟机必须保证一个类的 clinit() 方法在多线程下被同步加锁。
总结
类加载分为以下5个步骤:
- 加载:根据查找路径找到相应的class文件然后导入
- 验证:检查加载的class文件的正确性
- 准备:给类中的静态标量分配内存空间
- 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址
- 初始化:对静态变量和静态代码块进行初始化工作
类加载器的分类
- JVM 支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
- 从概念上讲,自定义类加载器一般指的是程序中由开发人员自定义的一种类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。
- 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有三个。
启动类加载器(Bootstrap ClassLoader)
也叫引导类加载器
- 这个类加载器使用 C/C++ 语言实现,嵌套在 JVM 内部。
- 它用来加载 Java 的核心库(JAVA——HOME/jre/lib/rt.jar、resource.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类。
- 并不继承自 java.lang.ClassLoader,没有父加载器。
- 加载扩展类加载器和应用程序类加载器,并指定为他们的父类加载器。
- 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。
扩展类加载器(Extension ClassLoader)
- Java 语言编写,由 sun.misc.Launcher$ClassLoader 实现。
- 派生于 ClassLoader 类。
- 父类加载器为启动类加载器。
- 从 java.ext.dires 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/lib/ext 子目录(扩展目录)下加载类库。如果用户创建的 jar 放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(AppClassLoader)
也叫系统类加载器
- Java 语言编写,由 sun.misc.Launcher$AppClassLoader 实现。
- 派生于 ExtensionClassLoader 类。
- 父类加载器为扩展类加载器。
- 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库。
- 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载。
- 通过 ClassLoader#getSystemClassLoader() 方法可以获取到该类加载器。
用户自定义类加载器
在 Java 的日常应用程序开发中,类的加载几乎是由上述三种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
自定义类加载器的作用
- 隔离加载类。
- 修改类加载的方式。
- 扩展加载源。
- 防止源码泄漏。
实现步骤
- 通过继承抽象类 java.lang.ClassLoader 类的方式,实现自己的类加载,以满足一些特殊的需求。
- 在 JDK1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass() 方法,从而实现自定义的类加载类,但是 JDK1.2 之后已经不在建议用户去覆盖 loadClass() 方法,而是建议把自定义的类加载逻辑写在 findClass() 中。
- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
双亲委派机制
工作原理
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
优点
- 避免类的重复加载。
- 保护程序安全,防止核心 API 被随意篡改。
- 例如用户自定义 Java 核心类库中的类:java.lang.String
沙箱安全机制
若用户定义了一个类,该类的类名和包名都和 Java 核心类库中的某一个类相同时,JVM 将不会加载用户的自定义类,而是加载 Java 核心类库中的类。这样可以保证对 Java 的核心源代码保护。
例如用户自定义一个java.lang 包,之后又在该包下自定义一个 String 类。
类
类相等两个必要条件
- 类的包名和完整类名必须一致。
- 加载这个类的 ClassLoader 必须相同。
对类加载器的引用
JVM 必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM 需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用
主动使用
- 创建类的实例。
- 访问某个类或接口的静态变量,或者对该静态变量赋值。
- 调用类的静态方法。
- 反射(例如:Class.forName(“ClassName”))
- 初始化一个类的子类。
- Java 虚拟机启动时被标明为启动类的类。
- JDK7 开始提供的动态语言支持:java.lang.invoke.MethodHandle 实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化。
除了以上几种情况,其他使用 Java 类的方式都被看做类的被动使用,都不会导致类的初始化。
Runtime Data Area
运行时数据区结构
线程划分结构图

线程独立区域:程序计数器、栈、本地栈。
线程共享区域:堆、方法区。
在 Hotspot JVM 中,每个线程都与操作系统的本地线程直接映射。
- 当一个 Java 线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java 线程执行终止后,本地线程也会回收。
- 操作系统负责所有线程的安排调度到任何一个可用的 CPU 上。一旦本地线程初始化成功,他就会调用 Java 线程中的 run() 方法。
Hotspot JVM 的主要后台线程
- 虚拟机线程:这种线程的操作是需要 JVM 达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要 JVM 达到安全点,这样堆才不会变化。这种线程的执行类型包括 “stop-the-world” 的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程:这种线程对在 JVM 里不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译成到本地代码。
- 信号调度线程:这种线程接收信号并发送给 JVM,在他内部通过调用适当的方法进行处理。
PC Register
JVM 的程序计数寄存器(Program Counter Register)中,Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的现场信息。CPU 只有把数据装载到寄存器才能够运行。
PC Register 并非物理寄存器,它只是对物理 PC 寄存器的一种抽象模拟。
作用
PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码,由执行引擎读取下一条指令。
- PC Register 是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行本地方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解译器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
相关面试题
为什么 PC 寄存器要设置为线程私有?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,所以要为每一个线程都分配一个 PC 寄存器,让每一个线程都可以进行独立运算,从而不会出现相互干扰的情况。由于 CPU 时间片限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然会导致经常中断或者恢复,因此需要把 PC 寄存器设为线程私有的。
PC 寄存器存储字节码指令地址(当前线程的执行地址)的作用?
CPU 需要不停地切换各个线程,当线程被切换回的时候,就得知道接着从哪里开始执行。JVM 的字节码解译器就需要通过改变 PC Regiser 的值来明确下一条应该执行什么样的字节码指令。
JVM Stack
由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。
基于栈的指令优点是跨平台,指令集更小,编译器容易实现,缺点是性能比寄存器差,实现同样的功能需要更多的指令。
栈是运行时的单位,而堆是存储的单位。
- 栈解决程序的运行问题,即程序如何执行,或者如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里。
简介
Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用。
Java 虚拟机栈是线程私有的。
生命周期
Java 虚拟机栈的生命周期与线程一致。
作用
主管 Java 程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM 对 Java 栈的操作只有两个:
- 每个方法执行,伴随着入栈。
- 执行结束后出栈。
对于栈来说不存在垃圾回收(GC)问题。
栈中可能出现的异常
Java 虚拟机栈规范允许 Java 栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建时独立选定。如果线程请求分配的栈容量超过 Java 虚拟机允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。
设置栈内存大小
使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
栈帧
- 每一个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
- 在这个线程上正在执行的每个方法都有各自对应一个栈帧(Stack Frame)。
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
当前栈帧 Current Frame
- 在一条活动的线程中,一个时间点上只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。该栈帧被称为当前栈帧(Current Frame),与其对应的方法被称为当前方法(Current Method),定义该方法的类就是当前类(Current Class)。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为新的当前栈帧。
每个栈帧存储着
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)(指向运行时常量池的方法引用)
- 方法返回地址
- 一些附加信息
局部变量表
局部变量表也被称之为局部变量数组或者本地变量表。
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包含各类基本数据类型、对象引用,以及 returnAddress 类型。参数值的存放总是在局部变量数据的 index0 开始,到数组长度 -1 的索引结束。
- 由于局部变量表是建立在栈上的,是线程的私有数据,因此不存在数据安全问题。
- 局部变量表所需的容量是在编译期确定的,并保存在方法的 Code 属性的 maximum local variables 数据中。在方法运行期间是不会改变局部变量表的大小的。
- 在栈桢中,与性能调优关系最为密切的部分就是局部变量表,在方法执行时,虚拟机使用局部变量表完成方法的传递。
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
slot(变量槽)
变量槽是局部变量表最基本的存储单位。
JVM 会为局部变量表中的每一个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
当一个实例方法被调用的时候,他的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上。
在局部变量表中,32位以内的类型只占用一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占用两个 slot。
- byte、short、char、boolean 在存储前被转换为 int。int 和 引用类型都占用一个 slot。
- long 和 double 占用两个 slot。
如果需要访问局部变量表中的一个64位的局部变量时,只需要使用前一个索引即可。
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为0的 slot 处,其余的参数按照参数表顺序继续排列。
slot 是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新局部变量将可能会复用过期局部变量的槽位,从而达到节省资源的目的。
操作数栈
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变址临时的存储空间。在方法执行过程中,操作数栈会根据字节码指令,在栈中写入数据或提取数据,即进行 push 或 pop 操作。
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。
比如:执行复制、交换、求和等操作。
操作数栈就是 JVM 执行引擎的一个工作区, 当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 code 属性中,为 max_stack 的值。
栈中的任何一个元素都是可以任意的 Java 数据类型。
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作进行一次数据访问。
如果被访问的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
动态链接
动态链接是指向运行时常量池的方法引用
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
- 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 class 文件的常量池里。(比如:描述一个方法调用其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。)
常量池的作用是为了提供一些符号和常量,便于指令的识别。
静态链接
- 当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接
- 如果被调用的方法在编译器无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接调用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
非虚方法
- 如果方法在编译器就确定了具体的调用版本,该调用版本在运行时是不可变的,这样的方法称为非虚方法。
- 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。
- 其他方法称为虚方法。
虚方法表
- 在面向对象编程中,会频繁使用动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。
- 为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table)来实现。使用索引来代替查找。
- 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
- 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
方法返回地址
存放调用该方法的 PC 寄存器的值。
一个方法结束,有两种方式
- 正常执行完成。
- 出现未处理的异常,非正常退出。
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 cp 寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
- 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。(方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码)
附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
Heap
概述
一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了。是 JVM 管理的最大一块内存空间。
- 堆内存的大小是可以调节的。
所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上他应该被视为连续的。
《Java 虚拟机规范》规定,所有的对象实例以及数组都应当在运行时分配在堆上。
数组合对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
堆内存细分
堆内存分代可以优化 GC 性能
Young Generation Space(新生区)
- 新生区又被划分为 Eden 区和 Survivor 区。
- 其中 Survivor 区共有两个,总有一个 Survivor 区是空的,作为对象的复制交换。也叫空的 Survivor 区为 to 区,非空的 Survivor 区为 from 区。
Tenure Generation Space(老年区)
Meta Space(元空间,在 JDK8 之前为 Permanent Space(永久区))
存储在 JVM 中的 Java 对象可以被划分为两类
- 一类是生命周期较短的瞬时对象,这两类对象的创建和消亡都非常迅速。
- 另外一类对象的生命周期非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致。
堆内存设置
Java 堆区用于存储 Java 对象实例,堆的大小在 JVM 启动时就已经设定好了。
可以通过
-Xmx和-Xms来设置堆内存大小。-Xms:用于设置堆区的初始内存(年轻代 + 老年代),等价于-XX:InitialHeapSize。-Xmx:用于设置堆区的最大内存(年轻代 + 老年代),等价于-XX:MaxHeapSize。
默认堆空间大小
- 初始堆内存大小:物理电脑内存大小 / 64
- 最大内存大小:物理电脑内存大小 / 4
一旦堆区的内存大小超过
-Xmx所指定的最大内存时,将会抛出 OOM。开发中建议将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
查看设置的参数
- jps / jstart -gc 进程 id
- -XX:+PrintGCDetails
年轻代与老年代
- 配置新生代与老年代在堆结构的占比。
- 默认
-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。
- 开发人员可以通过
几乎所有的 Java 对象都是在 Eden 区被 new 出来的。
绝大部分的 Java 对象的销毁都在新生代进行了。
可以通过
-Xmn设置新生代最大内存大小。(一般使用默认值)
对象的内存分配
new 的对象先放 Eden 区,该区有大小限制。
当 Eden 区空间被填满时,程序又需要创建对象,JVM 的垃圾回收器将对 Eden 区进行垃圾回收(Minor GC),将 Eden 区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到 Eden 区,然后将剩余对象移动到 Survivor0 区。
如果再次触发垃圾回收,此时上次幸存下来的放到 Survivor0 区的,如果没有回收 则会放到 Survivor1 区。
如果再次经历垃圾回收,此时会重新放回到 Survivor0 区,接着再去 Survivor1 区。
超过阈值之后,对象会被移动到老年区。(默认阈值是 15 次)
- 可以设置参数设置阈值:
-XX:MaxTenuringThreshold
- 可以设置参数设置阈值:
在老年区,相对悠闲。当老年区内存不足时,再次触发 GC:Major GC,进行老年区的内存清理。
若老年区执行了 Major GC 之后依然无法进行对象的保存,就会产生 OOM。
关于垃圾回收,频繁在新生区收集,很少在老年区收集,几乎不在永久区 / 元空间收集。
内存分配策略
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当他的年龄增加到一定程度(默认为 15 岁,每个 JVM、每个 GC 都有所不同)时,就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过 -XX:MAXTenuringThreshold 设置
针对不同对象年龄段的对象分配原则
优先分配到 Eden。
大对象直接分配到老年代。
尽量避免程序中出现过多的大对象。
长期存活的对象分配到老年代。
动态年龄对象判断。
如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
空间分配担保
-XX : HandlePromotionFailure(若设置为 true,在 Minor GC 之前会检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小)
JDK6 Update24 之后,该参数不会再影响到虚拟机的空间分配担保策略。改为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC)
对象分配过程:TLAB
Thread Local Allocation Buffer(TLAB)
为什么有TLAB
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
- 为了避免多个线程操作同一个地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB
从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
JVM 将 TLAB 作为内存分配的首选,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。
默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 所占用 Eden 空间的百分比大小。
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
默认开启 TLAB。
堆空间的常用参数
1 | --XX: +PrintFlagInitial # 查看所有的参数的默认初始值 |
Method Area
在 JDK7 及以前,习惯把方法区称为永久代。JDK8 开始,使用元空间取代了永久代。
In JDK 8, classses metadata is now in the native heap and this space is called Metaspace.
本质上,方法区和永久代并不等价。仅是对 Hotspot 而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。如:BEA JRockit / IBM J9 中不存在永久代的概念。
使用永久代会使 Java 程序更容易 OOM(超过 -XX:MaxPermSize 上限)
到了 JDK8,Hotspot 采用元空间代替了永久代。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
永久代、元空间二者并不只是名字变了,内部结构也调整了。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OOM。
方法区的演进
| jdk版本 | 方法区变化 |
|---|---|
| jdk1.6及之前 | 有永久代,静态变量存放在永久代上 |
| jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
| jdk1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间。但字符串常量池、静态变量仍在堆中 |
元空间替代永久代
- 在 Java 8 中,使用元空间(Metaspace)替代了永久代。元空间是一个与堆不相连的本地内存区域。
- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
- 改动原因
- 为永久代设置空间大小难以确定。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。而元空间不在虚拟机中,而是使用本地内存。因此元空间大小受本地内存限制。
- 难以对永久代进行调优。
方法区参数设置
设置方法区内存的大小
方法区的大小不必是固定的,JVM 可以根据应用的需要动态调整。
JDK7 及以前
- 通过 -XX:PermSize 来设置永久代初始分配空间。默认值是 20.75M
- -XX:MaxPermSize 来设置永久代最大可分配空间。32位机器默认是 64M,64位机器默认是 82M
- 当 JVM 加载的类信息容量超过了这个值,会报 OOM。
JDK8及以后
- 元空间大小通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 来指定,替代以上永久代的两个参数。
- 默认值依赖于平台。windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspaceSize 的值是 -1。即没有限制。
- 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出 OOM。
- -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 值为 21M。这就是最高水平线,一旦触及这个水平线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这些高水平线将会重置。新的高水平线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize 时,适当提高该值。如果释放空间过多,则适当降低该值。
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。
方法区内部结构
方法区(Method Area)用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全包名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表。
域(Field)信息
即属性
- JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
- 域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)
方法(Method)信息
JVM 必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(包括 void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
non-final的类变量(static修饰的变量)
- 静态变量和类关联在一起,随着类的加载而加载,它们称为类数据在逻辑上的一部分。
- 类变量被类的所有实例共享,即使没有类实例时也可以访问它。
全局常量static final
被声明为 final 的类变量即为全局常量,它在编译的时候就会被分配了。
常量池
常量池表(Constant Pool Table)位于字节码文件中,它包含了各种字面量和对类型、域和方法的符号引用。
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这些数据会很大以至于不能直接存到字节码里。换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。
常量池中存储的数据类型
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
运行时常量池中包含多种不同的常量,包含编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。
运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更丰富。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OOM。
方法区的垃圾回收
《Java虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集。但是对方法区的垃圾回收是有必要的。
- 一般来说方法区的垃圾回收效果比较难以令人满意,尤其是类型的卸载,条件相当苛刻。
- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
- 方法区内常量池中主要存放两大类常量:字面量和符号引用。
- 字面量:比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。
- 符号引用则属于编译原理方面的概念,包括以下三类常量:
- 类和接口的全限定名。
- 字段的名称和描述符。
- 方法的名称和描述符。
- Hotspot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收 Java 堆中的对象非常类似。
- 判断一个类型是否属于“不再被使用的类‘的条件比较苛刻。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,即 Java 堆中不存在该类及其任何派生子类的实例。
- 加载类的类加载器已经被回收,这个条件除非是经过精心设计的可替代类加载器的场景(如 OSGI, JSP 的重加载等),否则通常很难达成。
- 该类的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
本地方法栈
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
- 本地方法栈是线程私有的。
- 允许被实现成固定或者是可动态扩展的内存大小。
- 本地方法是使用 C 语言实现的。
- 它的具体做法是 Native Method Stack 中登记 Native Method,在 Execution Engine 执行时加载本地方法库。
本地方法的调用
- 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器。
- 直接从本地内存的堆中分配任意数量的内存。
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。
Execution Engine
执行引擎是 Java 虚拟机核心的组成部分之一。
执行引擎概述
JVM 的主要任务是负责装载字节码到其内部,但是字节码并不能够直接运行在操作系统之上,因此字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表以及其他辅助信息。
- 执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎的工作过程
执行引擎在执行的过程究竟需要执行什么样的字节码指令完全依赖于 PC 寄存器。
每当执行完一项指令操作后,PC 寄存器就会更新下一条需要被执行的指令地址。
在方法执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
Java代码的编译和执行过程
Hotspot 虚拟机是目前市面上高性能虚拟机的代表之一。它采用解释器与即时编译器并存的架构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,取长补短,选择最方式的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
解释器与编译器共存
- 当程序启动后,解释器可以马上发挥作用,省去编译的时间。然而编译器要想发挥作用,把字节码编译成本地机器指令,需要一定的执行时间。但编译完毕后,执行效率高。
- 解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
解释器
当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
JIT(Just In Time Compiler)编译器
即时编译器
JIT 编译器就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
即时编译的目的是避免函数被解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
编译的概念解释
- 前端编译期:把 .java 文件转变成 .class 文件的过程。
- 后端运行期编译(JIT 编译器):把字节码转变成机器码的过程。
- 静态提前编译(AOT 编译器):直接把 .java 文件编译成本地机器代码的过程。
热点代码及探测方式
是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,需要根据代码被调用执行的频率而定。
关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT 编译器在运行时会针对那些频繁被调用的“热点代码”作出深度优化,将其直接编译为对应平台的本地机器指令,以此提高 Java 程序的执行性能。
- 热点代码:一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换。(OSR 编译, On Stack Replacement)
- 热点探测功能:决定一个方法究竟被调用多少次才可以成为“热点代码”。目前 Hotspot 虚拟机采用的热点探测方式是基于计数器的。
采用基于计数器的热点探测,Hotspot 虚拟机会为每一个方法都建立 2 个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
- 方法调用计数器用于统计方法的调用次数。
- 回边计数器则用于统计循环体执行的循环次数。
方法调用计数器
方法调用计数器用于统计方法的调用次数。
- 该计数器的默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次。超过该阈值,就会触发 JIT 编译器。该阈值可以通过设置虚拟机参数 -XX:CompileThreshold 来设定。
- 当一个方法被调用,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码执行。如果不存在,则将此方法的调用计数器值 +1 ,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值,若超过阈值,则向即时编译器提交一个该方法的代码编译请求。
- 热度衰减(Counter Decay)
- 如果一个方法在一定时间内没有被调用,那么该方法的调用计数器将会被减少一半。这一个时间段被称为此方法统计的半衰周期(Counter Half Life Time)
- 热度衰减是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:UseCounterDecay 来关闭热度衰减。也可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
回边计数器
统计一个方法中循环代码执行的次数。建立回边计数器的统计的目的是为了触发 OSR 编译。(在字节码中遇到控制流后跳转的指令称为回边(Back Edge)
JIT分类
在 Hotspot 虚拟机中内嵌有两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler。我们对其简称为 C1 编译器和 C2 编译器。
-client:指定 JVM 运行在 Client 模式下,并使用 C1 编译器。
- C1 编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
-server:指定 JVM 运行在 Server 模式下,并使用 C2 编译器。
- C2 进行耗时较长的优化,以及激进优化。优化之后的代码执行效率更高。
C1和C2编译器不同的优化策略
C1 编译器上主要有方法内联,去虚拟化,冗余消除:
- 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
- 去虚拟化:对唯一的实现类进行内联。
- 冗余消除:在运行期间把一些不会执行的代码折叠掉。
C2 编译器优化主要在全局层面,逃逸分析是优化的基础:
栈上分配:对于未逃逸的对象分配在栈而不是堆。
同步省略:清除同步操作,通常指 Synchronized。
标量替换:用标量值替代聚合对象的属性值。
程序解释执行可以触发 C1 编译,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。
Hotspot VM设置程序执行方式
默认情况下 Hotspot 虚拟机是采用编译器与即时编译器并存的架构,开发人员可以设置参数来决定 JVM 在运行时是完全采用解释器执行,还是完全采用即时编译器执行。
-Xint:完全采用解释器模式执行程序。
-Xcomp:完全采用即时编译器执行程序。(如果即时编译出现问题,解释器会介入执行)
-Xmixed:采用解释器+即时编译器混合模式共同执行程序。(默认)
Native Method Interface
本地方法接口。本地方法接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。
本地方法
一个 Native Method 就是一个 Java 调用非 Java 代码的接口。一个 Native Method 并非由 Java 语言实现。
在定义一个 Native Method 时,并不提供实现体,因为其实现体是由非 Java 语言在外面实现的。
标识符 native 可以与所有其它的 Java 标识符连用,但是 abstract 除外。
并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。
对象的内存分配
对象的实例化
创建对象的方式
- new:最常见的方式。
- Class 的 newInstance:反射的方式,只能调用空参的构造器,权限必须为 public。
- Constructor 的 newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求。
- 使用 clone():不调用任何构造器,当前类需要实现 Cloneable 接口,实现 clone()。
- 使用反序列化:从文件中,从网络中获取一个对象的二进制流。
- 第三方库 Objenesis
创建对象的步骤
判断对象对应的类是否加载、链接、初始化
- 虚拟机遇到一条 new 指令,首先检查该指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。
- 如果没有,那么在双亲委派机制下,使用当前类加载器以 ClassLoader+包名+类名 为 Key 进行查找对应的 .class 文件。如果没有找到文件,则抛出 ClassNotFoundException 异常;如果找到,则进行类加载,并生成对应的 Class 类对象。
为对象分配内存
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。
- 如果内存规整,那么虚拟机将采用指针碰撞法(Bump The Pointer)来为对象分配内存。
- 如果内存不规整,已使用的内存和未使用的内存相互交错,那么虚拟机将采用空闲列表法来为对象分配内存。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
处理并发安全问题
- 采用 CAS 配上失败重试保证更新的原子性。
- 每个线程预先分配一块 TLAB。
初始化分配到的空间
- 所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。(属性的默认初始化)
设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方法取决于 JVM 实现。
执行 init() 进行初始化(属性的显示初始化)
在 Java 程序的视角来看,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
对象的访问定位
JVM 通过栈帧中的对象引用访问到其内部的对象实例
定位:通过栈上的 reference 访问。
对象的访问方式主要有两种
句柄访问
- 占用空间多,其好处是由于 reference 中存储稳定句柄地址,对象被移动(例如垃圾收集时被移动)时只会改变句柄中示实例数据指针即可,reference 本身不需要被修改。

直接指针(Hotspot 采用)
- 访问速度较块

直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。直接内存是在 Java 堆外的、直接向系统申请的内存区间。
概述
由于直接内存在 Java 堆外,因此它的大小不会直接受限于 -Xmx 指定的最大堆大小,但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。
- 直接内存的大小可以通过 MaxDirectMemorySize 设置。如果不指定该参数,则其默认大小与 Java 堆的最大值 -Xmx 参数值一致。
优点
- 通常,访问直接内存的速度会优于 Java 堆。即读写性能高。
- 出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
- Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区。
缺点
- 直接内存也可能导致 OOM。
- 直接内存分配回收成本较高。不受 JVM 内存回收管理。
String Table
String的基本特性
- String:字符串,用一对引号引起来表示。
- String 声明为 final 的,不可被继承。
- String 实现了 Serializable 接口,表示字符串支持序列化;实现了 Comparable 接口,表示 String 可以比较大小。
- String 在 JDK8 及之前内部定义了 final char[] value 用于存储字符串数据。而 JDK9 改为了 byte[]。
不可变性
- 当对字符串重新赋值时,需要重新指定的内存区域赋值,不能改变原有的 value。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值。
- 当调用 String 的 replace() 方式进行修改指定字符或字符串时,也需要重新指定内存区域赋值。
- 通过字面量声明(非 new)的子符串赋值,此时字符串声明的字符串在字符串常量池中。
String的内存分配
Java 中有 8 种数据类型和一种比较特殊的类型 String。为了提高它们在运行种的速度,更节省内存,Java 为它们提供了常量池的概念。JDK8 的字符串常量池放在堆中。
String 的常量池主要使用的方法有两种:
- 直接使用双引号声明的 String 对象会直接存储在常量池种。
- 如果不是使用双引号声明的 String 对象,可以使用 String 的 intern() 方法,若常量池中还该字符串对象,则将该对象放入池中,并返回此对象地址;若常量池已经存在了该字符串对象,则直接返回此对象地址。
字符串常量池中不存储相同内容的字符串。
- 可以使用 -XX:StringTableSize 设置 StringTable 长度。
- jdk6 中的 StringTable 固定为 1009 的长度。jdk7 中 StringTable 的默认长度为 60013。jdk8 开始 StringTable 中 1009 是可以设置的最小值。
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译优化。(编译期就被赋值了)
只要字符串拼接中有一个是变量,则结果就在堆中。原理是 StringBuilder。执行细节如下:
1
2
3
4
5
6
7s1 = "a", s2 = "b";
s = s1 + s2;
// 给 s 赋值的执行细节
StringBuilder s = new StringBuilder();
s.append(s1);
s.append(s2);
s.toString(); // 约等于 new String("ab");注意:以上当 s1 和 s2 为常量时(被 final 修饰),s 的拼接将会是编译优化。
执行效率总结
- 使用 StringBuilder 的 append() 的字符串拼接比直接使用字符串拼接要快的多。(因为字符串拼接会 new StringBuilder() 和 new String() )
intern()
intern() 会从字符串常量池中查询当前字符串是否存在。若不存在就会放入常量池中。
相关问题
new String(“ab”) 和 new Sting(“a”) + new String(“b”)
new String(“ab”):通过查看字节码,可以看出 new String(“ab”) 会创建两个对象。
- 第一个对象:new 关键字在堆空间创建的。
- 第二个对象:字符串常量池中的对象。(字节码指令:Ldc)
new Sting(“a”) + new String(“b”):一共创建 6 个对象。
- 对象1:new StringBuilder()
- 对象2:new String(“a”)
- 对象3:常量池中的 “a”
- 对象4:new String(“b”)
- 对象5:常量池中的 “b”
- 对象6:StringBuilder 的 toString() (return new String(),注意该 toString() 没有在字符串常量池中生成 “ab” )
intern() 问题
1 | String s1 = new String("a"); // 创建了两个对象,返回的是堆空间中的对象。 |
结论:对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用 intern() 可以节省内存空间。
调优
常用的调优工具
- JDK 命令行
- Eclipse : Memory Analyzer Tool
- Jconsole
- VisualVm
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
如何解决OOM
- 要解决 OOM 或 Heap Space 的异常,一般的手段是首先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 dump 出来的堆转储快照进行分析。重点是确认内存中的对象是否必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
- 如果是内存泄漏,可以进一步通过工具查看泄漏对象到 GC Roots 的引用链。于是就可以找到泄漏对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
- 如果不存在内存泄漏,也就是说内存中的对象都还活着,那就应当坚持虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
逃逸分析
在 JVM 中,对象是在堆中分配内存的。但是,经过逃逸分析(Escape Analysis)后发现,如果一个对象没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收。这也是最常见的堆外存储技术。
逃逸分析概述
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 逃逸分析是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的适用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只能在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
参数设置
- 在 JDK6 之后,Hotspot 中默认就已经开启了逃逸分析。
- 如果使用的是较早的版本,则可以通过设置参数来开启逃逸分析。
- -XX: +DoEscapeAnalysis:显示开启逃逸分析。
- -XX: +PrintEscapeAnalysis:查看逃逸分析的筛选结果。
代码优化
使用逃逸分析,编译可以对代码作出如下优化
栈上分配
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
栈上分配
- JIT 编译器在期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
- 常见的栈上分配场景
- 给成员变量赋值、方法返回值、实例引用传递。
同步省略
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
分离对象或标量替换
标量(Scalar)指无法再分解成更小数据的数据。Java 中的原始数据类型就是标量。
聚合量(Aggregate)指还可以分解的数据。Java 中的对象就是聚合量,它们还可以分解成其他聚合量和标量。
标量替换:在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其他包含的若干个成员变量来代替。
标量替换参数设置
- -XX: +EliminateAllocations:开启标量替换(默认开启),允许将对象打散分配在栈上。
GC
JVM 在进行 GC 时,并非每次都对三个内存区域一起回收,大部分的回收都是指新生代。
针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:部分收集(Partial GC)和整堆收集(Full GC)
部分收集:不完整收集整个堆的垃圾。其中又分为
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集。
老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
目前,只有 CMS GC 会有单独收集老年代的行为。
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
整堆收集:收集 Java 堆和方法区的垃圾收集。
Minor GC
- 当年轻代空间不足时,会触发 Minor GC,清理年轻代的内存。(指Eden 区满,Survivor 区不会引发GC)
- 由于 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
- Minor GC 会引发 STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。
Major GC
若出现了 Major GC,经常会伴随至少一次的 Minor GC(并非绝对,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发 Minor GC。如果之后空间还不足,则触发 Major GC。
Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长。
如果 Major GC 后,内存仍然不足,就报 OOM。
Full GC
出现 Full GC 的情况一般有如下五种:
- 调用 System.gc() 时,系统建议执行 Full GC,但不是必然执行。
- 老年代空间不足。
- 方法区空间不足。
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存。
- 由 Eden 区,Survivor0(From)区向 Survivor1(To)区复制时,对象大小大于 To 区可用内存,则把对象转到老年代,且老年代的可用内存小于该对象大小。
Full GC 是开发或调优中尽量要避免的。
垃圾回收概述
垃圾指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不及时对内存中的垃圾进行清理,那么这些垃圾所占的内存空间会一直保留到应用程序运行结束,这些空间无法被其他对象使用,甚至可能导致内存溢出。
- 需要垃圾回收的原因
- 若不进行垃圾回收,内存空间迟早会被消耗完。
- 除了释放没用的对象,垃圾回收也可以清除内存中的记录碎片。以便于 JVM 将整理出的内存分配给新的对象。
- 随着应用程序的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常运行。
垃圾标记阶段
- 在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为死亡的对象,GC 才会在执行垃圾回收时,释放其所占用的内存空间。该过程我们可以称为垃圾标记阶段。
- 当一个对象不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
- 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数算法(Reference Counting)
引用计数算法对每个对象都保存了一个整型的引用计数器属性,用于记录对象被引用的情况。
对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加一;当引用失效时,引用计数器就减一。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能在被使用,可进行回收。
优点:实现简单,垃圾对象便于识别;判断效率高,回收没有延迟性。
缺点:
- 他需要单独的字段存储计数器,这样的做法增加了存储空间开销。
- 每次赋值需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。该致命缺陷导致在 Java 的垃圾回收器中没有使用这类算法。
可达性分析算法(跟搜索算法、追踪性垃圾收集)
相对于引用计数算法,可达性分析算法不仅同样具备实现简单和执行效率高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
相对于引用计数算法,这里的可达性分析就是 Java,C# 选择的。这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)。
GC Roots(根集合):一组必须活跃的引用,它包括以下几类元素:
- 虚拟机栈中引用的对象。(比如:各个线程被调用的方法中使用到的参数、局部变量等)
- 本地方法栈内 JNI(本地方法)引用的对象。
- 方法区中类静态属性引用的对象。(比如:Java 类的引用类型静态变量)
- 方法区中常量引用的对象。(比如:字符串常量池里的引用)
- 所有被同步锁 synchronized 持有的对象。
- Java 虚拟机内部的引用。(基本数据类型对应的 Class 对象,一些常驻的异常对象,系统类加载器)
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了以上固定的 GC Roots 外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots。比如分代收集和局部回收(Partial GC)。
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这也是导致 GC 进行时必须 “Stop The World” 的一个重要原因。
- 即使是号称(几乎)不会停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
可达性分析算法基本思路:
- 可达性分析算法是以 GC Roots(根对象集合)为起点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
对象的终止(finalization)机制
对象的 finalization 机制允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize() 方法。
finalize() 方法允许被重写,用于在对象被回收时进行资源释放。
通常在该方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
注意:永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。原因包括以下三点:
- 在 finalize() 时可能会导致对象复活。
- finalize() 方法的执行时间是没有保障的,他完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行的机会。
- 一个糟糕的 finalize() 会严重影响 GC 的性能。
由于 finalize() 方法的存在,虚拟机中的对象一般处于三种可能的状态:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。
- 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象是不可能复活,因为 finalize() 只会被调用一次。
以上三种状态,是由于 finalbize() 方法的存在,进行的区分。只有对象在不可触及时才可以被回收。
判断一个对象 objA 是否可回收,至少要经历两次标记过程:
- 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要进行 finalize() 方法:
- 如果对象没有重写 finalize() 方法,或者 finalize() 已经被虚拟机调用过,则虚拟机视为”没有必要执行“,objA 被判定为不可触及的。
- 如果对象 objA 重写了 finalize() 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级 Finalizer 线程触发其 finalize() 方法执行。
- finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移除”即将回收“集合,之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize() 方法只会被调用一次。
垃圾清除阶段
当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便于有足够的可用内存空间为新对象分配内存。
目前在 JVM 中比较常见的三种垃圾收集算法有:
标记-清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)。
标记-清除算法
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
- 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。
- 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。
缺点:
- 效率不算高。
- 在进行 GC 的时候,需要停止整个应用程序。
- 这种方式清理出来的空闲内存是不连续的,会产生内存碎片。需要维护一个空闲列表。
清除指把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否足够,如果够就存放。
复制算法
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。(空间换时间)
优点:
- 没有标记和清除过程,实现简单,运行高效。
- 复制过去以后保证空间的连续性,不会出现”碎片”问题。
缺点:
- 需要两倍的内存空间。
- 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。
- 复制算法需要复制的存活对象数量需要非常低才行(垃圾对象多,存活对象少)。
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可用回收 70%~99% 的内存空间。回收性价比很高,一般在新生代都会采用复制算法回收。
标记-压缩算法
- 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩算法(Mark-Sweep-Compact)算法。
- 二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩算法是移动式的回收算法。
执行过程:
- 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象。
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
优点:
- 消除了标记-清除算法中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要只有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点:
- 从效率上看,标记-压缩算法要低于复制算法,甚至低于标记-清除算法。
- 移动对象的同时,如果对象被其它对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序(STW)。
三种算法对比
| Mark-Sweep | Mark-Compact | Copying | |
|---|---|---|---|
| 速度 | 中等 | 最慢 | 最快 |
| 空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的两倍大小(不堆积碎片) |
| 移动对象 | 否 | 是 | 是 |
分代收集算法
分代收集算法指不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以便提高垃圾回收的效率。(具体问题,具体分析)
目前几乎所有的 GC 都是采用分代收集算法执行垃圾回收的。
在 HotSpot 虚拟机中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。
年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 年轻代使用复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 HotSpot 虚拟机中的两个 survivor 的设计得到缓解。
老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 老年代存在大量存活率高的对象,复制算法明显变得不合适,一般是由标记-清除算法或者标记-压缩算法的混合实现。
- Mark 阶段的开销与存活对象的数量成正比。
- Sweep 阶段的开销与所管理区域的内存大小成正相关。
- Compact 阶段的开销与存活对象的数量成正比。
以 HotSpot 虚拟机中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的的 Serial Old 回收器作为补偿措施,当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。