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)

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

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

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,如果 allocation1 和 allocation2 存活且 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(); // 触发垃圾回收
    }
}

在上述代码中,staticField 和 instanceField 作为 GC Roots,obj1 和 obj2 的可达性由 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 使用情况和各个进程的资源使用情况。
    • htop:top 的增强版,提供更友好的界面和更多功能。
    • pidstat:显示指定进程的 CPU 使用情况。
    • mpstat:显示每个 CPU 的使用情况。
    top
    htop
    pidstat -p <pid> 1
    mpstat -P ALL 1
    
  • Windows:
    • 任务管理器:按 Ctrl + Shift + Esc 打开任务管理器,查看 CPU 使用情况。
    • 资源监视器:在任务管理器中选择“性能”选项卡,然后点击“打开资源监视器”。

2. 使用 jps 和 jstack 查看 Java 线程的 CPU 使用情况

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

jps
jstack <pid> > thread_dump.txt

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

3. 使用 top 和 jstack 结合分析

在 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 Control 和 VisualVM 进行深入分析

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

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

以下是一个示例,展示了如何使用 top 和 jstack 结合分析 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 使用情况:如 top、htop、pidstat、任务管理器等。
  • 使用 jps 和 jstack 查看 Java 线程的 CPU 使用情况:生成线程堆栈信息,确定占用 CPU 资源的线程。
  • 使用 top 和 jstack 结合分析:查看线程 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)

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

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

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/classes 和 WEB-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)提高执行效率。

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