learn-tech/专栏/消息队列高手课/14内存管理:如何避免内存溢出和频繁的垃圾回收?.md
2024-10-16 06:37:41 +08:00

96 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
14 内存管理:如何避免内存溢出和频繁的垃圾回收?
你好,我是李玥。今天,我们来聊一聊内存管理的问题。
不知道你有没有发现,在高并发、高吞吐量的极限情况下,简单的事情就会变得没有那么简单了。一个业务逻辑非常简单的微服务,日常情况下都能稳定运行,为什么一到大促就卡死甚至进程挂掉?再比如,一个做数据汇总的应用,按照小时、天这样的粒度进行数据汇总都没问题,到年底需要汇总全年数据的时候,没等数据汇总出来,程序就死掉了。
之所以出现这些情况,大部分的原因是,程序在设计的时候,没有针对高并发高吞吐量的情况做好内存管理。要想解决这类问题,首先你要了解内存管理机制。
现代的编程语言,像 Java、Go 语言等,采用的都是自动内存管理机制。我们在编写代码的时候,不需要显式去申请和释放内存。当我们创建一个新对象的时候,系统会自动分配一块内存用于存放新创建的对象,对象使用完毕后,系统会自动择机收回这块内存,完全不需要开发者干预。
对于开发者来说这种自动内存管理的机制显然是非常方便的不仅极大降低了开发难度提升了开发效率更重要的是它完美地解决了内存泄漏的问题。是不是很厉害当年Java 语言能够迅速普及和流行,超越 C 和 C++,自动内存管理机制是非常重要的一个因素。但是它也会带来一些问题,什么问题呢?这就要从它的实现原理中来分析。
自动内存管理机制的实现原理
做内存管理,主要需要考虑申请内存和内存回收这两个部分。
申请内存的逻辑非常简单:
计算要创建对象所需要占用的内存大小;
在内存中找一块儿连续并且是空闲的内存空间,标记为已占用;
把申请的内存地址绑定到对象的引用上,这时候对象就可以使用了。
内存回收的过程就非常复杂了,总体上,内存回收需要做这样两件事儿:先是要找出所有可以回收的对象,将对应的内存标记为空闲,然后,还需要整理内存碎片。
如何找出可以回收的对象呢?现代的 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总体看来效果并不是特别好。因此一般情况下我并不推荐你这样做具体还是要根据你的应用情况综合权衡做出一个相对最优的选择。
小结
现代的编程语言,大多采用自动内存管理机制,虚拟机会不定期执行垃圾回收,自动释放我们不再使用的内存,但是执行垃圾回收的过程会导致进程暂停。
在高并发的场景下,会产生大量的待回收的对象,需要频繁地执行垃圾回收,导致程序长时间暂停,我们的程序看起来就像卡死了一样。为了缓解这个问题,我们需要尽量少地使用一次性对象,对于需要频繁使用,占用内存较大的一次性对象,我们可以考虑自行回收并重用这些对象,来减轻垃圾回收的压力。