Spring Boot内存占用暴涨的常见模式与堆外内存泄漏排查技巧

Spring Boot 内存占用暴涨排查与堆外内存泄漏分析

大家好,今天我们来聊聊 Spring Boot 应用中内存占用暴涨以及堆外内存泄漏的排查技巧。这个问题在生产环境中非常常见,也比较棘手,因为它可能导致应用性能下降,甚至崩溃。我们将从常见的内存占用模式入手,逐步深入到堆外内存泄漏的排查和定位。

常见内存占用模式:问题与应对

首先,我们需要了解 Spring Boot 应用中内存占用的一些常见模式。这些模式并不一定都是问题,但了解它们有助于我们更快地定位真正的瓶颈。

1. 大对象分配:

应用需要处理大量数据,例如读取大文件、处理大型数据库查询结果等,导致 JVM 堆中分配大量大对象。

应对策略:

  • 流式处理: 避免一次性加载所有数据到内存。使用流式处理(例如 java.util.stream)逐行或分块处理数据。
  • 分页查询: 对于数据库查询,使用分页查询限制每次加载的数据量。
  • 对象池: 对于频繁创建和销毁的大对象,考虑使用对象池来复用对象,减少垃圾回收的压力。

代码示例 (流式处理):

try (BufferedReader reader = new BufferedReader(new FileReader("large_file.txt"))) {
    reader.lines()
          .forEach(line -> {
              // 处理每一行数据
              processLine(line);
          });
} catch (IOException e) {
    e.printStackTrace();
}

private void processLine(String line) {
    // 实际处理逻辑
    System.out.println("Processing line: " + line);
}

代码示例 (分页查询):

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Page<User> findAll(Pageable pageable);
}

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public Page<User> getUsers(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return userRepository.findAll(pageable);
    }
}

2. 缓存未有效管理:

使用缓存(例如 Redis, Caffeine)来提高应用性能,但缓存的容量没有限制或者过期策略不合理,导致缓存无限增长,最终耗尽内存。

应对策略:

  • 设置最大容量: 为缓存设置最大容量,防止无限增长。
  • 设置过期时间: 为缓存中的数据设置合理的过期时间,自动清理过期数据。
  • 淘汰策略: 选择合适的淘汰策略(例如 LRU, LFU)来清理缓存中的数据。
  • 监控缓存命中率: 监控缓存命中率,根据实际情况调整缓存配置。

代码示例 (Caffeine缓存配置):

@Configuration
public class CacheConfig {

    @Bean
    public CaffeineCacheManager caffeineCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(
                Caffeine.newBuilder()
                        .maximumSize(1000) // 设置最大容量为 1000
                        .expireAfterWrite(60, TimeUnit.MINUTES) // 设置过期时间为 60 分钟
        );
        return cacheManager;
    }
}

3. 字符串拼接不当:

在循环中频繁使用 +StringBuilder 进行字符串拼接,导致创建大量的临时字符串对象。

应对策略:

  • 使用 StringBuilderStringBuffer: 在循环中进行字符串拼接时,使用 StringBuilder(单线程)或 StringBuffer(多线程)来避免创建大量的临时字符串对象。
  • 避免在循环中创建字符串对象: 尽量在循环外部创建字符串对象,然后在循环中修改其内容。

代码示例:

// 不好的代码
String result = "";
for (int i = 0; i < 1000; i++) {
    result += "item" + i; // 每次循环都会创建一个新的字符串对象
}

// 好的代码
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append("item").append(i);
}
String result = sb.toString();

4. 集合类使用不当:

使用 ArrayListHashMap 等集合类时,没有预估集合的大小,导致集合在扩容时频繁分配内存。

应对策略:

  • 预估集合大小: 在创建集合时,预估集合的大小,并设置初始容量,避免频繁扩容。
  • 选择合适的集合类: 根据实际需求选择合适的集合类。例如,如果需要线程安全,可以使用 ConcurrentHashMapCopyOnWriteArrayList

代码示例:

// 预估集合大小
List<String> list = new ArrayList<>(1000); // 预估集合大小为 1000
Map<String, String> map = new HashMap<>(1000); // 预估集合大小为 1000

5. 日志级别设置过高:

将日志级别设置为 DEBUGTRACE,导致输出大量的日志信息,占用大量的内存。

应对策略:

  • 调整日志级别: 根据实际需求调整日志级别。在生产环境中,通常建议将日志级别设置为 INFOWARN
  • 控制日志输出: 控制日志输出的内容,避免输出不必要的日志信息。

代码示例:

// application.properties
logging.level.root=INFO // 设置根日志级别为 INFO

6. 数据库连接池配置不合理:

