JVM

 

JVM

1.什么是JVM?

JVM,也就是 Java 虚拟机,它是 Java 实现跨平台的基石。

Java 程序运行的时候,编译器会将 Java 源代码(.java)编译成平台无关的 Java 字节码文件(.class),接下来对应平台的 JVM 会对字节码文件进行解释,翻译成对应平台的机器指令并运行。

任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。

2.JVM的组织架构?

JVM(Java 虚拟机)的组织架构可以分为以下几个主要部分:

  1. 类加载器子系统(Class Loader Subsystem)
  2. 运行时数据区(Runtime Data Area)
  3. 执行引擎(Execution Engine)
  4. 本地方法接口(JNI - Java Native Interface)

1. 类加载器子系统(Class Loader Subsystem)

类加载器子系统负责加载 .class 文件,并将其转换为 JVM 可以执行的字节码。类加载器子系统包括以下几个部分:

  • Bootstrap ClassLoader:引导类加载器,负责加载核心 Java 类库(如 rt.jar)。
  • Extension ClassLoader:扩展类加载器,负责加载扩展类库(如 ext 目录下的类库)。
  • Application ClassLoader:应用类加载器,负责加载应用程序的类路径(classpath)下的类。

2. 运行时数据区(Runtime Data Area)

运行时数据区是 JVM 在执行 Java 程序时使用的内存区域,包括以下几个部分:

  • 方法区(Method Area):存储类信息、常量、静态变量和即时编译器编译后的代码。
  • 堆(Heap):存储所有对象实例和数组。堆是垃圾回收的主要区域。
  • Java 栈(Java Stack):存储每个线程的局部变量、操作数栈和帧数据。每个线程都有自己的 Java 栈。
  • 本地方法栈(Native Method Stack):存储本地方法调用的信息。
  • 程序计数器(Program Counter Register):存储当前线程执行的字节码指令的地址。

3. 执行引擎(Execution Engine)

执行引擎负责解释和执行字节码指令。现代 JVM 通常包含即时编译器(JIT),将热点代码编译为本地机器码,以提高执行效率。执行引擎包括以下几个部分:

  • 解释器(Interpreter):逐条解释执行字节码指令。
  • 即时编译器(JIT Compiler):将热点代码编译为本地机器码,提高执行效率。
  • 垃圾回收器(Garbage Collector):自动管理内存,通过垃圾回收机制回收不再使用的对象,防止内存泄漏。

4. 本地方法接口(JNI - Java Native Interface)

本地方法接口允许 Java 程序调用本地(非 Java)代码,如 C 或 C++ 编写的库。JNI 提供了一组 API,用于在 Java 和本地代码之间进行交互。

3.JVM内存结构

JVM 内存结构定义了 Java 程序在运行时使用的内存区域。主要包括以下几个部分:

  1. 方法区(Method Area)
  2. 堆(Heap)
  3. Java 栈(Java Stack)
  4. 本地方法栈(Native Method Stack)
  5. 程序计数器(Program Counter Register)

1. 方法区(Method Area)

  • 存储内容:方法区存储类信息、常量、静态变量和即时编译器编译后的代码。
  • 特点方法区是线程共享的区域,所有线程都可以访问其中的数据。
  • 用途:主要用于存储类的结构信息(如字段、方法、接口等)、常量池、方法数据和方法代码。

2. 堆(Heap)

  • 存储内容:堆存储所有对象实例和数组。
  • 特点堆是线程共享的区域,所有线程都可以访问其中的数据。
  • 用途:堆是垃圾回收的主要区域,JVM 使用垃圾回收机制来管理堆内存,回收不再使用的对象。

3. Java 栈(Java Stack)

  • 存储内容:Java 栈存储每个线程的局部变量、操作数栈和帧数据。
  • 特点每个线程都有自己的 Java 栈栈中的数据对其他线程不可见
  • 用途:每次方法调用都会创建一个新的栈帧,栈帧中包含了方法的局部变量表、操作数栈、动态链接和方法返回地址。

4. 本地方法栈(Native Method Stack)

  • 存储内容:本地方法栈存储本地方法调用的信息。
  • 特点每个线程都有自己的本地方法栈栈中的数据对其他线程不可见
  • 用途:用于支持本地方法的执行,存储本地方法调用的状态。

5. 程序计数器(Program Counter Register)

  • 存储内容:程序计数器存储当前线程执行的字节码指令的地址。
  • 特点每个线程都有自己的程序计数器计数器中的数据对其他线程不可见
  • 用途:用于记录当前线程执行的字节码指令地址,当线程切换时,可以通过程序计数器恢复到正确的执行位置。

6.内存结构图示

+------------------+
|    方法区        |
| (Method Area)    |
+------------------+
|       堆         |
|     (Heap)       |
+------------------+
|   Java 栈        |
| (Java Stack)     |
+------------------+
| 本地方法栈       |
| (Native Method   |
|    Stack)        |
+------------------+
| 程序计数器       |
| (Program Counter |
|   Register)      |
+------------------+

6.总结-3

  • 方法区:存储类信息、常量、静态变量和即时编译器编译后的代码,是线程共享的区域。
  • :存储所有对象实例和数组,是线程共享的区域,垃圾回收的主要区域。
  • Java 栈:存储每个线程的局部变量、操作数栈和帧数据,每个线程都有自己的 Java 栈。
  • 本地方法栈:存储本地方法调用的信息,每个线程都有自己的本地方法栈。
  • 程序计数器:存储当前线程执行的字节码指令的地址,每个线程都有自己的程序计数器。

JVM 内存结构确保了 Java 程序的高效执行和内存管理,通过垃圾回收机制自动管理内存,防止内存泄漏。

4.说一下 JDK1.6、1.7、1.8 内存区域的变化?

在不同版本的 JDK 中,JVM 内存区域的实现和管理方式有所变化,特别是在方法区和永久代(PermGen)方面。

JDK 1.6

  • 方法区(Method Area):方法区在 JDK 1.6 中被实现为永久代(PermGen)。
  • 永久代(PermGen):永久代用于存储类信息、常量、静态变量和即时编译器编译后的代码。永久代的大小是固定的,可以通过 -XX:PermSize-XX:MaxPermSize 参数进行配置。
  • 堆(Heap):堆用于存储所有对象实例和数组,是垃圾回收的主要区域。
  • Java 栈(Java Stack):每个线程都有自己的 Java 栈,存储局部变量、操作数栈和帧数据。
  • 本地方法栈(Native Method Stack):存储本地方法调用的信息。
  • 程序计数器(Program Counter Register):存储当前线程执行的字节码指令的地址。

JDK 1.7

  • 方法区(Method Area):方法区仍然被实现为永久代(PermGen),但 JDK 1.7 开始逐步移除永久代中的一些内容。
  • 永久代(PermGen):JDK 1.7 中,字符串常量池从永久代移到了堆中。这是为了减少永久代的内存压力,因为永久代的大小是固定的。
  • 堆(Heap):堆用于存储所有对象实例和数组,是垃圾回收的主要区域。
  • Java 栈(Java Stack):每个线程都有自己的 Java 栈,存储局部变量、操作数栈和帧数据。
  • 本地方法栈(Native Method Stack):存储本地方法调用的信息。
  • 程序计数器(Program Counter Register):存储当前线程执行的字节码指令的地址。

JDK 1.8

  • 方法区(Method Area):方法区在 JDK 1.8 中被实现为元空间(Metaspace)。
  • 元空间(Metaspace):元空间取代了永久代,用于存储类信息、常量、静态变量和即时编译器编译后的代码。元空间使用本地内存(Native Memory),其大小可以通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数进行配置。
  • 堆(Heap):堆用于存储所有对象实例和数组,是垃圾回收的主要区域。
  • Java 栈(Java Stack):每个线程都有自己的 Java 栈,存储局部变量、操作数栈和帧数据。
  • 本地方法栈(Native Method Stack):存储本地方法调用的信息。
  • 程序计数器(Program Counter Register):存储当前线程执行的字节码指令的地址。

主要变化

  1. 永久代(PermGen)到元空间(Metaspace)
    • JDK 1.6 和 JDK 1.7:方法区被实现为永久代(PermGen),用于存储类信息、常量、静态变量和即时编译器编译后的代码。JDK 1.7 中,字符串常量池从永久代移到了堆中。
    • JDK 1.8:永久代被移除,取而代之的是元空间(Metaspace)。元空间使用本地内存(Native Memory),其大小可以动态调整,减少了内存溢出的风险。
  2. 字符串常量池
    • JDK 1.6:字符串常量池位于永久代中。
    • JDK 1.7 和 JDK 1.8:字符串常量池被移到了堆中。

配置参数变化

  • JDK 1.6 和 JDK 1.7
    • -XX:PermSize:设置永久代的初始大小。
    • -XX:MaxPermSize:设置永久代的最大大小。
  • JDK 1.8
    • -XX:MetaspaceSize:设置元空间的初始大小。
    • -XX:MaxMetaspaceSize:设置元空间的最大大小。

总结-4

  • JDK 1.6:方法区被实现为永久代,字符串常量池位于永久代中。
  • JDK 1.7:开始逐步移除永久代中的一些内容,字符串常量池被移到了堆中。
  • JDK 1.8:永久代被移除,取而代之的是元空间,元空间使用本地内存,字符串常量池位于堆中。

这些变化主要是为了改进内存管理,减少内存溢出的风险,并提高 JVM 的性能和稳定性。

5.JDK 1.8 中的元空间(Metaspace)相对于永久代(PermGen)的优势?

在 JDK 1.8 中,元空间(Metaspace)取代了永久代(PermGen),用于存储类信息、常量、静态变量和即时编译器编译后的代码。元空间相对于永久代有以下几个主要优势:

1. 使用本地内存

  • 永久代(PermGen):使用的是 JVM 堆内存,大小是固定的,可以通过 -XX:PermSize-XX:MaxPermSize 参数进行配置。
  • 元空间(Metaspace):使用的是本地内存(Native Memory),其大小可以动态调整。默认情况下,元空间的大小是无限制的,可以通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数进行配置。

优势:使用本地内存可以避免永久代内存不足的问题,减少了 OutOfMemoryError: PermGen space 错误的发生。

2. 动态调整大小

  • 永久代(PermGen):大小是固定的,必须在启动时配置,运行时无法调整。
  • 元空间(Metaspace):大小可以动态调整,JVM 会根据需要自动扩展或收缩元空间的大小。

优势:动态调整大小使得内存管理更加灵活,能够更好地适应不同应用的需求,减少内存浪费。

3. 更好的垃圾回收性能

  • 永久代(PermGen):由于永久代是堆的一部分,垃圾回收器在进行垃圾回收时需要扫描永久代,增加了垃圾回收的开销。
  • 元空间(Metaspace):元空间使用本地内存,垃圾回收器不需要扫描元空间,从而减少了垃圾回收的开销,提高了垃圾回收的性能。

优势:减少了垃圾回收的开销,提高了垃圾回收的效率,从而提高了应用程序的性能。

4. 减少内存泄漏风险

  • 永久代(PermGen):由于永久代的大小是固定的,如果应用程序加载了大量的类或生成了大量的字符串常量,可能会导致永久代内存不足,从而引发内存泄漏。
  • 元空间(Metaspace):由于元空间的大小可以动态调整,能够更好地适应不同应用的需求,减少了内存泄漏的风险。

