JVM 架构图
文章目录
- 一 JVM 简介
- 二 类加载子系统:
- 1 作用
- 2 类的三个加载过程
- 3 类加载器的分类
- 4 双亲委派机制 & Tomcat为何不遵循
- 5 两个 class 对象为同一个类的必要条件
- 三 运行时数据区:PC寄存器(Program Counter Register)
- 四 运行时数据区:虚拟机栈
- 1 概述
- 2 栈可能出现的异常
- 3 栈的存储结构和运行原理
- 4 栈帧的组成
- 五 方法的调用
- 1 静态链接与动态链接
- 2 早期绑定与晚期绑定
- 3 (非)虚方法
- 4 JVM 方法调用的指令
- 5 虚方法表
- 六 运行时数据区:本地方法栈
- 七 运行时数据区:堆
- 1 概述
- 2 堆的内存结构
- 3 YGC / Minor GC
- 4 Major GC & Full GC
- 5 内存分配策略
- 6 线程分配缓冲区(Thread Local Allocation Buffer, TLAB)
- 7 逃逸分析与优化
- 8 对象的内存分配
- 八 运行时数据区:方法区(永久代 / 元空间)
- 1 概述
- 2 方法区与堆栈的交互
- 3 方法区的内部结构
- 4 方法区的发展:为什么需要元空间
- 5 方法区的垃圾回收
- 九 对象的实例化、内存布局、访问定位
- 1 对象创建的方法和步骤
- 2 !!对象的内存布局
- 十 String Table
- 1 String 的基本特性
- 2 String 拼接
- 3 intern()
- 4 创建了几个对象?
- 5 两道难题*
- 十一 垃圾回收相关概念
- 1 什么是垃圾
- 2 内存溢出
- 3 内存泄漏
- 4 强引用:存在就不回收
- 5 软引用:内存不足时回收
- 6 弱引用:发现即回收
- 7 虚引用:形同虚设,回收跟踪
- 十二 垃圾回收算法
- 1 标记阶段:可达性分析算法
- 2 finalize()
- 3 判断对象是否可回收的流程(至少两次标记)
- 4 清除阶段:标记-清除算法
- 5 清除阶段:标记-复制算法
- 6 清除算法:标记-压缩算法
- 7 三种清除算法的对比
- 8 分代收集算法
- 十三 垃圾回收器
- 1 基本概念
- 2 常用回收器
- 3 组合关系
- 3.1 Serial + Serial Old
- 3.2 ParNew + CMS
- 3.3 Parallel + Parallel Old
- 3.4 G1
一 JVM 简介
- JVM 本质上是二进制字节码的运行环境,是运行在操作系统上的,与硬件没有直接的交互
- Java 是跨平台的语言:一次编写,到处运行
- JVM 是跨语言的平台:JVM 是面向字节码文件的,只要符合 JVM 规范,JVM 不仅可以处理 Java 语言编译的字节码文件,还支持其它语言编译的字节码文件
二 类加载子系统:
1 作用
- 负责加载文件开头有特定的标识的 class 文件
- 只负责文件的加载,而不保证 class 文件可以运行(能否运行由执行引擎决定)
- 加载的类信息存放在方法区,除此之外,方法区还会存放运行时的常量池信息
- 在
class file -> JVM -> 元数据模板
的过程中作为“快递员”的角色
2 类的三个加载过程
- Loading(创建 Class 类型的对象)
- 通过类的全限定名获取定义此类的二进制字节流
- 将该字节流代表的静态存储结构,转化为方法区的运行时数据结构
- 在内存中声明一个
java.lang.Class
类型的对象,作为方法区的该类的各种数据的访问入口
- Linking(类变量分配内存空间,赋初始值)
验证阶段
- 确保 class 文件符合虚拟机要求,保证被加载类的正确性
准备阶段
- 为 类变量(static 修饰) 申请内存空间,并赋初始零值(final 修饰的 static 类变量为指定值)
- 此外,该阶段不会为 实例变量(通过 this 引用) 初始化,因为实例变量随着对象分配到堆中,而类变量分配到方法区中
解析阶段
- Initialization(类变量的赋值,执行静态代码块语句)
- 该阶段的任务是,执行类构造器方法
<clinit>()
的过程- 该方法是 javac 编译器(前端编译器)自动收集 类变量的赋值动作 和 静态代码块的语句 合并得到的
- 子类的
<clinit>()
在父类的<clinit>()
执行后才能执行 <clinit>()
不同于类的构造器,在 JVM 视角下,类的构造器是<init>()
方法- 虚拟机保证
<clinit>()
在多线程下被同步加锁,避免同一个类加载多次
class Parent {static int A = 1; // 执行顺序1static {A = 2; // 执行顺序2}
}class Sub extends Parent {static int B = A; // 执行顺序3static {System.out.println("Sub B before static block:" + B);B++; // 执行顺序4System.out.println("Sub B after static block:" + B);}
}class Test {public static void main(String[] args) {new Sub();}
}输出结果:
Sub B before static block:2
Sub B after static block:3
3 类加载器的分类
BootStrap ClassLoader(启动类加载器)
:使用 C/C++ 实现,没有父类(上级,非继承意义的父类)加载器,用于加载 Java 核心库Extension ClassLoader(扩展类加载器)
:继承自 ClassLoader 类,父类(上级,非继承意义的父类)加载器为启动类加载器System ClassLoader(应用类加载器)
:继承自 ClassLoader 类,父类(上级,非继承意义的父类)加载器为扩展类加载器,是程序默认的类加载器用户自定义类加载器
4 双亲委派机制 & Tomcat为何不遵循
- 是 JVM 加载类的 class 文件的机制,防止内存中存在多份同一个类的字节码(避免类的重复加载,防止核心 API 被篡改)
- 如果一个类加载器收到了类加载请求,它不会直接执行类的加载,而是将请求委托到上级的加载器
- 上级的加载器递归执行该过程,最终请求到达启动类加载器
- 如果上级加载器可以执行指定类的加载,则过程结束;否则向下级传递该请求,直到类可以被加载
为什么Tomcat 破坏了双亲委派机制
- 破坏双亲委派机制:自定义
ClassLoader
,重写loadClass
方法,不依次往上交给父加载器进行加载 - Tomcat 应用程序的需求
- 隔离:一个 Web 容器可能需要部署若干个应用程序,不同的应用程序可能会依赖 同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份。所以给每个应用程序一个专用的
WebAppClassLoader
- 共享:为了应用程序之间的共享,把
ShareClassLoader
作为WebAppClassLoader
的父类加载器,如果WebAppClassLoader
找不到要加载的类,则尝试用ShareClassLoader
进行加载
- 隔离:一个 Web 容器可能需要部署若干个应用程序,不同的应用程序可能会依赖 同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份。所以给每个应用程序一个专用的
- 如果 Tomcat 使用双亲委派机制的话,无法加载 两个相同类库的不同版本 ,默认的类加器无视版本,只在乎全限定类名,并且只有一份
5 两个 class 对象为同一个类的必要条件
- 全类名一致
- 加载类的
ClassLoader
(指ClassLoader
实例)相同
- 即:同一个 class 文件,被同一个 JVM 的不同
ClassLoader
实例加载,不能算作同一个类对象
三 运行时数据区:PC寄存器(Program Counter Register)
- 用于存储指向下一条指令的地址(执行引擎负责读取下一条指令)
- 线程私有,生命周期和线程保持一致
- 是 Java 内存中唯一一个没有规定
OutOfMemoryError
的区域 - 使用PC寄存器存储字节码指令地址的作用,为什么要记录当前线程的执行地址?
一个PC寄存器记录一个线程的字节码指令地址,程序运行时,CPU 需要在各个线程间切换,切换到某个线程时需要还原它切换之前的现场,通过PC寄存器确定继续执行的位置
四 运行时数据区:虚拟机栈
1 概述
- 主管 Java 程序的运行,保存方法的局部变量(8种基本数据类型+对象的引用地址)、部分结果,参与方法的调用和返回
- 栈是运行时的单位,解决程序运行的问题;堆是存储的单位,解决数据存储的问题
- 线程私有,生命周期和线程保持一致
2 栈可能出现的异常
- JVM 允许栈的容量为动态的,或是固定的
- 栈容量动态时,如果栈尝试扩展并无法申请到足够的内存,或是创建新线程时没有足够的内存创建对应的虚拟机栈,抛出
OutOfMemoryError
- 栈容量固定时,请求的容量超过指定容量时,抛出
StackOverflowError
- 栈容量动态时,如果栈尝试扩展并无法申请到足够的内存,或是创建新线程时没有足够的内存创建对应的虚拟机栈,抛出
3 栈的存储结构和运行原理
- 栈的存储格式是栈帧,栈帧是一个内存区块,维护着方法执行过程中的数据信息
- 栈帧和执行的方法是一一对应的
- 在一个活动线程中,同一时刻只有栈顶的栈帧是活动的(即:一个线程同一时刻只能执行一个方法),执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 不同线程中包含的栈帧不允许相互引用,即不能在某个栈帧中引用另外一个线程的栈帧
- 方法返回时(使用 return 指令 / 抛出未处理的异常),当前栈帧会将执行结果传递给前一个栈帧,之后丢弃该栈帧,使得其下一个栈帧成为新的栈顶栈帧
4 栈帧的组成
组成 | 作用 |
---|---|
局部变量表 | 存储方法的参数、定义在方法体内的局部变量 |
操作数栈 | 保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间 |
动态链接 | 将符号引用转换为调用方法的直接引用 |
方法返回地址 | 存放调用该方法的PC寄存器的值,即调用该方法的指令的下一条指令的地址 |
附加信息 | … |
4.1 局部变量表
- 是一个数字数组,主要用于存储方法的参数、定义在方法体内的局部变量
- 线程私有,所以不存在数据安全问题
- 所需容量大小在编译时确定,方法运行时不会更改
- 最基本的存储单元是 Slot
有关 Slot
- 引用类型、byte、short、char…占用1个 Slot;long、double 占用两个 Slot
- 如果当前方法可以访问 this.xxx(即:当前栈帧由构造方法或实例方法创建),则对当前对象的引用 this 放在首个 Slot(这解释了为什么静态方法不能访问 this.xxx,因为局部变量表里没有 this)
- Slot 是可重用的,如果某个局部变量超出其作用域,则该 Slot 可以被之后声明的变量使用
- 局部变量表中的变量,是重要的垃圾回收根节点,只要被局部变量表直接或间接引用的对象都不会被回收
- 成员变量(包括静态变量、实例变量)和局部变量的对比
变量类型 | 初始化过程 |
---|---|
静态变量 | 在类加载的 Linking 阶段申请内存空间并赋初始0值,在 Initialization 阶段显式赋值(静态代码块赋值) |
实例变量 | 对象创建时,在堆中申请内存空间并赋初始0值 |
局部变量 | 无初始0值,使用前必须要显式赋值 |
4.2 操作数栈
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间
- 方法刚开始执行时,操作数栈被创建,在编译时确定其最大深度:引用类型、byte、short、char…占用1个单位深度;long、double 占用两个单位深度
- 不能通过索引访问数据,只能通过栈的 push / pop
- 如果方法具有返回值,则返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器为下一条需要执行的字节码指令
4.3 动态链接
- Java 源代码被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在 class 文件的常量池(属于方法区)里,动态链接的作用是,将符号引用转换为调用方法的直接引用
- 为了实现动态链接,每个栈帧都包含一个指向运行时常量池中的该栈帧所属方法的引用
4.4 方法返回地址
- 方法的结束有两种方式:正常退出;出现未处理的异常,非正常退出
- 正常退出的方法会给调用者返回值,而非正常退出的方法不会
- 无论通过哪种方式退出,方法退出后都要返回其被调用的位置
- 方法返回地址的作用是,在方法正常退出时,存放调用该方法的PC寄存器的值,即调用该方法的指令的下一条指令的地址
五 方法的调用
1 静态链接与动态链接
- 此处的 “链接” 是将调用方法的符号引用转换为直接引用的过程,针对的是方法调用
- 某种程度上,动态链接对应语言的多态特性
- 静态链接:字节码文件被装载到 JVM 内部时,被调用的方法在编译期间可知,且运行时保持不变。这种情况下,将调用方法的符号引用转换为直接引用的过程,称为静态链接
- 动态链接:字节码文件被装载到 JVM 内部时,被调用的方法在编译期间不可知,只有在运行时才能将方法调用符号引用转换为直接引用,称为动态链接
2 早期绑定与晚期绑定
- “绑定” 指的是字段、方法、类的符号引用转换为直接引用的过程
- 早期绑定:对应静态链接,在编译时可以执行引用的转换
- 晚期绑定:对应动态链接,只能在运行时执行引用的转换
3 (非)虚方法
- 非虚方法:在编译时可以确定具体的调用版本,且在运行时不变,则该方法为非虚方法
- 静态方法、私有方法、final 方法、构造器方法、父类的方法 均为非虚方法,其它方法称为虚方法
- 多态的前提是类的继承或方法的重写,所以不涉及到继承和重写的方法均为非虚方法
4 JVM 方法调用的指令
指令 | 作用 |
---|---|
invokestatic | 调用静态方法 |
invokespecial | 调用<init>方法、私有方法、父类方法 |
invokevirtual | 调用虚方法(包括 final 修饰的方法) |
invokeinterface | 调用接口方法 |
invokedynamic | 动态解析并执行需要调用的方法 |
invokestatic
调用的方法,invokespecial
调用的方法,invokevirtual
调用的 final 方法,为非虚方法invokedynamic
是 Java8 中 lambda 表达式引入的新指令
5 虚方法表
- JVM 在每个类的方法区建立虚方法表(非虚方法不会在此出现),表中存放的是各个方法的实际入口,以提高动态链接情况下的查找性能
- 使用举例:
六 运行时数据区:本地方法栈
- 本地方法:Java 调用的非 Java 语言实现的方法
- 本地方法不是抽象方法,有具体实现,但非 Java 语言,所以
native
不能与abstract
共同使用 - 虚拟机栈用于管理 Java 方法的调用,本地方法栈用于管理本地方法的调用
- 本地方法栈的容量可以设置为可变,也可以设置为固定
七 运行时数据区:堆
1 概述
- 一个 JVM 实例只存在一个堆空间,在 JVM 启动时堆的大小已确定
- 堆在物理内存上可以不连续,但在逻辑上是连续的
- 除了 TLAB(Thread Local Allocation Buffer)区域,所有的线程共享堆内存
- 堆是 GC 的重点区域,方法结束后,堆中的对象不会被立刻回收,而是在垃圾回收时被移除
- 所有的 对象实例 和 数组,在运行时都在堆上分配,而不是在栈上(虚拟机栈的栈帧保存的是对象实例和数组的引用)
2 堆的内存结构
- 新生代(伊甸园区、幸存者1区、幸存者2区;默认比例为8:1:1)、老年代
- 永久代 / 元空间是 Hotspot JVM 对于方法区的具体实现,不属于堆
- 几乎所有对象都是在伊甸园区被创建的
- 分代的唯一理由是优化 GC 性能
3 YGC / Minor GC
- 是新生代的垃圾回收机制
- 相较于 Major GC、Full GC,执行更频繁,所需时间更短
- 执行流程:
- 当伊甸园区满的时候触发(幸存者区满时不会触发)
- 对新生代(包括伊甸园区和幸存者区)执行垃圾回收。对于没有回收的实例,将伊甸园区的实例转移到幸存者区,在幸存者区的实例从 from 区转移到 to 区
- 对于幸存者区的实例,转移次数超过设定值时,实例从幸存者区转移到老年代,转移到老年代的实例不再参与 Minor GC
4 Major GC & Full GC
- Major GC 针对老年代进行垃圾回收,如果进行之后内存仍不足,则 OOM
- Full GC 针对新生代、老年代、永久代进行垃圾回收,所需时间最长,通过调优尽量避免
5 内存分配策略
- 对象优先分配到伊甸园区
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 动态对象年龄判断:幸存者区中,年龄相同的对象如果占幸存者区空间的一半以上,则大于等于该年龄的对象直接进入老年代,而不用到达阈值
6 线程分配缓冲区(Thread Local Allocation Buffer, TLAB)
- 在伊甸园区,为每个线程分配一块线程独有的内存区域
- 多线程同时分配内存时,使用 TLAB 可以避免一系列线程安全问题,同时提升了内存分配吞吐量
- JVM 将 TLAB 作为内存分配的首选,无法在 TLAB 分配时,JVM 尝试使用加锁机制保证线程安全,并直接在伊甸园区分配内存
- 类的实例化过程:
7 逃逸分析与优化
- 逃逸分析是 减少 Java 程序的同步负载 和 堆分配压力的 跨函数全局流分析算法
- 如果经过逃逸分析发现,对象并没有逃逸出方法的话,该对象可能被优化为栈上分配而非堆上分配。这么做的好处是,无需对该对象进行垃圾回收,并减缓了堆的压力
- 结论:能使用局部变量的,就不要在方法外定义
public class EscapeAnalysis {public EscapeAnalysis obj;/*方法返回EscapeAnalysis对象,发生逃逸*/public EscapeAnalysis getInstance(){return obj == null? new EscapeAnalysis() : obj;}/*为成员属性赋值,发生逃逸*/public void setObj(){this.obj = new EscapeAnalysis();}//思考:如果当前的obj引用声明为static的?仍然会发生逃逸。/*对象的作用域仅在当前方法中有效,没有发生逃逸*/public void useEscapeAnalysis(){EscapeAnalysis e = new EscapeAnalysis();}/*引用成员变量的值,发生逃逸*/public void useEscapeAnalysis1(){EscapeAnalysis e = getInstance();//getInstance().xxx()同样会发生逃逸}
}
- 优化方法与作用
优化方法 | 作用 |
---|---|
栈上分配 | 如果对象没有逃逸出方法,可能被优化为栈上分配,避免了对其GC,减轻堆的压力 |
同步省略 | 借助逃逸分析,判断同步的代码块使用的锁对象是否只能被一个线程访问,如果是则取消代码块的同步 |
标量替换 | 将符合条件的对象“打散”,分配在栈上,避免对象的创建,因此不使用堆的内存 |
- 标量替换的实例
/*标量替换前*/
class Point {private int x;private int y;// 构造方法省略...
}
private static void alloc() {Point p = new Point(1, 2);System.out.println("x" + p.x + "y" + p.y);
}/*标量替换后*/
private static void alloc() {int x = 1;int y = 2;System.out.println("x" + x + "y" + y);
}
8 对象的内存分配
- 对象优先分配在 TLAB 上
- 如果 TLAB 无法容纳,则分配在伊甸园区
- 伊甸园区无法容纳,执行 YGC(虚线部分为 YGC 的一部分流程,“Survivor放得下” 指的是从伊甸园区转移过来的对象能否被幸存者区完全容纳,非新对象)
- YGC 后伊甸园仍然无法容纳,尝试分配到老年代
- 执行 FGC 后老年代无法容纳则 OOM
- 为对象分配内存的线程安全问题
- 采用 CAS + 失败重试 方式,保证内存分配的原子性
- 优先在当前线程的 TLAB 中分配,如果剩余空间不满足当前对象,使用同步锁定扩展缓存区域
八 运行时数据区:方法区(永久代 / 元空间)
1 概述
- 方法区用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等
- 类似于堆,在 JVM 启动时创建,物理内存空间可以不连续,容量可以设置为固定或可变
- JDK7 之前方法区实现为永久代,8 及之后为元空间
- 元空间最大的区别在于使用的是本地内存,而非 JVM 设置的内存
- 永久代 ≠ 方法区,因为永久代仅仅是针对 Hotspot JVM 的概念,而方法区是 JVM 的概念
- 方法区的容量决定了系统可以保存多少个类(类的个数而非类的实例个数),如果超出容量则 OOM: PermGen Space(JDK8 及之后为OOM: MetaSpace)
2 方法区与堆栈的交互
- 方法区和堆是线程共享的,而虚拟机栈、本地方法栈、PC寄存器是线程私有的
- 容量满时的异常类型不同:
-对象创建时,方法区与堆栈的交互:
- 程序执行实例:字节码指令存在于 class 文件
public static void main(String[] args) {int a = 500;int b = 100;int c = a / b;int d = 50;System.out.println(c + d);
}
3 方法区的内部结构
-
类型信息
对于每个加载的类型(class, interface, enum, annotation),JVM存储:
该类型的完整有效名称、该类型的直接父类的有效名称、该类型的直接接口的有效名称有序列表、该类型的修饰符 -
域信息
按 域声明顺序 存储:
域名称、域类型、域修饰符 -
方法信息
按 方法声明顺序 存储:
方法声明顺序、方法的返回值类型、方法参数的数量和类型(按声明顺序)、方法的修饰符、方法的字节码(方法名和方法体)、操作数栈大小、局部变量表大小、异常表 -
非 final 的类变量
即有 static 无 final 修饰的变量,类变量随着类的加载而加载,和类数据属于同一逻辑部分
被 final 修饰的类变量在编译时完成加载 -
运行时常量池
是 字节码文件常量池(可以看作一张表,存放索引到类名、方法名、参数类型、字面量等类型的映射,以及相互之间的调用关系) 经过类加载后得到的结果,存放在方法区中
JVM 为每个已加载的类型维护一个运行时常量池,运行时常量池的每一项通过索引访问
4 方法区的发展:为什么需要元空间
- 永久代的需要的空间难以估计,而元空间的容量仅受限于本地内存,不易产生 OOM
- 对永久代调优比较困难
版本 | 变化 |
---|---|
1.6及之前 | 有永久代 |
1.7 | 去永久代,运行时常量池中的字符串常量池、静态变量移动到堆中,原因:永久代的回收效率很低,只有老年代或永久代空间不足时触发 Full GC 才会进行回收;而放在堆里能及时回收内存 |
1.8及之后 | 无永久代,运行时常量池中的字符串常量池、静态变量仍在堆中,永久代的其余部分移动到本地内存中 |
5 方法区的垃圾回收
主要回收的内容:常量池中不再使用的常量,和不再使用的类型
- 常量池中不再使用的常量:类似于堆中实例的回收,一旦没有被任何地方引用,就可以被回收
- 不再使用的类型:需要满足以下三个条件
- 该类的所有实例都被回收(包括派生子类)
- 该类的类加载器已被回收
- 该类对应的 java.lang.Class 对象在任何地方都没有被引用,即无法通过反射访问该类的方法
九 对象的实例化、内存布局、访问定位
1 对象创建的方法和步骤
2 !!对象的内存布局
- 在
main
方法中创建了一个名为cust
的对象,其内存布局如下图所示 - 因为是静态方法,所以局部变量表的首位不是
this
十 String Table
1 String 的基本特性
- 声明为
final
,不可继承 - 实现了
Serializable
接口,可序列化;实现了Comparable
接口,可以比较大小 - JDK8 及之前使用
char[]
存储,JDK 9 之后使用byte[]
,同时StringBuffer
和StringTable
也随之更改 - 通过字面量的方式(而非 new)给一个字符串赋值,此时字符串声明在字符串常量池中
- 字符串常量池不会存储内容相同的字符串
- 具有不可变性,对字符串修改时,必须重新申请内存区域进行赋值,而不能在原内存空间中修改
- JDK6 属于永久代 -> JDK7 及之后属于堆空间(详见运行时数据区:方法区)
2 String 拼接
-
常量和常量的拼接结果,仍然放在字符串常量池,原理是编译期优化
-
拼接过程中只要有一个是变量(如果声明时被 final 修饰则不能视为“变量”,编译期优化),结果就在堆中,原理是变量拼接使用
StringBuilder
,拼接后调用toString()
,类似于new String()
-
区别:
new String()
生成的字符串会在常量池中保存一个字符串对象的复制(对象而非地址的复制),而toString()
不会
-
如果拼接的结果调用
intern()
,则将拼接得到的字符串放入常量池(如果使用 equals() 判断字符串已经存在则无需放入),并返回字符串在常量池中的地址
3 intern()
- 某个字符串调用
intern()
方法,该方法会从字符串常量池中查询当前字符串是否存在,若不存在则复制到字符串常量池中,并返回它在字符串常量池的地址 - 有关
intern()
“复制” 的说明:JDK1.6 及之前复制的是对象,将字符串对象从堆复制一份放在永久代(此时字符串常量池、静态变量仍在永久代中);1.7 及之后复制的是字符串对象的引用地址,将地址放入字符串常量池(此时的字符串常量池、静态变量移动到了堆中) - 注意以上仅针对
intern()
“复制” ,new String(...)
只是单纯地 创建两个对象 - 调用任意字符串的
intern()
方法,返回结果指向的实例,和以常量形式出现的字符串实例完全相同
String s1 = "aa";
String s2 = "bb";
(s1 + s2).intern() == "aabb"; // 成立
4 创建了几个对象?
new String()
生成的字符串会在常量池中保存一份 对象的复制,而toString()
不会- 以下对 JDK 6/7 均成立
String s = new String("abc");
// 创建了2个对象,分别是堆中和字符串常量池中的 String 类型的实例 "abc"
// 如何证明?看字节码文件
String s = new String("aa") + new String("bb");
/* 创建了6个对象
1. new StringBuilder(),因为涉及到字符串拼接
2. new String("aa")
3. 字符串常量池中的 "aa"
4. new String("bb")
5. 字符串常量池中的 "bb"
6. StringBuilder 在拼接后调用 toString(),方法内执行 new String("aabb"),但不把 "aabb" 放入常量池!!
*/
5 两道难题*
String s1 = new String("a"); // 不涉及到intern()的复制,只是单纯创建两个对象,无论6和7
s1.intern(); // 什么都没做
String s2 = "a";
// 6:s2 ==(字符串常量池的实例 "a" 的地址) != s1的地址
// 7:s2 ==(字符串常量池中实例 "a" 的地址) != s1的地址
sout(s1 == s2); // 6/7/8 均返回 false
String s3 = new String("a") + new String("a");
s3.intern(); // "aa"放入字符串常量池,是intern()放入的,而非 new String(...)放入,区别于上面
String s4 = "aa";
// 6:s4 ==(字符串常量池的实例 "aa" 的地址) != s3的地址
// 7:s4 ==(字符串常量池中存放的 堆中 "aa" 的地址) == s3的地址
sout(s3 == s4); // 6 返回 false,7/8 返回 true
- 问题2参考上述有关
intern()
“复制” 的说明:JDK1.6 及之前复制的是对象,将字符串对象从堆复制一份放在永久代,创建了新对象(1.6 字符串常量池、静态变量仍在永久代中);1.7 及之后复制的是字符串对象的引用地址,将地址放入字符串常量池(1.7 字符串常量池、静态变量移动到了堆中) - 注意以上仅针对
intern()
“复制” ,new String(...)
只是单纯地 创建两个对象 - 关键是
intern()
之前常量池里是否已有字符串,即intern()
是否起作用 - 补充三道例题和图解:
十一 垃圾回收相关概念
1 什么是垃圾
- 垃圾:运行程序中没有任何指针指向的对象
- 垃圾回收的对象是堆和方法区,重点是堆。从次数上讲,频繁收集年轻代,较少收集老年代,基本不收集永久代
- 垃圾回收的步骤分为 标记阶段 和 清除阶段
2 内存溢出
- 没有空闲内存,并且垃圾收集器无法提供更多内存时,发生 OOM
- 在抛出 OOM 前,通常 GC 会执行垃圾回收,尽可能清理出空间
- 发生原因:
可能是 JVM 堆内存设置不够;
也可能是代码中创建了大量大对象,并且长时间不能被垃圾收集器回收;
或者是申请了超大对象,超过了堆的最大值,此时不触发 GC 直接 OOM
3 内存泄漏
- 严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才称为内存泄漏
- 一些不好的实践导致对象生命周期变长,甚至进一步导致 OOM,是宽泛意义上的内存泄漏
- 举例:
1.单例模式。单例对象的生命周期和应用程序是一样长的,如果单例对象持有对外部对象的引用的话,那么这个外部对象就不能 被回收,导致内存泄漏
2.一些资源未手动关闭导致内存泄漏。数据库连接、套接字连接、IO连接必须手动关闭,否则不能被回收
4 强引用:存在就不回收
- 最传统的“引用”,默认的引用类型,无论任何情况下, 只要强引用还存在,垃圾收集器就永远不会回收被引用的对象
- 四种引用中,唯一需要为 OOM 负责的引用类型,即只有强引用才会导致 OOM
- 强引用可以直接访问目标对象
- 强引用指向的对象在任何时候都不会被回收,即使 OOM
5 软引用:内存不足时回收
- 在即将 OOM 之前,垃圾收集器会回收 仅 具有软引用的对象,如果 GC 后仍内存不足则 OOM
- 和弱引用类似,只不过 JVM 会尽量让软引用的对象存活得更久,迫不得已时才回收
Object obj = new Object(); // 声明强引用
SoftReference<Object> soft = new SoftReference<Object>(obj); // 声明软引用
obj = null; // 销毁强引用
6 弱引用:发现即回收
- 仅 具有弱引用的对象只能生存到下一次 GC 之前,无论内存是否足够,在执行 GC 时都会回收这类对象
- 由于 GC 线程的优先级很低,所以弱引用对象也能存在一定的时间
- 和软引用都适合存放可有可无的缓存数据
Object obj = new Object(); // 声明强引用
WeakReference<Object> soft = new WeakReference<Object>(obj); // 声明弱引用
obj = null; // 销毁强引用
7 虚引用:形同虚设,回收跟踪
- 虚引用不会对对象的生存周期造成影响,也无法通过虚引用获得对象(除此之外都可以通过引用获取对象),虚引用的作用仅仅是在对象被回收时收到系统通知
- 四种引用中,唯一一种不能用来获取被引用的对象的引用类型
- 虚引用可以跟踪对象的回收时间,因此可以将一些资源释放操作放置在虚引用对象中执行记录
- 必须和引用队列一起使用,当 GC 执行时,如果发现一个待回收对象具有虚引用,就会在对象回收后将虚引用加入到引用队列,以通知对象的回收情况
Object obj = new Object(); // 声明强引用
ReferenceQueue queue = new ReferenceQueue(); // 引用队列
PhantomReference<Object> soft = new PhantomReference<Object>(obj, queue); // 声明虚引用,需要传入引用队列
obj = null; // 销毁强引用
十二 垃圾回收算法
1 标记阶段:可达性分析算法
- 基本思路是,从 GC Roots 出发按照从上到下的搜索方式,确定对象是否可达,如果目标对象没有任何引用链相连,则是不可达的,标记为垃圾对象。只有能被根对象集合直接或间接到达的对象才是存活对象
- 可以解决循环引用问题,而引用计数算法不能解决
- 可以作为 GC Roots 的对象类型:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中引用的对象
- 方法区中 类的静态属性 引用的对象
- 方法区中 常量 引用的对象(字符串常量池中的对象)
- 被同步锁持有的对象
- 如果一个引用指向堆内存里的对象,引用本身又不在堆内存里,那么这个对象就是一个 GC Root
- 分析工作需要在能保障一致性的快照中进行,所以执行时必须 Stop the World
2 finalize()
- 垃圾回收器回收对象之前,总会先调用该对象的
finalize()
- 重写该方法可以自定义对象被销毁之前的处理逻辑,用于对象回收时的资源释放
- 不要主动调用对象的
finalize()
方法,而要交给垃圾回收器调用,原因:finalize()
可能导致对象复活- 糟糕的
finalize()
会严重影响 GC 性能 - 何时执行
finalize()
(即何时回收对象)是没有保障的,应该由 GC 决定
- 不要依赖对象的
finalize()
方法,而是使用finally
块做关闭操作 - 虚拟机中的对象处于 可触及、可复活、不可触及 三种状态
- 可触及:对象由 GC Roots 可达
- 可复活:对象所有引用都被释放,但
finalize()
没有调用,有可能在finalize()
中复活 - 不可触及:对象的
finalize()
已被调用并且没有复活,此时对象可以被安全回收。不可触及的对象不可能被复活,因为对象的finalize()
只会调用一次
3 判断对象是否可回收的流程(至少两次标记)
- 如果没有 GC Roots 到对象的引用链,则第一次标记
- 判断该对象有无必要执行
finalize()
方法:
① 如果对象没有重写finalize()
方法,或finalize()
被调用过,则视为“没有必要执行”,对象被判定为不可触及,执行垃圾回收
② 如果对象重写了finalize()
方法,并且未执行过,则对象被插入到 F-Queue 队列中,由虚拟机自动创建的低优先级线程 Finalizer 执行其finalize()
方法
③ 稍后 GC 对 F-Queue 中的对象进行二次标记,如果对象执行finalize()
后和引用链上的任意对象产生联系,则被移出“即将回收”集合。对象会再次出现没有引用存在的情况时,该对象的finalize()
不会再被调用,一旦 GC Roots 不可达则立刻进入不可触及状态
4 清除阶段:标记-清除算法
- 首先,垃圾收集器从根节点开始遍历,标记所有 引用的对象(而非标记垃圾对象);然后垃圾收集器 对堆内存从头到尾进行线性遍历,如果对象没有被标记则将其回收
- 缺点:
- 这种方式清理出来的空闲内存是不连续的
- 这里的清除指的是,把清除的对象地址保存在空闲地址列表里,以便再次为对象分配内存时使用,因此需要维护一个空闲链表
5 清除阶段:标记-复制算法
- 将内存空间分为两块,每次仅使用其中的一块。在垃圾回收时将使用的内存块中的存活对象复制到未被使用的块中,然后清除正在使用的内存块的所有对象,交换两个内存块的角色
- 适合存活对象很少,垃圾对象很多的场景(尤其是新生代)
- 优点:
- 保证垃圾回收后空间的连续性,不会出现碎片问题
- 三种算法中效率最高
- 缺点:
- 需要两倍的内存空间
- STW
(黑色箭头代表引用关系)
6 清除算法:标记-压缩算法
- 首先,垃圾收集器从根节点开始遍历,标记所有 引用的对象(而非标记垃圾对象);然后将存活的对象压缩到内存的一侧,按照顺序排放;最后清理边界外的所有空间
- 优点:
- 内存有序分布,可以使用指针碰撞的方式为新对象分配内存,效率高
- 解决了 标记-清除算法 的碎片问题
- 缺点:
- 效率低于上述两种算法
- STW
7 三种清除算法的对比
标记-清除 | 标记-复制 | 标记-压缩 | |
---|---|---|---|
执行速度 | 中等 | 最快 | 最慢 |
空间开销 | 少 | 多 | 少 |
是否产生碎片 | 是 | 否 | 否 |
是否移动对象 | 否 | 是 | 是 |
- 不移动对象的算法优势在于,GC 线程可以和用户线程并发执行,无需阻塞用户线程,延迟低
- 移动对象的算法优势在于,不产生内存碎片,吞吐量高
8 分代收集算法
-
核心思想是不同生命周期的对象采用不同的收集方式
-
假设与结论
- 绝大多数对象朝生夕死
新生代:区域比老年代小,对象生命周期短、存活率低、回收频繁。适用 标记-复制算法
- 经过越多次垃圾回收的对象,就越难以消亡
老年代:区域较大,对象生命周期长、存活率高、回收不频繁。一般采用 标记-清除算法 和 标记-压缩算法 混合实现
- 跨代引用相对于同代引用仅是极少数
不为少量的跨代引用而扫描整个老年代,仅标识出老年代哪块内存会存在跨代引用
在执行 YGC 的时候,只有这些块内的对象加入 GC Roots
十三 垃圾回收器
1 基本概念
- 三个性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例,高吞吐量使得 CPU 工作时间更长,适合在后台运算、不需要太多交互的任务
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 内存占用:执行垃圾收集时占用的堆空间大小
- 优秀的垃圾收集器最多三者得其二
- 吞吐量和暂停时间也是相互矛盾的目标,如果选择更大的吞吐量,就会降低垃圾回收的频率,导致暂停时间更长
- 选择更短的暂停时间,提高了垃圾回收的频率,降低了吞吐量
- 当前垃圾收集器的准则是:在保证吞吐量的前提下,尽量缩短暂停时间
- GC 的并发与并行
- 并发:用户线程和 GC 线程可以同时执行(无 STW)
- 并行:多个 GC 线程共同参与回收(默认有 STW)
2 常用回收器
回收器 | 工作内存 | 算法 | 工作线程数 | 是否STW |
---|---|---|---|---|
Serial | 新生代 | 复制 | 单线程 | 是 |
Serial Old | 老年代 | 压缩 | 单线程 | 是 |
ParNew | 新生代 | 复制 | 多线程 | 是 |
CMS | 老年代 | 清除 | 多线程 | 部分 |
Parallel | 新生代 | 复制 | 多线程 | 是 |
Parallel Old | 老年代 | 压缩 | 多线程 | 是 |
G1 | 新生代、老年代 | - | - | 否 |
- 使用不同 GC 算法,下次分配内存的方式不同
- 压缩,再分配时使用指针碰撞
- 非压缩,再分配时使用空闲链表
3 组合关系
(虚线代表在某个 JDK 版本中被移除)
3.1 Serial + Serial Old
- 均是串行回收器,执行垃圾回收时需要 STW
- 在单个单核 CPU 的场景下效率较高
3.2 ParNew + CMS
- ParNew 是并行回收器,执行垃圾回收时需要 STW (Serial 的多线程版本)
- CMS 是并发回收器,垃圾回收线程和用户线程同时运行,缩短 STW,响应速度快(适合强交互场景)
- CMS 的执行分为四个阶段
- 初始标记:仅标记 GC Roots 直接关联的对象,需要STW
- 并发标记:遍历对象图,耗时长但无需 STW
- 重新标记:修正并发标记,只能排除被标记的非垃圾的对象,需要 STW
- 并发清理:清理被标记的对象,因为是清除算法,所以无需 STW
- CMS 在初始标记和重新标记两个阶段需要 STW,因为可达性分析最终要在一致的环境中进行
- CMS 的弊端
- CMS 不能等到老年代几乎满的时候再进行垃圾回收,因为在并发标记阶段仍然会有用户线程执行,这时用户线程可能会继续申请老年代内存。如果 CMS 运行时预留内存不足,则使用 Serial Old 作为降级方案,停顿时间会很长
- 会产生内存碎片,并发清理阶段无 STW,所以清除算法也不能换用复制或压缩算法
- 无法清理并发标记阶段产生的新垃圾对象,只能在下次回收时处理
3.3 Parallel + Parallel Old
- 均是并行回收器,执行垃圾回收时需要 STW
- Parallel 类似 ParNew,是并行回收器,但是吞吐量优先
3.4 G1
-
目标是在延迟可控的前提下,尽量提升吞吐量
- 延迟可控指的是,在指定长度为 M 毫秒的时间内,消耗在垃圾收集上的时间大概率不超过 N 毫秒
- 期望停顿时间一般设置为 100~300 ms,过短的期望停顿时间可能造成堆满,从而触发 Full GC
-
G1 给后续 GC 的启发:垃圾收集的速度跟得上对象分配的速度即可,而不追求一次把新生代、老年代或整个堆清理干净
-
G1 和核心思路是放弃分代区域划分的思想,把堆区划分为若干个大小相等的 Region,以 Region 作为垃圾回收的最小单元
-
每个 Region 可以根据需要变换为 Eden / Survivor / Old / Homogeneous 空间
- 大小超过 Region 容量一半的对象判定为大对象,所处的 Region 作为 Homogeneous Region
- 对于大小超过一个 Region 的超级大对象,会被放在若干个连续的 Homogeneous Region 中
- Homogeneous Region 大多时间被 G1 视为老年代的一部分
-
用多次的 Mixed GC 来避免分代回收或全堆回收的长时间停顿
-
G1 执行过程
- 初始标记 STW
- 借用 Minor GC 同步完成,实际上没有额外的停顿
- 标记 GC Roots 能直接关联的对象,让并发标记阶段的用户线程在可用的 Region 中正确地分配新对象
- 并发标记
- 可达性分析
- 最终标记 STW
- 遗留的可达性分析
- 筛选回收 STW
-
对各个 Region 的回收价值和成本进行排序,根据用户期望的停顿时间指定回收计划
-
把决定回收的 Region 的存活对象复制到空 Region 中,再清理旧 Region 的全部空间
-
- 初始标记 STW
-
G1 采用算法(两种算法都不会产生内存碎片)
- 整体来看是 标记-压缩 算法
- 局部(两个 Region 之间)是 标记-复制 算法