JVM 内存调优

内存调优

内存泄漏(Memory Leak)和内存溢出(Memory Overflow)是两种常见的内存管理问题,它们都可能导致程序执行不正常或系统性能下降,但它们的原因和表现有所不同。

内存泄漏

内存泄漏(Memory Leak)指的是程序在动态分配内存后,未能正确释放或回收已经不再使用的内存,导致这部分内存无法再被程序使用,同时也不能被操作系统重新分配。每次内存泄漏都会使得系统的可用内存减少,长时间运行的程序可能会耗尽可用的内存资源,最终导致系统性能下降甚至崩溃。

在 Java 中如果不再使用一个对象,但是该对象依然在 GC ROOT 的引用链上,这个对象则不会被垃圾回收器回收,这种情况就是内存泄漏。如果对象之间存在循环引用并且没有适当地断开引用,这些对象可能也不会被垃圾回收器正确地释放。

内存泄漏大多数情况都是由于堆内存泄漏引起的。

内存溢出

内存溢出指的是程序试图分配超过其可用内存的空间。当程序尝试向已经被其他程序或操作系统占用的内存地址空间分配内存时,会导致内存溢出。通常会导致程序崩溃或异常终止。

程序在运行时请求了大量的内存,超过了系统当前可用的物理内存或虚拟内存。或者是递归函数未能正确地终止,导致了堆栈的溢出。

当然,内存泄漏也会导致内存溢出,例如在一些大型的 Java 后端应用中,在处理用户请求之后未能及时将用户的数据删掉,随着用户请求数量越来越多,持续内存泄漏的对象占满整个堆内存最终导致了内存溢出。但是产生内存溢出的并不仅仅只有内存泄漏这一种原因

解决内存溢出的四个步骤:

在 Linux 环境下,可以使用top命令查看系统的进程信息,它提供了实时系统资源的使用情况,进程使用的内存为 RES(常驻内存) - SHR(共享内存)。但是该命令只能查看最基础的进程信息,无法查看每个部分(如堆、方法区、堆外等)的内存占用,也无法查看内存变化的趋势图。

这个时候可以使用另一个工具:VisualVM

VisualVM是多功能合一的Java故障排除工具并且他是一款可视化工具,整合了命令行 JDK 工具和轻量级分析功能,功能非常强大。

这款软件在0racle JDK6~8中发布,但是在 0racle JDK9 之后不在JDK安装目录下需要单独下载。下载地址:https://visualvm.github.io

JDK 8及以下版本可以在 JDK 的 bin 目录下找到jvisualvm.exe 程序,双击打开即可。而 8 以上的版本下载解压完成之后,在解压后的bin目录下找到visualvm.exe程序双击打开。

在 IDEA 中快速启动 VisualVM,直接在 IDEA 中下载 VisualVM Launcher 插件,然后在设置中的其他设置里面配置之前下载好的visualvm.exe程序路径。

运行是选择用 VisualVM 运行即可

注意:VisualVM 仅限于测试时本地使用,禁止访问生产环境下的进程程序,其中的一些功能(如手动 Full GC )会导致生产环境下的用户进程暂停。

VisualVM 虽然可以实现实时监控系统的详细数据,但是对大量集群化部署的 Java 进程需要手动进行管理,非常麻烦。

产生原因

equals() 与 hashCode() 导致的内存泄漏

在定义新类时没有重写正确的equals()和hashCode()方法。在使用HashMap的场景下,如果使用这个类对象作为key,HashMap在判断key是否已经存在时会使用这些方法,如果重写方式不正确,会导致相同的数据被保存多份。

java 复制代码
public class UserTest{
    public static long count = 0;
    public static Map<User,Long> userMap = new HashMap<>();
    public static void main(String[] args) throws InterruptedException {
        while(true) {
            if(count++ % 100 == 0) {
                Thread.sleep(100);
            }
            userMap.put(new User(1L, "xiaoming"),5L);
        }
    }
}

class User {

    private Long id;
    private String username;
    private byte[] bytes;