优势:减少了内存泄漏的风险,提高了应用程序的稳定性。

5.总结-5

  • 使用本地内存:元空间使用本地内存,避免了永久代内存不足的问题。
  • 动态调整大小:元空间的大小可以动态调整,内存管理更加灵活。
  • 更好的垃圾回收性能:元空间减少了垃圾回收的开销,提高了垃圾回收的效率。
  • 减少内存泄漏风险:元空间减少了内存泄漏的风险,提高了应用程序的稳定性。

元空间相对于永久代具有显著的优势,使得 JVM 的内存管理更加高效和灵活,提高了应用程序的性能和稳定性。

6.对象的创建销毁的过程?

对象创建

类加载检查

JVM 首先检查类是否已经加载、链接和初始化。如果类没有加载,JVM 会通过类加载器加载类文件,并进行链接和初始化。

分配内存

JVM 在堆中为新对象分配内存。分配内存的方式有两种:指针碰撞(Bump-the-pointer)和空闲列表(Free-list)。具体使用哪种方式取决于堆是否规整。

①、指针碰撞(Bump the Pointer)

假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。

在分配内存时,Java 虚拟机维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动(碰撞)一段距离,然后将这段内存分配给对象实例即可。

②、空闲列表(Free List)

JVM 维护一个列表,记录堆中所有未占用的内存块,每个空间块都记录了大小和地址信息。

当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。

分配后,如果选中的空闲块未被完全利用,剩余的部分会作为一个新的空闲块加入到空闲列表中。

指针碰撞适用于管理简单、碎片化较少的内存区域(如年轻代),而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景(如老年代)。

初始化默认值

JVM 将分配的内存区域初始化为默认值(零值),确保对象的实例字段在初始状态下是确定的。

设置对象头

JVM 设置对象头,包括对象的元数据(如类信息)和哈希码等。

执行构造方法

JVM 调用对象的构造方法(<init> 方法),执行对象的初始化代码。

对象销毁

对象变为不可达

当对象不再被任何活动线程引用时,对象变为不可达。不可达的对象会被标记为垃圾回收的候选对象。

垃圾回收(Garbage Collection):

JVM 的垃圾回收器会定期扫描堆,标记和回收不可达的对象。垃圾回收的具体算法有多种,如标记-清除(Mark-Sweep)、标记-整理(Mark-Compact)和复制算法(Copying)。

调用 finalize 方法(已废弃):

在 JDK 9 之前,JVM 会在对象被回收前调用其 finalize 方法。finalize 方法允许对象在被回收前执行一些清理操作。然而,finalize 方法存在很多问题,如不确定性和性能开销,因此在 JDK 9 之后被废弃。

释放内存

垃圾回收器回收对象的内存,将其返回给堆,以便分配给新的对象。

7.JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?

会,假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。

对象创建的线程安全机制

线程本地分配缓冲区(TLAB - Thread Local Allocation Buffer):

JVM 为每个线程分配一个私有的内存区域,称为线程本地分配缓冲区(TLAB)。当一个线程需要分配内存时,首先尝试在自己的 TLAB 中分配。如果 TLAB 中有足够的空间,内存分配可以在没有锁竞争的情况下完成。

当 TLAB 中的空间不足时,线程会请求 JVM 在堆中分配新的 TLAB。如果堆中没有足够的空间,JVM 会进行垃圾回收以释放内存。

同步机制

当多个线程需要在堆中分配内存时,JVM 使用同步机制来确保线程安全。具体来说,JVM 使用 CAS(Compare-And-Swap)操作和锁来保证内存分配的原子性。

CAS 操作是一种无锁的并发编程技术,通过比较和交换操作来保证数据的一致性。在大多数情况下,CAS 操作比锁机制更高效,因为它避免了线程的阻塞和上下文切换。

8.对象的内存布局,对象的底层数据结构?

在 JVM 中,Java 对象的底层数据结构主要包括以下几个部分:

  1. 对象头(Header)
  2. 实例数据(Instance Data)
  3. 对齐填充(Padding)

1. 对象头(Header)

对象头是每个对象在内存中的开销部分,包含了对象的元数据。对象头通常包括以下两个部分:

  • 标记字(Mark Word)
    • 存储对象的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等。
    • Mark Word 的长度在 32 位和 64 位 JVM 中是不同的。在 32 位 JVM 中,Mark Word 通常是 32 位;在 64 位 JVM 中,Mark Word 通常是 64 位。在 64 位操作系统下占 8 个字节,32 位操作系统下占 4 个字节。
  • 类型指针(Class Pointer)
    • 指向对象的类元数据(Class Metadata),JVM 通过这个指针找到对象的类信息。
    • 在 32 位和 64 位 JVM 中,Class Pointer 的长度分别是 32 位和 64 位。
    • 在开启了压缩指针的情况下,这个指针可以被压缩。在开启指针压缩的情况下占 4 个字节,否则占 8 个字节。
  • 数组长度(ArrayLength) :
    • 如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。

2. 实例数据(Instance Data)

实例数据部分存储对象的实际字段值,包括所有的实例变量(包括从父类继承的变量)。实例数据的布局和顺序通常由编译器决定,具体顺序可能会根据字段的类型和访问权限进行优化。

  • 基本类型字段:如 intlongchar 等。
  • 引用类型字段:如对象引用、数组引用等。

3. 对齐填充(Padding)

为了提高内存访问的效率,JVM 要求对象的大小是某个特定字节数的倍数(通常是 8 字节)。如果对象的实例数据部分没有对齐,JVM 会在对象的末尾添加填充字节(Padding),以确保对象的大小满足对齐要求。

以下是一个简单的 Java 类,展示了对象的内存布局:

public class MyObject {
    int a;       // 4 bytes
    long b;      // 8 bytes
    char c;      // 2 bytes
    Object ref;  // 4 bytes (32-bit JVM) or 8 bytes (64-bit JVM)
}

内存布局示意图

假设在 32 位 JVM 中,MyObject 的内存布局如下:

+-----------------+-----------------+
|     对象头      |     Mark Word   |  4 bytes
+-----------------+-----------------+
|   Class Pointer |                 |  4 bytes
+-----------------+-----------------+
|       a         |                 |  4 bytes
+-----------------+-----------------+
|       b         |                 |  8 bytes
+-----------------+-----------------+
|       c         |                 |  2 bytes
+-----------------+-----------------+
|      ref        |                 |  4 bytes
+-----------------+-----------------+
|    Padding      |                 |  2 bytes (to align to 8 bytes)
+-----------------+-----------------+

总结-8

  • 对象头(Header):包含 Mark Word 和 Class Pointer,用于存储对象的元数据和类信息。
  • 实例数据(Instance Data):存储对象的实际字段值,包括基本类型字段和引用类型字段。
  • 对齐填充(Padding):用于确保对象的大小满足对齐要求,提高内存访问的效率。

对象的内存布局是 JVM 内存管理的重要组成部分,通过合理的内存布局和对齐策略,JVM 能够提高内存访问的效率和程序的性能。

9.对象如何访问定位?

在 JVM 中,对象的访问定位主要依赖于对象引用和对象在内存中的布局。JVM 使用两种主要的方式来访问和定位对象:

  1. 句柄访问(Handle Access)
  2. 直接指针访问(Direct Pointer Access)

1. 句柄访问(Handle Access)

在句柄访问方式中,JVM 会为每个对象分配一个句柄。句柄是一个固定大小的内存块,包含了对象实例数据和对象类型数据的指针。对象引用指向句柄,而不是直接指向对象实例数据。

  • 句柄结构
    • 句柄指针:对象引用指向句柄。
    • 句柄内容:句柄包含两个指针,一个指向对象实例数据,一个指向对象类型数据(类元数据)。
  • 优点
    • 对象在内存中的移动不会影响对象引用,因为引用指向的是句柄,句柄中的指针可以更新。
    • 适用于需要频繁移动对象的垃圾回收算法。
  • 缺点
    • 访问对象时需要两次内存间接访问(一次访问句柄,一次访问对象实例数据),性能略低。

2. 直接指针访问(Direct Pointer Access)

在直接指针访问方式中,对象引用直接指向对象实例数据。对象实例数据包含对象头和实例字段。

  • 直接指针结构
    • 对象引用:直接指向对象实例数据。
    • 对象实例数据:包含对象头和实例字段。
  • 优点
    • 访问对象时只需要一次内存间接访问,性能较高。
    • 适用于对象较少移动的垃圾回收算法。
  • 缺点
    • 对象在内存中的移动需要更新所有引用该对象的指针,增加了垃圾回收的复杂性。

HotSpot 虚拟机主要使用直接指针来进行对象访问。

10.说说内存溢出(OOM)和内存泄漏(Leak Memory)的原因?

内存溢出是什么

在 Java 中,OOM(OutOfMemoryError)错误表示 JVM 无法再为应用程序分配所需的内存。OOM 错误可能发生在以下几个内存区域:

  1. 堆内存(Heap Memory)
  2. 方法区(Method Area)/元空间(Metaspace)
  3. 栈内存(Stack Memory)
  4. 直接内存(Direct Memory)
1. 堆内存(Heap Memory)

描述

  • 堆内存用于存储所有对象实例和数组,是垃圾回收的主要区域。

OOM 发生原因

  • 对象过多:应用程序创建了过多的对象,导致堆内存耗尽。
  • 内存泄漏:对象不再被使用,但仍然被引用,导致垃圾回收器无法回收这些对象。
  • 大对象分配:尝试分配一个非常大的对象,超过了堆内存的最大限制。

示例

public class HeapOOM {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        while (true) {
            list.add(new Object());
        }
    }
}
2. 方法区(Method Area)/元空间(Metaspace)

描述

  • 方法区在 JDK 1.8 之前被实现为永久代(PermGen),在 JDK 1.8 之后被实现为元空间(Metaspace)。方法区用于存储类信息、常量、静态变量和即时编译器编译后的代码。

OOM 发生原因

  • 类加载过多:动态生成大量类,导致方法区或元空间耗尽。
  • 常量池过大:大量字符串常量或其他常量,导致常量池耗尽。

示例

public class MetaspaceOOM {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
}
3. 栈内存(Stack Memory)

描述

  • 栈内存用于存储每个线程的局部变量、操作数栈和帧数据。每个线程都有自己的栈内存。

OOM 发生原因

  • 栈帧过多:递归调用过深或方法调用层次过多,导致栈内存耗尽。
  • 线程过多:创建了过多的线程,每个线程都需要分配栈内存,导致栈内存耗尽。

示例

public class StackOverflow {
    public static void main(String[] args) {
        recursiveCall();
    }

    public static void recursiveCall() {
        recursiveCall();
    }
}
4. 直接内存(Direct Memory)

描述

  • 直接内存是 JVM 之外的内存区域,通过 java.nio 包中的 ByteBuffer 类进行分配和管理。

OOM 发生原因

  • 直接内存分配过多:分配了过多的直接内存,超过了系统的限制。

示例

public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        List<ByteBuffer> buffers = new ArrayList<>();
        while (true) {
            buffers.add(ByteBuffer.allocateDirect(_1MB));
        }
    }
}
总结-10-1
  • 堆内存(Heap Memory):对象过多、内存泄漏、大对象分配。
  • 方法区(Method Area)/元空间(Metaspace):类加载过多、常量池过大。
  • 栈内存(Stack Memory):栈帧过多、线程过多。
  • 直接内存(Direct Memory):直接内存分配过多。

