类加载器和双亲委派

类加载机制

image-20240705124059646

类加载器的分类

启动类加载器(Bootstrap Class Loader): 是最顶层的类加载器,负责加载 Java 核心类库(如java.lang包中的类)。

扩展类加载器(Extension Class Loader): 负责加载 Java 扩展库(JAVA_HOME/lib/ext目录下的 JAR 文件)。

系统类加载器(System Class Loader): 也称为应用程序类加载器,负责加载应用程序的类文件,通常从类路径(classpath)中加载。

自定义类加载器(Custom Class Loader): 开发人员可以自定义类加载器来实现特定的加载逻辑,比如从数据库中加载类、动态生成类等。

自定义类加载器可以继承自 ClassLoader 类,并重写 findClass 方法来实现自定义的类加载逻辑。通过自定义类加载器,开发人员可以实现一些高级的类加载需求,比如热部署、类隔离等。

双亲委派机制

委派流程图

image-20240705122118353

双亲委派的好处

  1. 类的唯一性: 双亲委派模型确保了类加载器的层次结构,每个类加载器只加载它所负责的类,避免同一个类被不同的类加载器加载多次,确保了类的唯一性。
  2. 安全性: 双亲委派模型可以保护核心 Java 类库不受恶意代码的篡改,因为核心类库由启动类加载器加载,不会被普通类加载器替换。
  3. 避免类冲突: 双亲委派模型可以避免类冲突问题,即不同类加载器加载同一个类可能导致的冲突。由于子类加载器会委托父类加载器加载类,因此同一个类只会被加载一次。
  4. 代码隔离: 双亲委派模型使得不同的类加载器加载的类相互隔离,这样不同的类加载器加载的类之间不会相互影响,提高了应用程序的稳定性和安全性。
  5. 性能优化: 双亲委派模型可以提高类加载的性能。由于类加载器会优先委托给父类加载器加载类,如果父类加载器已经加载过该类,就不需要重复加载,可以节省时间和资源。
  6. 模块化和可扩展性: 双亲委派模型使得 Java 类加载器具有模块化和可扩展的特性,可以根据需要自定义类加载器,实现特定的加载逻辑,从而实现更灵活的类加载机制。

如何打破双亲委派机制

  1. 重写 loadClass 方法:自定义类加载器需要继承自 ClassLoader 类,并重写 loadClass 方法,在该方法中控制类的加载逻辑,包括是否委托给父类加载器加载等。

    注意:重写findClass 方法不会打破双亲委派机制

  2. 使用反射: 可以通过反射机制来绕过类加载器的双亲委派机制,直接调用类加载器的 defineClass 方法来加载类。

  3. 使用线程上下文类加载器: 在某些情况下,可以使用线程上下文类加载器(Thread Context Class Loader)来打破双亲委派机制。线程上下文类加载器可以通过 Thread.currentThread().setContextClassLoader(classLoader) 来设置。

JAVA内存模型

各区域分布图

image-20240705122118353

线程私有的

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道了程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

虚拟机栈

与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

局部变量表

主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

动态链接

主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为动态连接 。

方法返回

Java方法有两种返回方式,一种是return语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

本地方法栈

本地方法栈和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

为什么年龄只能是 0-15?

因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

对象内存布局

对象头(Header)

Mark Word标记

用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、同步锁信息、偏向锁标识等等。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

类型指针

类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。Java对象的类数据保存在方法区。

数组长度(只有数组对象才有)

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度。如果对象是数组类型,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

实例数据(Instance Data)

实例数据部分存放类的属性数据信息,包括父类的属性信息。

JVM结构图如下所示:

image-20240725144834548

对齐填充(Padding)

由于虚拟机要求对象起始地址必须是8字节的整数倍,所以后面有几个字节用于把对象的大小补齐至8字节的整数倍,没有特别的功能,对齐填充不是必须存在的,仅仅是为了字节对齐。

为什么必须是8个字节?

根据“计算机组成原理”,8个字节是计算机读取和存储的最佳实践。

使用JOL工具分析对象内存布局

接下来我们使用JOL(Java Object Layout)工具,它是一个用来分析JVM中Object布局的小工具。包括Object在内存中的占用情况,实例对象的引用情况等等。

直接在maven工程中加入对应的依赖:

1
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

通过JOL查看new Object()的对象布局信息:

