<audio id="audio" title="14 | 内存管理：如何避免内存溢出和频繁的垃圾回收？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/d0/777920abea0b82e5facae1e3782958d0.mp3"></audio>

你好，我是李玥。今天，我们来聊一聊内存管理的问题。

不知道你有没有发现，在高并发、高吞吐量的极限情况下，简单的事情就会变得没有那么简单了。一个业务逻辑非常简单的微服务，日常情况下都能稳定运行，为什么一到大促就卡死甚至进程挂掉？再比如，一个做数据汇总的应用，按照小时、天这样的粒度进行数据汇总都没问题，到年底需要汇总全年数据的时候，没等数据汇总出来，程序就死掉了。

之所以出现这些情况，大部分的原因是，程序在设计的时候，没有针对高并发高吞吐量的情况做好内存管理。要想解决这类问题，首先你要了解内存管理机制。

现代的编程语言，像Java、Go语言等，采用的都是自动内存管理机制。我们在编写代码的时候，不需要显式去申请和释放内存。当我们创建一个新对象的时候，系统会自动分配一块内存用于存放新创建的对象，对象使用完毕后，系统会自动择机收回这块内存，完全不需要开发者干预。

对于开发者来说，这种自动内存管理的机制，显然是非常方便的，不仅极大降低了开发难度，提升了开发效率，更重要的是，它完美地解决了内存泄漏的问题。是不是很厉害？当年，Java语言能够迅速普及和流行，超越C和C++，自动内存管理机制是非常重要的一个因素。但是它也会带来一些问题，什么问题呢？这就要从它的实现原理中来分析。

## 自动内存管理机制的实现原理

做内存管理，主要需要考虑申请内存和内存回收这两个部分。

申请内存的逻辑非常简单：

1. 计算要创建对象所需要占用的内存大小；
1. 在内存中找一块儿连续并且是空闲的内存空间，标记为已占用；
1. 把申请的内存地址绑定到对象的引用上，这时候对象就可以使用了。

内存回收的过程就非常复杂了，总体上，内存回收需要做这样两件事儿：先是要找出所有可以回收的对象，将对应的内存标记为空闲，然后，还需要整理内存碎片。

如何找出可以回收的对象呢？现代的GC算法大多采用的是“标记-清除”算法或是它的变种算法，这种算法分为标记和清除两个阶段：

- 标记阶段：从GC Root开始，你可以简单地把GC Root理解为程序入口的那个对象，标记所有可达的对象，因为程序中所有在用的对象一定都会被这个GC Root对象直接或者间接引用。
- 清除阶段：遍历所有对象，找出所有没有标记的对象。这些没有标记的对象都是可以被回收的，清除这些对象，释放对应的内存即可。

这个算法有一个最大问题就是，在执行标记和清除过程中，必须把进程暂停，否则计算的结果就是不准确的。这也就是为什么发生垃圾回收的时候，我们的程序会卡死的原因。后续产生了许多变种的算法，这些算法更加复杂，可以减少一些进程暂停的时间，但都不能完全避免暂停进程。

完成对象回收后，还需要整理内存碎片。什么是内存碎片呢？我举个例子你就明白了。

假设，我们的内存只有10个字节，一开始这10个字节都是空闲的。我们初始化了5个Short类型的对象，每个Short占2个字节，正好占满10个字节的内存空间。程序运行一段时间后，其中的2个Short对象用完并被回收了。这时候，如果我需要创建一个占4个字节的Int对象，是否可以创建成功呢？

答案是，不一定。我们刚刚回收了2个Short，正好是4个字节，但是，创建一个Int对象需要连续4个字节的内存空间，2段2个字节的内存，并不一定就等于一段连续的4字节内存。如果这两段2字节的空闲内存不连续，我们就无法创建Int对象，这就是内存碎片问题。

所以，**垃圾回收完成后，还需要进行内存碎片整理，将不连续的空闲内存移动到一起，以便空出足够的连续内存空间供后续使用。**和垃圾回收算法一样，内存碎片整理也有很多非常复杂的实现方法，但由于整理过程中需要移动内存中的数据，也都不可避免地需要暂停进程。

虽然自动内存管理机制有效地解决了内存泄漏问题，带来的代价是执行垃圾回收时会暂停进程，如果暂停的时间过长，程序看起来就像“卡死了”一样。

## 为什么在高并发下程序会卡死？

在理解了自动内存管理的基本原理后，我再带你分析一下，为什么在高并发场景下，这种自动内存管理的机制会更容易触发进程暂停。

