有钱人的烦恼——服务器CPU和JVM GC卡顿问题排查和解决

两周前,公司IT部门将公司的服务器从48核更换为了96核,其他各方面配置也对应提升,上线第一天,即发现


问题时刻CPU监控信息

这是非常高的cpu_system占用,看了下以往的数据,这个数值一般也就2%,但现在是20%。而从业务系统层面,用户持续感觉到环境卡顿,于是马上开展了环境排查。
经排查,同时刻排除了以下几种情况:
1、IO问题,没有见到明显的IO瓶颈
2、GC问题,不存在特别高频GC,更没有FGC。
3、数据库连接问题

至此,基本可以排除业务系统自身导致出现本问题,问题转向到操作系统方向做排查。

两日后,趁着晚上人少,根据当时的监控图,做了CPU火焰图的抽取,信息如下:


夜间时CPU_System占用仍然很高
同时刻CPU火焰图

从上图可以非常清楚的看到,绝大部分CPU被耗费在了GC上,但,并不是在内存的回收上,而是一段从未见过的堆栈:


主要CPU占用堆栈

一下子走到了我的知识盲区,毕竟,这已经是内核态的方法了,作为一个Java码农,对于Linux内核确确实实就是一只小白。

虽然到这里,已经基本可以确认,当前问题和应用程序无关,但问题到底是什么,如何解决,客户如果遇到怎么办?这几个问题一直萦绕在我脑海里,让我不想放弃对于这个问题的排查。

隔日,开始认真解读这一段堆栈,先拆解放下来

page_fault
do_page_fault
_do_page_fault
handle_mm_fault
do_numa_page
raw_qspin_lock
queued_spin_lock_slowpath
native_queued_spin_lock_slowpath

page_fault是缺页错误,这个是很显然的。但为何GC会走到缺页错误呢?难道是回收内存时,遇到了无法申请到内存页?或者是虚拟内存页指向错误?而且第4层堆栈handle_mm_fault,正好就是内存映射错误的处理,难道真的是无法申请到内存了吗?

没有思路,继续往下看,第5层堆栈,do_numa_fault,查询到了一篇介绍非常详细的文章numa balance。

根据这张numa balance的作业流程图


作业流程图

可知,前面的page_fault实际是numa balance的一种特定的执行过程,为了达到多核心就近访问的目的,在linux内核中设置的陷阱,一旦系统发生特定内存页访问时,系统就会陷入内核态中,执行内存页迁移的操作。

因此,我们做了numa关闭的验证,结果发现


numa关闭前后对比图

如上图,cpu_system时间基本归零了,也侧面证明了,这个思路方向是对的。

但,为何这个为了提升多核心访问效率的linux特有行为,为何反而成了系统卡顿的原因呢?所以问题到这里,仍然不算很好的解决,继续向下。

第6行raw_qspin_lock,找不到对应的资料,可能是当前的linux版本的代码跟最新的代码不一致的关系,只找到了raw_spin_lock的解释,其本义,就是spin_lock,也就是本文真正的主角:“自旋锁”。(恨自己英文不好,如果第一时间看懂了,何必要花这么久的时间,泪啊)

走到这里,相信大多数读者都想到问题点所在了。我们都知道自旋锁一种同步策略,常用于资源竞争,但我们也都知道,自旋锁也是尤其局限性的,那就是如果竞争锁的线程比较多时,其性能反而会下降,以至于反倒不如独占锁,这里就不展开了。

这里还有另外一篇文章,也提到了类似的问题,大家可以参考, Why having more and faster cores makes my multithreaded software slower?

于是,读到这里,结合前面的场景,我有了一个猜测:

这是否是一个由Linux操作系统,96核X86CPU服务器以及JVM的GC线程共同导演的一场运行事故呢?

事实可能是这样的:

由于目前应用JVM采取的是G1的垃圾回收策略,而G1的垃圾回收线程数默认是8个,当CPU核心数超过8时,其线程数为8+(核心数-8)5/8,按照目前的服务器则为8+(96-8)5/8=63,当执行垃圾回收时,按照多核心下linux操作系统的逻辑,会执行numa自平衡操作,这里会通过自旋锁进行页表的抢占式竞争,而得不到页表的线程,会等待直至前面的线程完成操作,又因为同一时刻进行抢占的线程非常多,导致了大量的自旋带来的CPU消耗,又因为当前CPU完全陷落在内核态中,用户线程完全无法得到响应,最终导致业务系统出现卡顿的现象。

既然有了这样的猜测,那么解决的方法就很简单了,即,限制最大GC线程数,从而降低自旋锁的竞争度,提高执行效率。于是手动设置GC线程数,即-XX:ParallelGCThreads=24,验证,重启后CPU监控结果如下:


调整GC线程数以后的CPU监控情况

明显可知,CPU_System占用已经降低到了完全不影响用户事情的程度。

为何是24,原因是原系统是48核,经计算,其默认GC线程数为33,为了保险起见,我把线程数定在了24,根据后续验证情况而定。

至此

问题终于得到了比较完美的解决。

为何只是比较,因为这里还有一个场景问题没有回答,即当前环境是X86架构,而我们都知道ARM架构下,多核心是常态,常有96以及192核心的ARM架构服务器,而ARM架构下,指令集比X86架构的指令集更精简,其实现自旋的逻辑是否与X86效率相似,目前没有答案,因此,在ARM架构下,同类问题是否存在以及如何处理,仍然需要机会验证。

这里忍不住要吐槽一下,这个问题在X86的Linux环境下,只要CPU数量足够多,发生这个问题是大概率事件,如果JVM的G1垃圾回收器没有做任何有效的应对和处置,这个开发能力,真是不忍直视,自觉应该找地方给他们提的ISSUE。

同时,也是建议,任何超过48核心的X86架构Linux系统,都务必要关注垃圾回收器的线程数,避免出现此次楼主的尴尬时刻。
当然,这也是为何题目说这是有钱人的烦恼了,因为穷人,谁又会用96核单独跑一个Java进程呢?

其他参考文档:
深入理解Page Fault处理
关于numa loadbance的死锁分析
Linux源码

特别感谢运维负责人:龙波

你可能感兴趣的:(有钱人的烦恼——服务器CPU和JVM GC卡顿问题排查和解决)