1
2
3
4
5
6
7
8
9
10
public class JOLDemo {
public static void main(String[] args) {
Object obj = new Object();
System.out.println("+进制hashcode = " + obj.hashcode());
System.out.println("十六进制hashcode = " + Integer.toHexString(obj.hashcode()));
System.out.println("二进制hashCode = " + Integer.toBinaryString(obj.hashcode()));
String str = ClassLayout.parseInstance(obj).toPrintable();
System.out.println(str);
}
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
十进制hashCode = 1956725890
十六进制hashCode = 74a14482
二进制hashCode = 1110100101000010100010010000010
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 82 44 a1 (00000001 10000010 01000100 10100001) (-1589345791)
4 4 (object header) 74 00 00 00 (01110100 00000000 00000000 00000000) (116)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

解释下各个字段的含义:

  • OFFSET是偏移量,也就是到这个字段位置所占用的字节数;
  • SIZE是后面类型的大小;
  • TYPE是Class中定义的类型;
  • DESCRIPTION是类型的描述;
  • VALUE是TYPE在内存中的值;

从上图可以看出Object obj = new Object();在内存中占16个字节,注意最后面的(loss due to the next object alignment)其实就是对齐填充的字节数,这里由于Object obj = new Object();没有实例数据,对象头总共占用了12个字节(默认开启了指针压缩-XX:+UseCompressedOops),由于虚拟机要求对象起始地址必须是8字节的整数倍,所以还需要对齐填充4个字节,达到2倍的8bit。

image-20240725145514541

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法区常用参数

JDK1.8之前

1
2
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK1.8

1
2
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

1.元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

2.永久代无法动态调整大小

3.永久代垃圾回收效率低下

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译时期生成的各种字面量符号引用。在Java虚拟机启动时,会为每个被加载的类维护一个运行时常量池。

字符串常量池

字符串常量池是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

对象创建过程

  1. 类加载检查
  2. 分配内存
  3. 初始化零值
  4. 设置对象头
  5. 执行init方法

垃圾回收

内存分配和回收原则

  • 对象有限进入Eden区
  • 大对象直接进入老年区
  • 长期存活对象进入老年区

gc的区域

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

判断对象死亡的方法

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效,计数器就减 1,任何时候计数器为 0 的对象就是不可能再被使用的。

优点:简单,效率高。

缺点:无法解决循环引用问题

可达性分析算法

GC Roots 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

简单来说就是到GC Roots不可达则代表需要回收

哪些对象可以作为 GC Roots
  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

简单来说就是正在使用的对象

对象可以被回收,就代表一定会被回收吗?

需要经历两次标记后才被回收

引用类型

  • 强引用:强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
1
Object obj = new Object();
  • 软引用:是用来描述还有用但并非必需的对象。只有在内存不足的情况下,软引用才会被垃圾回收器回收。
1
SoftReference<Object> softRef = new SoftReference<>(new Object());
  • 弱引用:是用来描述非必需对象的,弱引用与软引用的区别在于弱引用的对象会更早被垃圾回收器回收,不管当前内存空间足够与否,都会回收它的内存。
1
WeakReference<Object> weakRef = new WeakReference<>(new Object());
  • 虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。它的唯一目的就是在对象被垃圾收集器回收时收到一个通知。
1
2
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

如何判断一个常量是废弃常量?

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断一个类是无用类?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

标记-清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

缺点:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

复制算法

为了解决解决标记-清除算法的效率和内存碎片问题,复制算法将内存分为大小相同的两块,每次使用其中的一块,清理时将存活的对象复制到另一块中。

缺点:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

标记-整理算法

标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

缺点:多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

新生代使用复制算法,老年代使用标记-整理算法。

垃圾回收器

image-20240725173703824

Serial收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束

新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

serial-old-收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

CMS收集器

CMS收集器主要针对那些对停顿时间要求较高的应用程序,如Web服务器等。

以下是CMS收集器的一些特点和工作原理:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 。
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

Parallel 收集器

Parallel 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?

1
2
3
4
5
# 使用 Parallel 收集器+ 老年代串行
-XX:+UseParallelGC

# 使用 Parallel 收集器+ 老年代并行
-XX:+UseParallelOldGC

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。

新生代采用标记-复制算法,老年代采用标记-整理算法。

parallel old收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

ZGC收集器

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。