一般来说，我们的微服务在收到一个请求后，执行一段业务逻辑，然后返回响应。这个过程中，会创建一些对象，比如说请求对象、响应对象和处理中间业务逻辑中需要使用的一些对象等等。随着这个请求响应的处理流程结束，我们创建的这些对象也就都没有用了，它们将会在下一次垃圾回收过程中被释放。

你需要注意的是，直到下一次垃圾回收之前，这些已经没有用的对象会一直占用内存。

那么，虚拟机是如何决定什么时候来执行垃圾回收呢？这里面的策略非常复杂，也有很多不同的实现，我们不展开来讲，但是无论是什么策略，如果内存不够用了，那肯定要执行一次垃圾回收的，否则程序就没法继续运行了。

在低并发情况下，单位时间内需要处理的请求不多，创建的对象数量不会很多，自动垃圾回收机制可以很好地发挥作用，它可以选择在系统不太忙的时候来执行垃圾回收，每次垃圾回收的对象数量也不多，相应的，程序暂停的时间非常短，短到我们都无法感知到这个暂停。这是一个良性的循环。

在高并发的情况下，一切都变得不一样了。

我们的程序会非常繁忙，短时间内就会创建大量的对象，这些对象将会迅速占满内存，这时候，由于没有内存可以使用了，垃圾回收被迫开始启动，并且，这次被迫执行的垃圾回收面临的是占满整个内存的海量对象，它执行的时间也会比较长，相应的，这个回收过程会导致进程长时间暂停。

进程长时间暂停，又会导致大量的请求积压等待处理，垃圾回收刚刚结束，更多的请求立刻涌进来，迅速占满内存，再次被迫执行垃圾回收，进入了一个恶性循环。如果垃圾回收的速度跟不上创建对象的速度，还可能会产生内存溢出的现象。

于是，就出现了我在这节课开始提到的那个情况：一到大促，大量请求过来，我们的服务就卡死了。

## 高并发下的内存管理技巧

对于开发者来说，垃圾回收是不可控的，而且是无法避免的。但是，我们还是可以通过一些方法来降低垃圾回收的频率，减少进程暂停的时长。

我们知道，只有使用过被丢弃的对象才是垃圾回收的目标，所以，我们需要想办法在处理大量请求的同时，尽量少的产生这种一次性对象。

最有效的方法就是，优化你的代码中处理请求的业务逻辑，尽量少的创建一次性对象，特别是占用内存较大的对象。比如说，我们可以把收到请求的Request对象在业务流程中一直传递下去，而不是每执行一个步骤，就创建一个内容和Request对象差不多的新对象。这里面没有多少通用的优化方法，你需要根据我告诉你的这个原则，针对你的业务逻辑来想办法进行优化。

对于需要频繁使用，占用内存较大的一次性对象，我们可以考虑自行回收并重用这些对象。实现的方法是这样的：我们可以为这些对象建立一个对象池。收到请求后，在对象池内申请一个对象，使用完后再放回到对象池中，这样就可以反复地重用这些对象，非常有效地避免频繁触发垃圾回收。

如果可能的话，使用更大内存的服务器，也可以非常有效地缓解这个问题。

以上这些方法，都可以在一定程度上缓解由于垃圾回收导致的进程暂停，如果你优化的好，是可以达到一个还不错的效果的。

当然，要从根本上来解决这个问题，办法只有一个，那就是绕开自动垃圾回收机制，自己来实现内存管理。但是，自行管理内存将会带来非常多的问题，比如说极大增加了程序的复杂度，可能会引起内存泄漏等等。

流计算平台Flink，就是自行实现了一套内存管理机制，一定程度上缓解了处理大量数据时垃圾回收的问题，但是也带来了一些问题和Bug，总体看来，效果并不是特别好。因此，一般情况下我并不推荐你这样做，具体还是要根据你的应用情况，综合权衡做出一个相对最优的选择。

## 小结

现代的编程语言，大多采用自动内存管理机制，虚拟机会不定期执行垃圾回收，自动释放我们不再使用的内存，但是执行垃圾回收的过程会导致进程暂停。

在高并发的场景下，会产生大量的待回收的对象，需要频繁地执行垃圾回收，导致程序长时间暂停，我们的程序看起来就像卡死了一样。为了缓解这个问题，我们需要尽量少地使用一次性对象，对于需要频繁使用，占用内存较大的一次性对象，我们可以考虑自行回收并重用这些对象，来减轻垃圾回收的压力。

## 思考题

如果我们的微服务的需求是处理大量的文本，比如说，每次请求会传入一个10KB左右的文本，在高并发的情况下，你会如何来优化这个程序，来尽量避免由于垃圾回收导致的进程卡死问题？欢迎你在留言区与我分享讨论。

感谢阅读，如果你觉得这篇文章对你有一些启发，也欢迎把它分享给你的朋友。


