JVM类加载机制、调优

类加载机制

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

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

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

类的生命周期

类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

类加载过程

Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

载入过程中,JVM 需要做三件事情:

  1. 通过全类名获取定义此类的二进制字节流。
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
  3. 在内存中生成一个代表该类的 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 次,查看 YGCFull 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 文件。

或使用第三方式具分析的,如 JProfilerGCViewer 工具。

或使用在线分析平台 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 参数。

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

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

  • 20
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值