数据库连接池配置过大,导致创建大量的数据库连接,占用大量的内存。

应对策略:

  • 调整连接池大小: 根据实际并发量调整连接池大小。
  • 设置最大连接数: 设置连接池的最大连接数,防止连接池无限增长。
  • 设置连接超时时间: 设置连接超时时间,避免长时间占用连接。

代码示例 (HikariCP 配置):

spring.datasource.hikari.maximum-pool-size=10 // 设置最大连接数为 10
spring.datasource.hikari.connection-timeout=30000 // 设置连接超时时间为 30 秒

以上是一些常见的内存占用模式。在实际排查问题时,需要结合应用的具体情况进行分析。接下来,我们将重点介绍堆外内存泄漏的排查技巧。

堆外内存泄漏:原因与排查

堆外内存是指 JVM 堆之外的内存,例如 Direct Memory、Native Memory 等。堆外内存泄漏通常比堆内内存泄漏更难排查,因为它不容易被垃圾回收器管理。

1. 堆外内存泄漏的常见原因:

  • Direct Memory 泄漏: ByteBuffer.allocateDirect() 分配的 Direct Memory 没有被及时释放。
  • NIO Buffer 泄漏: 使用 NIO 进行 IO 操作时,如果没有正确释放 Buffer,会导致内存泄漏。
  • JNI 代码泄漏: 使用 JNI 调用 Native 代码时,如果 Native 代码没有正确释放内存,会导致内存泄漏。
  • 第三方库泄漏: 使用的第三方库可能存在内存泄漏问题。

2. 堆外内存泄漏的排查工具:

  • jcmd: JVM 自带的命令行工具,可以用于获取 JVM 的各种信息,包括内存使用情况。
  • jmap: JVM 自带的命令行工具,可以用于生成 Heap Dump 文件,分析堆内存使用情况。虽然不能直接分析堆外内存,但可以辅助分析。
  • NMT (Native Memory Tracking): JVM 自带的 Native Memory Tracking 工具,可以用于跟踪 Native Memory 的使用情况。
  • VisualVM: 一个图形化的 JVM 监控工具,可以用于监控 JVM 的各种指标,包括内存使用情况。
  • MAT (Memory Analyzer Tool): 一个强大的 Heap Dump 分析工具,可以用于分析堆内存泄漏。

3. 使用 NMT 排查 Direct Memory 泄漏:

NMT (Native Memory Tracking) 是一个非常有用的工具,可以帮助我们定位 Native Memory 泄漏。

步骤:

  1. 启用 NMT:

    在 JVM 启动参数中添加 -XX:NativeMemoryTracking=summary-XX:NativeMemoryTracking=detailsummary 模式提供概要信息,detail 模式提供更详细的信息。建议在排查问题时使用 detail 模式。

  2. 获取 NMT 数据:

    使用 jcmd <pid> VM.native_memory summaryjcmd <pid> VM.native_memory detail 命令获取 NMT 数据。其中 <pid> 是 JVM 进程的 ID。

  3. 分析 NMT 数据:

    分析 NMT 数据,找到内存占用增长最快的区域。通常,Direct Memory 泄漏会导致 Direct Buffers 区域的内存占用快速增长。

  4. 定位泄漏代码:

    根据 NMT 数据中的线程 ID 和分配大小,结合代码分析,定位泄漏的代码。

代码示例 (Direct Memory 泄漏):

import java.nio.ByteBuffer;

public class DirectMemoryLeak {

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 分配 1MB 的 Direct Memory
            // 没有释放 buffer
            Thread.sleep(1);
        }
    }
}

分析 NMT 数据示例:

假设我们运行了上面的代码,并使用 jcmd <pid> VM.native_memory detail 命令获取了 NMT 数据。我们可能会看到类似以下的输出:

Native Memory Tracking:

Total: reserved=1055335KB, committed=1055335KB
-                 Java Heap (reserved=262144KB, committed=262144KB)
                            (mmap: reserved=262144KB, committed=262144KB)