    public User(Long id, String username) {
        this.id = id;
        this.username = username;
        this.bytes = new byte[1024];
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

因为 hashCode 方法未实现,会导致相同id的用户对象计算出来的hash值并不同,会被分配到不同的槽中。而 equals 方法未实现,会导致 key 在比对的时候,即使用户对象的id是相同的,也会被认定为不同的key。

所以在阿里巴巴java开发手册中就有这样的规定,其目的也是为了防止编码的不规范而出现内存溢出:

这种问题的解决方案有以下三点:

  1. 在定义新实体时,始终重写 equals() 和 hashCode() 方法。
  2. 重写时一定要确定使用了唯一标识去区分不同的对象,比如用户的 id 等。
  3. Hashmap 使用时尽量使用编号 id 等数据作为 key,不要将整个实体类对象作为 key 存放。

内部类引用外部类

非静态的内部类默认会持有外部类,尽管代码上不再使用外部类,所以如果有地方引用了这个非静态内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类。

匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者。

java 复制代码
/**
 * 外部类
 *
 * @author HeXin
 * @date 2024/07/09
 */
public class Outer {

    private byte[] bytes = new byte[1024 * 1024];

    private static String name = "外部类";

    public List<String> newList(){
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        return list;
    }

    /**
     * 内部类
     *
     * @author HeXin
     * @date 2024/07/09
     */
    class Inner{
        private String name;

        public Inner(){
            this.name = Outer.name + "==> 内部类";
            System.out.println(this.name);
        }
    }

    public static void main(String[] args) {
        int count = 0;
        List<Inner> inners = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        // 非静态的内部类默认会持有外部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类。
        while(true) {
            System.out.println(++count);
            inners.add(new Outer().new Inner());
        }
        // 匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者。
        while(true) {
            System.out.println(++count);
            objects.add(new Outer().newList());
        }
    }
}

解决上述内存溢出问题的办法是:

  1. 若不想持有外部类对象,应该使用静态内部类,而使用内部类的原因是可以直接获取外部类中的成员变量值。
  2. 使用静态方法,避免匿名内部类持有调用者对象。

ThreadLocal 使用后未及时清理

若仅仅只是使用手动创建的线程,即便没有调用 ThreadLocal 的 remove 方法清理数据,也不会产生内存泄漏问题。因为线程被回收时,ThreadLocal 也同样会被回收,但若使用线程池则不一样,有可能会出现内存泄漏问题。

java 复制代码
// 未发送内存泄漏
public class ThreadLocalTest {
    public static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        while(true) {
            new Thread(()->{
                threadLocal.set(new byte[1024 * 1024 * 100]);
            }).start();
            Thread.sleep(100);
        }
    }
}
java 复制代码
public class ThreadLocalTest {
    public static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                Integer.MAX_VALUE,
                Integer.MAX_VALUE,
                0, TimeUnit.DAYS,new SynchronousQueue<>());
        int count = 0;
        while(true) {
            System.out.println(executor.getPoolSize());
            executor.execute(()-> threadLocal.set(new byte[1024 * 1024 * 1024]));
            Thread.sleep(100);
        }
    }
}

当线程方法执行完毕后,一定要调用 ThreadLocal 中的 remove 方法清理对象。

String 的 intern 方法

在 JDK6 中字符串常量池位于堆内存中的Perm Gen永久代中,如果不同字符串的 intern 方法被大量调用,字符串常量池会不停的变大超过永久代内存上限之后就会产生内存溢出问题。

解决办法:

  1. 注意代码逻辑,尽量不要将随机生成的字符串加入字符串常量池。
  2. 增大永久代空间的大小,根据实际的测试/估算结果进行设置JVM参数-XX:MaxPermSize=512M

通过静态字段保存对象

如果大量数据在静态变量中被长期引用,数据就不会得到释放。若这些数据将永久不被使用,则这些数据也成为了内存泄漏。

  1. 尽量减少将对象长时间的保存在静态变量中,如果不再使用,必须将对象删除(比如在集合中)或
    者将静态变量设置为 null。
  2. 使用单例模式时,尽量使用懒加载( @Lazy 懒加载注解 ),而不是立即加载。
  3. Spring 的 Bean 中不要长期存放大对象,如果是缓存用于提升性能,尽量设置过期时间定期失效。

并发请求问题

并发请求问题指用户通过发送请求向 Java 程序获取数据,正常情况下 Java 后端程序将数据返回之后,这部分数据就可以在内存中被释放掉。但是由于用户的并发请求量有可能很大,同时处理数据的时间很长,导致大量的数据存在于内存中,最终超过了内存的上限,导致内存溢出。这类问题的处理思路和内存泄漏类似,首先要定位到对象产生的根源。

