JVM 篇,本章主要介绍 JVM 相关的知识,包括堆结构,垃圾回收算法,类加载机制等。

JVM内存结构

JVM的内存结构主要指运行时数据区,其主要由五个部分:

  • 程序计数器PC:线程私有,主要记录当前线程执行到哪一条命令,存储的是字节码指令的地址。

  • 虚拟机栈:线程私有,每个线程对应一个栈,栈由栈帧组成,每调用一个方法,就会创建一个栈帧入栈;方法执行完毕,栈帧销毁出栈。

  • 本地方法栈:线程私有,功能和虚拟机栈几乎一样,区别是本地方法栈主要存放本地方法的栈帧。

  • 堆:存储所有对象实例、数组也分配在这里。

  • 方法区:线程共享,存储类的元信息。

JVM 内存结构示意图


程序计数器

是一小块线程私有的空间,主要用于记录当前线程执行的字节码地址,主要用于线程重新获得CPU时间时继续从上次执行结束的位置开始继续向下执行。

线程计数器是唯一不会OOM的内存区域,因为它只存储一个地址,而地址的长度是确定的,因此不会出现OOM

堆

堆是 JVM 中最大的一块内存区域,主要用来保存对象实例、数组等,堆的容量是有限的,当达到最大值时,如果此时再申请内存就会抛出OOM。

在现代 JVM 中堆一般分为年轻代和老年代,年轻代又细分为Eden区和两个大小相同的S区(幸存者区),年轻代主要存放存活时间不是很长的对象,当触发minor GC时,JVM 会将Eden区和其中一个S区中存活的对象全部复制到另一个S区中;当一个对象经历了很多次垃圾回收之后仍然存活时,JVM 便会认为该对象会是一个长期存活的对象,会将其升级到老年代,老年代一般存放存活时间较长的对象和一些大对象。

虚拟机栈

虚拟机栈是每个线程运行时所需的内存,每个栈由多个栈帧组成,每个栈帧都对应一次方法的调用,栈帧中存储了方法调用信息,例如参数、返回值、局部变量等。每个线程都对应一个虚拟机栈,每个虚拟机栈中可能有多个栈帧,对应多次方法的调用,但是每个线程只能由一个活动栈帧,也就是当前正在执行的方法对应的栈帧。

补充知识:

  1. 垃圾回收不涉及栈内存,垃圾回收主要操作的是堆内存,栈帧弹出栈之后其内存就会自动释放,不需要垃圾回收。

  2. 栈内存不是越大越好,栈内存大确实不容易栈溢出,但是栈内存变大之后会导致活动线程数变少,栈内存一般采用默认值即可。

  3. 方法内的局部变量不一定线程安全,是否是线程安全取决于局部变量有没有被外部线程引用,如果局部变量是在方法内创建,并且没有对外暴露引用,那么这个局部变量就是线程安全的,因为只在方法内部被操作,否则就是非线程安全的。

本地方法栈结构和功能和虚拟机栈类似,主要区别是本地方法栈存放的是本地方法的栈帧。

方法区

方法区是一块线程共享的区域,主要存储类的元信息和运行时常量池。

运行时常量池是将class文件常量池加载到内存后的版本,里面将class文件常量池中的符号引用变为了直接引用(内存地址),主要存放字面量和符号引用,符号引用会在类的解析阶段转换为真实的物理引用。

方法区在 JDK 1.7 和 1.8 实现的区别:方法区在 1.7 和 1.8 的区别主要是位置上的不同。在 JDK 1.7 中,方法区主要存放在堆上的永久代中, JDK 1.8 将方法区从 永久代移动到了本地内存的元空间中。

更换实现的原因:

  1. 永久代位于 Java 堆内,有固定上限,需要手动配置大小。动态代理、热部署场景会不断生成新类,容易出现OOM;调大永久代又会挤压普通 Java 对象的堆空间,很难预估合适大小。

  2. 永久代只有 Full GC 时才会尝试做类卸载,类元数据回收效率差,内存泄漏风险高。

  3. 元空间使用本地内存,默认自动扩容,上限受物理内存限制;到达阈值就触发 GC,对废弃类加载器和类元数据做回收,类卸载能力相比永久代大幅提升。

  4. 把类元数据放到堆外,将 Java 对象堆 和类元信息存储做隔离,互不抢占内存。