OOM 错误通常是由于内存分配超过了 JVM 或系统的限制,了解这些内存区域及其可能的 OOM 原因,有助于在开发和调试过程中更好地管理和优化内存使用。

内存泄漏可能由哪些原因导致呢?

内存泄漏可能的原因有很多种,比如说静态集合类引起内存泄漏、单例模式、数据连接、IO、Socket 等连接、变量不合理的作用域、hash 值发生变化、ThreadLocal 使用不当等。

①、静态集合类引起内存泄漏:

静态集合的生命周期和 JVM 一致,所以静态集合引用的对象不能被释放。

public class OOM {
 static List list = new ArrayList();

 public void oomTests(){
   Object obj = new Object();

   list.add(obj);
  }
}

②、单例模式:

和上面的例子原理类似,单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。

③、数据连接、IO、Socket 等连接:

创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。

try {
    Connection conn = null;
    Class.forName("com.mysql.jdbc.Driver");
    conn = DriverManager.getConnection("url", "", "");
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("....");
  } catch (Exception e) {

  }finally {
    //不关闭连接
  }

④、变量不合理的作用域:

一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。

public class Simple {
    Object object;
    public void method1(){
        object = new Object();
        //...其他代码
        //由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放
        object = null;
    }
}

⑤、hash 值发生变化:

对象 Hash 值改变,使用 HashMap、HashSet 等容器中时候,由于对象修改之后的 Hah 值和存储进容器时的 Hash 值不同,所以无法找到存入的对象,自然也无法单独删除了,这也会造成内存泄漏。说句题外话,这也是为什么 String 类型被设置成了不可变类型。

⑥、ThreadLocal 使用不当:

ThreadLocal 的弱引用导致内存泄漏也是个老生常谈的话题了(键是弱引用会回收,但是值是强引用),使用完 ThreadLocal 一定要记得使用 remove 方法来进行清除。

11.Java 堆的内存分区了解吗?

Java 堆是 JVM 内存管理的重要组成部分,用于存储所有对象实例和数组。为了提高垃圾回收的效率,Java 堆通常被划分为几个不同的区域,每个区域有不同的用途和垃圾回收策略。主要的内存分区包括:

  1. 新生代(Young Generation)
  2. 老年代(Old Generation)
  3. 永久代(PermGen)/元空间(Metaspace)

1. 新生代(Young Generation)

新生代用于存储新创建的对象。新生代进一步划分为三个区域:

  • Eden 区:大部分新对象在 Eden 区分配。当 Eden 区满时,会触发一次 Minor GC,将存活的对象移动到 Survivor 区。
  • Survivor 区:新生代包含两个 Survivor 区,分别称为 S0(From)和 S1(To)。每次 Minor GC 后,存活的对象会在两个 Survivor 区之间复制和交换。一个 Survivor 区是空的,另一个 Survivor 区存储存活的对象。

垃圾回收

  • Minor GC:新生代的垃圾回收称为 Minor GC,频率较高,但速度较快。Minor GC 主要回收 Eden 区和一个 Survivor 区的对象。

2. 老年代(Old Generation)

老年代用于存储生命周期较长的对象。经过多次 Minor GC 仍然存活的对象会被移动到老年代。老年代的空间通常比新生代大,垃圾回收的频率较低,但回收时间较长。

垃圾回收

  • Major GC / Full GC:老年代的垃圾回收称为 Major GC 或 Full GC,频率较低,但速度较慢。Full GC 会回收整个堆,包括新生代和老年代。

3. 永久代(PermGen)/元空间(Metaspace)

永久代和元空间用于存储类信息、常量、静态变量和即时编译器编译后的代码。

  • 永久代(PermGen):在 JDK 1.8 之前,永久代用于存储类元数据。永久代的大小是固定的,可以通过 -XX:PermSize-XX:MaxPermSize 参数进行配置。
  • 元空间(Metaspace):在 JDK 1.8 之后,永久代被移除,取而代之的是元空间。元空间使用本地内存(Native Memory),其大小可以动态调整,可以通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数进行配置。

内存分区示意图

+------------------+
|      堆          |
| +--------------+ |
| |   新生代      | |
| | +----------+ | |
| | |  Eden    | | |
| | +----------+ | |
| | | Survivor | | |
| | |    S0    | | |
| | +----------+ | |
| | | Survivor | | |
| | |    S1    | | |
| | +----------+ | |
| +--------------+ |
| +--------------+ |
| |   老年代      | |
| +--------------+ |
+------------------+
+------------------+
|  永久代/元空间   |
+------------------+

总结-11

  • 新生代(Young Generation):用于存储新创建的对象,进一步划分为 Eden 区和两个 Survivor 区。主要通过 Minor GC 进行垃圾回收。
  • 老年代(Old Generation):用于存储生命周期较长的对象。主要通过 Major GC 或 Full GC 进行垃圾回收。
  • 永久代(PermGen)/元空间(Metaspace):用于存储类信息、常量、静态变量和即时编译器编译后的代码。在 JDK 1.8 之前为永久代,之后为元空间。

通过合理的内存分区和垃圾回收策略,JVM 能够高效地管理内存,确保应用程序的性能和稳定性。

12.对象什么时候会进入老年代?

在 Java 的垃圾回收机制中,对象从新生代(Young Generation)移动到老年代(Old Generation)的过程称为对象晋升(Promotion)。对象进入老年代的主要情况如下:

1. 对象年龄达到阈值

  • 对象年龄:每个对象在新生代中存活的时间被称为对象年龄。每次 Minor GC 后,存活的对象会在 Eden 区和 Survivor 区之间复制和交换,对象的年龄会增加。
  • 年龄阈值:当对象的年龄达到某个阈值(默认是 15,可以通过 -XX:MaxTenuringThreshold 参数配置)时,对象会被晋升到老年代。

2. Survivor 区空间不足

  • Survivor 区空间不足:如果在进行 Minor GC 时,Survivor 区没有足够的空间来存放存活的对象,这些对象会直接晋升到老年代。
  • 动态年龄判定:JVM 可能会根据 Survivor 区的使用情况动态调整对象晋升的年龄阈值,以优化内存使用。

3. 大对象直接进入老年代

  • 大对象:一些大对象(如大数组、大字符串等)可能会直接分配到老年代,以避免在新生代中频繁复制和移动。
  • 大对象阈值:可以通过 -XX:PretenureSizeThreshold 参数配置大对象的阈值,超过该阈值的对象会直接分配到老年代。

4. 新生代空间不足

  • 新生代空间不足:如果新生代的空间不足以容纳新创建的对象,JVM 会触发 Minor GC。如果在 Minor GC 后,新生代仍然没有足够的空间,部分存活的对象会被晋升到老年代。

以下是一个简单的 Java 示例,展示了对象晋升到老年代的过程:

public class ObjectPromotionExample {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[4 * _1MB]; // 触发 Minor GC
    }
}

在上述代码中,allocation3 的分配会触发一次 Minor GC,如果 allocation1allocation2 存活且 Survivor 区空间不足,它们可能会被晋升到老年代。

总结-12

  • 对象年龄达到阈值:对象在新生代中存活的时间达到某个阈值时,会被晋升到老年代。
  • Survivor 区空间不足:在进行 Minor GC 时,如果 Survivor 区没有足够的空间存放存活的对象,这些对象会被晋升到老年代。
  • 大对象直接进入老年代:一些大对象可能会直接分配到老年代,以避免在新生代中频繁复制和移动。
  • 新生代空间不足:如果新生代空间不足以容纳新创建的对象,部分存活的对象会被晋升到老年代。

通过合理的内存管理和垃圾回收策略,JVM 能够高效地管理对象的生命周期,确保应用程序的性能和稳定性。

13.什么是 Stop The World ? 什么是 OopMap ?什么是安全点?

Stop The World (STW)

Stop The World (STW) 是指在垃圾回收过程中,JVM 会暂停所有应用程序线程的执行,以便进行垃圾回收操作。这种暂停是全局性的,所有的应用程序线程都会被挂起,直到垃圾回收完成。

特点

  • 全局暂停:所有应用程序线程都会被暂停,只有垃圾回收线程在运行。
  • 暂停时间:暂停时间取决于垃圾回收算法和堆的大小。对于一些垃圾回收算法(如 Full GC),暂停时间可能会较长。
  • 影响:STW 会影响应用程序的响应时间和性能,特别是在大堆内存和长时间暂停的情况下。

OopMap

OopMap 是 JVM 中的一种数据结构,用于记录对象引用的位置。OopMap 在垃圾回收过程中起到了关键作用,帮助垃圾回收器快速找到对象引用,以便进行标记和回收。

特点

  • 记录引用位置:OopMap 记录了每个栈帧和寄存器中对象引用的位置。
  • 生成时机:JVM 在编译字节码时生成 OopMap,并在方法调用和返回、异常处理等关键点更新 OopMap。
  • 作用:在垃圾回收过程中,OopMap 帮助垃圾回收器快速找到对象引用,避免全堆扫描,提高垃圾回收效率。

安全点(Safepoint)

安全点(Safepoint) 是指 JVM 在执行过程中,能够安全地暂停所有应用程序线程的特定位置。垃圾回收器需要在安全点暂停线程,以确保线程在一致的状态下进行垃圾回收。

特点

  • 特定位置:安全点通常设置在方法调用、循环回边和异常处理等位置。
  • 线程一致性:在安全点暂停线程,确保线程在一致的状态下进行垃圾回收。
  • 触发机制:当垃圾回收器需要进行 STW 操作时,会触发安全点,暂停所有应用程序线程。

以下是一个简单的 Java 示例,展示了垃圾回收过程中可能涉及的 STW、OopMap 和安全点:

public class StopTheWorldExample {
    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            allocateMemory();
        }
    }

    private static void allocateMemory() {
        byte[] array = new byte[1024 * 1024]; // 分配 1MB 内存
    }
}

在上述代码中,allocateMemory 方法会频繁分配内存,可能会触发垃圾回收。在垃圾回收过程中,JVM 会暂停所有应用程序线程(STW),使用 OopMap 记录对象引用的位置,并在安全点暂停线程。

总结-13

  • Stop The World (STW):在垃圾回收过程中,JVM 会暂停所有应用程序线程的执行,以便进行垃圾回收操作。
  • OopMap:一种数据结构,用于记录对象引用的位置,帮助垃圾回收器快速找到对象引用,提高垃圾回收效率。
  • 安全点(Safepoint):JVM 在执行过程中能够安全地暂停所有应用程序线程的特定位置,确保线程在一致的状态下进行垃圾回收。

通过合理的垃圾回收策略和机制,JVM 能够高效地管理内存,确保应用程序的性能和稳定性。

14.对象一定分配在堆中吗?有没有了解逃逸分析技术?

对象一定分配在堆中吗?

在 Java 中,通常情况下,对象是分配在堆内存中的。然而,通过一些优化技术,特别是逃逸分析(Escape Analysis),对象也可以在栈上分配,从而减少堆内存的使用和垃圾回收的开销。

逃逸分析(Escape Analysis)

逃逸分析是一种编译时优化技术,用于确定对象的动态作用域。通过逃逸分析,JVM 可以判断对象是否会逃逸出方法或线程的作用域,从而决定对象的分配位置。

