类加载机制
JVM 的操作对象是 Class 文件,JVM 把 Class 文件中描述类的数据结构加载到内存中,并对数据进行校验、解析和初始化,最终形成可以被 JVM 直接使用的类型,这个过程被称为类加载机制。
其中最重要的三个概念就是:类加载器、类加载过程和类加载器的双亲委派模型。
- 类加载器:负责加载类文件,将类文件加载到内存中,生成 Class 对象。
- 类加载过程:加载、验证、准备、解析和初始化。
- 双亲委派模型:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,依次递归,直到最顶层的类加载器,如果父类加载器无法完成加载请求,子类加载器才会尝试自己去加载。
类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
类加载过程
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析
载入过程中,JVM 需要做三件事情:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口。
载入阶段结束后,JVM 外部的二进制字节流就按照虚拟机所设定的格式存储在方法区(逻辑概念)中了,方法区中的数据存储格式完全由虚拟机自行实现。
JVM 会在验证阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。
JVM 会在准备阶段对类变量(也称为静态变量,static 关键字修饰的变量)分配内存并初始化,初始化为数据类型的默认值,如 0、0L、null、false 等。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、成员方法等。
初始化阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值了,而在初始化阶段,类变量将被赋值为代码期望赋的值。
类加载器有哪些?
类加载器(ClassLoader)用于动态加载 Java 类到 Java 虚拟机中。主要有四种类加载器:
①、启动类加载器(Bootstrap ClassLoader)负责加载 JVM 的核心类库,如 rt.jar 和其他核心库位于JAVA_HOME/jre/lib
目录下的类。
②、扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader
(或其它类似实现)实现。负责加载JAVA_HOME/jre/lib/ext
目录下,或者由系统属性java.ext.dirs
指定位置的类库。
③、应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader
(或其它类似实现)实现。
负责加载系统类路径(classpath)上的类库,通常是我们在开发 Java 应用程序时的主要类加载器。
我们编写的任何类都是由应用程序类加载器加载的,除非显式使用自定义类加载器。
④、用户自定义类加载器 (User-Defined ClassLoader),我们可以通过继承java.lang.ClassLoader
类来创建自己的类加载器。
这种类加载器通常用于加载网络上的类、执行热部署(动态加载和替换应用程序的组件)或为了安全目的自定义类的加载方式。
什么是双亲委派模型?
双亲委派模型(Parent Delegation Model)是 Java 类加载机制中的一个重要概念。这种模型指的是一个类加载器在尝试加载某个类时,首先会将加载任务委托给其父类加载器去完成。
只有当父类加载器无法完成这个加载请求(即它找不到指定的类)时,子类加载器才会尝试自己去加载这个类。
- 当一个类加载器需要加载某个类时,它首先会请求其父类加载器加载这个类。
- 这个过程会一直向上递归,也就是说,从子加载器到父加载器,再到更上层的加载器,一直到最顶层的启动类加载器(Bootstrap ClassLoader)。
- 启动类加载器会尝试加载这个类。如果它能够加载这个类,就直接返回;如果它不能加载这个类(因为这个类不在它的搜索范围内),就会将加载任务返回给委托它的子加载器。
- 子加载器接着尝试加载这个类。如果子加载器也无法加载这个类,它就会继续向下传递这个加载任务,依此类推。
- 这个过程会继续,直到某个加载器能够加载这个类,或者所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。
为什么要用双亲委派模型?
可以为 Java 应用程序的运行提供一致性和安全性的保障。
①、保证 Java 核心类库的类型安全
如果自定义类加载器优先加载一个类,比如说自定义的 Object,那在 Java 运行时环境中就存在多个版本的 java.lang.Object,双亲委派模型确保了 Java 核心类库的类加载工作由启动类加载器统一完成,从而保证了 Java 应用程序都是使用的同一份核心类库。
②、避免类的重复加载
在双亲委派模型中,类加载器会先委托给父加载器尝试加载类,这样同一个类不会被加载多次。如果没有这种模型,可能会导致同一个类被不同的类加载器重复加载到内存中,造成浪费和冲突。
怎么实现一个热部署功能?
实现一个热部署(Hot Deployment)功能通常涉及到类的加载和卸载机制,使得在不重启应用程序的情况下,能够动态替换或更新应用程序的组件。
第一步,使用文件监控机制(如 Java NIO 的 WatchService)来监控类文件或配置文件的变更。当监控到文件变更时,触发热部署流程。
class FileWatcher {
public static void watchDirectoryPath(Path path) {
// 检查路径是否是文件夹
try {
Boolean isFolder = (Boolean) Files.getAttribute(path, "basic:isDirectory", LinkOption.NOFOLLOW_LINKS);
if (!isFolder) {
throw new IllegalArgumentException("Path: " + path + " is not a folder");
}
} catch (IOException ioe) {
// 文件 I/O 错误
ioe.printStackTrace();
}
System.out.println("Watching path: " + path);
// 我们获得文件系统的WatchService对象
FileSystem fs = path.getFileSystem();
try (WatchService service = fs.newWatchService()) {
// 注册路径到监听服务
// 监听目录内文件的创建、修改、删除事件
path.register(service, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
// 开始无限循环,等待事件发生
WatchKey key = null;
while (true) {
key = service.take(); // 会阻塞直到有事件发生
// 对于每个发生的事件
for (WatchEvent<?> watchEvent : key.pollEvents()) {
WatchEvent.Kind<?> kind = watchEvent.kind();
// 获取文件路径
@SuppressWarnings("unchecked")
WatchEvent<Path> ev = (WatchEvent<Path>) watchEvent;
Path fileName = ev.context();
System.out.println(kind.name() + ": " + fileName);
}
// 重置watchKey
boolean valid = key.reset();
// 退出循环如果watchKey无效
if (!valid) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
// 监控当前目录
Path pathToWatch = Paths.get(".");
watchDirectoryPath(pathToWatch);
}
}
第二步,创建一个自定义类加载器,继承自java.lang.ClassLoader
,重写findClass()
方法,实现类的加载。
public class HotSwapClassLoader extends ClassLoader {
public HotSwapClassLoader() {
super(ClassLoader.getSystemClassLoader());
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 加载指定路径下的类文件字节码
byte[] classBytes = loadClassData(name);
if (classBytes == null) {
throw new ClassNotFoundException(name);
}
// 调用defineClass将字节码转换为Class对象
return defineClass(name, classBytes, 0, classBytes.length);
}
private byte[] loadClassData(String name) {
// 实现从文件系统或其他来源加载类文件的字节码
// ...
return null;
}
}
像 Intellij IDEA 就提供了热部署功能,当我们修改了代码后,IDEA 会自动编译,如果是 Web 项目,在 Chrome 浏览器中装一个 LiveReload 插件,一旦编译完成,页面就会自动刷新。对于测试或者调试来说,就非常方便。
打破双亲委派模型方法
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。
说说解释执行和编译执行的区别
先说解释和编译的区别:
- 解释:将源代码逐行转换为机器码。
- 编译:将源代码一次性转换为机器码。
一个是逐行,一个是一次性,再来说说解释执行和编译执行的区别:
- 解释执行:程序运行时,将源代码逐行转换为机器码,然后执行。
- 编译执行:程序运行前,将源代码一次性转换为机器码,然后执行。
Java 一般被称为“解释型语言”,因为 Java 代码在执行前,需要先将源代码编译成字节码,然后在运行时,再由 JVM 的解释器“逐行”将字节码转换为机器码,然后执行。
这也是 Java 被诟病“慢”的主要原因。
但 JIT 的出现打破了这种刻板印象,JVM 会将热点代码(即运行频率高的代码)编译后放入 CodeCache,当下次执行再遇到这段代码时,会从 CodeCache 中直接读取机器码,然后执行。这大大提升了 Java 的执行效率。
JVM调优
常用的命令行性能监控和故障处理工具?
- 操作系统工具
- top:显示系统整体资源使用情况
- vmstat:监控内存和 CPU
- iostat:监控 IO 使用
- netstat:监控网络使用
- JDK 性能监控工具
- jps:虚拟机进程查看
- jstat:虚拟机运行时信息查看
- jinfo:虚拟机配置查看
- jmap:内存映像(导出)
- jhat:堆转储快照分析
- jstack:Java 堆栈跟踪
- jcmd:实现上面除了 jstat 外所有命令的功能
JVM 的常见参数配置知道哪些?
堆配置:
- -Xms:初始堆大小
- -Xms:最大堆大小
- -XX:NewSize=n:设置年轻代大小
- -XX:NewRatio=n:设置年轻代和年老代的比值。如:为 3 表示年轻代和年老代比值为 1:3,年轻代占整个年轻代年老代和的 1/4
- -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如 3 表示 Eden: 3 Survivor:2,一个 Survivor 区占整个年轻代的 1/5
- -XX:MaxPermSize=n:设置持久代大小
收集器设置:
- -XX:+UseSerialGC:设置串行收集器
- -XX:+UseParallelGC:设置并行收集器
- -XX:+UseParalledlOldGC:设置并行年老代收集器
- -XX:+UseConcMarkSweepGC:设置并发收集器
并行收集器设置
- -XX:ParallelGCThreads=n:设置并行收集器收集时使用的 CPU 数。并行收集线程数
- -XX:MaxGCPauseMillis=n:设置并行收集最大的暂停时间(如果到这个时间了,垃圾回收器依然没有回收完,也会停止回收)
- -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为:1/(1+n)
- -XX:+CMSIncrementalMode:设置为增量模式。适用于单 CPU 情况
- -XX:ParallelGCThreads=n:设置并发收集器年轻代手机方式为并行收集时,使用的 CPU 数。并行收集线程数
打印 GC 回收的过程日志信息
- -XX:+PrintGC
- -XX:+PrintGCDetails
- -XX:+PrintGCTimeStamps
- -Xloggc:filename
线上服务 CPU 占用过高怎么排查?
首先,使用 top 命令查看 CPU 占用情况,找到占用 CPU 较高的进程 ID。
top
接着,使用 jstack 命令查看对应进程的线程堆栈信息。
jstack -l <pid> > thread-dump.txt
上面👆🏻这个命令会将所有线程的堆栈信息输出到 thread-dump.txt 文件中。
然后再使用 top 命令查看进程中线程的占用情况,找到占用 CPU 较高的线程 ID。
top -H -p <pid>
注意,top 命令显示的线程 ID 是十进制的,而 jstack 输出的是十六进制的,所以需要将线程 ID 转换为十六进制。
printf "%x\n" PID
在 jstack 的输出中搜索这个十六进制的线程 ID,找到对应的堆栈信息。
"Thread-5" #21 prio=5 os_prio=0 tid=0x00007f812c018800 nid=0x1a85 runnable [0x00007f811c000000]
java.lang.Thread.State: RUNNABLE
at com.example.MyClass.myMethod(MyClass.java:123)
at ...
最后,根据堆栈信息定位到具体的业务方法,查看是否有死循环、频繁的垃圾回收(GC)、资源竞争(如锁竞争)导致的上下文频繁切换等问题。
内存飙高问题怎么排查?
内存飚高一般是因为创建了大量的 Java 对象所导致的,如果持续飙高则说明垃圾回收跟不上对象创建的速度,或者内存泄漏导致对象无法回收。
排查的方法主要分为以下几步:
第一,先观察垃圾回收的情况,可以通过 jstat -gc PID 1000
查看 GC 次数和时间。
或者 jmap -histo PID | head -20
查看堆内存占用空间最大的前 20 个对象类型。
第二步,通过 jmap 命令 dump 出堆内存信息。
第三步,使用可视化工具分析 dump 文件,比如说 VisualVM,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。
有没有处理过内存泄漏问题?是如何定位的?
严重的内存泄漏往往伴随频繁的 Full GC,所以排查内存泄漏问题时,需要从 Full GC 入手。主要有以下操作步骤:
第一步,使用 jps
查看运行的 Java 进程 ID
第二步,使用top -p [pid]
查看进程使用 CPU 和内存占用情况
第三步,使用 top -Hp [pid]
查看进程下的所有线程占用 CPU 和内存情况
第四步,将线程 ID 转换为 16 进制:printf "%x\n" [pid]
,输出的值就是线程栈信息中的 nid。
例如:
printf "%x\n" 29471
,输出 731f。
第五步,抓取线程栈:jstack 29452 > 29452.txt
,可以多抓几次做个对比。
在线程栈信息中找到对应线程号的 16 进制值,如下是 731f 线程的信息。线程栈分析可使用 VisualVM 插件 TDA。
"Service Thread" #7 daemon prio=9 os_prio=0 tid=0x00007fbe2c164000 nid=0x731f runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
第六步,使用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
次数太多,就很大概率存在内存泄漏了
第八步,使用 jmap -histo:live [pid]
输出每个类的对象数量,内存大小(字节单位)及全限定类名。
第九步,生成 dump
文件,借助工具分析哪个对象非常多,基本就能定位到问题根源了。
使用 jmap 生成 dump 文件:
# jmap -dump:live,format=b,file=29471.dump 29471
Dumping heap to /root/dump ...
Heap dump file created
第十步,dump 文件分析
可以使用 jhat 命令分析:jhat -port 8000 29471.dump
,浏览器访问 jhat 服务,端口是 8000。
也可以使用图形化工具分析,如 JDK 自带的 jvisualvm,从菜单 > 文件 > 装入 dump 文件。
或使用第三方式具分析的,如 JProfiler、GCViewer 工具。
或使用在线分析平台 GCEasy。
注意:如果 dump 文件较大的话,分析会占比较大的内存。
在 dump 文析结果中查找存在大量的对象,再查对其的引用。基本上就可以定位到代码层的逻辑了。
有没有处理过 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
参数。
或者检查代码中是否存在内存泄漏,如未关闭的资源、长生命周期的对象等。
之后,我会在本地进行压力测试,模拟高负载情况下的内存表现,确保修改有效,且没有引入新的问题。