内存管理:避免内存溢出和频繁的垃圾回收

一、前言

在高并发、高吞吐量的极限情况下,简单的事情就会变得没有那么简单了。

  • 一个业务逻辑非常简单的微服务,日常情况下都能稳定运行,一到大促就卡死甚至进程挂掉。
  • 一个做数据汇总的应用,按照小时、天这样的粒度进行数据汇总都没有问题,到年底需要汇总全年数据的时候,没等数据汇总出来,程序就死掉了。

出现这些情况的大部分原因是,在程序设计时,没有针对高并发高吞吐量的情况做好内存管理

 

二、内存管理机制

现代编程语言,想Java、Go等,采用的都是自动内存管理机制。在编写代码时,不需要显式去申请和释放内存。当创建一个新对象时,系统会自动分配一块内存用于存放新创建的对象,对象使用完毕后,系统会自动择机收回这块内存,完全不需要开发者干预。对于开发者,这种自动内存管理机制,显然是非常方便的,不仅极大降低了开发难度,提升了开发效率,更重要的是,它完美地解决了内存泄漏的问题。但是它也会带来一些问题,这要从它的实现原理来分析。

 

做内存管理,主要需要考虑申请内存和内存回收:

1、申请内存的逻辑

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

 

2、内存回收的逻辑

(1)、先是要找到所有可以回收的对象,将对应的内存标记为空闲
现代的GC算法大多采用的是“标记 - 清除” 算法或是它的变种算法,这种算法分为标记和清除两个阶段:

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

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

 

(2)、然后还需要整理内存碎片。

假设,我们的内存只有 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,总体看来,效果并不是特别好。因此,一般情况下并不推荐你这样做,具体还是要根据你的应用情况,综合权衡做出一个相对最优的选择。

posted @ 2019-09-06 11:50  chjxbt  阅读(963)  评论(0编辑  收藏  举报