逃逸分析的类型
  1. 方法逃逸(Method Escape)
    • 如果对象在方法外部被引用,则认为对象发生了方法逃逸。
    • 例如,将对象作为参数传递给其他方法,或者将对象赋值给类的成员变量。
  2. 线程逃逸(Thread Escape)
    • 如果对象在方法外部被其他线程引用,则认为对象发生了线程逃逸。
    • 例如,将对象作为参数传递给其他线程,或者将对象存储在共享数据结构中。
逃逸分析的优化
  1. 栈上分配(Stack Allocation)
    • 如果对象没有发生逃逸,JVM 可以将对象分配在栈上,而不是堆中。这样,当方法执行完毕时,对象会自动销毁,无需垃圾回收。
    • 优点:减少堆内存的使用,降低垃圾回收的频率和开销。
  2. 标量替换(Scalar Replacement)
    • 如果对象没有发生逃逸,且对象的字段可以被拆分为标量变量,JVM 可以将对象的字段直接分配在栈上,而不是创建对象。
    • 优点:进一步减少内存分配和垃圾回收的开销。
  3. 同步消除(Synchronization Elimination)
    • 如果对象没有发生线程逃逸,JVM 可以消除对象上的同步操作,从而提高并发性能。
    • 优点:减少不必要的同步开销,提高程序的并发性能。

以下是一个简单的 Java 示例,展示了逃逸分析的应用:

public class EscapeAnalysisExample {
    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            allocateMemory();
        }
    }

    private static void allocateMemory() {
        Point p = new Point(1, 2); // 可能在栈上分配
        System.out.println(p);
    }
}

class Point {
    int x, y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "Point{" + "x=" + x + ", y=" + y + '}';
    }
}

在上述代码中,Point 对象在 allocateMemory方法中创建。如果 JVM 通过逃逸分析确定 Point 对象没有发生逃逸,则可以将其分配在栈上,而不是堆中。

总结-14

  • 对象分配位置:通常情况下,对象分配在堆中。然而,通过逃逸分析,JVM 可以将未逃逸的对象分配在栈上,从而减少堆内存的使用和垃圾回收的开销。
  • 逃逸分析:一种编译时优化技术,用于确定对象的动态作用域,判断对象是否会逃逸出方法或线程的作用域。
  • 优化技术
    • 栈上分配:将未逃逸的对象分配在栈上。
    • 标量替换:将未逃逸对象的字段拆分为标量变量,直接分配在栈上。
    • 同步消除:消除未发生线程逃逸对象上的同步操作。

通过逃逸分析和相关优化技术,JVM 能够提高内存管理的效率,减少垃圾回收的开销,从而提高应用程序的性能。

15.JVM垃圾回收机制?

垃圾回收(Garbage Collection, GC)是 JVM 自动管理内存的一种机制,用于回收不再使用的对象所占用的内存空间。GC 的主要目标是释放无用对象的内存,防止内存泄漏和内存溢出,从而提高应用程序的性能和稳定性。

对象回收判定

在 JVM 中,判断对象是否存活是垃圾回收的核心任务。主要有两种方法来判断对象是否存活:

  1. 引用计数法(Reference Counting)
  2. 可达性分析法(Reachability Analysis)
1. 引用计数法(Reference Counting)

描述

  • 每个对象维护一个引用计数器,记录有多少引用指向该对象。
  • 当一个新的引用指向该对象时,引用计数器加一;当一个引用不再指向该对象时,引用计数器减一。
  • 当引用计数器为零时,表示该对象不再被引用,可以被回收。

优点

  • 实现简单,判断对象是否存活的时间复杂度为 O(1)。

缺点

  • 无法处理循环引用的问题。例如,两个对象互相引用,但它们不再被其他对象引用,引用计数器永远不会为零,导致内存泄漏。
2. 可达性分析法(Reachability Analysis)

描述

  • 从根对象(GC Roots)开始,沿着引用链遍历对象,标记所有可达的对象。
  • 未被标记的对象即为不可达,可以被回收。

GC Roots

  • 栈中的引用(如局部变量、方法参数等)
  • 静态字段引用
  • 常量池中的引用
  • JNI(Java Native Interface)引用

优点

  • 能够正确处理循环引用的问题。

缺点

  • 实现复杂,遍历对象图的时间复杂度较高。

以下是一个简单的 Java 示例,展示了对象的可达性分析:

public class GCRootsExample {
    private static GCRootsExample staticField;
    private GCRootsExample instanceField;

    public static void main(String[] args) {
        GCRootsExample obj1 = new GCRootsExample();
        staticField = obj1; // 静态字段作为 GC Roots
        GCRootsExample obj2 = new GCRootsExample();
        obj1.instanceField = obj2; // 实例字段引用
        obj2 = null; // obj2 不再引用 GCRootsExample 对象
        System.gc(); // 触发垃圾回收
    }
}

在上述代码中,staticFieldinstanceField 作为 GC Roots,obj1obj2 的可达性由 GC Roots 判断。

总结-15-1
  • 引用计数法(Reference Counting):每个对象维护一个引用计数器,记录有多少引用指向该对象。当引用计数器为零时,表示对象不再被引用,可以被回收。缺点是无法处理循环引用的问题。
  • 可达性分析法(Reachability Analysis):从根对象(GC Roots)开始,沿着引用链遍历对象,标记所有可达的对象。未被标记的对象即为不可达,可以被回收。优点是能够正确处理循环引用的问题。

垃圾回收算法

垃圾收集算法是 JVM 内存管理的重要组成部分,用于自动回收不再使用的对象所占用的内存空间。常见的垃圾收集算法包括:

  1. 标记-清除算法(Mark-Sweep)
  2. 复制算法(Copying)
  3. 标记-整理算法(Mark-Compact)
  4. 分代收集算法(Generational Collection)
1. 标记-清除算法(Mark-Sweep)

描述

  • 标记阶段:从根对象(GC Roots)开始,标记所有可达的对象。
  • 清除阶段:遍历堆,回收未被标记的对象。

优点

  • 实现简单,能够处理大多数场景。

缺点

  • 清除阶段会产生内存碎片,影响内存分配效率。
2. 复制算法(Copying)

描述

  • 将内存划分为两个相等的区域(From 和 To)。
  • 每次垃圾回收时,将存活的对象从 From 区复制到 To 区,未被复制的对象即为垃圾。
  • 复制完成后,交换 From 和 To 的角色。

优点

  • 没有内存碎片,内存分配效率高。

缺点

  • 需要两倍的内存空间,内存利用率较低。
3. 标记-整理算法(Mark-Compact)

描述

  • 标记阶段:从根对象(GC Roots)开始,标记所有可达的对象。
  • 整理阶段:将所有存活的对象压缩到堆的一端,清理掉边界以外的内存。

优点

  • 没有内存碎片,内存利用率高。

缺点

  • 整理阶段需要移动对象,开销较大。
4. 分代收集算法(Generational Collection)

描述

  • 将堆内存划分为新生代(Young Generation)和老年代(Old Generation)。
  • 新生代使用复制算法,老年代使用标记-整理算法。

优点

  • 结合了复制算法和标记-整理算法的优点,能够高效地管理内存。

缺点

  • 实现复杂,需要调优。
总结-15-2
  • 标记-清除算法(Mark-Sweep):标记所有可达的对象,清除未被标记的对象。缺点是会产生内存碎片。
  • 复制算法(Copying):将存活的对象从一个区域复制到另一个区域,没有内存碎片,但内存利用率较低。
  • 标记-整理算法(Mark-Compact):标记所有可达的对象,将存活的对象压缩到堆的一端,没有内存碎片,但需要移动对象。
  • 分代收集算法(Generational Collection):将堆内存划分为新生代和老年代,结合了复制算法和标记-整理算法的优点,能够高效地管理内存。

垃圾回收类型

在 JVM 中,垃圾回收(GC)可以根据回收的内存区域和回收策略分为不同的类型。主要的垃圾回收类型包括:

  1. Minor GC / Young GC
  2. Major GC / Old GC
  3. Mixed GC
  4. Full GC
1. Minor GC / Young GC

描述

  • Minor GC 也称为 Young GC,主要针对新生代(Young Generation)进行垃圾回收。
  • 新生代通常使用复制算法(Copying),将存活的对象从 Eden 区和一个 Survivor 区复制到另一个 Survivor 区。

触发条件

  • 当 Eden 区满时,会触发 Minor GC。

特点

  • 频率较高,但回收速度较快。
  • 只回收新生代,不涉及老年代。
2. Major GC / Old GC

描述

  • Major GC 也称为 Old GC,主要针对老年代(Old Generation)进行垃圾回收。
  • 老年代通常使用标记-整理算法(Mark-Compact),标记所有可达的对象,并将存活的对象压缩到堆的一端。

触发条件

  • 当老年代满时,会触发 Major GC。

特点

  • 频率较低,但回收速度较慢。
  • 只回收老年代,不涉及新生代。
3. Mixed GC

描述

  • Mixed GC 是 G1 垃圾回收器的一种回收策略,回收新生代和部分老年代。
  • G1 垃圾回收器将堆划分为多个区域(Region),Mixed GC 会同时回收新生代和一些老年代的区域。

触发条件

  • 当新生代满时,或者根据 G1 的回收策略触发。

特点

  • 结合了 Minor GC 和 Major GC 的优点,能够高效地管理内存。
  • 回收范围更广,能够减少 Full GC 的频率。
4. Full GC

描述

  • Full GC 是一次全堆的垃圾回收,回收新生代、老年代和元空间(Metaspace)。
  • Full GC 通常使用标记-整理算法(Mark-Compact)或标记-清除算法(Mark-Sweep)。

触发条件

  • 当堆内存不足,或者显式调用 System.gc() 时,会触发 Full GC。

特点

  • 频率最低,但回收速度最慢。
  • 暂停时间最长,对应用程序的性能影响最大。
总结-15-3
  • Minor GC / Young GC:主要针对新生代进行垃圾回收,频率较高,但回收速度较快。
  • Major GC / Old GC:主要针对老年代进行垃圾回收,频率较低,但回收速度较慢。
  • Mixed GC:G1 垃圾回收器的一种回收策略,回收新生代和部分老年代,结合了 Minor GC 和 Major GC 的优点。
  • Full GC:一次全堆的垃圾回收,回收新生代、老年代和元空间,频率最低,但回收速度最慢,对应用程序的性能影响最大。

垃圾收集器

JVM 提供了多种垃圾收集器,每种垃圾收集器都有其独特的特点和适用场景。以下是一些常见的垃圾收集器:

1. Serial 收集器

描述

  • Serial 收集器是最基本的垃圾收集器,使用单线程进行垃圾回收。
  • 适用于单线程环境或小型应用。

特点

  • 简单高效,适用于单核 CPU。
  • 在进行垃圾回收时,会暂停所有应用程序线程(Stop The World)。

适用场景

  • 单线程环境或小型应用。
2. ParNew 收集器

描述

  • ParNew 收集器是 Serial 收集器的多线程版本,使用多线程进行垃圾回收。
  • 主要用于新生代的垃圾回收。

特点

  • 多线程并行回收,适用于多核 CPU。
  • 与 CMS 收集器配合使用。

适用场景

  • 多线程环境,适用于新生代的垃圾回收。
3. Parallel 收集器

