后端学习 - JVM(上)内存与垃圾回收

JVM 架构图
在这里插入图片描述

文章目录


一 JVM 简介

  • JVM 本质上是二进制字节码的运行环境,是运行在操作系统上的,与硬件没有直接的交互
  • Java 是跨平台的语言:一次编写,到处运行
    在这里插入图片描述
  • JVM 是跨语言的平台:JVM 是面向字节码文件的,只要符合 JVM 规范,JVM 不仅可以处理 Java 语言编译的字节码文件,还支持其它语言编译的字节码文件
    在这里插入图片描述

二 类加载子系统:

在这里插入图片描述

1 作用

  • 负责加载文件开头有特定的标识的 class 文件
  • 只负责文件的加载,而不保证 class 文件可以运行(能否运行由执行引擎决定
  • 加载的类信息存放在方法区,除此之外,方法区还会存放运行时的常量池信息
  • class file -> JVM -> 元数据模板 的过程中作为“快递员”的角色
    在这里插入图片描述

2 类的三个加载过程

  1. Loading(创建 Class 类型的对象)
  • 通过类的全限定名获取定义此类的二进制字节流
  • 将该字节流代表的静态存储结构,转化为方法区的运行时数据结构
  • 在内存中声明一个 java.lang.Class 类型的对象,作为方法区的该类的各种数据的访问入口
  1. Linking(类变量分配内存空间,赋初始值)
  • 验证阶段
    • 确保 class 文件符合虚拟机要求,保证被加载类的正确性
  • 准备阶段
    • 类变量(static 修饰) 申请内存空间,并赋初始零值(final 修饰的 static 类变量为指定值)
    • 此外,该阶段不会为 实例变量(通过 this 引用) 初始化,因为实例变量随着对象分配到堆中,而类变量分配到方法区中
  • 解析阶段
  1. Initialization(类变量的赋值,执行静态代码块语句)
  • 该阶段的任务是,执行类构造器方法 <clinit>() 的过程
    • 该方法是 javac 编译器(前端编译器)自动收集 类变量的赋值动作静态代码块的语句 合并得到的
    • 子类的 <clinit>() 在父类的 <clinit>() 执行后才能执行
    • <clinit>() 不同于类的构造器,在 JVM 视角下,类的构造器是 <init>() 方法
    • 虚拟机保证 <clinit>() 在多线程下被同步加锁,避免同一个类加载多次
class Parent {
    static int A = 1;  // 执行顺序1
    static {
        A = 2;  // 执行顺序2
    }
}

class Sub extends Parent {
    static int B = A;  // 执行顺序3
    static {
        System.out.println("Sub B before static block:" + B);
        B++;  // 执行顺序4
        System.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 进行加载
  • 如果 Tomcat 使用双亲委派机制的话,无法加载 两个相同类库的不同版本 ,默认的类加器无视版本,只在乎全限定类名,并且只有一份

在这里插入图片描述

5 两个 class 对象为同一个类的必要条件

  1. 全类名一致
  2. 加载类的 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

  1. 引用类型、byte、short、char…占用1个 Slot;long、double 占用两个 Slot
  2. 如果当前方法可以访问 this.xxx(即:当前栈帧由构造方法或实例方法创建),则对当前对象的引用 this 放在首个 Slot(这解释了为什么静态方法不能访问 this.xxx,因为局部变量表里没有 this)
  3. 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,执行更频繁,所需时间更短
  • 执行流程:
    1. 当伊甸园区满的时候触发(幸存者区满时不会触发
    2. 对新生代(包括伊甸园区和幸存者区)执行垃圾回收。对于没有回收的实例,将伊甸园区的实例转移到幸存者区,在幸存者区的实例从 from 区转移到 to 区
    3. 对于幸存者区的实例,转移次数超过设定值时,实例从幸存者区转移到老年代,转移到老年代的实例不再参与 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 对象的内存分配

  1. 对象优先分配在 TLAB 上
  2. 如果 TLAB 无法容纳,则分配在伊甸园区
  3. 伊甸园区无法容纳,执行 YGC(虚线部分为 YGC 的一部分流程,“Survivor放得下” 指的是从伊甸园区转移过来的对象能否被幸存者区完全容纳,非新对象
  4. YGC 后伊甸园仍然无法容纳,尝试分配到老年代
  5. 执行 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[],同时 StringBufferStringTable 也随之更改
  • 通过字面量的方式(而非 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 判断对象是否可回收的流程(至少两次标记)

  1. 如果没有 GC Roots 到对象的引用链,则第一次标记
  2. 判断该对象有无必要执行 finalize() 方法:
    ① 如果对象没有重写 finalize() 方法,或 finalize() 被调用过,则视为“没有必要执行”,对象被判定为不可触及,执行垃圾回收
    ② 如果对象重写了 finalize() 方法,并且未执行过,则对象被插入到 F-Queue 队列中,由虚拟机自动创建的低优先级线程 Finalizer 执行其 finalize() 方法
    ③ 稍后 GC 对 F-Queue 中的对象进行二次标记,如果对象执行 finalize() 后和引用链上的任意对象产生联系,则被移出“即将回收”集合。对象会再次出现没有引用存在的情况时,该对象的 finalize() 不会再被调用,一旦 GC Roots 不可达则立刻进入不可触及状态

4 清除阶段:标记-清除算法

  • 首先,垃圾收集器从根节点开始遍历,标记所有 引用的对象(而非标记垃圾对象);然后垃圾收集器 对堆内存从头到尾进行线性遍历,如果对象没有被标记则将其回收
  • 缺点:
    • 这种方式清理出来的空闲内存是不连续的
    • 这里的清除指的是,把清除的对象地址保存在空闲地址列表里,以便再次为对象分配内存时使用,因此需要维护一个空闲链表
      在这里插入图片描述

5 清除阶段:标记-复制算法

  • 将内存空间分为两块,每次仅使用其中的一块。在垃圾回收时将使用的内存块中的存活对象复制到未被使用的块中,然后清除正在使用的内存块的所有对象,交换两个内存块的角色
  • 适合存活对象很少,垃圾对象很多的场景(尤其是新生代)
  • 优点:
    • 保证垃圾回收后空间的连续性,不会出现碎片问题
    • 三种算法中效率最高
  • 缺点:
    • 需要两倍的内存空间
    • STW
      在这里插入图片描述
      (黑色箭头代表引用关系)

6 清除算法:标记-压缩算法

  • 首先,垃圾收集器从根节点开始遍历,标记所有 引用的对象(而非标记垃圾对象);然后将存活的对象压缩到内存的一侧,按照顺序排放;最后清理边界外的所有空间
  • 优点:
    • 内存有序分布,可以使用指针碰撞的方式为新对象分配内存,效率高
    • 解决了 标记-清除算法 的碎片问题
  • 缺点:
    • 效率低于上述两种算法
    • STW
      在这里插入图片描述

7 三种清除算法的对比

标记-清除标记-复制标记-压缩
执行速度中等最快最慢
空间开销
是否产生碎片
是否移动对象
  • 不移动对象的算法优势在于,GC 线程可以和用户线程并发执行,无需阻塞用户线程延迟低
  • 移动对象的算法优势在于,不产生内存碎片,吞吐量高

8 分代收集算法

  • 核心思想是不同生命周期的对象采用不同的收集方式

  • 假设与结论

    1. 绝大多数对象朝生夕死

    新生代:区域比老年代小,对象生命周期短、存活率低、回收频繁,适用 标记-复制算法

    1. 经过越多次垃圾回收的对象,就越难以消亡

    老年代:区域较大,对象生命周期长、存活率高、回收不频繁,一般采用 标记-清除算法标记-压缩算法 混合实现
    不能使用标记-复制算法,因为老年代对象的存活率高,使用复制算法要执行更多的复制操作

    1. 跨代引用相对于同代引用仅是极少数

    不为少量的跨代引用而扫描整个老年代,仅标识出老年代哪块内存会存在跨代引用
    在执行 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 执行过程
    1. 初始标记 STW
      • 借用 Minor GC 同步完成,实际上没有额外的停顿
      • 标记 GC Roots 能直接关联的对象,让并发标记阶段的用户线程在可用的 Region 中正确地分配新对象
    2. 并发标记
      • 可达性分析
    3. 最终标记 STW
      • 遗留的可达性分析
    4. 筛选回收 STW
      • 对各个 Region 的回收价值和成本进行排序,根据用户期望的停顿时间指定回收计划

      • 把决定回收的 Region 的存活对象复制到空 Region 中,再清理旧 Region 的全部空间

        在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值