6 - JVM 篇
6.1 JVM 组成
6.1.1 JVM 组成 / Java 架构的核心组件?
- 类加载器: 负责加载类的字节码,分为启动类加载器、扩展类加载器和应用类加载器。
- 内存模型: 管理 JVM 内存的分配和回收。
- 栈: 每个线程有独立的栈,存储局部变量和方法调用信息。(非静态变量)
- 堆: 存储所有的对象实例,GC 主要在此区域进行。(非静态变量)
- 方法区: 存储类信息、常量、静态变量等数据。(静态变量)
- 程序计数器: PC 是线程私有的,用于记录正在执行的字节码指令的地址。
- 本地方法栈: 用于存储本地方法的信息。
- 执行引擎: 负责执行字节码,包括解释执行和即时编译 (JIT)。
- 垃圾回收器: 自动管理内存,回收不再使用的对象。
6.1.2 介绍一下堆?
- 堆主要用来保存对象实例,数组等,内存不够则抛出
OutOfMemoryError
异常。- 组成:年轻代 + 老年代
- 年轻代:Eden区、两个Survivor区。如果对象在Survivor区中经历了多次GC仍然存活,会被晋升到老年代。
- 老年代:用于存放长期存活的对象。这些对象在年轻代中经历了多次垃圾收集后仍然存在,被认为生命周期较长。
- jdk1.7 和 1.8 的区别:
- 1.7 中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
- 1.8 移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
6.1.3 什么是虚拟机栈?
- 虚拟机栈是JVM运行时数据区的一部分,它描述的是JVM栈的内存模型。
- 每个线程 在创建时都会创建一个虚拟机栈,其内部包含了多个 栈帧(Stack Frame),每个栈帧对应一个方法的调用。
- 栈帧中存储了局部变量表、操作数栈、动态链接信息和方法返回地址等。
- 局部变量表:存储方法的参数和局部变量。
- 操作数栈:用于存储操作数,支持方法中字节码指令的操作。
- 动态链接:用于确定方法调用时的动态连接,即方法的具体实现。
- 方法返回地址:存储方法执行完毕后的返回地址。
6.1.4 垃圾回收是否涉及栈内存?
垃圾回收(Garbage Collection, GC)主要涉及的是堆(Heap)内存,而不是栈(Stack)内存。
栈内存主要用于存储方法调用的信息,这些数据在方法执行完毕后会 自然释放,因此不需要垃圾回收。
垃圾回收主要处理的是堆内存中的不再被引用的对象,以释放内存空间。
6.1.5 栈内存分配越大越好吗?
栈内存分配并不是越大越好。虽然增加栈内存可以减少线程因栈溢出而崩溃的风险,
但是会占用更多的物理内存,导致内存资源的浪费,并且增加 线程创建的开销。
6.1.6 方法内的局部变量是否线程安全?
方法内的局部变量是线程安全的。每个线程都有自己的虚拟机栈,因此局部变量是线程私有的,不同线程之间不会共享局部变量。
但是,如果局部变量引用了 共享对象,那么对这个共享对象的访问就需要考虑线程安全。
6.1.7 内存溢出(栈溢出、堆溢出)、内存泄漏?
- 内存溢出(Out of Memory):内存溢出是一次性的内存分配失败。例:对象的创建太多、递归的调用太深。
- 指程序在申请内存时,没有足够的空间供其使用,抛出内存溢出异常。最常见的是
java.lang.OutOfMemoryError
。- 栈溢出(调用堆栈)和堆溢出(调用堆)都是内存溢出的特殊情况。栈溢出抛出
java.lang.StackOverflowError
。- 内存泄漏(Memory Leak):内存泄漏是长期累积的内存分配问题。例:不再使用的对象没有被垃圾回收。
- 指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏可能危害不大,但堆积的后果就是内存溢出。
- 内存泄漏通常是由于疏忽或错误导致程序未能释放不再使用的内存。
6.1.8 JVM 堆和栈的区别?
- 栈内存用来存储 局部变量 和 方法调用,而堆内存用来存储 对象 和 数组。
- 栈内存是线程 私有的,而堆内存是线程 共有的。
- 栈不会垃圾回收,而堆会 垃圾回收。 栈溢出
StackOverFlowError
,堆溢出OutOfMemoryError
。- 栈的内存分配和回收速度比堆快得多。栈使用的是连续的内存空间,并且遵循LIFO(后进先出)原则。
- 堆的内存分配和回收涉及到更复杂的算法,如标记-清除、复制算法等,这些算法需要额外的时间来管理内存。
6.1.9 介绍一下方法区?
方法区是JVM中的一块内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区是所有线程共享的内存区域,通常也被称为“永久代”,不过在Java 8中,永久代已经被 元空间 所取代。
6.1.10 介绍一下运行时常量池?
运行时常量池是 方法区 的一部分,用于存放编译期生成的各种字面量和符号引用。
这些数据从 Class文件的常量池 中提取出来,并在运行期解析或计算后存储在运行时常量池中。
常量池中的数据包括类和接口的全限定名、字段名称和描述符、方法名称和描述符等。
6.1.11 介绍一下直接内存?
直接内存并 不是 JVM 运行时数据区的一部分,而是指Java程序通过NIO(New Input/Output)类直接申请的堆外内存。
直接内存的申请不受 JVM 堆内存大小的限制,这部分内存的申请和释放需要程序员 手动管理,否则可能导致内存泄漏。
直接内存主要用于 大文件 的读写和网络通信,可以提高性能,因为它减少了JVM堆到本地内存之间的数据复制次数。
6.1.12 String 底层原理?字符串常量池?
1. 常量池:
类文件常量池:存储编译期确定的字面量和符号引用。
运行时常量池:存放类文件常量池内容,并可在运行时添加新变量。
字符串常量池:位于堆中,用于存储字符串字面量,以节省内存和避免重复。
在Hotspot JVM中,字符串常量池是一个HashTable,使用数组加链表结构存储。
String.intern() 将字符串对象添加到字符串常量池中,并返回常量池中对象的引用。
2. String 的特性:
String
类是不可继承的(final)。value
成员是私有的(private)且不可变的(final)。- 字符串字面量存储在字符串常量池中,内容不可修改。
- JDK 1.8及之前,
value
用char[]
数组存储;JDK 1.9之后,使用byte[]
数组存储。3. String 的创建方式:
- 直接使用字面量赋值会在常量池中存储引用。
- 使用
new
关键字会在堆中创建新的String对象。- 使用
+
运算符连接常量时,编译器会优化,直接在常量池中创建结果字符串。4. String 不可变的原因:
- 保证线程安全。
- 缓存HashCode值,保证一致性。
- 提高安全性,防止字符串被恶意修改。
6.2 类加载器
6.2.1 什么是类加载器,类加载器有哪些?
JVM 只会运行 二进制 文件,类加载器的作用是将 字节码 文件加载到 JVM 中,从而让 Java 程序能够启动起来。
- 启动类加载器(
BootStrap ClassLoader
):加载 JAVA_HOME/jre/lib 目录下的库。- 扩展类加载器(
ExtClassLoader
):加载 JAVA_HOME/jre/lib/ext 目录中的类。- 应用类加载器(
AppClassLoader
):加载 classPath下的类。- 自定义加载器(
CustomizeClassLoader
):自定义类继承 ClassLoader,实现自定义加载规则。
6.2.2 什么是双亲委派模型?
- 类加载器 - 层次结构:
- 启动类加载器:用C++实现,是虚拟机自带的类加载器。
- 扩展类加载器:由Java语言实现,继承自ClassLoader类。
- 应用程序类加载器:也称为系统类加载器,由Java语言实现。
- 双亲委派模型 - 原理:
- 当一个类需要被加载时,JVM不会直接委派给应用程序类加载器。
- 它会先委派给父类加载器去加载,如果父类加载器没有找到这个类,子类加载器才会尝试自己去加载。
- 双亲委派模型 - 优点:
- 避免类的多次加载:确保一个类在JVM中只被加载一次。
- 安全机制:Java核心库的类只能由启动类加载器加载,防止核心库被随意篡改。
- 隔离机制:不同层次的类加载器加载不同层次的类,例如,用户自定义的类加载器加载用户自定义的类。
- 双亲委派模型 - 破坏:
- 热替换:在运行时替换某个类的定义。
- OSGi环境:需要加载不同版本的同一个类。
- 容器环境:如Tomcat,需要隔离不同Web应用的类加载。
6.2.3 JVM 为什么采用双亲委派机制?
JVM采用双亲委派机制是为了确保Java核心库的安全性和一致性。这种机制的工作方式如下:
- 安全与一致性:可以避免重复加载同一个类,确保了Java核心库的类在各个类加载器中是唯一的。
- 避免核心库被篡改:通过这种机制,可以防止用户自定义的类加载器加载核心库中的类,从而保护了Java核心库不被篡改。
- 层次性:类加载器之间存在层次关系,从顶层的启动类加载器到用户自定义的类加载器,有助于维护类加载的顺序和隔离性。
6.2.4 类装载的执行过程?
- 加载:查找和导入 class 文件。
- 验证:保证加载类的准确性。
- 准备:为类变量分配内存并设置类变量初始值。
- 解析:把类中的符号引用转换为直接引用。
- 初始化:对类的静态变量,静态代码块执行初始化操作。
- 使用:JVM 开始从入口方法开始执行用户的程序代码。
- 卸载:当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象。
6.2.5 对象的深拷贝和浅拷贝?
- 浅拷贝:
- 浅拷贝只复制对象的顶层数据,对于对象中的引用类型,仅复制引用的地址,不复制引用的对象。
- 结果是原对象和拷贝对象共享同一个引用对象,修改其中一个对象的引用类型属性会影响另一个对象。
- 调用
clone()
方法可以用于对象的浅拷贝。- 深拷贝:
- 深拷贝会递归复制对象及其所有嵌套对象,创建完全独立的副本。
- 原对象和拷贝对象不共享任何引用对象,修改一个对象不会影响另一个。
- 实现
Cloneable
接口并重写clone()
方法用于深拷贝。- Java 中实现深拷贝的方法:
- 序列化和反序列化:通过实现
Serializable
接口,将对象序列化为字节流,再从字节流反序列化出新对象。- 拷贝构造函数和覆写
clone
方法:手动编写代码递归复制所有属性,注意处理循环引用问题。
6.2.6 Java 源码从编译到执行,发生了什么?
编译:经过 语法分析、语义分析、注解处理,最后生成class文件。
加载:又可以细分步骤为:装载 -> 连接(验证->准备->解析)-> 初始化。
装载则把class文件装载至JVM,连接则校验class信息、分配内存空间及赋默认值,初始化则为变量赋值为正确的初始值。
解释:则是把字节码转换成操作系统可识别的执行指令,在 JVM 中会有字节码解释器和即时编译器。
在解释时会对代码进行分析,查看是否为热点代码,如果是则触发 JIT 编译,下次执行时就无需重复进行解释,提高速度。
执行:调用系统的硬件执行最终的程序指令。
6.3 垃圾回收
6.3.1 对象什么时候可以被垃圾器回收?
没有引用:对象没有任何引用指向它,即它变得不可达。
可达性分析:通过一系列的“GC Roots”(如线程栈中的局部变量、静态变量等)可达性分析,如果对象不可达,则可被回收。
finalize() 方法:如果对象被判定为可回收,并且对象的
finalize()
还没有被调用,那么垃圾回收器可能会调用这个方法。如果
finalize()
方法中对象被重新引用,则该对象可以逃过回收。弱引用、软引用、虚引用:即使存在弱引用、软引用或虚引用,如果对象没有其他强引用指向它,也可以被回收。
6.3.2 说一下强引用、软引用、弱引用、虚引用?
强引用 是最常见的引用类型。如果一个对象有强引用指向它,那么即使系统内存紧张,垃圾回收器也不会回收这个对象。
Object strongReference = new Object();
软引用 用于缓存,只有在内存不足时,垃圾回收器才会回收被软引用指向的对象。使用软引用可以减少内存占用。
SoftReference<Object> softRef = new SoftReference<>(new Object());
弱引用 的对象在垃圾回收时,无论内存是否充足,都会被回收。弱引用常用于实现规范化的缓存。
WeakReference<Object> weakRef = new WeakReference<>(new Object());
虚引用 在对象被回收后,虚引用会被加入到引用队列中,允许程序在对象被回收后执行某些清理操作。
ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), refQueue);
6.3.3 JVM 垃圾回收算法有哪些?
标记清除: 这种算法首先标记所有活跃的对象,然后清除那些未被标记的对象。
这个过程可能会导致内存碎片,因为清除是随机的,不是连续的。
标记整理: 这种算法首先标记所有活跃的对象,然后它将这些对象移动到内存的一端紧凑地排列,从而消除了内存碎片。
这种方法比标记-清除更高效,因为它可以回收更连续的内存块,但可能需要更多的计算资源,因为涉及到对象的移动。
复制: 在这种算法中,内存被分为两个半区,垃圾回收器将活跃对象从当前半区复制到另一个半区。
当一个半区被填满时,复制过程开始,所有活跃对象被移动到另一个半区,而原半区则被清空。但内存使用效率较低。
6.3.4 说一下 JVM 中的分代回收?
JVM中的分代回收是一种垃圾回收策略,它基于对象的生命周期将堆内存分为不同的区域:
新生代(Young Generation):新创建的对象首先被分配到这里,新生代又分为Eden区和两个Survivor区(S0和S1)。
新生代的垃圾回收频繁,因为大部分对象的生命周期都很短。
老年代(Old Generation):在新生代中经过多次垃圾回收后仍然存活的对象会被移动到老年代。
老年代的垃圾回收不如新生代频繁,因为对象的生命周期较长。
元空间(Metaspace):用于存储类的元数据,取代了永久代。
MinorGC、 Mixed GC 、 FullGC 的区别是什么?
Minor GC:只发生在新生代的垃圾回收,回收的是新生代中的对象。
因为新生代对象的生命周期短,所以Minor GC发生的频率较高,回收速度也较快。
Mixed GC:同时发生在新生代和老年代的垃圾回收,但是不涉及元空间。
Mixed GC在某些垃圾回收器中被用来回收老年代中的一部分空间,以减少Full GC的发生。
Full GC:涉及整个堆内存(包括新生代、老年代和元空间)的垃圾回收。
Full GC发生的频率较低,但是回收时间长,因为需要检查整个堆内存中的对象。
6.3.5 JVM 垃圾回收器有哪些?CMS 回收过程?
1. JVM 垃圾回收器:
- Serial GC:这是最基本的垃圾回收器,使用单线程进行垃圾回收,适合客户端应用。
- Parallel GC:也称为吞吐量优先收集器,使用多线程进行垃圾回收,适合多处理器机器。
- CMS (Concurrent Mark-Sweep) GC:通过并发方式进行标记和清除,减少应用程序的停顿时间,主要针对老年代。
- G1 (Garbage-First) GC:这是服务器端的垃圾收集器,旨在提高吞吐量的同时,尽可能满足垃圾收集暂停时间的要求。
2. CMS 回收过程:
- 初始标记:这个阶段会 STW(Stop-The-World),标记所有从GC Roots直接可达的对象。
- 并发标记:在这个阶段,GC线程与应用程序线程并发运行,标记所有从GC Roots可达的对象。
- 重新标记:由于并发标记阶段应用程序仍在运行,可能会产生新的垃圾,因此短暂的STW来修正这些遗漏的对象。
- 并发清除:最后,清除所有标记为可回收的对象,这个阶段也是并发执行的,不会暂停应用程序。
6.3.6 G1 垃圾收集器?它是如何改善性能的?
1. G1 垃圾收集器:
- 区域划分:G1将堆内存划分为 Eden区、Survivor区、Old区或大对象区,这样可以更加灵活地管理内存。
- 复制算法:G1在新生代回收时使用复制算法,这样可以减少内存碎片,并且提高回收效率。
- 性能兼顾:G1试图平衡停顿时间和吞吐量。它通过预测算法来决定哪些区域的回收收益最高,从而优先回收这些区域。
- 回收阶段:新生代回收、并发标记、混合回收(新生代 + 部分老年代)。
- 并发失败:如果并发标记阶段发现回收速度跟不上新建速度,G1可能触发Full GC来清理整个堆内存,以避免内存耗尽。
2. G1 如何改善性能:
- 减少停顿时间:通过优先收集垃圾最多的Region,G1减少了每次垃圾回收的停顿时间。
- 提高响应性:G1的并发和增量式收集减少了应用程序的Stop-The-World事件。
- 适应性:G1可以根据应用程序的行为动态调整垃圾回收的策略,以适应不同的工作负载。
- 减少内存碎片:G1在回收过程中会进行内存压缩,减少了内存碎片,有助于提高内存分配的效率。
- 避免Full GC:G1通过有效的Region管理和垃圾回收策略,减少了Full GC的发生,从而避免了长时间的垃圾回收停顿。
6.4 JVM 实践
6.4.1 JVM 调优的参数可以在哪里设置?
- 启动参数:通过在JVM启动时传递参数来设置,例如使用
-Xms
和-Xmx
来设置堆的初始大小和最大大小。- JVM监控工具:使用JVM监控工具(如JConsole、VisualVM)可以在运行时调整某些参数。
- JVM配置文件:如
jvm.options
或jvm.cfg
文件,这些文件中可以包含JVM启动参数。- 代码中动态设置:在Java代码中可以通过
System.setProperty()
方法动态设置某些JVM参数。
6.4.2 JVM 调优的参数都有哪些?
- 设置堆空间大小:
-Xms
和-Xmx
参数用于设置JVM堆的初始大小和最大大小。- 虚拟机栈的设置:
-Xss
参数用于设置每个线程的堆栈大小。- 年轻代的大小比例:
-XX:NewRatio
参数用于设置年轻代和老年代的比率。- 晋升老年代的阈值:
-XX:MaxTenuringThreshold
参数用于设置对象在年轻代中经过多少次垃圾回收后晋升到老年代。- 设置垃圾回收器:
-XX:+UseG1GC
参数用于启用G1垃圾回收器。
6.4.3 JVM 调优的工具?
命令工具:
jps
:进程状态信息jstack
:查看进程内线程的堆栈信息jmap
:生成堆转储快照(heap dump)可视化工具:
jconsole
:监控 jvm 的内存,线程,类的情况VisualVM
:监控线程,内存情况
6.4.4 Java 内存溢出、内存泄漏的排查与定位?
1. 栈内存溢出:栈帧过多导致栈内存溢出;栈帧过大导致栈内存溢出。
- 用
top
命令:定位是哪一个进程对 CPU 的占用过高- 用
ps
命令:结合grep
命令,定位是哪个线程引起的- 用
jstack
命令获取线程堆栈信息,再根据线程id
找到有问题的线程,定位是哪一行代码引起的2. 堆内存溢出:程序分配的内存超出可用堆内存时,导致应用程序崩溃或异常
- 用
jps
命令:查看当前系统中有哪些 java 进程- 用
jmap
命令:查看堆内存占用情况jmap -heap 进程id
- 用
jconsole
工具:图形界面的,多功能的监测工具,可以连续监测3. 内存泄漏:通常是指堆内存,指一些大对象不被回收的情况。
- 用
jmap
命令:获取堆内存快照 dump。- 用
VisualVM
工具:加载 dump,查看堆信息的情况,定位是哪行代码出了问题。- 通过阅读代码上下文的情况,进行修复即可。
6.4.5 CPU 飙高排查方案与思路?
- 通过
top
命令查看是哪一个进程占用 cpu 较高。- 使用
ps
命令查看进程中的线程信息。- 使用
jstack
命令查看进程中哪些线程出现了问题,最终定位问题。