- JVM
- 1.什么是JVM?
- 2.JVM的组织架构?
- 3.JVM内存结构
- 4.说一下 JDK1.6、1.7、1.8 内存区域的变化?
- 5.JDK 1.8 中的元空间(Metaspace)相对于永久代(PermGen)的优势?
- 6.对象的创建销毁的过程?
- 7.JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?
- 8.对象的内存布局,对象的底层数据结构?
- 9.对象如何访问定位?
- 10.说说内存溢出(OOM)和内存泄漏(Leak Memory)的原因?
- 11.Java 堆的内存分区了解吗?
- 12.对象什么时候会进入老年代?
- 13.什么是 Stop The World ? 什么是 OopMap ?什么是安全点?
- 14.对象一定分配在堆中吗?有没有了解逃逸分析技术?
- 15.JVM垃圾回收机制?
- 16.有了 CMS,为什么还要引入 G1?
- 16.有哪些常用的命令行性能监控和故障处理工具?
- 17.了解哪些可视化的性能监控和故障处理工具?
- 18.JVM 的常见参数配置知道哪些?
- 19.线上服务 CPU 占用过高怎么排查?
- 20.内存飙高问题怎么排查?
- 21.频繁 minor gc 怎么办?
- 22.频繁 Full GC 怎么办?
- 23.有没有处理过内存泄漏问题?是如何定位的?
- 24.有没有处理过 OOM 问题?
- 25.了解类的加载机制吗?
- 26.类加载器有哪些?
- 27.能说一下类的生命周期吗?
- 28.什么是双亲委派模型?
- 29.为什么要用双亲委派模型?
- 30.如何破坏双亲委派机制?
- 31.Tomcat 的类加载机制了解吗?
- 32.你觉得应该怎么实现一个热部署功能?
- 33.解释执行和编译执行的区别?
JVM
1.什么是JVM?
JVM,也就是 Java 虚拟机,它是 Java 实现跨平台的基石。
Java 程序运行的时候,编译器会将 Java 源代码(.java)编译成平台无关的 Java 字节码文件(.class),接下来对应平台的 JVM 会对字节码文件进行解释,翻译成对应平台的机器指令并运行。
任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。
2.JVM的组织架构?
JVM(Java 虚拟机)的组织架构可以分为以下几个主要部分:
- 类加载器子系统(Class Loader Subsystem)
- 运行时数据区(Runtime Data Area)
- 执行引擎(Execution Engine)
- 本地方法接口(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 程序在运行时使用的内存区域。主要包括以下几个部分:
- 方法区(Method Area)
- 堆(Heap)
- Java 栈(Java Stack)
- 本地方法栈(Native Method Stack)
- 程序计数器(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):存储当前线程执行的字节码指令的地址。
主要变化
- 永久代(PermGen)到元空间(Metaspace):
- JDK 1.6 和 JDK 1.7:方法区被实现为永久代(PermGen),用于存储类信息、常量、静态变量和即时编译器编译后的代码。JDK 1.7 中,字符串常量池从永久代移到了堆中。
- JDK 1.8:永久代被移除,取而代之的是元空间(Metaspace)。元空间使用本地内存(Native Memory),其大小可以动态调整,减少了内存溢出的风险。
- 字符串常量池:
- 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 对象的底层数据结构主要包括以下几个部分:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(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 使用两种主要的方式来访问和定位对象:
- 句柄访问(Handle Access)
- 直接指针访问(Direct Pointer Access)
1. 句柄访问(Handle Access)
在句柄访问方式中,JVM 会为每个对象分配一个句柄。句柄是一个固定大小的内存块,包含了对象实例数据和对象类型数据的指针。对象引用指向句柄,而不是直接指向对象实例数据。
- 句柄结构:
- 句柄指针:对象引用指向句柄。
- 句柄内容:句柄包含两个指针,一个指向对象实例数据,一个指向对象类型数据(类元数据)。
- 优点:
- 对象在内存中的移动不会影响对象引用,因为引用指向的是句柄,句柄中的指针可以更新。
- 适用于需要频繁移动对象的垃圾回收算法。
- 缺点:
- 访问对象时需要两次内存间接访问(一次访问句柄,一次访问对象实例数据),性能略低。
2. 直接指针访问(Direct Pointer Access)
在直接指针访问方式中,对象引用直接指向对象实例数据。对象实例数据包含对象头和实例字段。
- 直接指针结构:
- 对象引用:直接指向对象实例数据。
- 对象实例数据:包含对象头和实例字段。
- 优点:
- 访问对象时只需要一次内存间接访问,性能较高。
- 适用于对象较少移动的垃圾回收算法。
- 缺点:
- 对象在内存中的移动需要更新所有引用该对象的指针,增加了垃圾回收的复杂性。
HotSpot 虚拟机主要使用直接指针来进行对象访问。
10.说说内存溢出(OOM)和内存泄漏(Leak Memory)的原因?
内存溢出是什么
在 Java 中,OOM(OutOfMemoryError)错误表示 JVM 无法再为应用程序分配所需的内存。OOM 错误可能发生在以下几个内存区域:
- 堆内存(Heap Memory)
- 方法区(Method Area)/元空间(Metaspace)
- 栈内存(Stack Memory)
- 直接内存(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 堆通常被划分为几个不同的区域,每个区域有不同的用途和垃圾回收策略。主要的内存分区包括:
- 新生代(Young Generation)
- 老年代(Old Generation)
- 永久代(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 可以判断对象是否会逃逸出方法或线程的作用域,从而决定对象的分配位置。
逃逸分析的类型
- 方法逃逸(Method Escape):
- 如果对象在方法外部被引用,则认为对象发生了方法逃逸。
- 例如,将对象作为参数传递给其他方法,或者将对象赋值给类的成员变量。
- 线程逃逸(Thread Escape):
- 如果对象在方法外部被其他线程引用,则认为对象发生了线程逃逸。
- 例如,将对象作为参数传递给其他线程,或者将对象存储在共享数据结构中。
逃逸分析的优化
- 栈上分配(Stack Allocation):
- 如果对象没有发生逃逸,JVM 可以将对象分配在栈上,而不是堆中。这样,当方法执行完毕时,对象会自动销毁,无需垃圾回收。
- 优点:减少堆内存的使用,降低垃圾回收的频率和开销。
- 标量替换(Scalar Replacement):
- 如果对象没有发生逃逸,且对象的字段可以被拆分为标量变量,JVM 可以将对象的字段直接分配在栈上,而不是创建对象。
- 优点:进一步减少内存分配和垃圾回收的开销。
- 同步消除(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 中,判断对象是否存活是垃圾回收的核心任务。主要有两种方法来判断对象是否存活:
- 引用计数法(Reference Counting)
- 可达性分析法(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 内存管理的重要组成部分,用于自动回收不再使用的对象所占用的内存空间。常见的垃圾收集算法包括:
- 标记-清除算法(Mark-Sweep)
- 复制算法(Copying)
- 标记-整理算法(Mark-Compact)
- 分代收集算法(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)可以根据回收的内存区域和回收策略分为不同的类型。主要的垃圾回收类型包括:
- Minor GC / Young GC
- Major GC / Old GC
- Mixed GC
- 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 使用情况:
-
使用
top
查看指定 Java 进程的线程 CPU 使用情况:top -H -p <pid>
-
找到占用 CPU 资源较高的线程 ID(
tid
),将其转换为十六进制格式:printf "%x\n" <tid>
-
使用
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);
}
}
在这个示例中,类的生命周期如下:
- 加载:ClassLoader加载
Test
类的字节码,并创建一个 Class 对象。 - 验证:验证
Test
类的字节码是否符合 JVM 规范。 - 准备:为
Test
类的静态变量value
分配内存,并将其初始化为默认值0
。 - 解析:将
Test
类的符号引用转换为直接引用。 - 初始化:
- 静态变量赋值:将静态变量
value
赋值为42
。 - 静态代码块执行:执行静态代码块,输出 “Static block executed”。
- 静态变量赋值:将静态变量
- 使用:在
main
方法中使用Test
类,输出 “Value: 42”。 - 卸载:当
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());
}
}
解释
- 重写
loadClass
方法:在自定义类加载器中重写loadClass
方法,控制类加载的逻辑。 - 条件判断:如果类名以 “java.” 开头,仍然使用父类加载器加载,以确保核心类库的安全性。
- 查找已加载的类:首先尝试查找已经加载的类,避免重复加载。
- 自定义加载逻辑:实现自定义的类加载逻辑,例如从文件系统或网络加载类的字节码。
- 定义类:使用
defineClass
方法将字节码转换为 Class 对象。 - 回退机制:如果自定义加载失败,仍然使用父类加载器加载。
总结-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 的类加载器层次结构如下:
- 启动类加载器(Bootstrap ClassLoader)
- 系统类加载器(System ClassLoader)
- 公共类加载器(Common ClassLoader)
- Web 应用程序类加载器(WebappClassLoader)
类加载顺序
- 启动类加载器:首先由启动类加载器加载 JDK 核心类库。
- 系统类加载器:然后由系统类加载器加载 Tomcat 自身的类库。
- 公共类加载器:接着由公共类加载器加载所有 Web 应用程序共享的类库。
- 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. 监控文件变化
可以使用 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
- 监控文件变化:使用
WatchService
API 监控文件系统的变化。 - 卸载旧类:使用自定义类加载器并通过垃圾回收器卸载旧的类。
- 加载新类:使用自定义类加载器加载新的类和资源。
- 重启应用:在不重启整个服务器的情况下,重启应用程序。
通过这些步骤,可以实现一个基本的热部署功能。需要注意的是,热部署可能会带来一些复杂性和潜在的问题,如类冲突和内存泄漏等,因此在实际应用中应谨慎使用。
33.解释执行和编译执行的区别?
解释执行和编译执行是两种不同的程序执行方式,它们在执行流程、性能和适用场景等方面存在显著差异。
解释执行
解释执行是指程序在运行时由解释器逐行读取源代码,并将其转换为机器码执行。
- 执行流程:
- 逐行读取:解释器逐行读取源代码。
- 逐行翻译:每读取一行代码,立即将其翻译为机器码并执行。
- 即时执行:翻译后的机器码立即执行,不生成独立的可执行文件。
- 优点:
- 跨平台:源代码可以在不同平台上运行,只需提供相应平台的解释器。
- 调试方便:可以逐行执行代码,便于调试和测试。
- 缺点:
- 性能较低:每次执行都需要重新翻译代码,执行速度较慢。
- 依赖解释器:需要解释器的支持,不能生成独立的可执行文件。
- 适用场景:
- 脚本语言:如 Python、JavaScript、Ruby 等。
- 开发和调试:需要频繁修改和测试代码的场景。
编译执行
编译执行是指程序在运行前由编译器将源代码一次性翻译为机器码,生成独立的可执行文件,然后由操作系统加载执行。
- 执行流程:
- 编译:编译器将源代码一次性翻译为机器码,生成可执行文件。
- 链接:将生成的机器码与库文件链接,生成最终的可执行文件。
- 执行:操作系统加载并执行生成的可执行文件。
- 优点:
- 性能较高:编译后的机器码直接执行,执行速度较快。
- 独立性强:生成独立的可执行文件,不依赖编译器或解释器。
- 缺点:
- 跨平台性差:编译后的可执行文件只能在特定平台上运行。
- 调试不便:需要重新编译整个程序,调试和测试较为繁琐。
- 适用场景:
- 系统编程:如 C、C++ 等语言,用于开发操作系统、驱动程序等。
- 性能要求高:需要高性能执行的场景,如游戏开发、科学计算等。
Java 的混合模式
Java 采用了一种混合模式,结合了解释执行和编译执行的优点。
- 解释执行:Java 源代码首先被编译为字节码(
.class
文件),由 JVM 的解释器逐行解释执行。 - 即时编译(JIT):JVM 在运行时将热点代码(执行频繁的代码)编译为机器码,提高执行效率。
总结-33
- 解释执行:
- 逐行读取和翻译:逐行读取源代码并翻译为机器码执行。
- 优点:跨平台、调试方便。
- 缺点:性能较低、依赖解释器。
- 适用场景:脚本语言、开发和调试。
- 编译执行:
- 一次性翻译:将源代码一次性翻译为机器码,生成可执行文件。
- 优点:性能较高、独立性强。
- 缺点:跨平台性差、调试不便。
- 适用场景:系统编程、性能要求高的场景。
- Java 的混合模式:结合了解释执行和编译执行的优点,通过解释执行和即时编译(JIT)提高执行效率。
通过理解解释执行和编译执行的区别,可以更好地选择适合的编程语言和执行方式,优化程序性能和开发效率。