You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

91 lines
10 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 14 | 内存管理:如何避免内存溢出和频繁的垃圾回收?
你好,我是李玥。今天,我们来聊一聊内存管理的问题。
不知道你有没有发现,在高并发、高吞吐量的极限情况下,简单的事情就会变得没有那么简单了。一个业务逻辑非常简单的微服务,日常情况下都能稳定运行,为什么一到大促就卡死甚至进程挂掉?再比如,一个做数据汇总的应用,按照小时、天这样的粒度进行数据汇总都没问题,到年底需要汇总全年数据的时候,没等数据汇总出来,程序就死掉了。
之所以出现这些情况,大部分的原因是,程序在设计的时候,没有针对高并发高吞吐量的情况做好内存管理。要想解决这类问题,首先你要了解内存管理机制。
现代的编程语言像Java、Go语言等采用的都是自动内存管理机制。我们在编写代码的时候不需要显式去申请和释放内存。当我们创建一个新对象的时候系统会自动分配一块内存用于存放新创建的对象对象使用完毕后系统会自动择机收回这块内存完全不需要开发者干预。
对于开发者来说这种自动内存管理的机制显然是非常方便的不仅极大降低了开发难度提升了开发效率更重要的是它完美地解决了内存泄漏的问题。是不是很厉害当年Java语言能够迅速普及和流行超越C和C++,自动内存管理机制是非常重要的一个因素。但是它也会带来一些问题,什么问题呢?这就要从它的实现原理中来分析。
## 自动内存管理机制的实现原理
做内存管理,主要需要考虑申请内存和内存回收这两个部分。
申请内存的逻辑非常简单:
1. 计算要创建对象所需要占用的内存大小;
2. 在内存中找一块儿连续并且是空闲的内存空间,标记为已占用;
3. 把申请的内存地址绑定到对象的引用上,这时候对象就可以使用了。
内存回收的过程就非常复杂了,总体上,内存回收需要做这样两件事儿:先是要找出所有可以回收的对象,将对应的内存标记为空闲,然后,还需要整理内存碎片。
如何找出可以回收的对象呢现代的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左右的文本在高并发的情况下你会如何来优化这个程序来尽量避免由于垃圾回收导致的进程卡死问题欢迎你在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。