描述

  • Parallel 收集器也称为吞吐量收集器,使用多线程进行垃圾回收,主要关注吞吐量。
  • 适用于新生代和老年代的垃圾回收。
  • 吞吐量 = 运行用户代码的时间/(运行垃圾收集的时间+运行用户代码的时间) 特点

  • 多线程并行回收,适用于多核 CPU。
  • 关注吞吐量,适用于后台计算等对响应时间要求不高的场景。

适用场景

  • 需要高吞吐量的应用,如后台计算、批处理等。
4. CMS 收集器

描述

  • CMS(Concurrent Mark-Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
  • 主要用于老年代的垃圾回收。

特点

  • 并发回收,减少停顿时间。
  • 适用于对响应时间要求高的应用,如 Web 服务器。
  • CMS(Concurrent Mark Sweep)主要使用了标记-清除算法进行垃圾收集,分 4 大步:
    • 初始标记(Initial Mark):标记所有从 GC Roots 直接可达的对象,这个阶段需要 STW,但速度很快。
    • 并发标记(Concurrent Mark):从初始标记的对象出发,遍历所有对象,标记所有可达的对象。这个阶段是并发进行的,STW。
    • 重新标记(Remark):完成剩余的标记工作,包括处理并发阶段遗留下来的少量变动,这个阶段通常需要短暂的 STW 停顿。
    • 并发清除(Concurrent Sweep):清除未被标记的对象,回收它们占用的内存空间。

remark的过程:

remark 阶段通常会结合三色标记法来执行,确保在并发标记期间所有存活对象都被正确标记。目的是修正并发标记阶段中可能遗漏的对象引用变化。

在 remark 阶段,垃圾收集器会停止应用线程(STW),以确保在这个阶段不会有引用关系的进一步变化。这种暂停通常很短暂。remark 阶段主要包括以下操作:

  • 1.处理写屏障记录的引用变化:在并发标记阶段,应用程序可能会更新对象的引用(比如一个黑色对象新增了对一个白色对象的引用),这些变化通过写屏障记录下来。在 remark 阶段,GC 会处理这些记录,确保所有可达对象都正确地标记为灰色或黑色。
  • 2.扫描灰色对象:再次遍历灰色对象,处理它们的所有引用,确保引用的对象正确标记为灰色或黑色。
  • 3.清理:确保所有引用关系正确处理后,灰色对象标记为黑色,白色对象保持不变。这一步完成后,所有存活对象都应当是黑色的。

三色标记法

白色(White):尚未访问的对象。垃圾回收结束后,仍然为白色的对象会被认为是不可达的对象,可以回收。

灰色(Gray):已经访问到但未标记完其引用的对象。灰色对象是需要进一步处理的。

黑色(Black):已经访问到并且其所有引用对象都已经标记过。黑色对象是完全处理过的,不需要再处理。 三色标记法的工作流程:

①、初始标记(Initial Marking):从 GC Roots 开始,标记所有直接可达的对象为灰色。

②、并发标记(Concurrent Marking):在此阶段,标记所有灰色对象引用的对象为灰色,然后将灰色对象自身标记为黑色。这个过程是并发的,和应用线程同时进行。

此阶段的一个问题是,应用线程可能在并发标记期间修改对象的引用关系,导致一些对象的标记状态不准确。

③、重新标记(Remarking):重新标记阶段的目标是处理并发标记阶段遗漏的引用变化。为了确保所有存活对象都被正确标记,remark 需要在 STW 暂停期间执行。

④、使用写屏障(Write Barrier)来捕捉并发标记阶段应用线程对对象引用的更新。通过遍历这些更新的引用来修正标记状态,确保遗漏的对象不会被错误地回收。

适用场景

  • 对响应时间要求高的应用,如 Web 服务器。
5. G1 收集器

描述

  • G1(Garbage First)收集器是一种面向服务端应用的垃圾收集器,适用于多核 CPU 和大内存环境。
  • 将堆划分为多个区域(Region),使用并行和并发的方式进行垃圾回收。这种区域化管理使得 G1 可以更灵活地进行垃圾收集,只回收部分区域而不是整个新生代或老年代。
  • G1(Garbage-First Garbage Collector)在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器

运行过程

①、并发标记,G1 通过并发标记的方式找出堆中的垃圾对象。并发标记阶段与应用线程同时执行,不会导致应用线程暂停。

②、混合收集,在并发标记完成后,G1 会计算出哪些区域的回收价值最高(也就是包含最多垃圾的区域),然后优先回收这些区域。这种回收方式包括了部分新生代区域和老年代区域。

选择回收成本低而收益高的区域进行回收,可以提高回收效率和减少停顿时间。

③、可预测的停顿,G1 在垃圾回收期间仍然需要「Stop the World」。不过,G1 在停顿时间上添加了预测机制,用户可以 JVM 启动时指定期望停顿时间,G1 会尽可能地在这个时间内完成垃圾回收。

特点

  • 并行和并发回收,减少停顿时间。
  • 适用于大内存和多核 CPU 环境。
  • 支持预测停顿时间。

适用场景

  • 大内存和多核 CPU 环境,适用于对响应时间和吞吐量都有要求的应用。
6. ZGC 收集器

描述

  • ZGC(Z Garbage Collector)是一种低延迟垃圾收集器,适用于大内存和低延迟要求的应用。
  • 使用并发标记和并发压缩技术,减少停顿时间。

特点

  • 低延迟,停顿时间通常在 10 毫秒以内。
  • 适用于大内存和低延迟要求的应用。

适用场景

  • 大内存和低延迟要求的应用,如金融交易系统。
7. Shenandoah 收集器

描述

  • Shenandoah 收集器是一种低延迟垃圾收集器,适用于大内存和低延迟要求的应用。
  • 使用并发标记和并发压缩技术,减少停顿时间。

特点

  • 低延迟,停顿时间通常在 10 毫秒以内。
  • 适用于大内存和低延迟要求的应用。

适用场景

  • 大内存和低延迟要求的应用,如金融交易系统。
总结-15-4
  • Serial 收集器:单线程垃圾收集器,适用于单线程环境或小型应用。
  • ParNew 收集器:多线程版本的 Serial 收集器,适用于多线程环境,主要用于新生代的垃圾回收。
  • Parallel 收集器:关注吞吐量的多线程垃圾收集器,适用于需要高吞吐量的应用。
  • CMS 收集器:并发标记-清除垃圾收集器,适用于对响应时间要求高的应用。
  • G1 收集器:面向服务端应用的垃圾收集器,适用于大内存和多核 CPU 环境。
  • ZGC 收集器:低延迟垃圾收集器,适用于大内存和低延迟要求的应用。
  • Shenandoah 收集器:低延迟垃圾收集器,适用于大内存和低延迟要求的应用。

16.有了 CMS,为什么还要引入 G1?

特性 CMS G1
设计目标 低停顿时间 可预测的停顿时间
并发性
内存碎片 是,容易产生碎片 否,通过区域划分和压缩减少碎片
收集代数 年轻代和老年代 整个堆,但区分年轻代和老年代
并发阶段 并发标记、并发清理 并发标记、并发清理、并发回收
停顿时间预测 较难预测 可配置停顿时间目标
容易出现的问题 内存碎片、Concurrent Mode Failure 较少出现长时间停顿

CMS 适用于对延迟敏感的应用场景,主要目标是减少停顿时间,但容易产生内存碎片。G1 则提供了更好的停顿时间预测和内存压缩能力,适用于大内存和多核处理器环境。

16.有哪些常用的命令行性能监控和故障处理工具?

操作系统工具

  • top:显示系统整体资源使用情况
  • vmstat:监控内存和 CPU
  • iostat:监控 IO 使用
  • netstat:监控网络使用

JDK 性能监控工具

  • jps:虚拟机进程查看
  • jstat:虚拟机运行时信息查看
  • jinfo:虚拟机配置查看
  • jmap:内存映像(导出)
  • jhat:堆转储快照分析
  • jstack:Java 堆栈跟踪
  • jcmd:实现上面除了 jstat 外所有命令的功能

17.了解哪些可视化的性能监控和故障处理工具?

除了命令行工具,Java 生态系统中还有许多强大的可视化工具,用于性能监控和故障处理。这些工具提供了图形化界面,使得监控和诊断更加直观和便捷。以下是一些常用的可视化工具:

1. VisualVM

描述

  • VisualVM 是一个集成的性能监控和故障处理工具,提供了对 Java 应用程序的实时监控和分析功能。
  • 它可以监控 CPU、内存、线程、垃圾回收等信息,并支持生成和分析堆转储、线程转储等。

特点

  • 实时监控和分析 Java 应用程序。
  • 支持生成和分析堆转储、线程转储。
  • 提供插件扩展功能。

使用方法

  • VisualVM 通常随 JDK 一起提供,可以在 JDK 的 bin 目录下找到 jvisualvm 可执行文件。

2. JConsole

描述

  • JConsole 是 JDK 提供的一个基于 JMX(Java Management Extensions)的监控工具,用于监控和管理 Java 应用程序。
  • 它可以监控内存使用、线程活动、类加载、垃圾回收等信息。

特点

  • 基于 JMX,支持远程监控。
  • 提供实时监控和管理功能。
  • 界面简单直观。

使用方法

  • JConsole 通常随 JDK 一起提供,可以在 JDK 的 bin 目录下找到 jconsole 可执行文件。

3. Java Mission Control (JMC)

描述

  • Java Mission Control 是 Oracle 提供的一个高级监控和分析工具,集成了 JFR(Java Flight Recorder)功能。
  • 它可以对 Java 应用程序进行低开销的监控和分析,适用于生产环境。

特点

  • 集成 JFR,提供低开销的监控和分析。
  • 支持生成和分析飞行记录(Flight Recording)。
  • 提供详细的性能分析和故障诊断功能。

使用方法

  • Java Mission Control 通常随 JDK 一起提供,可以在 JDK 的 bin 目录下找到 jmc 可执行文件。

4. Eclipse Memory Analyzer (MAT)

描述

  • Eclipse Memory Analyzer 是一个强大的堆转储分析工具,用于查找内存泄漏和分析内存使用情况。
  • 它可以生成详细的内存报告,帮助开发人员定位内存问题。

特点

  • 强大的堆转储分析功能。
  • 支持生成详细的内存报告。
  • 提供内存泄漏检测和分析功能。

使用方法

  • Eclipse Memory Analyzer 可以从 Eclipse 官方网站下载,并作为独立应用程序运行。

5. Grafana + Prometheus

描述

  • Grafana 和 Prometheus 是开源的监控和告警工具,常用于监控分布式系统和微服务架构。
  • Prometheus 负责数据采集和存储,Grafana 负责数据可视化。

特点

  • 强大的数据采集和存储功能。
  • 灵活的可视化和告警配置。
  • 支持多种数据源和插件扩展。

使用方法

  • 需要安装和配置 Prometheus 和 Grafana,可以从各自的官方网站下载并按照文档进行配置。

总结-17

  • VisualVM:集成的性能监控和故障处理工具,提供实时监控和分析功能。
  • JConsole:基于 JMX 的监控工具,支持远程监控和管理。
  • Java Mission Control (JMC):高级监控和分析工具,集成 JFR 功能,适用于生产环境。
  • Eclipse Memory Analyzer (MAT):强大的堆转储分析工具,用于查找内存泄漏和分析内存使用情况。
  • Grafana + Prometheus:开源的监控和告警工具,常用于监控分布式系统和微服务架构。

通过这些可视化工具,开发人员和运维人员可以更加直观和便捷地监控和诊断 Java 应用程序的性能和故障,确保应用程序的稳定运行。

18.JVM 的常见参数配置知道哪些?

JVM 提供了许多参数配置选项,用于调整内存管理、垃圾回收、性能优化等方面。以下是一些常见的 JVM 参数配置:

1. 内存设置参数

  • -Xms:设置 JVM 初始堆内存大小。

    -Xms512m
    
  • -Xmx:设置 JVM 最大堆内存大小。

    -Xmx1024m
    
  • -Xmn:设置新生代内存大小。

    -Xmn256m
    
  • -XX:PermSize:设置永久代初始大小(适用于 JDK 1.8 之前)。

    -XX:PermSize=128m
    
  • -XX:MaxPermSize:设置永久代最大大小(适用于 JDK 1.8 之前)。

    -XX:MaxPermSize=256m
    
  • -XX:MetaspaceSize:设置元空间初始大小(适用于 JDK 1.8 及之后)。

    -XX:MetaspaceSize=128m
    
  • -XX:MaxMetaspaceSize:设置元空间最大大小(适用于 JDK 1.8 及之后)。

    -XX:MaxMetaspaceSize=256m
    

2. 垃圾回收参数

  • -XX:+UseSerialGC:使用 Serial 垃圾收集器。

    -XX:+UseSerialGC
    
  • -XX:+UseParallelGC:使用 Parallel 垃圾收集器(新生代)。

    -XX:+UseParallelGC
    
  • -XX:+UseParallelOldGC:使用 Parallel Old 垃圾收集器(老年代)。

    -XX:+UseParallelOldGC
    
  • -XX:+UseConcMarkSweepGC:使用 CMS 垃圾收集器。

    -XX:+UseConcMarkSweepGC
    
  • -XX:+UseG1GC:使用 G1 垃圾收集器。

    -XX:+UseG1GC
    
  • -XX:+UseZGC:使用 ZGC 垃圾收集器(适用于 JDK 11 及之后)。

    -XX:+UseZGC
    
  • -XX:+UseShenandoahGC:使用 Shenandoah 垃圾收集器(适用于 JDK 12 及之后)。

    -XX:+UseShenandoahGC
    

3. 性能调优参数

  • -XX:+AggressiveOpts:启用 JVM 的性能优化选项。

    -XX:+AggressiveOpts
    
  • -XX:+UseCompressedOops:启用指针压缩(适用于 64 位 JVM)。

    -XX:+UseCompressedOops
    
  • -XX:ParallelGCThreads:设置并行垃圾收集器的线程数。

    -XX:ParallelGCThreads=4
    
  • -XX:ConcGCThreads:设置 CMS 垃圾收集器的并发线程数。

    -XX:ConcGCThreads=4
    
  • -XX:InitiatingHeapOccupancyPercent:设置 G1 垃圾收集器的堆占用触发百分比。

    -XX:InitiatingHeapOccupancyPercent=45
    

4. 调试和诊断参数

  • -XX:+PrintGC:打印垃圾回收日志。

    -XX:+PrintGC
    
  • -XX:+PrintGCDetails:打印详细的垃圾回收日志。

    -XX:+PrintGCDetails
    
  • -XX:+PrintGCTimeStamps:打印垃圾回收时间戳。

    -XX:+PrintGCTimeStamps
    
  • -XX:+PrintHeapAtGC:在每次垃圾回收前后打印堆信息。

    -XX:+PrintHeapAtGC
    
  • -Xloggc:将垃圾回收日志输出到指定文件。

    -Xloggc:gc.log
    
  • -XX:+HeapDumpOnOutOfMemoryError:在发生 OutOfMemoryError 时生成堆转储文件。

    -XX:+HeapDumpOnOutOfMemoryError
    
  • -XX:HeapDumpPath:指定堆转储文件的路径。

    -XX:HeapDumpPath=/path/to/dump
    

以下是一个示例,展示了如何配置 JVM 参数来优化性能和调试垃圾回收:

java -Xms512m -Xmx1024m -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=45 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump MyApplication

总结-18

  • 内存设置参数:用于配置堆内存、新生代、永久代/元空间的大小。
  • 垃圾回收参数:用于选择和配置不同的垃圾收集器。
  • 性能调优参数:用于启用性能优化选项和配置垃圾收集器的线程数。
  • 调试和诊断参数:用于打印垃圾回收日志、生成堆转储文件等。

通过合理配置 JVM 参数,可以优化 Java 应用程序的性能和稳定性,并在出现问题时进行有效的诊断和调试。

19.线上服务 CPU 占用过高怎么排查?

当线上服务出现 CPU 占用过高的问题时,可能会影响系统的性能和响应时间。以下是一些常见的排查步骤和方法:

1. 使用操作系统工具查看 CPU 使用情况

首先,可以使用操作系统提供的工具查看系统的整体 CPU 使用情况和各个进程的 CPU 使用情况。

  • Linux
    • top:实时显示系统的 CPU 使用情况和各个进程的资源使用情况。
    • htoptop 的增强版,提供更友好的界面和更多功能。
    • pidstat:显示指定进程的 CPU 使用情况。
    • mpstat:显示每个 CPU 的使用情况。
    top
    htop
    pidstat -p <pid> 1
    mpstat -P ALL 1
    
  • Windows
    • 任务管理器:按 Ctrl + Shift + Esc 打开任务管理器,查看 CPU 使用情况。
    • 资源监视器:在任务管理器中选择“性能”选项卡,然后点击“打开资源监视器”。

2. 使用 jpsjstack 查看 Java 线程的 CPU 使用情况

使用 jps 工具查看正在运行的 Java 进程的 PID,然后使用 jstack 工具生成线程堆栈信息。

jps
jstack <pid> > thread_dump.txt

生成的线程堆栈信息可以帮助确定哪些线程占用了大量的 CPU 资源。

3. 使用 topjstack 结合分析

在 Linux 系统中,可以使用 top 命令查看哪个线程占用了大量的 CPU 资源,然后将线程 ID 转换为十六进制格式,并在 jstack 输出中查找对应的线程堆栈信息。

top -H -p <pid>  # 查看指定 Java 进程的线程 CPU 使用情况

找到占用 CPU 资源较高的线程 ID(tid),将其转换为十六进制格式(printf "%x\n" <tid>),然后在 jstack 输出中查找对应的线程堆栈信息。

4. 使用 jstat 查看垃圾回收情况

使用 jstat 工具查看 JVM 的垃圾回收情况,判断是否是频繁的垃圾回收导致了 CPU 占用过高。

jstat -gc <pid> 1000 10  # 每秒显示一次垃圾回收信息,共显示10次

5. 使用 jmap 查看内存使用情况

使用 jmap 工具查看 JVM 的内存使用情况,判断是否是内存问题导致了 CPU 占用过高。

jmap -heap <pid>

6. 使用 Java Mission ControlVisualVM 进行深入分析

使用 Java Mission Control(JMC)和 VisualVM 等可视化工具进行深入分析,查看 CPU 使用情况、线程活动、垃圾回收等信息。

  • Java Mission Control
    • 启动 JMC,连接到目标 JVM,查看 CPU 使用情况、线程活动、垃圾回收等信息。
  • VisualVM
    • 启动 VisualVM,连接到目标 JVM,查看 CPU 使用情况、线程活动、垃圾回收等信息。

以下是一个示例,展示了如何使用 topjstack 结合分析 Java 线程的 CPU 使用情况:

  1. 使用 top 查看指定 Java 进程的线程 CPU 使用情况:

    top -H -p <pid>
    
  2. 找到占用 CPU 资源较高的线程 ID(tid),将其转换为十六进制格式:

    printf "%x\n" <tid>
    
  3. 使用 jstack 生成线程堆栈信息,并在输出中查找对应的线程堆栈信息:

    jstack <pid> > thread_dump.txt
    

总结-19

  • 使用操作系统工具查看 CPU 使用情况:如 tophtoppidstat、任务管理器等。
  • 使用 jpsjstack 查看 Java 线程的 CPU 使用情况:生成线程堆栈信息,确定占用 CPU 资源的线程。
  • 使用 topjstack 结合分析:查看线程 CPU 使用情况,并在堆栈信息中查找对应的线程。
  • 使用 jstat 查看垃圾回收情况:判断是否是频繁的垃圾回收导致了 CPU 占用过高。
  • 使用 jmap 查看内存使用情况:判断是否是内存问题导致了 CPU 占用过高。
  • 使用 Java Mission Control 和 VisualVM 进行深入分析:查看 CPU 使用情况、线程活动、垃圾回收等信息。

通过这些步骤和工具,可以有效地排查和解决线上服务 CPU 占用过高的问题,确保系统的性能和稳定性。

20.内存飙高问题怎么排查?

内存飚高一般是因为创建了大量的 Java 对象所导致的,如果持续飙高则说明垃圾回收跟不上对象创建的速度,或者内存泄漏导致对象无法回收。

排查的方法主要分为以下几步:

第一,先观察垃圾回收的情况,可以通过 jstat -gc PID 1000 查看 GC 次数和时间。

或者 jmap -histo PID head -20 查看堆内存占用空间最大的前 20 个对象类型。

第二步,通过 jmap 命令 dump 出堆内存信息。

第三步,使用可视化工具分析 dump 文件,比如说 VisualVM,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。

21.频繁 minor gc 怎么办?

频繁的 Minor GC(也称为 Young GC)通常表示新生代中的对象频繁地被垃圾回收,可能是因为新生代空间设置过小,或者是因为程序中存在大量的短生命周期对象(如临时变量、方法调用中创建的对象等)。

可以使用 GC 日志进行分析,查看 GC 的频率和耗时,找到频繁 GC 的原因。

-XX:+PrintGCDetails -Xloggc:gc.log

或者使用监控工具(如 VisualVM、jstat、jconsole 等)查看堆内存的使用情况,特别是新生代(Eden 和 Survivor 区)的使用情况。

如果是因为新生代空间不足,可以通过 -Xmn 增加新生代的大小,减少新生代的填满速度。

java -Xmn256m your-app.jar

如果对象未能在 Survivor 区足够长时间存活,就会被晋升到老年代,可以通过 -XX:SurvivorRatio 参数调整 Eden 和 Survivor 的比例。默认比例是 8:1,表示 8 个空间用于 Eden,1 个空间用于 Survivor 区。

-XX:SurvivorRatio=6

这将减少 Eden 区的大小,增加 Survivor 区的大小,以确保对象在 Survivor 区中存活的时间足够长,避免过早晋升到老年代。

22.频繁 Full GC 怎么办?

Full GC 是指对整个堆内存(包括新生代和老年代)进行垃圾回收操作。Full GC 频繁会导致应用程序的暂停时间增加,从而影响性能。

常见的原因有:

  • 大对象(如大数组、大集合)直接分配到老年代,导致老年代空间快速被占用。
  • 程序中存在内存泄漏,导致老年代的内存不断增加,无法被回收。比如 IO 资源未关闭。
  • 一些长生命周期的对象进入到了老年代,导致老年代空间不足。
  • 不合理的 GC 参数配置也导致 GC 频率过高。比如说新生代的空间设置过小。

该怎么排查 Full GC 频繁问题?

假如是因为大对象直接分配到老年代导致的 Full GC 频繁,可以通过 -XX:PretenureSizeThreshold 参数设置大对象直接进入老年代的阈值。

或者能不能将大对象拆分成小对象,减少大对象的创建。比如说分页。

假如是因为内存泄漏导致的 Full GC 频繁,可以通过分析堆内存 dump 文件找到内存泄漏的对象,再找到内存泄漏的代码位置。

假如是因为长生命周期的对象进入到了老年代,要及时释放资源,比如说 ThreadLocal、数据库连接、IO 资源等。

假如是因为 GC 参数配置不合理导致的 Full GC 频繁,可以通过调整 GC 参数来优化 GC 行为。或者直接更换更适合的 GC 收集器,如 G1、ZGC 等。

23.有没有处理过内存泄漏问题?是如何定位的?

内存泄漏是指程序在运行过程中由于未能正确释放已分配的内存,导致内存无法被重用,从而引发内存耗尽等问题。

常用的可视化监控工具有 JConsole、VisualVM、JProfiler、Eclipse Memory Analyzer (MAT)等。

也可以使用 JDK 自带的 jmap、jstack、jstat 等命令行工具来配合内存泄露问题的排查。

严重的内存泄漏往往伴随频繁的 Full GC,所以排查内存泄漏问题时,可以从 Full GC 入手

第一步,使用 jps -l 查看运行的 Java 进程 ID。

第二步,使用top -p [pid]查看进程使用 CPU 和内存占用情况。

第三步,使用 top -Hp [pid] 查看进程下的所有线程占用 CPU 和内存情况。

第四步,抓取线程栈:jstack -F 29452 > 29452.txt,可以多抓几次做个对比。

29452 为 pid,顺带作为文件名

看看有没有线程死锁、死循环或长时间等待这些问题。

第五步,可以使用jstat -gcutil [pid] 5000 10每隔 5 秒输出 GC 信息,输出 10 次,查看 YGC 和 Full GC 次数。

通常会出现 YGC 不增加或增加缓慢,而 Full GC 增加很快。

或使用 jstat -gccause [pid] 5000 输出 GC 摘要信息。

或使用 jmap -heap [pid] 查看堆的摘要信息,关注老年代内存使用是否达到阀值,若达到阀值就会执行 Full GC。

如果发现 Full GC 次数太多,就很大概率存在内存泄漏了。

第六步,生成 dump 文件,然后借助可视化工具分析哪个对象非常多,基本就能定位到问题根源了。

执行命令 jmap -dump:format=b,file=heap.hprof 10025 会输出进程 10025 的堆快照信息,保存到文件 heap.hprof 中。

第七步,可以使用图形化工具分析,如 JDK 自带的 VisualVM,从菜单 > 文件 > 装入 dump 文件。

然后在结果观察内存占用最多的对象,找到内存泄漏的源头。

24.有没有处理过 OOM 问题?

OOM,也就是内存溢出,Out of Memory,是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。

当发生 OOM 时,可以导出堆转储(Heap Dump)文件进行分析。如果 JVM 还在运行,可以使用 jmap 命令手动生成 Heap Dump 文件:

jmap -dump:format=b,file=heap.hprof <pid>

生成 Heap Dump 文件后,可以使用 MAT、JProfiler 等工具进行分析,查看内存中的对象占用情况,找到内存泄漏的原因。

如果生产环境的内存还有很多空余,可以适当增大堆内存大小,例如 -Xmx4g 参数。

或者检查代码中是否存在内存泄漏,如未关闭的资源、长生命周期的对象等。

之后,在本地进行压力测试,模拟高负载情况下的内存表现,确保修改有效,且没有引入新的问题。

25.了解类的加载机制吗?

JVM 的操作对象是 Class 文件,JVM 把 Class 文件中描述类的数据结构加载到内存中,并对数据进行校验、解析和初始化,最终形成可以被 JVM 直接使用的类型,这个过程被称为类加载机制。

其中最重要的三个概念就是:类加载器、类加载过程和类加载器的双亲委派模型。

  • 类加载器:负责加载类文件,将类文件加载到内存中,生成 Class 对象。
  • 类加载过程:加载、验证、准备、解析和初始化。
  • 双亲委派模型:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,依次递归,直到最顶层的类加载器,如果父类加载器无法完成加载请求,子类加载器才会尝试自己去加载。

26.类加载器有哪些?

Java 中的类加载器(ClassLoader)负责将类文件加载到 JVM 中,并生成对应的 Class 对象。类加载器主要分为以下几种:

1. 启动类加载器(Bootstrap ClassLoader)

  • 描述:启动类加载器是 JVM 自带的类加载器,用于加载核心类库。
  • 加载范围:负责加载 JAVA_HOME/lib 目录下的核心类库,如 rt.jar
  • 实现:启动类加载器是用本地代码实现的,并不是一个 Java 类。

2. 扩展类加载器(Extension ClassLoader)

  • 描述:扩展类加载器是由 sun.misc.Launcher$ExtClassLoader 实现的,用于加载扩展类库。
  • 加载范围:负责加载 JAVA_HOME/lib/ext 目录下的类库,或者通过 java.ext.dirs 系统属性指定的类库。
  • 实现:扩展类加载器是一个 Java 类,继承自 ClassLoader

3. 应用程序类加载器(Application ClassLoader)

  • 描述:应用程序类加载器是由 sun.misc.Launcher$AppClassLoader 实现的,用于加载应用程序的类路径(classpath)下的类。
  • 加载范围:负责加载用户类路径(classpath)下的类库。
  • 实现:应用程序类加载器是一个 Java 类,继承自 ClassLoader

4. 自定义类加载器(Custom ClassLoader)

  • 描述:用户可以通过继承 ClassLoader 类来创建自定义类加载器,以实现特殊的类加载需求。
  • 加载范围:由用户定义,可以加载特定路径或特定来源的类库。这种类加载器通常用于加载网络上的类、执行热部署(动态加载和替换应用程序的组件)或为了安全目的自定义类的加载方式。
  • 实现:自定义类加载器需要继承 ClassLoader 类,并重写 findClass 方法。

总结-26

  • 启动类加载器(Bootstrap ClassLoader):加载核心类库,如 rt.jar
  • 扩展类加载器(Extension ClassLoader):加载扩展类库,如 JAVA_HOME/lib/ext 目录下的类库。
  • 应用程序类加载器(Application ClassLoader):加载用户类路径(classpath)下的类库。
  • 自定义类加载器(Custom ClassLoader):用户可以通过继承 ClassLoader 类来创建自定义类加载器,以实现特殊的类加载需求。

27.能说一下类的生命周期吗?

Java 类的生命周期包括以下几个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。每个阶段都有特定的任务和作用。

1. 加载(Loading)

加载是指将类的字节码从不同的来源(如文件系统、网络等)加载到内存中,并创建一个 Class 对象来表示这个类。

  • 类加载器(ClassLoader):负责加载类的字节码。Java 提供了几种默认的类加载器,如启动类加载器、扩展类加载器和应用程序类加载器。

2. 验证(Verification)

验证是指确保类的字节码符合 JVM 规范,保证代码的安全性和正确性。

  • 字节码验证:检查字节码的格式和结构是否正确。
  • 符号引用验证:检查符号引用是否能被正确解析。
  • 数据类型验证:检查数据类型是否匹配。

3. 准备(Preparation)

准备是指为类的静态变量分配内存,并将其初始化为默认值。

  • 静态变量初始化:将静态变量初始化为默认值(如 0null 等)。

4. 解析(Resolution)

解析是指将类的符号引用转换为直接引用。

  • 符号引用:在类的字节码中使用的符号名称。
  • 直接引用:在内存中指向实际对象的引用。

5. 初始化(Initialization)

初始化是指对类的静态变量赋予正确的初始值,并执行类的静态代码块。

  • 静态变量赋值:将静态变量赋予正确的初始值。
  • 静态代码块执行:执行类中的静态代码块。

6. 使用(Using)

使用是指类在程序中被实际使用的阶段。

  • 实例化对象:创建类的实例对象。
  • 调用方法:调用类的方法。

7. 卸载(Unloading)

卸载是指类在 JVM 中被卸载,释放其占用的内存。

  • 类卸载条件:类的所有实例都被回收,且没有任何对该类的引用。
  • 类卸载过程:JVM 的垃圾回收器负责类的卸载。

以下是一个简单的类生命周期示例:

public class Test {
    static {
        System.out.println("Static block executed");
    }

    public static int value = 42;

    public static void main(String[] args) {
        System.out.println("Value: " + Test.value);
    }
}

在这个示例中,类的生命周期如下:

  1. 加载:ClassLoader加载 Test 类的字节码,并创建一个 Class 对象。
  2. 验证:验证 Test 类的字节码是否符合 JVM 规范。
  3. 准备:为 Test 类的静态变量 value 分配内存,并将其初始化为默认值 0
  4. 解析:将 Test 类的符号引用转换为直接引用。
  5. 初始化
    • 静态变量赋值:将静态变量 value 赋值为 42
    • 静态代码块执行:执行静态代码块,输出 “Static block executed”。
  6. 使用:在 main 方法中使用 Test 类,输出 “Value: 42”。
  7. 卸载:当 Test 类的所有实例都被回收,且没有任何对该类的引用时,JVM 的垃圾回收器会卸载 Test 类。

总结-27

  • 加载(Loading):将类的字节码加载到内存中,并创建 Class 对象。
  • 验证(Verification):确保类的字节码符合 JVM 规范,保证代码的安全性和正确性。
  • 准备(Preparation):为类的静态变量分配内存,并将其初始化为默认值。
  • 解析(Resolution):将类的符号引用转换为直接引用。
  • 初始化(Initialization):对类的静态变量赋予正确的初始值,并执行静态代码块。
  • 使用(Using):类在程序中被实际使用的阶段。
  • 卸载(Unloading):类在 JVM 中被卸载,释放其占用的内存。

28.什么是双亲委派模型?

双亲委派模型(Parent Delegation Model)是 Java 类加载机制中的一个重要概念。这种模型指的是一个类加载器在尝试加载某个类时,首先会将加载任务委托给其父类加载器去完成。

只有当父类加载器无法完成这个加载请求(即它找不到指定的类)时,子类加载器才会尝试自己去加载这个类。

  • 当一个类加载器需要加载某个类时,它首先会请求其父类加载器加载这个类。
  • 这个过程会一直向上递归,也就是说,从子加载器到父加载器,再到更上层的加载器,一直到最顶层的启动类加载器(Bootstrap ClassLoader)。
  • 启动类加载器会尝试加载这个类。如果它能够加载这个类,就直接返回;如果它不能加载这个类(因为这个类不在它的搜索范围内),就会将加载任务返回给委托它的子加载器。
  • 子加载器接着尝试加载这个类。如果子加载器也无法加载这个类,它就会继续向下传递这个加载任务,依此类推。
  • 这个过程会继续,直到某个加载器能够加载这个类,或者所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。

29.为什么要用双亲委派模型?

可以为 Java 应用程序的运行提供一致性和安全性的保障。

①、保证 Java 核心类库的类型安全

如果自定义类加载器优先加载一个类,比如说自定义的 Object,那在 Java 运行时环境中就存在多个版本的 java.lang.Object,双亲委派模型确保了 Java 核心类库的类加载工作由启动类加载器统一完成,从而保证了 Java 应用程序都是使用的同一份核心类库。

②、避免类的重复加载

在双亲委派模型中,类加载器会先委托给父加载器尝试加载类,这样同一个类不会被加载多次。如果没有这种模型,可能会导致同一个类被不同的类加载器重复加载到内存中,造成浪费和冲突。

30.如何破坏双亲委派机制?

在某些情况下,可能需要破坏双亲委派机制来实现特定的功能,例如加载不同版本的类或实现插件系统。破坏双亲委派机制的常见方法是自定义类加载器,并重写 loadClass 方法,使其不再委托给父类加载器。

示例:自定义类加载器

以下是一个自定义类加载器的示例,通过重写 loadClass 方法来破坏双亲委派机制:

public class CustomClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 如果类名以 "java." 开头,仍然使用父类加载器加载
        if (name.startsWith("java.")) {
            return super.loadClass(name);
        }

        // 尝试查找已经加载的类
        Class<?> loadedClass = findLoadedClass(name);
        if (loadedClass != null) {
            return loadedClass;
        }

        try {
            // 自定义加载类的逻辑,例如从文件系统或网络加载类的字节码
            byte[] classData = loadClassData(name);
            if (classData != null) {
                return defineClass(name, classData, 0, classData.length);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 如果自定义加载失败,仍然使用父类加载器加载
        return super.loadClass(name);
    }

    private byte[] loadClassData(String name) {
        // 自定义类加载逻辑,例如从文件系统或网络加载类的字节码
        // 这里仅作为示例,实际实现需要根据需求编写
        return null;
    }

    public static void main(String[] args) throws ClassNotFoundException {
        CustomClassLoader customClassLoader = new CustomClassLoader();
        Class<?> clazz = customClassLoader.loadClass("Test");
        System.out.println("Class loaded: " + clazz.getName());
    }
}

解释

  1. 重写 loadClass 方法:在自定义类加载器中重写 loadClass 方法,控制类加载的逻辑。
  2. 条件判断:如果类名以 “java.” 开头,仍然使用父类加载器加载,以确保核心类库的安全性。
  3. 查找已加载的类:首先尝试查找已经加载的类,避免重复加载。
  4. 自定义加载逻辑:实现自定义的类加载逻辑,例如从文件系统或网络加载类的字节码。
  5. 定义类:使用 defineClass 方法将字节码转换为 Class 对象。
  6. 回退机制:如果自定义加载失败,仍然使用父类加载器加载。

总结-30

通过自定义类加载器并重写 loadClass 方法,可以破坏双亲委派机制,实现特定的类加载需求。然而,破坏双亲委派机制可能会带来类型安全性和类冲突等问题,因此在实际应用中应谨慎使用。

31.Tomcat 的类加载机制了解吗?

Tomcat 是一个流行的 Java Servlet 容器,它的类加载机制在标准的 Java 类加载机制基础上进行了扩展,以支持多应用程序的隔离和热部署。Tomcat 的类加载机制主要包括以下几个类加载器:

1. 启动类加载器(Bootstrap ClassLoader)

  • 描述:负责加载 JDK 核心类库(如 rt.jar)。
  • 加载范围$JAVA_HOME/lib 目录下的类库。

2. 系统类加载器(System ClassLoader)

  • 描述:负责加载 Tomcat 自身的类库。
  • 加载范围$CATALINA_HOME/lib 目录下的类库。

3. 公共类加载器(Common ClassLoader)

  • 描述:负责加载所有 Web 应用程序共享的类库。
  • 加载范围$CATALINA_HOME/lib 目录下的类库。

4. Web 应用程序类加载器(WebappClassLoader)

  • 描述:负责加载特定 Web 应用程序的类库。
  • 加载范围WEB-INF/classesWEB-INF/lib 目录下的类库。

5. 自定义类加载器(Custom ClassLoader)

  • 描述:用户可以通过配置自定义类加载器,以实现特定的类加载需求。

类加载器的层次结构

Tomcat 的类加载器层次结构如下:

  1. 启动类加载器(Bootstrap ClassLoader)
  2. 系统类加载器(System ClassLoader)
  3. 公共类加载器(Common ClassLoader)
  4. Web 应用程序类加载器(WebappClassLoader)

类加载顺序

  1. 启动类加载器:首先由启动类加载器加载 JDK 核心类库。
  2. 系统类加载器:然后由系统类加载器加载 Tomcat 自身的类库。
  3. 公共类加载器:接着由公共类加载器加载所有 Web 应用程序共享的类库。
  4. Web 应用程序类加载器:最后由 Web 应用程序类加载器加载特定 Web 应用程序的类库。

Tomcat 实际上也是破坏了双亲委派模型的。

Tomact 是 web 容器,可能需要部署多个应用程序。不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。如多个应用都要依赖 hollis.jar,但是 A 应用需要依赖 1.0.0 版本,但是 B 应用需要依赖 1.0.1 版本。这两个版本中都有一个类是 com.hollis.Test.class。如果采用默认的双亲委派类加载机制,那么无法加载多个相同的类。

所以,Tomcat 破坏了双亲委派原则,提供隔离的机制,为每个 web 容器单独提供一个 WebAppClassLoader 加载器。每一个 WebAppClassLoader 负责加载本身的目录下的 class 文件,加载不到时再交 CommonClassLoader 加载,这和双亲委派刚好相反。

32.你觉得应该怎么实现一个热部署功能?

实现热部署功能通常涉及以下几个步骤:

  1. 监控文件变化:监控应用程序的文件系统,检测到文件变化时触发相应的操作。
  2. 卸载旧类:卸载旧的类和资源,释放内存。
  3. 加载新类:加载新的类和资源,替换旧的类。
  4. 重启应用:在不重启整个服务器的情况下,重启应用程序。

1. 监控文件变化

可以使用 Java 的 WatchService API 来监控文件系统的变化。

import java.nio.file.*;

public class FileWatcher implements Runnable {
    private final Path path;

    public FileWatcher(Path path) {
        this.path = path;
    }

    @Override
    public void run() {
        try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
            path.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);

            while (true) {
                WatchKey key = watchService.take();
                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) {
                        System.out.println("File modified: " + event.context());
                        // 触发热部署操作
                        reloadApplication();
                    }
                }
                key.reset();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void reloadApplication() {
        // 实现热部署逻辑
    }

    public static void main(String[] args) {
        Path path = Paths.get("/path/to/your/application");
        FileWatcher fileWatcher = new FileWatcher(path);
        new Thread(fileWatcher).start();
    }
}

