目录
(一) JVM 概述
虚拟机(Virtual Machine): 是一台虚拟的计算机软件, 用来执行一系列虚拟计算机指令. 可分成 系统虚拟机(VMware) 和 程序虚拟机(JVM虚拟机)
Java虚拟机: 是一台执行Java字节码的虚拟计算机, 它拥有独立的运行机制, 如 跨平台性、优秀的垃圾回收器、以及可靠的即时编译器等等. 常用的虚拟机有: Hotspot VM、JRockit、J9等等
作用: Java虚拟机就是二进制字节码的运行环境, 负责装载字节码到其内部, 解释编译为对应平台上的机器指令执行, 且运行的Java字节码也未必是由Java语言编译而成的
JVM 跨语言的平台: JVM虚拟机不关心运行在其内部的程序到底是使用何种编程语言编写的, 它只关心 ‘‘字节码’’ 文件, 任何编程语言编写的代码, 通过自己的编译器生成的字节码文件, 只要此字节码文件遵循了JAVA虚拟机规范, 那么都可以被JVM解释运行
JVM是运行在操作系统之上的, 它与硬件没有直接的交互
HotSpot VM 是目前市面上高性能虚拟机的代表作之一, 它采用解释器与即时编译器(JIT)并存的架构. 如图:
在接下的博文中都会一一解释上图中的各个模块, 如: Java编译器(源码编译成Class文件)、类加载系统(Class Loader)、运行时数据区(Runtime Data Area)、执行引擎(Excution Engine)等等
JVM的种类:
- Sun Classic VM: 由Sum公司发布的第一款商用Java虚拟机, 只支持Java解析器(下文会解释). 在JDK1.4时完全淘汰.
- Exact VM: 提供了准确是内存管理, JVM可以知道内存中某个位置的数据具体是什么类型; 还提供了现代高性能虚拟机的雏形: 热点探测, 编译器与解释器混合工作模式
- HotSpot VM: 从JDK 1.3时, HotSpot VM 成为了默认虚拟机. 提供了高效的热点代码探测技术 和 通过编译器和解释器协同工作, 在最优化的程序响应时间与最佳执行性能中取得平衡 以及 提供了 方法区 概念 (JVM系列的博客默认介绍的虚拟机都是HotSpot VM)
- JRockit: 专注于服务器端应用, 它不太关注程序启动速度, 因此JRockit内部不包含Java解析器, 全部代码都靠即时编译器编译执行. 是目前世界上最快的JVM
- J9: IBM Technology for Java Virtual Machine, 简称IT4J, 内部代号J9. 与 HotSpot虚拟机相似, 但也号称世界上最快的Java虚拟机
(二) JAVA 编译器
JAVA 编译器可以分为三种: 前端编译器、JIT编译器、AOT编译器
-
前端编译器: 将Java源代码编译成 .class字节码文件
public class Test { public static void main(String[] args) { int i = 3; int j = 5; int k = i + j; } }
通过javac 命令将Test.java 编译成 Test.class 二进制文件
javac Test.java
Test.class 文件不能直接通过文本编辑器打开(乱码), 可以使用 javap 命令反编译class文件
javap -c Test.class
public class com.lwx.test.Test { public com.lwx.test.Test(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: iconst_3 // 创建常量3 1: istore_1 // 将常量3存储到局部变量1中 2: iconst_5 // 创建常量5 3: istore_2 // 将常量5存储到局部变量2中 4: iload_1 // 加载局部变量1 5: iload_2 // 加载局部变量2 6: iadd // 局部变量1与局部变量2相加 7: istore_3 // 将相加的值8存储到局部变量3 8: return }
或者借助第三方字节码阅读器 JClassLib, IDEA 安装 jclasslib Bytecode viewer, 点击 View --> show bytecode with jclasslib
-
JIT编译器: 即时编译器. JVM有两种方式来运行class文件
- JAVA解释器: 解释执行class字节码文件. Java解释器的启动速度是高于JIT编译 (侧重于启动响应性能)
- JIT编译器: 对class文件中反复执行的代码(热点代码), 编译成机器指令后直接执行无需解析, 并将其缓存. 机器指令的运行效率是高于Java解析器(侧重于执行性能)
-
AOT编译器: 静态提前编译器, 在程序运行前, 直接将Java源代码编译成本地机器码, 以此最求更快的执行性能. 但由于AOT是在程序运行前编译, 导致有些代码无法编译. 如: 动态类的加载
Java代码的执行流程图:
(三) 类加载子系统
类加载子系统: 只负责从文件系统中或者网络中加载Class文件, 至于它是否可以运行, 则由ExcutionEngine执行引擎决定. 加载的类信息存放于一块称为方法区的内存空间(还存储常量池信息).
类加载过程: Loading加载阶段 --> Linking链接阶段 --> Init初始化阶段
Loading加载阶段:
- 通过一个类的全限定名(类名) 获取此类的二进制字节流, 加载.class文件的方式:
- 从本地磁盘中直接加载
- 通过网络获取
- 从zip压缩包中读取(jar, war)
- 运行时生成: 动态代理技术
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class对象, 作为方法区这个类的访问入口
Linking链接阶段:
- Verify验证: 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求, 保证被加载类的正确性
- Perpare准备: 为类变量分配内存并初始化默认值. 此时, 不会为实例变量(类中对象)初始化, 且 final 修饰的 static常量在编译时就初始化
- Resolve解析: 将常量池内的符号引用转换为直接引用的过程**(Java虚拟机栈中的动态链接 #num )**
Init初始化阶段:
-
初始化阶段就是执行类构造器<clinit>()方法
-
此方法不需要定义, 是javac编译器自动收集类中的所有静态变量的赋值动作和静态代码块中的语句合并而成
-
构造器方法中指令按语句在源文件中出现的顺序执行. 如下代码中<clinit>()方法执行顺序:
1. 先为num变量赋默认值: 0
2. 然后执行静态代码块, 将常量2赋值给num
3. 最后再将常量1赋值给numpublic class Test { static { num = 2; } private static int num = 1; // 定义静态变量 num public static void main(String[] args) { System.out.println(num); // 输出 1 (先后顺序执行) } }
-
<clinit>() 不同于类的构造器 <init>(): 任何一个类声明以后, 内部至少存在一个类的构造器
-
若该类具有父类, JVM会保证先执行父类的<clinit>()的方法, 然后再执行子类的<clinit>()的方法
-
虚拟机必须保证一个类的<clinit>() 方法在多线程下被同步加锁
类加载器ClassLoader: JVM支持两种类型的类加载器, 分别为 引导类加载器Bootstrap ClassLoader 和 自定义类加载器User-Defined ClassLoader (所有继承抽象类ClassLoader的类加载器都划分为自定义类加载器)
Bootstrap ClassLoader 引导类(启动类)加载器:
- 由C/C++语言实现, 嵌套在JVM内部, 因此并不继承自 java.lang.ClassLoader, 没有父类加载器
- 加载Java的核心类库(包名为: java、javax、sun), 用于提供JVM自身需要的类
- 加载扩展类加载器 和 系统(应用类)加载器
Extension ClassLoader 扩展类加载器:
- Java语言编写, 继承于ClassLoader, 父类加载器为启动类加载器
- 加载JDK的安装目录的 jre/lib/ext 子目录(扩展目录)下的类, 以及 用户自创建的JAR放在此目录下, 也由扩展类加载器加载
Application ClassLoader 应用程序(系统)类加载器:
- Java语言编写, 继承于ClassLoader, 父类加载器为 扩展类加载器
- 程序中默认的类加载器, 一般加载用户创建的类
- 通过 ClassLoader.getSystemClassLoader() 获取 应用程序(系统)类加载器类
ClassLoader抽象类: 除了BootstrapClassLoader加载器, 其他加载器都需要直接或间接继承ClassLoader抽象类
ClassLoader的获取方式:
- clazz.getClassLoader(): 获取当前类的ClassLoader
- Thread.currentThread().getContextClassLoader(): 获取当前线程上下文的ClassLoader
- ClassLoader.getSystemClassLoader(): 获取应用程序(系统)的ClassLoader
public class Test {
public static void main(String[] args) {
// 获取应用程序(系统)类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取系统类加载器的上层: 扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader@4554617c
// 获取扩展类加载器的上层: null, 获取不到引导类加载器(原因: 引导类加载器由C/C++语言实现)
ClassLoader bootStrapClassLoader = extClassLoader.getParent();
System.out.println(bootStrapClassLoader); // null
// 获取自定类的加载器: 默认使用系统类加载器进行加载
ClassLoader classLoader = Test.class.getClassLoader();
System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取Java核心类的加载器: null, Java的核心类库都是由引导类加载器进行加载的
ClassLoader stringClassLoader = String.class.getClassLoader();
System.out.println(stringClassLoader); // null
}
}
Java虚拟机对class文件采用的是按需加载的方式, 也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象. 且 加载某个class文件时, Java虚拟机采用的是双亲委派机制
双亲委派机制:
- 如果一个类加载器收到类加载请求, 它并不会自己去加载, 而是将这个请求委托给父类的加载器执行, 如果父类加载器还存在其父类加载器, 则进一步向上委托, 依次递归, 请求最终将到达顶层的启动类加载器.
- 此时, 如果父类加载器可以完成类加载任务, 则加载成功返回; 如果父类加载器无法完成此加载任务, 子加载器才会尝试自己去加载
验证: java.lang.String 核心类是由Bootstrap ClassLoader 加载, 此时, 我们也自定义一个 同包名的java.lang.String.String类(自定类由 Application ClassLoader 加载), 看看 自定义String类是由哪个类加载器加载
package java.lang;
public class String {
public String() {
System.out.println("自定义String类 实例化");
}
}
package com.lwx.test;
public class Test {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
// 打印 String 类的加载器
System.out.println(str.getClass().getClassLoader());
}
}
双亲委派机制优点:
- 避免类的重复加载
- 保护程序安全, 防止核心API被随意篡改(沙箱安全机制)
沙箱安全机制:
继续在自定义同包名String类上加 main 方法, 执行:
package java.lang;
public class String {
public String() {
System.out.println("自定义String类 实例化");
}
public static void main(String[] args) {
System.out.println();
}
}
报错信息为: 不存在main方法, 是因为 由Bootstrap ClassLoader加载的核心类String, 核心类String不存在main方法. 这样可以保证对Java核心源代码的保护, 这是就是 沙箱安全机制
或者是自定义一个 java.lang.TestA类, 也是无法加载执行的, 会抛出异常: java.lang.SecurityException: Prohibited package name: java.lang
补充知识:
JVM 判断两个Class对象是否为同一个类的两个必要条件:
- 类的完整类名(包名.类名) 完全一致
- 加载这两个类的ClassLoader 相同
Java程序对类的使用方式分为: 主动使用 和 被动使用:
- 主动使用的7种方式:
- new 创建类的实例
- 访问类中的静态变量, 或者是对静态变量赋值
- 调用类的静态方法
- 反射执行
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类
- JDK 7 开始提供的动态语言支持: java.lang.incoke.MethodHandle实例的解析结果
- 被动使用: 除了以上七种, 其他使用Java类的方式都别看作是对类的被动使用, 被动使用不会导致类的初始化(Initialization)过程
(四) 运行时数据区
运行时数据区分为 4 + 1 部分:方法区(Hotspot Vm独有区域)、堆、程序计数器(PC寄存器)、本地方法栈、虚拟机栈, 如下图:
Java虚拟机定义了程序运行期间会使用带的运行时数据区,其中有一些会随着虚拟机启动而创建, 随着虚拟机退出而销毁(红色框区域)。 另外些则是与线程一一对应,这些区域会随线程开始和结束而创建和销毁(灰色框区域)。 即:
- 灰色框区域:程序计数器、虚拟机栈、本地方法栈 是每一个线程都会创建且私有
- 红色框区域:方法区、堆 是多线程共享区域
线程: 是一个程序里的运行单元, JVM允许一个应用有多个线程并行的执行. 在Hotspt JVM中, 每个线程都与操作系统的本地线程直接映射 (当一个Java线程准备好执行以后, 此时一个操作系统的本地线程也同时创建以供JAVA执行, Java线程执行完成后, 本地线程也会回收)
4.1 程序计数器 (PC寄存器)
程序计数寄存器(Program Counter Register): 用来存储 指向下一条指令的地址(即将要执行的指令代码), 由执行引擎读取下一条指令
- PC寄存器是一块很小的内存空间, 几乎可以忽略不记, 也是运行快速最快的存储区域
- 在JVM规范中, 每个线程都有他自己的程序计数器, 是线程私有的, 生命周期与线程保持一致 (CPU需要不停的切换各个线程工作, 同一时间只有一个线程在执行(单核CPU), 因此每个线程必须要记录当前线程正在执行的字节码指令地址)
- 任何时候一个线程都只有一个方法在执行, 也就是所谓的的 当前方法, 程序计数器会存储当前线程正在执行的Java方法的JVM指令地址, 即: 可以知道当前线程执行到哪一步代码
- 它是程序控制流的指示器: 分支、循环、跳转、异常处理、线程恢复等基础功能需要依赖程序计数器来实现
- 字节码解释器工作时就时通过改变这个计数器的值来选取下一条需要执行的字节码指令
- PC寄存器是唯一一个在JAVA虚拟机规范中没有规定任何OutOtMemory情况的区域
4.2 Java虚拟机栈
4.2.1 Java栈简述
内存中的栈和堆:
- 栈是运行时的单位: 栈解决程序的运行问题, 即 程序如何执行
- 堆时存储的单位: 堆解决数据存储的问题
Java虚拟机栈(Java virtual Machine Stack): Java栈, 每个线程在创建时都会创建一个虚拟机栈, 其内部保存一个个栈帧(Stack Frame), 对应着一次次的Java方法调用. 管理Java程序的运行, 保存 方法的局部变量、部分结果、并参与方法的调用和返回
栈的特点
- 栈是一种快速有效的分配存储方式, 访问速度仅次于程序计数器
- JVM对Java栈的操作: 每个方法执行, 伴随着入栈操作; 方法结束后的出栈操作
- 对于Java栈也不存在垃圾回收问题
Java栈出现的异常
- StackOverFlowError: 栈溢出异常, Java栈的大小允许是动态的或者是固定不变的, 但采用固定大小的栈时, 当线程请求分配栈容量超过最大容量时, 会抛出此异常
- OutOfMemoryError: 栈内存溢出异常, Java虚拟机栈大小是动态的, 并且在尝试扩展时, 无法申请到足够的内存 或者 新线程创建对应的Java栈时没有足够的内存创建, 会抛出此异常
- JVM启动参数设置Java栈空间大小: -Xss (-Xss256k: 栈空间大小为256k)
4.2.2 栈帧的内部结构
一个栈帧对应着一个Java方法, 内部存储5部分:
-
局部变量表(Local Variables): 也称之为局部变量数组或本地变量表, 定义成一个数字数组, 主要用于存储方法参数和定义在方法体内的局部变量. 局部变量表所需要的大小是在编译期确认下来的, 在方法运行期间大小不会改变, 且数据是安全的(线程私有)
- 变量槽(Slot): 局部变量表最基本的存储单元, 存储: 基本数据类型(8种)、引用类型. 32位以内的类型只占有一个slot, 64位的类型(long和double)占用两个slot (1M = 1024kb、1kb = 1204b、1b(字节) = 8位(bit) )
- 当一个实力方法被调用时, 它的方法参数和方法体内部定义的局部变量会按照顺序复制到局部变量表中的每一个slot, 且当前帧是由构造方法或者是实例方法创建的, 那么改对象引用this将会存放在index为0的slot处
- JVM会为局部变量表中的每一个Slot都分配一个访问索引
- 局部变量表中的变量是重要的垃圾回收根节点, 只要被局部变量表中直接或间接引用的对象都不会被回收
-
操作数栈(Operand Stack): 在方法执行过程中, 根据字节码指令, 往操作数栈写入数据或提取数据(执行复制、交换、求和等操作). 主要用于保存计算过程的中间结果, 同时作为计算过程中变量的临时存储空间
public static void main(String[] args) { byte i = 15; int j = 8; int k = i + j; }
-
动态链接: 指向运行时常量池的方法引用. 在字节码文件中, 所有的变量和方法引用都作为符号引用(#数字) 保存在class文件的常量池里, 动态链接的作用就是通过符号引用找到对应的变量或方法
public class PCTest { int num = 1; public void methodA() { System.out.println("methodA running.."); methodB(); } public void methodB() { System.out.println("methodB running.."); num++; } }
-
方法返回地址: 存放调用该方法结束时的pc寄存器的值, 返回给执行引擎, 执行引擎执行下一步操作指令
一个方法的结束有两种方式: 1.正常执行完成; 2.出现未处理的异常, 非正常退出
无论通过哪种方式退出, 在方法退出后都返回到该方法被调用的位置. 方法正常退出时: 调用者的pc计数器的值作为方法返回地址(即 下一条指令的地址). 方法异常退出时:方法返回地址是要通过异常表来确定 -
附加信息: 栈帧中还允许携带与Java虚拟机实现相关的一些附加信息, 例如: 对程序调试提供支持的信息
方法中定义的局部变量是否是线程安全?
- 在方法内部声明的局部变量是线程安全的
public void method() {
// stringBuilder 在方法内部声明, 线程安全
StringBuilder stringBuilder = new StringBuilder();
}
- 方法参数传来的局部变量是线程不安全的
public void method(StringBuilder stringBuilder) {
// stringBuilder 是通过方法参数传递, 线程不安全
}
- 方法返回的局部变量是线程不安全的
public StringBuilder method() {
// stringBuilder 是方法的返回对象, 线程不安全
StringBuilder stringBuilder = new StringBuilder();
return StringBuilder;
}
4.3 本地方法栈
4.3.1 本地方法
本地方法: 被Java关键字 Native 修饰的方法就是本地方法, 该方法的实现由非JAVA语言实现, 比如C语言. 定义一个native method时, 无需提供实现体. 例如: Object.getClass()
使用Native Method本地方法的原因:
- 与Java环境外交互(C/C++)
- 与操作系统交互
- Sun’s Java: Sun的解释器是用C实现的
4.3.1 本地方法栈
本地方法栈: Java虚拟机栈用于管理Java方法的调用, 而本地方法栈用于管理本地方法的调用
- 本地方法栈, 也是线程私有的
- 允许设置为固定或者动态扩展的内存大小
- 在 Hotspot JVM 中, 直接将本地方法栈和虚拟机栈合二为一
4.3 堆
堆的概述:
- 一个JVM实例只存在一个堆内存, 堆也是Java内存管理的核心区域
- Java 堆在JVM启动的时候创建即被创建, 其空间大小也确定了. 是JVM管理的最大一块内存空间(堆内存的大小是可以设置的)
- 《Java虚拟机规范》规定, 堆可以处于物理上不连续的内存空间中, 但在逻辑上它应该被视为连续的
- 所有的线程共享Java堆, 在这里还可以划分线程私有的缓冲区(TLAB: Thread Local Allocation Buffer)
- 《Java虚拟机规范》中对Java堆的描述: 所有的对象实例以及数组都应当在运行时分配在堆上
- 数组和对象可能永远不会存储在栈(局部变量表)上, 因为局部变量表保存地址引用, 指向在堆中的位置
- 在方法结束后, 堆中的对象不会马上被移除, 仅仅在垃圾收集的时候才会被移除
- 堆, 是GC(Garbage Collection 垃圾收集器) 执行垃圾回收的重点区域
堆空间划分:
- JDK7及之前将堆内存逻辑上分为三部分: 新生代 + 老年代 + 永久代
- JDK8及之后将堆内存逻辑上分为三部分: 新生代 + 老年代 + 元空间
- 新生代又分为三个区域: Enden + S0(Survivor1) + S1(Survivor2)
- 永久代/元空间: 实际上却由方法区实现
堆空间大小
- Java堆用于存储Java对象实例, 在JVM启动时就初始化了大小, 可以可通过设置JVM启动参数设置堆内存大小
- -Xms: 设置堆起始(年轻代 + 永久代)内存 (-X: jvm运行参数, ms: menory start)
- -Xmx: 设置堆最大(年轻代 + 永久代)内存 (mx: menory max)
- 通常会将 -Xms 和 -Xmx 的值设置为相同的值, 其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小, 从而提高性能
- 堆起始内存默认: 物理电脑内存 / 64; 最大内存: 物理电脑内存 / 4
- 一旦堆内存超过 “-Xmx” 所指定的最大内存时, 将会抛出 OutOfMenoryError异常
- 查看堆内存大小: 堆内存大小 = 老年代 + Enden + (S0/S1); S0 和 S1同时只有一个在工作
- 代码中查看: java.lang.Runtime
// 堆初始化内存(单位: b) System.out.println("-Xms: " + Runtime.getRuntime().totalMemory()); // 堆最大内存(单位: b) System.out.println("-Xmx: " + Runtime.getRuntime().maxMemory());
- jps: 是java提供的一个显示当前所有java进程pid的命令; jstat -gc pid: 显示堆各个区域内存使用和垃圾回收的统计信息
- 代码中查看: java.lang.Runtime
年轻代与老年代
Java堆区可以划分为年轻代(YoungGen) 和 老年代(OldGen)
- -XX:NewRatio : 设置新生代与老年代在堆内存的大小占比. 默认 -XX:NewRatio=2 (老年代占2, 新生代占1, 即老年代占堆内存的2/3), 一般使用默认值
- 在HotSpot中, 年轻代又可以划分为Eden空间、Survivor0空间 和 Survivor1空间(也称之为 from区、to区), 默认比例是 8 : 1 : 1, 可以通过"-XX:SurvivorRation=8" 设置空间比例
- 几乎所有的Java对象都是在Eden区被new出来的, 绝大部Java对象(80%)的销毁都在新生代进行的
对象分配的过程
- new的对象先存储在Eden区, 此区有大小限制
- 当Eden区内存不足时, 有需要创建对象, JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC/YGC), 将Eden区中的不再被其他对象引用的对象进行销毁, 再加载新的对象放到Eden区
- 然后将Eden区中剩余对象移动到Survivor0区, 每次对象的移动都会累加age次数
- 又当Eden区内存不足时, 分别对 Eden区 和 Survivor0区 进行垃圾回收(YGC), 将存在引用的对象移动到Survivor1区, 此时 Survivor0区 又空了
- 无论在任何时候, Survivor0 或 Survivor1 中的某个区一定是空的, 且YGC后的对象一定是移动到空区
- 当对象的移动次数超过默认值(15)时, 此对象将会移动到老年代 (可通过-XX:MaxTenuringThreshold=n 进行设置)
- 针对幸存者S0,S1区: 回收之后有交换, 谁空谁是to区
- 当老年代内存不足, 触发OGC(Major GC), 进行老年代的内村清理
- 若老年代执行了OGC之后也还无法进行对象的保存, 则抛出OOM异常
- 垃圾回收频繁在新生代收集, 很少在养老区收集, 几乎不在永久区/元空间收集
对象分配的特殊情况: 新生代 1 : 老年代 2 、Eden 8 : S0 1 : S1 1
- 新对象申请内存较大时, Eden 内存不够, 会直接存储到 老年代. 如果老年代内存不足则触发FGC, 还不足直接抛出OOM
- 发生YGC后剩下的对象, to区内存不足, 则直接存储到 老年代
Minor GC、Major GC、Full GC
- JVM 在进行GC操作时, 并非每次对上面三个内存区域一起回收的(新生代、老年代、方法区), 大部分时候回收的都是新生代
- 针对HotSpot VM的实现, 它里面的GC按照回收区域又分为两大类: 部分收集(Partial GC)、整堆收集(Full GC)
- 新生代收集(Minor GC/YGC): 只是新生代(Eden、S0、S1)的垃圾回收
- 老年代收集(Major GC/OGC): 只是老年代的垃圾收集
- 目前, 只有CMS GC会有单独的老年代垃圾收集
- 注意, 很多时候MajorGC 和FullGC 混淆使用, 需要具体区分是老年代回收还是整堆回收
- 混合收集(Mixed GC): 收集整个新生代以及部分老年代的垃圾收集(G1 GC会有这种收集机制)
- 整堆收集(Fuu GC): 收集整个java堆和方法去的垃圾收集
垃圾回收触发的机制
- 年轻代GC(Minor GC)触发的机制: 当Eden区内存不足时, 触发Minor GC, 同时清理Eden区 和From区的内存 (Survivor区内存不足不会触发GC, Minor GC 会引发STW, 暂停其他的用户线程, 等待垃圾回收结束, 用户线程才恢复运行)
- 老年代GC(Major GC)触发机制: 当老年代内存不足时, 会尝试触发Minor GC, 如果内存还是不足, 则触发Major GC (MajorGC 的速度一般比MinorGC慢10倍以上, STW时间更长)
- Full GC触发的机制
- 调用System.gc()
- 老年代空间不足
- 方法区不足
- 通过Minor GC 后进入老年代的平均大小大于老年代的可用内存
- Full GC 是开发或调优中尽量避免, 防止STW时间太长
对象的内存分配策略
- 对象优先分配到Eden
- 大对象直接分配到老年代(程序中尽量避免过多的大对象)
- 长期存活的对象分配到老年代(数据库连接对象, 流对象等)
- 动态对象年龄判断: 当 Surviver区中相同年龄的所有对象内存大小大于Survivor区内存的一半, 则年龄大于等于该年龄的对象可以直接进入老年代
/**
* -Xms60m -Xmx60m -XX:+PrintGCDetails
*
* PSYoungGen 20M (Eden 16M : Survivor0 2M : Survivor1 2M)
* ParOldGen 40M
*
*/
public class FullGC {
public static void main(String[] args) throws Exception {
byte[] buffer = new byte[20 * 1024 * 1024]; // 20M的数组大对象(大于 Eden区16M) 存储在 老年代
}
}
堆区是线程共享区域, 由于对象实例的创建在JVM中非常频繁, 因此并发环境下在堆区中划分内存空间是线程不安全的, 为避免多个线程操作同一地址, 需要使用加锁等机制, 进而影响分配速度
TLAB: Thread Local Allocation Buffer
- JVM为每个线程在Eden分配了一个私有缓存区域, 内存大小仅占有整个Eden空间的1%
- JVM确实将TLAB作为内存分配的首选, 即 对象首先会尝试在TLAB中分配内存, 如果分配失败, JVM才会通过加锁机制在Eden区分配内存
- 多线程分配内存时, 使用TLAB可以避免一系列的线程安全问题, 同时还能提升内存分配的吞吐量, 这种内存分配方式称之为快速分配策略
堆是分配对象存储的唯一选择吗?
随着JIT编译器的发展 与 逃逸分析技术 逐渐成熟, 栈上分配、标量替换优化技术 将会导致对象分配到堆上也渐渐的变得不那么绝对了
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后, 对象只在方法内部使用, 则认为没有发生逃逸
public void escape() {
Object obj = new Object();
}
- 当一个对象在方法中被定义后, 对象被外部方法所引用, 则认为是发生逃逸
public Object escape() {
Object obj = new Object();
return obj;
}
对未逃逸的对象, 编译器可以对此对象进行优化:
-
栈上分配: 未逃逸对象将由堆分配转化为栈分配. 由于栈的生命周期随线程结束而结束, 因此无需进行垃圾回收
- -XX:+DoEscapeAnalysis: JVM开启逃逸分析(JDK7及以上版本默认开启: +代表开启, -代表关闭)
- 栈上分配代码测试, 需要借助JDK自带的可视化监控JVM工具: Java VisualVM
/** * -Xms1G -Xmx1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails */ public class StackAllocation { public static void main(String[] args) { // 创建1千万个User对象 for (int i = 0; i < 10000000; i++) { alloc(); } try { // 线程睡1000秒, 方便 Java visualVM 监测 Thread.sleep(1000 * 1000); } catch (InterruptedException e) { e.printStackTrace(); } } private static void alloc() { // user对象未发生逃逸, 理应优化成栈上分配 User user = new User(); } static class User {} }
-
同步省略: 如果一个未逃逸对象被发现只能从一个线程被访问到, 那么对于这个对象的操作可以不考虑同步
-
标量替换: 一个未逃逸对象可以被JIT拆解成若干个成员变量(标量)来替代 (标量: 指一个无法再分解成更小的数据的数据, Java中基本类型就是标量)
static class User {
private int id;
private int age;
}
private static void alloc() {
// user对象未发生逃逸
User user = new User(1, 20);
}
private static void alloc() {
// User对象被拆解成多个成员标量, 内存分配在栈中
int id = 1;
int age = 20;
}
4.4 方法区
方法区概述
- 方法区(Mehtod Area) 与 Java堆一样, 是各个线程共享的内存区域
- 方法区的大小, 跟堆空间一样, 可以选择固定大小或者可扩展
- 方法区再JVM启动时创建, 并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
- 方法区在逻辑上是属于堆的一部分, 实际上方法区可以看作是独立与Java堆的内存空间(在 HotSpotJVM 中, 方法区又称之为Non-Heap 非堆)
- 方法区存储类加载信息(class)、运行时常量池、静态变量等
- 在JDK7及以前, 习惯将方法区称之为永久代(PermGen). 在JDK8开始, 元空间(Metaspace )取代了永久代
- 元空间的本质和永久代类似, 都是对JVM规范中方法区的实现. 两者最大的区别: 元空间不在虚拟机设置的内存中, 而是使用本地内存
方法区大小
- JDK7及以前: 通过 -XX:PermSize来设置永久代初始分配空间(默认20.75M); 通过-XX:MaxPermsize来设定永久代最大可分配空间(32位机器默认是64M, 64位默认82M)
- 当JVM加载的类信息容量超过最大值, 会抛出异常: OutOfMemoryError: PermGenspace
- JDK8以后元数据区大小可以使用-XX:MetaspaceSize= 和 -XX:MaxMetaspaceSize指定(默认值依赖平台: -1 代表没有限制)
- 如果元空间没有指定大小, 虚拟机会耗尽所用可用系统内存. 元数据区发生溢出, 会抛出异常: OutOfMemoryError: Metaspace
方法区的内部结构
- 《深入理解Java虚拟机》 书中对方法区(Method Area) 存储的内容包括: 虚拟机加载的类型信息、常量、静态变量、即时编译器等编译后的代码缓存等
- 类型信息(class、interface、enum、annotation)
- 类的完整有效名称: 包名.类名
- 类的直接父类的完整有效名称
- 类的修饰符(public、abstrace、final)
- 类的直接接口的有序列表
- 域(Field)信息(成员变量)
- JVM必须在方法中保存类的所有域相关的信息以及域的声明顺序
- 域相关的信息包括: 域名称、域类型、域修饰符(public、private、protected、static、volatile、transient)
- 方法(Method)信息: 方法名称、方法的返回类型、方法参数的数量和类型、方法的修饰符、方法的字节码、操作数栈、局部变量表、异常表
- 运行时常量池: 在Class类加载到内存后, 会将Class文件中的常量池表(Constant Pool)存放到方法区的运行时常量池中
- JVM为每个已加载的类型(类或接口) 都维护一个常量池, 通过索引访问(#num)
字节码文件(.class)中的常量池(Contant Pool): 一个有效的字节码文件中包含类的版本信息、字段、方法、以及接口等描述信息外, 还包含最大一块常量池: 包括各种字面量和对类型、域和方法的符号引用
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
常量池: 可以看作一张表, 虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等
方法区的实现变化
只有HotSpotVM 才存在永久代, BEA JRockit、IBM J9 不存在永久代概念, 使用元空间实现方法区.
原则上方法区的实现属于虚拟机实现细节, 不受《Java虚拟机规范》约束
HotSpot中的方法区的变化
JDK1.6及之前 | 有永久代, 静态变量存放在永久代上 |
---|---|
JDK 1.7 | 有永久代, 但已经逐步 “去永久代”, 字符串常量池、静态变量移除, 保存在堆中 |
JDK 1.8及以后 | 使用本地内存的元空间存储: 类型信息、字段、方法、常量. 但字符串常量池、静态变量仍在堆中 |
元空间替换永久代的原因
- 为永久代设置空间大小是很难确定的, 无法控制动态加载类的数量, 设置过小很容易抛出OOM. 而元空间使用的是本地内存, 默认情况下, 元空间的大小仅受本地内存大小的限制
- 对永久代进行调优是很困难的
StringTable: 字符串常量池, JDK1.6之前是放在永久代中, 永久代的回收效率很低(FullGC), 这样导致StringTable回收慢. 在程序中存在大量的字符串被创建, 且回收条件苛刻, 导致永久代内存不足
方法区的垃圾回收:
- 《Java虚拟机规范》对方法区的约束是非常宽松的, 提到过可以不要求虚拟机在方法区中实现垃圾回收, 事实上也确实未有实现或完整实现方法区类型卸载的收集器存在
- 方法区的垃圾回收两部分内容: 常量池中废弃的常量 和 不在使用的类型
- 常量池中存储两大类常量: 字面量 和 符号引用, 只要常量没有被任何地方引用, 即可回收
- 不在使用的类型回收需要满足三个条件:
- 该类的所有实例都已经被回收, 也是Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用, 无法在任何地方通过反射访问该类的方法
对象实例化过程: 加载类元信息(方法区) ==> 为对象分配内存 ==> 处理并发问题(TLAB、CAS、加锁) ==> 属性的默认初始化 ==> 设置对象头信息 ==> 属性、代码块、构造器显示初始化
对象在内存中的布局
- 对象头(Header): 运行时元数据(哈希值、GC分代年龄age、锁状态标志) 和 类型指针(指向元空间中的类信息)
- 实例数据: 存储对象真正有效信息
- 对齐填充: 占位符
(五) 执行引擎
执行引擎概述
- 执行引擎是Java虚拟机核心的组成部分之一
- JVM的主要任务是负载装载字节码到其内部, 但字节码并不能直接运行在操作系统之上, 需要执行引擎将字节码指令解释编译为对应平台上的本地机器指令才可以运行在操作系统之上. 执行引擎充当了将高级语言翻译为机器语言的译者
- Java虚拟机的执行引擎输入、输出都是一致的: 输入的是字节码二进制流, 处理过程是字节码解析执行的等效过程, 输入的是执行结果
- 执行引擎包括三个部分: 解释器、JIT即时编译器、垃圾回收器. (Java是半编译半解释型语言)
机器码(二进制) --> 指令 --> 指令集 --> 汇编语言 --> 高级语言 : 执行效率逐级递减
Java代码编译和执行过程图:
解释器: 当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行, 将每条字节码文件中的内容 转译 为对应平台的机器指令执行
- JVM设计的初衷仅仅是单纯为了满足Java程序实现跨平台性, 因此避免采用静态编译的方式直接申城本地机器指令, 从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法
- 由于解释器在设计上和实现上非常简单, 但是执行效率低下, 为了解决这个问题, JVM平台支持一种叫做即时编译器的技术
JIT即时编译器: 虚拟机运行中, 将源代码直接编译成和本地机器平台相关的机器码, 并将机器码缓存在方法区
- JIT编译器将代码编译成机器码, 执行效率提高. 但编译过程需要耗费一定量的时间, 导致jvm启动时间较长
- HotSpot VM 采用解释器与即时编译器并存的架构, 在虚拟机启动时, 解释器可以首先发挥作用, 而不必等待即时编译器全部编译完成后执行, 缩短编译时间. 随着部分代码的执行频率提高, 即时编译器将热点代码编译成本地机器码, 获得更高的执行效率
热点代码及探测方式
- 一个被多次调用的方法, 或者是一个方法体内部循环次数较多的循环体都可以称之为 “热点代码”
- JIT编译器将执行频率高的热点代码, 编译成机器码, 由于这种编译方式发生在方法的执行过程中, 因此也被称之为栈上替换(OSR编译: On Stack Replacement)
- 热点探测: 采用基于计数器的热点探测, HotSpotVM将会为每一个方法都建立2个不同的类型计数器:
- 方法调用计数器: 用于统计方法的调用次数
- 回边计数器:用于统计循环体执行的循环次数
AOT静态提前编译器: JDK9引入的, 在程序运行前, 便将字节码转换为机器码的过程(缺点: 破坏了java"一次编译, 到处运行"的原则, 降低了Java链接过程的动态性)
补充内容: 常量池 StringTable
String: 字符串对象, 被final修饰, 不可被继承, 实现了Serializable接口 和 Comparable接口. 有两种实例化方式(new String()、“字面量方式声明”)
- JDK1.8 及以前: 使用 final char[] value 存储字符串
- JDK9 及以后: 使用 final byte[] value 存储字符串 (节约了空间)
String 代表不可变的字符序列(不可变性):
- 当对字符串重新赋值时, 需要重新指定新内存区域赋值, 不能使用原有的value进行赋值
- 当对字符串进行连接操作时, 也需要重新指定新内存区域赋值, 不能使用原有的value进行赋值
- 当调用String的replac()方法修改指定字符或字符串时, 也需要重新指定内存区域赋值, 不能使用原有的value进行赋值
字符串常量池(String Pool)
- 字面量的方式创建字符串 和 调用String.intern()方法, 字符串会存在在字符串常量池中(new String() 存储在堆中)
- 字符串常量池 是一个固定长度的Hashtable, 默认值大小长度是60013, 可设置长度(-XX:StringTableSize)
- 字符串常量池存储的String较多时, 会造成Hash冲突严重, 从而导致链表会很长, 查询效率低下(intern)
- 字符串常量池中时不会存储相同内容的字符串
- 常量与常量的拼接结果在常量池中, 原理是编译器优化 (“abc” == “a” + “b” + “c”)
- 只要其中一个是变量拼接, 结果都在堆里. 变量拼接的原理是 StringBuilder
常量池 StringTable:
- 在Java语言中有8中基本数据类型和一种比较特殊的类型String. JVM为了使这些类型在运行过程中速度更快、更节省内存. 提供一种常量池的概念
- 常量池类似一个Java系统级别提供的缓存. 8种基本类型的常量池都是系统协调的
- String类型的常量池比较特殊, 使用方式存在两种:
- 直接双引号申明的Stirng对象会直接存储到常量池中
- 使用String提供的intern() 方法
public native String intern(): 会从常量池中查询当前字符串是否存在
- 常量池中存在: 返回常量池中的字符串的地址
- 常量池中不存在: 则在常量池中创建该字符串 (如果在堆中存在该字符串的对象, 则常量池中的创建的对象引用, 指向堆中的该字符串的地址)
public static void main(String[] args) {
String str1 = new String("a"); // 创建了两个对象: str1对象 (堆内存) 和 "a"对象(常量池中)
str1.intern(); // 此时, "a" 字符在常量池中存在, 不做任何对象地址操作
String str2 = "a"; // 创建了str2对象 内存地址指向常量池中
System.out.println(str1 == str2); // false: 此时比较的是 str1的堆空间地址值 和 字符串常量池的地址值
}
public static void main(String[] args) {
/**
* 这里创建了5个对象, 通过字节码文件中可知
*
* 1. StringBuilder: 存储在堆内存中, 所有的字符串拼接操作都是通过 StringBuilder.append() 方法实现的
* 2. String: 存储在堆内存中, 代码中第一个 new 的对象
* 3. "1": 存储在常量池中的常量 "1"
* 4. String: 存储在堆内存中, 代码中第二个 new 的对象
* 5. String: 存储在堆内存中, StringBuilder.toString()方法创建的堆对象, 值为11, 最后赋值给 str3
*
* 即: 堆中有4个对象: StringBuilder、第一个new String, 第二个new String, str3(值为11)
* 常量池中只有一个常量: "1"
*/
String str3 = new String("1") + new String("1");
str3.intern(); // 常量池中不存在 "11"常量, 且 "11" 这个对象在堆内存中存在(str3), 即 在常量池中创建 "11"常量的引用, 指向 堆内存中的 "11"对象(str3)
String str4 = "11"; // 常量池中存在 "11", 并指向堆对象中的"11"对象
System.out.println(str3 == str4);// ture
}
基于JDK7及其以上讲解