双亲委派机制

Java的双亲委派机制是指,当一个类加载器接收到加载类的任务时,会先尝试委托上级加载器加载,如果上级加载器也没能成功加载,则由自己尝试加载。

类加载器按照层级分为四类:

  • 启动类加载器:由 c++ 实现,加载 Java 中最核心的基础类,例如String类等。

  • 扩展类加载器:Java 实现,主要加载的是 JDK 扩展包,例如数据库驱动类等。

  • 应用程序类加载器:主要加载我们自己写的类,默认扫描目录是项目的 CLASSPATH 路径。

  • 自定义类加载器:业务中自定义的类加载器,实现了自定义的类加载逻辑,例如从数据库读取字节码文件等。

为什么 JVM 采用双亲委派机制 ?

  1. 通过双亲委派机制可以防止类被重复加载,如果父类加载器已经加载过这个类了,子类加载器就不会重复加载了,保证了类的唯一性。

  2. 保证类的安全性,防止恶意代码替换核心类。


类的生命周期

类的声明周期可以分为如下几个阶段:

  • 加载:通过类的全名获取类的二进制流,解析这个二进制流为方法区的数据结构(Klass对象)并创建Class实例作为方法区中Klass对象的访问入口。

  • 连接:连接阶段又可以细分为三个阶段。

    • 验证:验证类是否符合 JVM 规范,检查内容包括文件格式、元数据、字节码等。并且检查符号引用是否存在。

    • 准备:为类变量分配内存并设置初始值。static变量赋默认值,赋值在初始化阶段完成,非引用类型的final变量在准备阶段直接进行赋值。

    • 解析:将类中的符号引用转换为直接引用。方法名可以理解为符号引用,指向方法的指针就是直接引用。

  • 初始化:对类的静态变量、静态代码块执行初始化操作。

  • 使用:在应用中使用类,例如调用静态方法、创建对象实例等。

  • 卸载:销毁类的class对象,当一个类满足如下三个条件时,就会被卸载,释放类元数据。

    • 该类的所有实例对象均已被GC。

    • 指向该类的class对象不存在任何强引用。

    • 加载这个类的类加载器本身已经被GC。


JVM垃圾回收算法

JVM 的垃圾回收算法一般有三种:标记清除算法、复制算法、标记整理算法。

  • 标记清除算法分为两个阶段:标记和清除

    1. 根据可达性分析算法得出的垃圾进行标记。

    2. 将标记出的垃圾进行回收。

    优点:不需要移动对象,直接回收,实现简单。

    缺点:容易产生大量内存碎片,内存不连贯,导致大对象很难分配。

  • 复制算法:复制算法将内存分为了两个区域,每次只使用其中的一块,GC时将存活对象复制到另一块区域中,然后将该区域的内存清空。

优点:没有内存碎片。

缺点:两个内存区域中只有一个能存储对象,另一个需要用来在GC时存放存活对象,内存利用率低。

  • 标记整理算法:和标记清除算法类似,第一阶段也是先对存活对象进行标记,第二阶段会将存活对象向内存的一端移动,然后清理边界以外的垃圾。

优点:无内存碎片。

缺点:需要进行对象的移动,效率低。

JVM 的分代回收算法

在 JDK8 之后, JVM采用的垃圾回收算法是分代回收算法,该算法将整个堆内存分为了两大部分:新生代和老年代。

  • 新生代一般分为一个 Eden 区和两个 Survivor 区(from区和to区),新创建的对象会分配到 Eden 区,当 Eden 区满时会触发年轻代GC(Minor GC),会标记 Eden 区和 from 区中的存活对象,然后将其复制到 to 区中,并将 from 和 to 区角色互换。某些对象如果经历了很多次 GC 依然存活,那么 JVM 会认为该对象会长期存活,会将其晋升到老年代.

  • 老年代一般存放分代年龄较大的对象以及一些大对象,老年代的空间较大,因此GC停顿时间较长,所以老年代的GC频率一般较低。