一、复现条件
- 准备环境 :SpringBoot 整合 Redis
- 测试服务,设置 VM 参数 -Xmx100m 启动。
- JMeter 压测某个业务接口,并发 200
二、异常情况:Lettuce客户端 Netty框架内部原因导致。
1、压测
使用 Redis 的业务接口 ,产生 OutOfDirectMemoryError(堆外内存溢出),如图:
2、详细错误信息:
lettuce.core.RedisException: io.netty.util.internal.OutOfDirectMemoryError:
failed to allocate 46137344 byte(s) of direct memory (used: 58720256, max: 100663296
3、源码分析:
public final class PlatformDependent {
// 直接内存大小,可通过 “-Dio.netty.maxDirectMemory”参数设置。
private static final long DIRECT_MEMORY_LIMIT;
// 默认的最大直接内存大小 ,方法略。
// 大概意思还是:先获取“Eclipse OpenJ9”的“sun.misc.VM”参数,如果没有则获取JVM的“-XX:MaxDirectMemorySize”作为默认值。
private static final long MAX_DIRECT_MEMORY = maxDirectMemory0();
static {
// 其他赋值操作,略
// 给 直接内存大小赋值,如果有设置 "-Dio.netty.maxDirectMemory" 参数,则使用用户设置的,如果没有则使用默认的
logger.debug("-Dio.netty.maxDirectMemory: {} bytes", maxDirectMemory);
DIRECT_MEMORY_LIMIT = maxDirectMemory >= 1 ? maxDirectMemory : MAX_DIRECT_MEMORY;
}
private static void incrementMemoryCounter(int capacity) {
if (DIRECT_MEMORY_COUNTER != null) {
long newUsedMemory = DIRECT_MEMORY_COUNTER.addAndGet(capacity);
//关键判断:如果 netty内部使用的内存大小 大于 “直接内存大小”的话,就抛出 "OutOfDirectMemoryError"异常。
if (newUsedMemory > DIRECT_MEMORY_LIMIT) {
DIRECT_MEMORY_COUNTER.addAndGet(-capacity);
throw new OutOfDirectMemoryError("failed to allocate " + capacity
+ " byte(s) of direct memory (used: " + (newUsedMemory - capacity)
+ ", max: " + DIRECT_MEMORY_LIMIT + ')');
}
}
}
}
4、总结原因:
1)、Springboot 2.x 以后默认使用 Lettuce作为操作 redis 的客户端。它是使用 netty 进行网络通信的。
2)、从spring-boot-starter-data-redis(2.1.14.RELEASE) 依赖可以看出内置使用的确实是 Lettuce 客户端,
分析源码得知,lettuce 使用的 netty 框架,引用的netty包netty-common-4.1.49.Final.jar里面有一个PlatformDependent.java类 ,
底层有个-Dio.netty.maxDirectMemory 参数,会自己校验堆外内存是否大于当前服务可使用的内存,如果大于则抛出 OutOfDirectMemoryError(堆外内存溢出)。
显然,这是属于 Netty(netty-common-4.1.49.Final.jar)的bug导致堆外内存溢出的。
5、解决方案:
不能使用-Dio.netty.maxDirectMemory 只调大堆外内存,这只能延迟bug出现的时机,不能完全解决该问题。想解决有如下两个方案:
升级 Lettuce客户端,期待新版本会解决该问题。
排除 Lettuce客户端,切换使用没有该问题的 Jedis 客户端。
PS: Netty 框架性能更好,吞吐量更大,但是Springboot默认使用的Lettuce 客户端对Netty的支持不够好;
Jedis客户端虽然没有Netty更快,但胜在稳定,没有上述bug。
spring-boot-starter-data-redis 依赖 切换使用 Jedis客户端
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.14.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- 引入 redis 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 排除 lettuce 客戶端,引入 Jedis客戶端-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
```
## 三、正常情况:其他原因综合导致
1、压测
Redis 使用 Jedis 客户端后,继续 Jmeter压测,200并发
2、详细错误信息
```text
Exception in thread "File Watcher" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.lang.String.toLowerCase(String.java:2578)
at java.io.WinNTFileSystem.hashCode(WinNTFileSystem.java:640)
at java.io.File.hashCode(File.java:2132)
at org.springframework.boot.devtools.filewatch.FileSnapshot.hashCode(FileSnapshot.java:72)
at java.util.HashMap.hash(HashMap.java:338)
at java.util.HashMap.put(HashMap.java:611)
at java.util.HashSet.add(HashSet.java:219)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:70)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
at org.springframework.boot.devtools.filewatch.FolderSnapshot.<init>(FolderSnapshot.java:58)
at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.getCurrentSnapshots(FileSystemWatcher.java:280)
at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.scan(FileSystemWatcher.java:254)
at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.run(FileSystemWatcher.java:239)
at java.lang.Thread.run(Thread.java:745)
2022-06-04 01:34:33.183 ERROR 11988 --- [o-10000-exec-27] o.a.c.c.C.[.[.[/].[dispatcherServlet] :
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed;
nested exception is java.lang.OutOfMemoryError: Java heap space] with root cause
java.lang.OutOfMemoryError: Java heap space
3、Jvisualvm - Visual GC 界面分析:
测试服务前,设置启动参数 VM options: -Xmx100m ,意思是本次测试的服务 JVM 最大堆内存只有 100M。
这次虽然还是 OutOfMemoryError,但是原因已经变了,变成了“GC overhead limit exceeded”,
已经没有前面由于框架问题导致的异常信息了。这次异常是属于“正常情况”。
4、真正造成OutOfMemoryError的原因
由于本次测试设置了服务JVM堆内存最大只有 100M。测试的并发数 200,瞬间吞吐量压力过大,导致Old Gen 区直接占满。
JVM的GC过程会因为STW,只不过停顿短到不容易感知。当引起停顿时间的98%都是在进行 GC,但是结果只能得到小于2%的堆内存恢复时,
就会抛出 java.lang.OutOfMemoryError: GC overhead limit exceeded这个错误。
这个错误其实就是空闲内存与GC之间平衡的一个限制,当经过几次GC之后,只有少于2%的内存被释放,也就是很少的空闲内存,可能会再次被快速填充,
这样就会触发再一次的GC。这就是一个恶性循环了,CPU大部分的时间在做GC操作,没有时间做具体的业务操作,
可能几毫秒的任务需要几分钟都无法完成,整个应用程序就形同虚设了。
5、解决方案
我这里主要是限制了JVM `-Xmx100m`模拟高并发场景导致的,重启一下服务或者调大`-Xmx`,减少并发数,都是能解决的
而实际生产环境会有更多复杂的因素影响综合导致,因此要考虑有哪些原因会造成“性能瓶颈”,具体情况要具体分析;
造成性能瓶颈往往在“中间件的选取 ”和 业务代码的编写(业务代码主要表现在数据库的IO操作过多或者数据量多大),
可以优先从这两个方面入手优化,最后才是硬件上的扩容升级