2. 卸载旧类

使用自定义类加载器来加载和卸载类。可以通过将类加载器设置为 null 并调用垃圾回收器来卸载旧的类。

public class CustomClassLoader extends ClassLoader {
    // 自定义类加载逻辑
}

public class ApplicationReloader {
    private CustomClassLoader classLoader;

    public void reload() {
        // 卸载旧类
        if (classLoader != null) {
            classLoader = null;
            System.gc();
        }

        // 加载新类
        classLoader = new CustomClassLoader();
        // 重新初始化应用程序
        initializeApplication();
    }

    private void initializeApplication() {
        // 初始化应用程序逻辑
    }
}

3. 加载新类

使用自定义类加载器加载新的类和资源。

public class CustomClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 自定义加载类的逻辑
        return super.loadClass(name);
    }
}

4. 重启应用

在不重启整个服务器的情况下,重启应用程序。

public class ApplicationReloader {
    private CustomClassLoader classLoader;

    public void reload() {
        // 卸载旧类
        if (classLoader != null) {
            classLoader = null;
            System.gc();
        }

        // 加载新类
        classLoader = new CustomClassLoader();
        // 重新初始化应用程序
        initializeApplication();
    }

    private void initializeApplication() {
        // 初始化应用程序逻辑
    }
}