快速定位

当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照(Heap Profile)文件。

使用MAT打开hprof文件,并选择内存泄漏检测功能,MAT会自行根据内存快照中保存的数据分析内存泄漏的根源。

如果想要生成内存快照则需要添加以下的 JVM 参数:

-XX:+HeapDumpOnOutOfMemoryError:当程序发生内存溢出(Out of Memory Error)错误时,会自动生成 hprof 内存快照文件。

-XX:HeapDumpPath=<path>:指定 hprof 文件的输出路径。

如果需要导出运行中系统的内存快照,有两种方式,注意只需要导出标记为存活的对象

  1. 通过JDK自带的jmap命令导出,格式为:

    java 复制代码
    jmap -dump:live,format=b,file=文件路径和文件名 进程ID
  2. 通过arthas的heapdump命令导出,格式为:

    java 复制代码
    heapdump --live 文件路径和文件名

在开发使用的机器内存范围内的快照文件,可以直接使用 MAT 打开分析。但是可能经常遇到服务器上的程序占用的内存达到 10G 之上的,开发环境无法正常打开内存快照的情况,并且还需要将其快照文件下载转移到开发环境,也是一个较为耗时的操作。此时需要下载服务器操作系统对应的MAT(https://eclipse.dev/mat/downloads.php)。

然后在服务器上执行下面的 MAT 中的脚本生成分析报告(几个静态页面),将其转移到开发环境中查看分析。

bash 复制代码
# suspects->内存泄漏检测报告  overview->总览图  top_components->组件图
./ParseHeapDump.sh 快照文件路径 org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components

注意:默认MAT分析时只使用了1G的堆内存,如果快照文件超过1G,需要修改MAT目录下的 MemoryAnalyzer.ini配置文件调整最大堆内存。最好修改为快照文件大小的 1.5 倍左右

MAT 内存泄漏检测原理

MAT提供了称为支配树(Dominator Tree)的对象图。支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。

支配树中对象本身占用的空间称之为浅堆(Shallow Heap)

支配树中对象的子树就是所有被该对象支配的内容,这些内容组成了对象的深堆(Retained Heap),也称之为保留集(Retained Set )。深堆的大小表示该对象如果可以被回收,能释放多大的内存空间。

MAT就是根据支配树,从叶子节点向根节点"收缩"遍历,若发现深堆的大小超过整个堆内存的一定比例阈值,则会将其标记为内存泄露的可疑对象

修复问题

造成内存溢出问题的原因有以下三点:

  1. 编写代码时代码规范意识不强,导致其产生了代码中的内存泄漏。
  2. 由于参数设置不合理,并发场景下引起内存溢出,例如堆内存设置过小,会导致并发量增加之后超过堆内存的上限。
  3. 系统的设计方案不合理,例如直接使用*查询数据库,获取大量无用数据;线程池的设计不合理(线程池参数设置不当,会导致大量线程的创建或者队列中保存了大量的数据)等。
相关推荐
FuckPatience9 分钟前
关于C#项目中 服务层使用接口的问题
java·开发语言·c#
天上掉下来个程小白28 分钟前
缓存套餐-01.Spring Cache介绍和常用注解
java·redis·spring·缓存·spring cache·苍穹外卖
揣晓丹1 小时前
JAVA实战开源项目:健身房管理系统 (Vue+SpringBoot) 附源码
java·vue.js·spring boot·后端·开源
编程轨迹_1 小时前
使用 Spring 和 Redis 创建处理敏感数据的服务
java·开发语言·restful
奔驰的小野码1 小时前
SpringAI实现AI应用-自定义顾问(Advisor)
java·人工智能·spring boot·spring
奔驰的小野码1 小时前
SpringAI实现AI应用-使用redis持久化聊天记忆
java·数据库·人工智能·redis·spring
裁二尺秋风1 小时前
k8s(11) — 探针和钩子
java·容器·kubernetes
一方~2 小时前
XML语言
xml·java·web
LSL666_2 小时前
Java——多态
java·开发语言·多态·内存图
麓殇⊙2 小时前
CurrentHashMap的整体系统介绍及Java内存模型(JVM)介绍
java·开发语言·jvm