-                     Class (reserved=10657KB, committed=10657KB)
                            (classes #425 total, #425 loaded)
                            (malloc=857KB #6812)
                            (mmap: reserved=9800KB, committed=9800KB)

-                    Thread (reserved=15360KB, committed=15360KB)
                            (thread #16 total, #0 daemon)
                            (malloc=14848KB #282)
                            (stack: reserved=512KB, committed=512KB)

-                      Code (reserved=24900KB, committed=24900KB)
                            (malloc=1536KB #3063)
                            (mmap: reserved=23364KB, committed=23364KB)

-                    GC (reserved=7567KB, committed=7567KB)
                            (malloc=4067KB #89)
                            (mmap: reserved=3500KB, committed=3500KB)

-              Compiler (reserved=131KB, committed=131KB)
                            (malloc=131KB #635)

-              Internal (reserved=1266KB, committed=1266KB)
                            (malloc=1266KB #2465)

-                   Other (reserved=132KB, committed=132KB)
                            (malloc=132KB #25)

-               Direct Buffers (reserved=754976KB, committed=754976KB)  <-- 注意这里
                            (malloc: reserved=754976KB, committed=754976KB)

-    Shared memory (reserved=12288KB, committed=12288KB)
                            (mmap: reserved=12288KB, committed=12288KB)

-         Arena Chunk (reserved=117KB, committed=117KB)
                            (malloc=117KB)

-         Symbol Table (reserved=1100KB, committed=1100KB)
                            (malloc=1100KB)

-          Synchronizer (reserved=102KB, committed=102KB)
                            (malloc=102KB)

-            Metaspace (reserved=38796KB, committed=38796KB)
                            (mmap: reserved=38796KB, committed=38796KB)

-      String Deduplication (reserved=0KB, committed=0KB)
                            (malloc=0KB)

从输出中可以看到,Direct Buffers 区域的内存占用非常大,并且还在不断增长。这说明可能存在 Direct Memory 泄漏。

接下来,我们可以进一步分析 NMT 数据,找到分配 Direct Memory 的线程 ID,然后结合代码分析,定位泄漏的代码。

4. 使用 MAT 分析 Heap Dump 文件:

虽然 MAT 主要用于分析堆内存,但它也可以辅助分析堆外内存泄漏。例如,如果 Direct Memory 泄漏导致大量的 DirectByteBuffer 对象无法被回收,MAT 可以帮助我们找到这些对象,并分析它们的引用链,从而定位泄漏的代码。

步骤:

  1. 生成 Heap Dump 文件:

    使用 jmap -dump:live,format=b,file=heapdump.bin <pid> 命令生成 Heap Dump 文件。其中 <pid> 是 JVM 进程的 ID。

  2. 使用 MAT 打开 Heap Dump 文件:

    使用 MAT 打开 Heap Dump 文件。

  3. 查找 DirectByteBuffer 对象:

    在 MAT 中,使用 OQL 查询语句 SELECT * FROM java.nio.DirectByteBuffer 查找所有的 DirectByteBuffer 对象。

  4. 分析引用链:

    分析 DirectByteBuffer 对象的引用链,找到持有这些对象的代码,并分析是否存在泄漏。

5. 其他排查技巧:

  • 代码审查: 仔细审查代码,特别是涉及 Direct Memory、NIO、JNI 等操作的代码,检查是否存在内存泄漏的风险。
  • 监控: 监控 JVM 的内存使用情况,包括堆内存和堆外内存,及时发现内存泄漏的迹象。
  • 压力测试: 使用压力测试工具模拟高并发场景,加速内存泄漏的发生,方便排查问题。

案例分析:解决一起实际的堆外内存泄漏问题

假设我们的 Spring Boot 应用使用了 Netty 框架进行网络通信,并且出现了堆外内存泄漏的问题。经过排查,我们发现是由于 Netty 的 ByteBuf 没有被及时释放导致的。

代码示例 (Netty ByteBuf 泄漏):

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;

public class NettyByteBufLeak {

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            ByteBuf buffer = Unpooled.buffer(1024); // 分配 1KB 的 ByteBuf
            buffer.writeBytes("Hello, Netty!".getBytes());
            // 没有释放 buffer
            Thread.sleep(1);
        }
    }
}

解决方案:

在使用完 ByteBuf 后,必须调用 release() 方法释放它。Netty 使用引用计数来管理 ByteBuf 的生命周期,如果忘记释放 ByteBuf,会导致内存泄漏。

修复后的代码:

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;

public class NettyByteBufLeakFixed {

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            ByteBuf buffer = Unpooled.buffer(1024); // 分配 1KB 的 ByteBuf
            try {
                buffer.writeBytes("Hello, Netty!".getBytes());
            } finally {
                buffer.release(); // 释放 ByteBuf
            }
            Thread.sleep(1);
        }
    }
}

通过添加 finally 块,确保 ByteBuf 在任何情况下都能被释放,避免内存泄漏。

总结

内存占用暴涨和堆外内存泄漏是 Spring Boot 应用中常见的问题。通过了解常见的内存占用模式,使用合适的排查工具,并结合代码分析,我们可以有效地定位和解决这些问题。掌握这些技巧,可以帮助我们构建更稳定、更高效的 Spring Boot 应用。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注