总结-32

  1. 监控文件变化:使用 WatchService API 监控文件系统的变化。
  2. 卸载旧类:使用自定义类加载器并通过垃圾回收器卸载旧的类。
  3. 加载新类:使用自定义类加载器加载新的类和资源。
  4. 重启应用:在不重启整个服务器的情况下,重启应用程序。

通过这些步骤,可以实现一个基本的热部署功能。需要注意的是,热部署可能会带来一些复杂性和潜在的问题,如类冲突和内存泄漏等,因此在实际应用中应谨慎使用。

33.解释执行和编译执行的区别?

解释执行和编译执行是两种不同的程序执行方式,它们在执行流程、性能和适用场景等方面存在显著差异。

解释执行

解释执行是指程序在运行时由解释器逐行读取源代码,并将其转换为机器码执行。

  • 执行流程
    1. 逐行读取:解释器逐行读取源代码。
    2. 逐行翻译:每读取一行代码,立即将其翻译为机器码并执行。
    3. 即时执行:翻译后的机器码立即执行,不生成独立的可执行文件。
  • 优点
    • 跨平台:源代码可以在不同平台上运行,只需提供相应平台的解释器。
    • 调试方便:可以逐行执行代码,便于调试和测试。
  • 缺点
    • 性能较低:每次执行都需要重新翻译代码,执行速度较慢。
    • 依赖解释器:需要解释器的支持,不能生成独立的可执行文件。
  • 适用场景
    • 脚本语言:如 Python、JavaScript、Ruby 等。
    • 开发和调试:需要频繁修改和测试代码的场景。

