基础集合JVM 场景题
JDK序列化问题排查
场景简要概述
新加了个字段,然后发版,上线就发现了报错

当时这个问题很简单,其实就是用的是 JDK序列化,当时这个类实现了 Serializable接口,但是没显示定义 serialVersionUID,这样一来序列化时会根据当前类的信息计算得到一个 serialVersionUID
当数据在序列化存入redis后,接着业务需要,就在代码里把要接收这个数据的类中新加了一个字段,这时候再从 redis 获取之前的值反序列化,由于当前的类还没有 serialVersionUID,于是就会很据当前的类信息计算的 serialVersionUID,而由于结构变了(新增了一个信息),类信息也就变了,所以计算出来的 serialVersionUID不一致。因此序列化就失败。
解决方式就是显示指定 serialVersionUID,这样就不需要动态计算了。
扩展知识:序列化和反序列化
- 序列化:把对象转换为字节序列的过程称为对象的序列化.
- 反序列化:把字节序列恢复为对象的过程称为对象的反序列化.
什么时候会用到
当只在本地 JVM 里运行下 Java 实例,这个时候是不需要什么序列化和反序列化的,但当出现以下场景时,就需要序列化和反序列化了:
- 当需要将内存中的对象持久化到磁盘,数据库中时
- 当需要与浏览器进行交互时
- 当需要实现 RPC 时
但是当我们在与浏览器交互时,还有将内存中的对象持久化到数据库中时,好像都没有去进行序列化和反序列化,因为我们都没有实现 Serializable 接口,但一直正常运行?
先给出结论:只要我们对内存中的对象进行持久化或网络传输,这个时候都需要序列化和反序列化.
理由:服务器与浏览器交互时真的没有用到 Serializable 接口吗? JSON 格式实际上就是将一个对象转化为字符串,所以服务器与浏览器交互时的数据格式其实是字符串,我们来看来 String 类型的源码:
public final class String implements java.io.Serializable,Comparable<String>,CharSequence {
/\*\* The value is used for character storage. \*/
private final char value\[\];
/\*\* Cache the hash code for the string \*/
private int hash; // Default to 0
/\*\* use serialVersionUID from JDK 1.0.2 for interoperability \*/
private static final long serialVersionUID = -6849794470754667710L;
......
}String 类型实现了 Serializable 接口,并显示指定 serialVersionUID 的值.
然后再来看对象持久化到数据库中时的情况,Mybatis 数据库映射文件里的 insert 代码:
<insert id="insertUser" parameterType="org.tyshawn.bean.User">
INSERT INTO t\_user(name,age) VALUES (#{name},#{age})
</insert>实际上并不是将整个对象持久化到数据库中,而是将对象中的属性持久化到数据库中,而这些属性(如Date/String)都实现了 Serializable 接口。
为什么要实现 Serializable 接口?
在 Java 中实现了 Serializable 接口后, JVM 在类加载的时候就会发现我们实现了这个接口,然后在初始化实例对象的时候就会在底层实现序列化和反序列化。如果被写对象类型不是String、数组、Enum,并且没有实现Serializable接口,那么在进行序列化的时候,将抛出NotSerializableException。源码如下:
// remaining cases
if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
} else {
if (extendedDebugInfo) {
throw new NotSerializableException(
cl.getName() + "\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}为什么要显示指定 serialVersionUID 的值?
如果不显示指定 serialVersionUID,JVM 在序列化时会根据属性自动生成一个 serialVersionUID,然后与属性一起序列化,再进行持久化或网络传输。在反序列化时,JVM 会再根据属性自动生成一个新版 serialVersionUID,然后将这个新版 serialVersionUID 与序列化时生成的旧版 serialVersionUID 进行比较,如果相同则反序列化成功,否则报错.
如果显示指定了 serialVersionUID,JVM 在序列化和反序列化时仍然都会生成一个 serialVersionUID,但值为显示指定的值,这样在反序列化时新旧版本的 serialVersionUID 就一致了.
当然了,如果类写完后不再修改,那么不指定serialVersionUID,不会有问题,但这在实际开发中是不可能的,类会不断迭代,一旦类被修改了,那旧对象反序列化就会报错。 所以在实际开发中,都会显示指定一个 serialVersionUID。
static 属性为什么不会被序列化?
因为序列化是针对对象而言的,而 static 属性优先于对象存在,随着类的加载而加载,所以不会被序列化.
看到这个结论,是不是有人会问,serialVersionUID 也被 static 修饰,为什么 serialVersionUID 会被序列化? 其实 serialVersionUID 属性并没有被序列化,JVM 在序列化对象时会自动生成一个 serialVersionUID,然后将显示指定的 serialVersionUID 属性值赋给自动生成的 serialVersionUID。
如果有些字段不想进行序列化怎么办?transient关键字的作用?
Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。
也就是说被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。
为什么不推荐使用 JDK 自带的序列化?
我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:
- 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
- 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
- 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
常见序列化的方式
序列化只是定义了拆解对象的具体规则,那这种规则肯定也是多种多样的,比如现在常见的序列化方式有:JDK 原生、JSON、ProtoBuf、Hessian、Kryo等。
- JDK 原生
作为一个成熟的编程语言,JDK自带了序列化方法。只需要类实现了Serializable接口,就可以通过ObjectOutputStream类将对象变成byte[]字节数组。
JDK 序列化会把对象类的描述信息和所有的属性以及继承的元数据都序列化为字节流,所以会导致生成的字节流相对比较大。
另外,这种序列化方式是 JDK 自带的,因此不支持跨语言。
简单总结一下:JDK 原生的序列化方式生成的字节流比较大,也不支持跨语言,因此在实际项目和框架中用的都比较少。
- ProtoBuf
谷歌推出的,是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于通信协议、数据存储等。序列化后体积小,一般用于对传输性能有较高要求的系统。
- Hessian
Hessian 是一个轻量级的二进制 web service 协议,主要用于传输二进制数据。
在传输数据前 Hessian 支持将对象序列化成二进制流,相对于 JDK 原生序列化,Hessian序列化之后体积更小,性能更优。
- Kryo
Kryo 是一个 Java 序列化框架,号称 Java 最快的序列化框架。Kryo 在序列化速度上很有优势,底层依赖于字节码生成机制。
由于只能限定在 JVM 语言上,所以 Kryo 不支持跨语言使用。
- JSON
上面讲的几种序列化方式都是直接将对象变成二进制,也就是byte[]字节数组,这些方式都可以叫二进制方式。
JSON 序列化方式生成的是一串有规则的字符串,在可读性上要优于上面几种方式,但是在体积上就没什么优势了。
另外 JSON 是有规则的字符串,不跟任何编程语言绑定,天然上就具备了跨平台。
总结一下:JSON 可读性强,支持跨平台,体积稍微逊色。
JSON 序列化常见的框架有:fastJSON、Jackson、Gson 等。
序列化技术的选型
上面列举的这些序列化技术各有优缺点,不能简单地说哪一种就是最好的,不然也不会有这么多序列化技术共存了。
既然有这么多序列化技术可供选择,那在实际项目中如何选型呢?
我认为需要结合具体的项目来看,比较技术是服务于业务的。你可以从下面这几个因素来考虑:
协议是否支持跨平台:如果一个大的系统有好多种语言进行混合开发,那么就肯定不适合用有语言局限性的序列化协议,比如 JDK 原生、Kryo 这些只能用在 Java 语言范围下,你用 JDK 原生方式进行序列化,用其他语言是无法反序列化的。
序列化的速度:如果序列化的频率非常高,那么选择序列化速度快的协议会为你的系统性能提升不少。
序列化生成的体积:如果频繁的在网络中传输的数据那就需要数据越小越好,小的数据传输快,也不占带宽,也能整体提升系统的性能,因此序列化生成的体积就很关键了。
假设有一个 1G 大的 HashMap,此时用户请求过来刚好触发它的扩容,会怎样?让你改造下 HashMap 的实现该怎样优化?
前置知识,需要先了解 HashMap原理
如果刚好触发扩容,那么当前用户请求会被阻塞,因为 HashMap的底层是基于数组+链表(红黑树)来实现的,一旦它发生扩容,就需要新增一个比之前大2倍的数组,然后将元素copy到新的数组上
而 1G 的 HashMap 够大,所以扩容需要一定的时间,而扩容使用的又是当前的线程,所以用户此时会被阻塞,等待扩容完毕。
如何改造现有 HashMap 结构而优化处理这种场景?
此时可以借鉴 Redis 的 Hash 结构,因为 Redis 处理命令恰好是单线程的,它的 Hash 表如果很大,触发扩容的时候是不是也会导致阻塞?
所以它是怎么优化的? 答案就是:渐进式rehash
我们都知道 HashMap 默认的扩容过程是一次性重哈希,即每次扩容都会创建一个更大的数组,并将所有元素重新哈希并放入新数组
而渐进式 rehash 就是把扩容过程分批完成,通过分批扩容来减少单次扩容的开销。
简单来说不要一次性扩容完毕,而是分批搬运数据。
在分批扩容过程中,HashMap 需要维护两个数组:
- 旧数组:扩容之前的数组,包含了部分尚未迁移的数据。
- 新数组:扩容过程中创建的新数组,用于存储迁移后的数据。
实现方式:
- 扩容分批化:将重新哈希的过程分成多个步骤,而不是一次性完成。在扩容时,先创建新的数组,但只重新哈希一部分旧数据。
- 增量式迁移:每次插入、修改或查询时,检查当前是否有未完成的扩容任务。如果有,则迁移少量旧数据到新数组中,直到完成所有数据的迁移。
- 迁移状态管理:通过状态字段记录扩容的进度,确保每次操作时扩容任务逐步推进。
有两个数组,那么 get操作时候如何查询呢?
- 优先查找新数组:当用户发起 get 请求时,优先从新数组中查找。因为已经迁移的数据会直接放入新数组。
- 回退查找旧数组:如果在新数组中没有找到对应的键,说明该键还未迁移至新数组,需要回退到旧数组查找
其实这就是空间换时间的概念,也是一种权衡。
- 优点:节省的用户扩容阻塞时间,把扩容时间的消耗平均分散都后面的处理中,基本上做到了无感知
- 缺点:空间开销比较大,因为在扩容的时候,同时存在两个大数组。
这类题目就是借助一个场景,看起来问的是hashmap,实际上考察对 Redis的渐进式 Hash,是否有深入的理解,考验面试者是不是仅就死记硬背,无法应用到真实的场景。
如果没有内存限制,如何快速、安全地将 1000 亿条数据插入到 HashMap 中?
前置知识,需要先了解 HashMap原理
我们分析一下题目的关键点,HashMap、1000 亿条数据、无限制内存、快速、安全地插入。
首先无限制内存就不需要考虑机器或者 JVM 的内存溢出问题,在这个条件下 1000 亿还是 10000 亿都不影响,反正知道数据量很大就对了。
其次 Hashmap +插入,需要考虑影响性能的点:
- 哈希冲实,如果数据哈希冲突很大,都命中一个槽,如果是 JDK8之前版本,只用链表法解决冲突,那么将会非常耗时,JDK8及之后引入红黑树,红黑材的插入时间复杂度是O(logn),虽然比链表法好了很多,但终究没 (1)快。
- 扩容,Hashmap的插入过程,会先判定Hashmap的空间是否足够,如果不够的话则会扩容,每次扩容都又需要将元素迁移,很浪费时间。Hashmap默认初始容量是为16,如果插入到 1000 亿那得扩容多少次?
再者就是快速和安全,基于这两点大家应该能联想到多线程与并发安全,多线程插入肯定可以提高效率,但是Hashmap,又是一个非线程安全容器,因此使用多线程后就不安全了。
综上:
- 避免动态扩容,在 Hashmap创建的时候指定初始容量稍微超过 1000 亿,且负载因子配置为1(默认负载因子为 0.75)。负载因子的作用:假设当前 HashMap 容量设置为 16,负载因子是 0.75,则 16*8.75=12,当 HashMap 存储的数据达到了 12 的时,就会执行扩容操作。
- 采用多线程的方式、因为Hashmap线程不安全的特性、其多线程插入的时候大概率会出现数据覆盖的情况、即线程不安全的情况。针对这种情况,我们可以使用 concurrentHashmap 解决并发安全问题、concurrentHashmap 也是HashMap
- 如果面试官非要用 Hashap 来做,那么我们可以把 concurrentHashmap 的线程安全机制搬运到外部来实现。concurrentHashMap 对于冲突节点插入操作用的是CAS +synchronized 来保证线程安全。

参考concurrentHashmap,我们可以有以下思路:
- 将 1000 亿条数据分成多份,再对应起多个线程。
- 同时创建一个数组(可以大小为 1000 亿,反正不考虑内存,存放锁对象。
- 多线程并发将对应批次的数据插入到 HashMap
- 但是每个数据插入之前需要抢锁,针对 Hashap的槽都建立一把对应的锁(仿照 concurrentHashmap 的设计)
- 每个数据先按照 HashMap 的哈希算法定位哈希表的下标,根据下标从锁对象数组找到对应的锁对象,如果当前下标锁对象数组没数据,则创建一个锁对象,并通过 CAS 插入到锁对象数组中。(得到的锁对象)加锁,抢到锁之后再执行插入,这样就能避免多线程插入数据覆盖等问题了
- 当前线程利用 synchronized 加锁,抢到锁后再执行插入,这样就能避免多线程插入覆盖的问题了
上述的本质就是 concurrentHashMap的实现原理,无非就是搬运到外部来实现。
这里再提一下,线程数的控制可以自定义,和锁对象数组的大小没关系。(一千个门对应有 1000 把锁,但不是非得叫 1000 个人去开锁)
关于 concurrentHashMap的实现原理 可以看 这篇文章
如何快速定位到五分钟内重复登录了两次的QQ号,用什么数据结构?
可以使用 HashMap+LinkedList 的组合结构:
- HashMap:key 为 QQ 号,值为该 QQ号最近登录时间的有序队列(LinkedList).
- LinkedList:每个 QQ 号对应一个队列,按登录时间排序,队头为最早登录时间。
处理逻辑如下:
- 每次登录时,检查 HashMap 中是否存在该 QQ 号:
- 若存在,获取其时间队列,删除超过 5分钟前的记录。若队列不为空,则判定为重复登录。
- 若不存在,创建新队列并添加当前时间,说明没有重复。
- 插入当前登录时间到队列尾部,并更新 HashMap。
这个操作的时间复杂度为 O(1),空间复杂度O(n),因为存储了所有 QQ 号的最近登录记录。
ArrayList 里有1亿条数据,要怎么去重?
面试官已经说 ArayList 里有1亿条数据,所以不要说什么考虑内存放不放得下的问题,除非他后面再提内存方面的限制,不然假装不知道,不要给自己找事儿
常见有以下几种方案:
- HashSet 去重(最常用)
List<Integer> list = ...; // 1亿数据
Set<Integer> set = new HashSet<>(list);
List<Integer> result = new ArrayList<>(set);实现最简单,且复杂度为 O(n),这里唯一要注意的是预估容量,避免扩容开销。
- 排序 + 遍历去重
Collections.sort(list);
List<Integer> result = new ArrayList<>();
Integer prev = null;
for (Integer num : list) {
if (!num.equals(prev)) {
result.add(num);
prev = num;
}
}这个时间复杂度为 O(n log n),比 HashSet 慢。不过相比 HashSet 占内存小一些,还有一点会丢失原顺序
- 位图(BitMap)
如果面试官给了内存限制,说内存放不下,位图这个方案很合适。先遍历一遍 ArrayList,用1个二进制位标记这个数是否存在,比如数是 1234567,那么这个位图的第 1234567 位置设置成 1即可。
然后遍历位图,收集所有标记为1的位的位置,这就是去重的结果。但是这个实现方式需要注意,数据的范围。比如就是 0 到1亿之间的数字,那用 位图 只需要 12MB 内存如果是 int 范围,512MB 也够了
但如果是long 呢?long 是 64 位有符号整数,范围是 -2^63 ~ 2^63 - 1 ≈ -9.22e18 ~ 9.22e18
也就是说,能表示大约 1.8 x10^19 个不同的数。 这个量级无法实现
- 外部排序/分片去重
除了位图,用 外部排序 + 归并去重 也可以适用内存限制场景,思路类似 Hadoop/Spark的 MapReduce:
- 把数据分片写到磁盘,
- 对每个分片排序并去重。
- 多路归并,最后得到全局去重结果
HashMap 是不是线程安全的?如果让你来实现一个线程安全的 HashMap 你要怎么设计?如果不用加锁你要怎么设计?
Hashmap是 非线程安全的。因为 Hashap的内部实现并没有加锁,多个线程同时访问和修改时可能会引发数据竞争,导致数据不一致或陷入死循环等问题。
要实现一个线程安全的 HashMap,有多种设计方案,下面是几种常见的实现方法:
使用 Collections.synchronizedMap
Java 提供了一个简单的方法,可以将非线程安全的 Hashnap 包装为线程安全的版本:
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());这种方法通过在 Hashmap的方法上加 synchronized 锁实现线程安全。不过,这种方式对整个 map加锁,会导致较高的锁竞争和性能开销,尤其是在高并发情况下。
不使用锁的设计方案
如果要实现一个线程安全的 Hashmap且不使用锁,可以考虑以下几种方案
CAS(Compare-And-Swap)+分段机制
可以借鉴 concurrentHashmap 的思想,使用分段机制结合 CAS 操作来减少锁的需求。以下是实现思路:
- 分段机制:将 Map分成多个分段(如 16 个分段),每个分段是一个小的 Hashap。通过 key的 hash 值定位到具体的分段,这样可以减少锁的粒度,
- CAS操作:CAS是一种无锁操作,可以避免传统锁带来的性能开销,CAS 操作检查某个变量是否等于期望值,如果是,则将其更新为新值;否则,返回失败,使用CAS 操作可以在无锁的情况下实现线程安全的写入。
- 内部状态控制:对于需要扩容的操作,可以借助 CAS 和重试机制,比如扩容时,只对需要扩容的分段进行扩展,而不是整个 Map,以减少性能影响。
Copy-on-Write 机制
copy-on-write 是一种无锁实现的思路,适合读多写少的场景。实现思路如下:
- 每次写入操作 (如 put 或 remove ) 时,复制当前的 Map,在副本上进行修改,然后将副本替换为当前的 Map。
- 读取时始终访问当前的 Map实例,保证不会受到写入操作的影响。
这种方式的缺点是每次写入都需要复制整个 Map,会带来较大的内存和性能开销,所以只适合读多写少的场景。
让你设计一个 HashMap ,怎么设计?
这个问题我觉得可以从 HashMap 的一些关键点入手,例如 hash函数、如何处理冲突、如何扩容。
可以先说下你对 HashMap 的理解。
比如:HashMap 无非就是一个存储 <key,value>格式的集合,用于通过 key 就能快速查找到 value。
基本原理就是将 key 经过 hash 函数进行散列得到散列值,然后通过散列值对数组取模找到对应的 index。
所以 hash 函数很关键,不仅运算要快,还需要分布均匀,减少 hash 碰撞。
而因为输入值是无限的,而数组的大小是有限的所以肯定会有碰撞,因此可以采用拉链法来处理冲突。
为了避免恶意的 hash 攻击,当拉链超过一定长度之后可以转为红黑树结构。
当然超过一定的结点还是需要扩容的,不然碰撞就太严重了,
而普通的扩容会号致某次 put 延时较大,特别是 HashMap 存储的数据比较多的时候,所以可以考虑和 redis 那样搞两个table延迟移动,一次可以只移动一部分。
不过这样内存比较吃紧,所以也是看场景来权衡了
不过最好使用之前预估准数据大小,避免频繁的扩容。
基本上这样答下来差不多了,HashMap 几个关键要素都包含了,接下来就看面试官怎么问了可能会延伸到线程安全之类的问题,反正就照着 currentHashMap 的设计答。
如果让你统计每个接口每分钟调用次数怎么统计?
最简单的可以使用 ConcurrentHashMap+AtomicInteger + 定时任务实现内存中的统计。
ConcurenthashMap的key为方法的名称,value为 AtomicInteger 类型,记录调用次数,可以通过 aop切面实现每个方法调用部记录型 ConcurenthashMap 中,然后利用定时任务每 60s统计一次所有方法的数量,再清空ConcurrentHashMap.
ConcurrentHashMap 保证多方法并发统计时线程安全,AtomicInteger 保证每次累计时线程安全
@Aspect
@Component
public class ApiCallAspect {
private ConcurrentHashMap<String, AtomicInteger> apiCallCounts = new ConcurrentHashMap<>();
@Before("execution(* com.example.controller.*.*(..))")
public void recordApiCall(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
apiCallCounts.computeIfAbsent(methodName, k -> new AtomicInteger(0)).incrementAndGet();
}
public ConcurrentHashMap<String, AtomicInteger> getApiCallCounts() {
return apiCallCounts;
}
public void reset() {
apiCallCounts.clear();
}
}
//每分钟
@Scheduled(fixedRate = 60000)
public void reportApiCallCounts() {
ConcurrentHashMap<String, AtomicInteger> apiCallCounts = apiCallAspect.getApiCallCounts();
apiCallCounts.forEach((apiName, count) -> {
// 记录到 redis 或其他介质中
});
//清空记录
apiCallCounter.reset();
}优点:简单。
缺点:不准确,因为定时任务记录时候,ConcurentHashMap也一直在统计,所以最终的结果不一定是一分钟内的,而是超过一分钟的数据。且数据存储到内存中,如果意外宕机,数据就丢失了。
可以采用日志记录实现接口的统计。
每次接口调用都用日志记录:接口名、时间等信息。
利用日志采集工具将日志统一发送并存储至 es 中(或者其他 NoSQL 中),利用 es 即可统计每分钟每个接口的调用量.
也可以利用 MQ,每次接口调用时都将接口名、时间戳封装发送消息,消费端可以将这些信息存储至 NoSQL 中,最终进行统计分析。
因为存储了时间戳,所以接口的调用次数是准确的。
你常用哪些工具来分析 JVM 性能?
- jmap:用于生成堆转储的命令行工具,可以用于分析JVM内存使用情况,尤其是内存泄漏问题
- jstack:用于生成线程转储的命令行工具,可以用于分析线程状态,排查死锁等问题
- jistat:用于监控JVM统计信息的命令行工具,提供了实时的性能数据,如类加载、垃圾回收、编译器等信息
- MAT:用于分析堆转储文件的工具,可以帮助识别内存泄漏和优化内存使用
- jconsole:可以监控JVM的内存使用、垃圾回收、线程、类加载等信息
- VisualVM:可实时显示 JVM 的内存使用、垃圾回收、类加载等信息,也可以分析 Heap Dump 等.
- Arthas:一个强大的Java 诊断工具,提供了实时监控和分析功能。通过命令行界面,可以查看 的状态、监控方法调用、追踪 SQL 查询、分析性能瓶颈等。
如何在 Java 中进行内存泄漏分析?
先确认是否真的发生了内存泄漏,即观察内存使用情况。
利用 jstat 命令(jstat -gc <pid> <interal in ms>)来观察 gc 概要信息,如果发现,GC 后内存并没有明显的减少目还是持续增加持续触发gc,那说明内存泄漏的概率很大。
此时可以利用 jmap(jmap -dump:format=b,fi1e=heapdump.hprof <pid> )生成 heap dump,然后将其导入 Ecdipse MAT 或者 VsuaVM 工具内进行分析,通过大量内存的占用可以找到对应的对象。
通过对象找到对应的代码分析,确认是否可能存在内存泄漏的场景,最终修复代码,解决内存泄漏的问题。
Java 应用的内存持续性增长,但是监控显示堆内存没有什么变化,可能的原因有哪些?
这种情况,主要有以下两个原因:
- 堆外内存泄漏,比如未释放的 DirectByteBuffer(NIO)、JNI调用的本地库内存、MappedByteBuffer(文件映射)。
- 元空间膨胀,比如动态生成的类(如CGLIB代理)太多了,或者未卸载的类加载器导致元空间持续增长。
数据同步过程中出现 OOM 内存溢出,应该如何排查和解决?
先快速排查,找到 OOM 的原因:
- 看OOM 日志:初步定位可以JVM 崩溃时的错误提示(比如Java heap space是堆内存不够,GC overhead limit exceeded是GC太努力还是清不掉垃圾,Metaspace或者PermGen space是方法区溢出),锁定是堆还是方法区的问题
- 生成堆Dump:用 jmap-dump:format=b,file-heap.bin <进程ID>手动生成,或加IVM参数-xx:+HeapDumpOnOutOfMemoryError 自动保存崩溃时的内存快照。
- 分析堆文件:用工具(Ecipse MAT、VisuaIVM)打开,重点看占用内存最大的对象(比如100万条数据的List),以及在持有这些对象(GCRoots引用链),定位到代码
数据同步常见 OOM 原因及解决方法:
- 批量加载数据太贪心
比如一次性从数据库查 100 万条数据到内存(比如SELECT* FROM user),直接撑爆堆
解决方案:分批次处理,每次查1000条,处理完再查下一批;或给JDBC加 setFetchSize(Integer.MIN VALUE),让数据库分批传数据。
- 缓存只进不出
比如用静态HashMap存同步的用户信息,没设过期时间,越同步缓存越大。
解决方案:给缓存加“淘汰规则”,比如用 Guava Cache 设置 maximumsize(10000)(最多存1万条)或expireAfterWrite(1,HOURS)(1小时后自动删)
- JVM参数不合理
比如堆内存设太小(比如-Xmx1g),或用SerialGC处理大堆,GC效率极低
解决方案:调大堆内存(比如-Xmx4g),或换G1GC(-XX:+UseG1GC),G1会自动分块回收,更适合大内存场景。
双十一期间订单量暴增,实时数据同步系统出现消息堆积,如何应对?
核心应对思路可分为“应急处理”和“长期优化”两方面:
应急处理(快速削峰)
- 临时扩容消费端实例,增加并行处理能力(如Kafka增加分区数、启动更多消费者)
- 降级非核心同步任务(如用户行为日志可延迟同步),优先保障订单、支付等核心流程。
- 短暂关闭同步链路中的非必要校验逻辑(如冗余的格式检查),减少处理耗时。
长期优化
- 优化消费端处理效率(如批量处理消息、异步写入、索引优化)。
- 设计流量控制机制(限流、熔断),防止上游数据生产速度超过系统承载上限。
- 建立监控预警体系,提前发现堆积趋势并自动扩容。
为什么会出现消息堆积?
消息堆积本质是“生产速度>消费速度”,在双十一这种高峰期这种失衡会被放大:
- 生产端:订单系统每秒产生数万条消息(下单、支付、取消等),远超平时流量。
- 消费端:同步系统需要解析消息、校验数据、写入数据库,环节多且耗资源,一旦某个环节卡壳(比如数据库写入慢),就会形成堆积。
举个具体场景:平时系统每秒能处理100条消息,双十一突然中到50条1秒,消费端处理不过来,消息就像排队的人一样越积越多。如果堆积超过消息队列的存储上限(过期时间),还会导致数据丢失。
应急处理的关键动作拆解
当监控发现消息堆积时,每一分钟都很关键,正确的处理步骤应该是:
- 临时扩容:这是最快的手段。比如Kafka 的一个主题原本有4个分区,对应4个消费者,紧急扩容到16个分区和16个消费者,理论处理能力提升4倍,但要注意:分区数只能增不能成,且需确保消息能被均匀分配型新分区
- 业务峰级:比如把“订单评价同步” “物流轨协实时更新”这类非核心任务暂停,把资源计给“"支付结果同步” “库存扣减确认”。等峰值过去再重启这些任务,通过补同先机制馆回数据。
- 简化流程:临时关闭消费端的“冗余日志打印”“非关键字段校验”“实时统计计算”等操作,让代码跑得更快。比如原本每条消息要做3次数据库查询,临时减到1次必要查询。
怎么分析 JVM 当前的内存占用情况?OOM 后怎么分析?
利用 jstat监控和分析 JM 内部的垃圾回收、内存等运行状态。可以用它来查看堆内存、非堆内存等的实时状态。
可以使用 jmap 查看JVM 堆的详细信息(包括堆的配置、内存使用情况、GC活动等)。
在发生OOM时,可以根据 jmap 得到堆转储文件 (建议增加JVM启动参数,-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,在发生 OOM 后自动生成装储文件),再导入到 MAT、VisualVM、GCeasy)等工具中分析文件,找出哪些对象占用了大量的内存,再定位到具体的代码解决问题,
JVM 当前的内存占用情况查看
- jstat
它是 JDK 自带的工县,用于监控JVM 各种运行时信息
jstat -gc <pid> 1000 10- gc选项:显示垃圾收集信息(也可以用 gcutil,gcutil以百分比形式显示内存的使用情况,gc显示的是内存占用的字节数,以KB 的形式输出堆内存的使用情况)
- pid:Java 进程的 PID。
- 1000:每 1000 毫秒采样一次。
- 10:采样 10 次。
示例输出
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
1536.0 1536.0 0.0 0.0 30720.0 1024.0 708608.0 2048.0 44800.0 43712.6 4864.0 4096.0 4 0.072 1 0.015 0.087
1536.0 1536.0 0.0 0.0 30720.0 2048.0 708608.0 2048.0 44800.0 43712.6 4864.0 4096.0 4 0.072 1 0.015 0.087
1536.0 1536.0 0.0 0.0 30720.0 3072.0 708608.0 2048.0 44800.0 43712.6 4864.0 4096.0 4 0.072 1 0.015 0.087字段含义:
- S0C(Survivor Space 0 Capacity):第一个 Survivor 区域的容量(字节数).
- S1C(Survivor Space 1 Capacity):第二个 Survivor 区域的容量(字节数)。
- S0U(Survivor Space 0 Utilization):第一个 Survivor 区域的使用量(字节数)
- S1U(Survivor Space 1 Utilization):第二个 Survivor 区域的使用量(字节数)。
- EC(Eden Space Capacity): Eden 区域的容量(字节数)。
- EU(Eden Space Utilization): Eden 区域的使用量(字节数)
- OC(Old Generation Capacity): 老年代的容量(字节数)
- OU(Old Generation Utilization): 老年代的使用量(字节数)
- MC(Metaspace Capacity):方法区(Metaspace)的容量(字节数)
- MU (Metaspace Utilization):方法区的使用量(字节数)。
- CCSC(Compressed Class Space Capacity): 压缩类空间的容量(字节数)
- CCSU(Compressed Class Space Utilization): 压缩类空间的使用量(字节数)
- YGC (Young Generation GC Count):年轻代垃圾回收的次数
- YGCT (Young Generation GC Time):年轻代垃圾回收的总时间(秒)。
- FGC (Full GC Count): full gc 的次数。
- FGCT(Full GC Time): full gc 的总时间(秒)。
- GCT(Garbage Collection Time): 总的垃圾回收时间(秒)。
注意:如果 FGC 变化频率很高,则说明系统性能和吞吐量将下降,或者可能出现内存溢出。
使用 jmap 工具生成堆转储文件
jmap -dump:format=b,file=heap_dump.hprof <pid>大部分系统内存占用2GB~8GB,此命令会导致虚拟机暂停工作1~3秒左右
可以在 JVM 内存溢出后,主动 dump 生成文件,在启动时增加以下参数即可。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof使用工具分析堆转储文件
再使用MAT 或 GCeasy等工具分析转储文件