编译执行

编译执行是指程序在运行前由编译器将源代码一次性翻译为机器码,生成独立的可执行文件,然后由操作系统加载执行。

  • 执行流程
    1. 编译:编译器将源代码一次性翻译为机器码,生成可执行文件。
    2. 链接:将生成的机器码与库文件链接,生成最终的可执行文件。
    3. 执行:操作系统加载并执行生成的可执行文件。
  • 优点
    • 性能较高:编译后的机器码直接执行,执行速度较快。
    • 独立性强:生成独立的可执行文件,不依赖编译器或解释器。
  • 缺点
    • 跨平台性差:编译后的可执行文件只能在特定平台上运行。
    • 调试不便:需要重新编译整个程序,调试和测试较为繁琐。
  • 适用场景
    • 系统编程:如 C、C++ 等语言,用于开发操作系统、驱动程序等。
    • 性能要求高:需要高性能执行的场景,如游戏开发、科学计算等。

Java 的混合模式

Java 采用了一种混合模式,结合了解释执行和编译执行的优点。

  • 解释执行:Java 源代码首先被编译为字节码(.class 文件),由 JVM 的解释器逐行解释执行。
  • 即时编译(JIT):JVM 在运行时将热点代码(执行频繁的代码)编译为机器码,提高执行效率。

总结-33

  • 解释执行
    • 逐行读取和翻译:逐行读取源代码并翻译为机器码执行。
    • 优点:跨平台、调试方便。
    • 缺点:性能较低、依赖解释器。
    • 适用场景:脚本语言、开发和调试。
  • 编译执行
    • 一次性翻译:将源代码一次性翻译为机器码,生成可执行文件。
    • 优点:性能较高、独立性强。
    • 缺点:跨平台性差、调试不便。
    • 适用场景:系统编程、性能要求高的场景。
  • Java 的混合模式:结合了解释执行和编译执行的优点,通过解释执行和即时编译(JIT)提高执行效率。

通过理解解释执行和编译执行的区别,可以更好地选择适合的编程语言和执行方式,优化程序性能和开发效率。