大内存硬件上的程序部署策略
一个15万PV/日左右的在线文档类
型网站最近更换了硬件系统,服务器的硬件为四路志强处理器、16GB物理内存,操作系统为64位
CentOS 5.4,Resin作为Web服务器。整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给
这访问量并不算太大的文档网站使用。软件版本选用的是64位的JDK 5,管理员启用了一个虚拟机实
例,使用-Xmx和-Xms参数将Java堆大小固定在12GB。使用一段时间后发现服务器的运行效果十分不
理想,网站经常不定期出现长时间失去响应。
监控服务器运行状况后发现网站失去响应是由垃圾收集停顿所导致的,在该系统软硬件条件下,
HotSpot虚拟机是以服务端模式运行,默认使用的是吞吐量优先收集器,回收12GB的Java堆,一次Full
GC的停顿时间就高达14秒。由于程序设计的原因,访问文档时会把文档从磁盘提取到内存中,导致内
存中出现很多由文档序列化产生的大对象,这些大对象大多在分配时就直接进入了老年代,没有在
Minor GC中被清理掉。这种情况下即使有12GB的堆,内存也很快会被消耗殆尽,由此导致每隔几分
钟出现十几秒的停顿,令网站开发、管理员都对使用Java技术开发网站感到很失望。
调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了
10GB内存。另外建立一个Apache服务作为前端均衡代理作为访问门户。考虑到用户对响应速度比较关
心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度较低,因此改为CMS收集器
进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比起硬件升级前有较大提升。
堆外内存导致的溢出错误
这是一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考
试数据,系统使用了逆向AJAX技术(也称为Comet或者Server Side Push),选用CometD 1.1.1作为服
务端推送框架,服务器是Jetty 7.1.4,硬件为一台很普通PC机,Core i5 CPU,4GB内存,运行32位
Windows操作系统。
测试期间发现服务端不定时抛出内存溢出异常,服务不一定每次都出现异常,但假如正式考试时
崩溃一次,那估计整场电子考试都会乱套。网站管理员尝试过把堆内存调到最大,32位系统最多到
1.6GB基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像还更加频繁。加入-XX:
+HeapDumpOnOutOfMemoryError参数,居然也没有任何反应,抛出内存溢出异常时什么文件都没有
产生。无奈之下只好挂着jstat紧盯屏幕,发现垃圾收集并不频繁,Eden区、Survivor区、老年代以及方
法区的内存全部都很稳定,压力并不大,但就是照样不停抛出内存溢出异常。最后,在内存溢出后从
系统日志中找到异常堆栈如代码清单5-1所示。
[org.eclipse.jetty.util.log] handle failed java.lang.OutOfMemoryError: null
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
……我们
知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的32位Windows平台的限制是
2GB,其中划了1.6GB给Java堆,而Direct Memory耗用的内存并不算入这1.6GB的堆之内,因此它最大
也只能在剩余的0.4GB空间中再分出一部分而已。在此应用中导致溢出的关键是垃圾收集进行时,虚
拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就主
动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,“顺便”帮它清理掉内存的废弃对
象。否则就不得不一直等到抛出内存溢出异常时,先捕获到异常,再在Catch块里面通过System.gc()命
令来触发垃圾收集。但如果Java虚拟机再打开了-XX:+DisableExplicitGC开关,禁止了人工触发垃圾
收集的话,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。而本案
例中使用的CometD 1.1.1框架,正好有大量的NIO操作需要使用到直接内存
从实践经验的角度出发,在处理小内存或者32位的应用问题时,除了Java堆和方法区之外,我们
注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制:
·直接内存:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOf-MemoryError或
者OutOfMemoryError:Direct buffer memory。
·线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(如果线程请求的栈深度大
于虚拟机所允许的深度)或者OutOfMemoryError(如果Java虚拟机栈容量可以动态扩展,当栈扩展时
无法申请到足够的内存)。
·JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中,而是占用Java虚
拟机的本地方法栈和本地内存的。
·虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的。
不恰当数据结构导致内存占用过大
一个后台RPC服务器,使用64位Java虚拟机,内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew加
CMS的收集器组合。平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每
10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个
HashMap<Long,Long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿,对于这种长度
的停顿时间就接受不了了。
观察这个案例的日志,平时Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的,在
Minor GC之后Eden和Survivor基本上处于完全空闲的状态。但是在分析数据文件期间,800MB的Eden
空间很快被填满引发垃圾收集,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道
ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如
果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性就成为一个沉重的负担,因
此导致垃圾收集的暂停时间明显变长。
如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑直接将Survivor空间去掉(加入
参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+Always-Tenure),让新生
代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再去清理它们。这种措施
可以治标,但也有很大副作用;治本的方案必须要修改程序,因为这里产生问题的根本原因是用
HashMap<Long,Long>结构来存储数据文件空间效率太低了。
我们具体分析一下HashMap空间效率,在HashMap<Long,Long>结构中,只有Key和Value所存放
的两个长整型数据是有效数据,共16字节(2×8字节)。这两个长整型数据包装成java.lang.Long对象之
后,就分别具有8字节的Mark Word、8字节的Klass指针,再加8字节存储数据的long值。然后这2个
Long对象组成Map.Entry之后,又多了16字节的对象头,然后一个8字节的next字段和4字节的int型的
hash字段,为了对齐,还必须添加4字节的空白填充,最后还有HashMap中对这个Entry的8字节的引
用,这样增加两个长整型数字,实际耗费的内存为(Long(24byte)×2)+Entry(32byte)+HashMap
Ref(8byte)=88byte,空间效率为有效数据除以全部内存空间,即16字节/88字节=18%,这确实太低了。
由安全点导致长时间停顿
有一个比较大的承担公共计算任务的离线HBase集群,运行在JDK 8上,使用G1收集器。每天都
有大量的MapReduce或Spark离线分析任务对其进行访问,同时有很多其他在线集群Replication过来的
数据写入,因为集群读写压力较大,而离线分析任务对延迟又不会特别敏感,所以将-XX:
MaxGCPauseMillis参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以
上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒,(我理解的是g1提前计算出需要回收的region大致清理时间,然后具体执行的时候可能会受实际情况而导致延长,而不是到时间了就中断不清理了)现象如以下日志所示。
[Times: user=1.51 sys=0.67, real=0.14 secs]
2019-06-25T 12:12:43.376+0800: 3448319.277: Total time for which application threads were stopped: 2.2645818 seconds·user:进程执行用户态代码所耗费的处理器时间。
·sys:进程执行核心态代码所耗费的处理器时间。
·real:执行动作从开始到结束耗费的时钟时间。
前面两个是处理器时间,而最后一个是时钟时间,它们的区别是处理器时间代表的是线
程占用处理器一个核心的耗时计数,而时钟时间就是现实世界中的时间计数。如果是单核单线程的场
景下,这两者可以认为是等价的,但如果是多核环境下,同一个时钟时间内有多少处理器核心正在工
作,就会有多少倍的处理器时间被消耗和记录下来。
在垃圾收集调优时,我们主要依据real时间为目标来优化程序,因为最终用户只关心发出请求到得
到响应所花费的时间,也就是响应速度,而不太关心程序到底使用了多少个线程或者处理器来完成任
务。
日志显示这次垃圾收集一共花费了0.14秒,但其中用户线程却足足停顿了有2.26秒,两者差距已经
远远超出了正常的TTSP(Time To Safepoint)耗时的范畴。所以先加入参数-XX:
+PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1去查看安全点日志,具体如下所示:
vmop [threads: total initially_running wait_to_block]
65968.203: ForceAsyncSafepoint [931 1 2]
[time: spin block sync cleanup vmop] page_trap_count
[2255 0 2255 11 0] 1日志显示当前虚拟机的操作(VM Operation,VMOP)是等待所有用户线程进入到安全点,但是
有两个线程特别慢,导致发生了很长时间的自旋等待。日志中的2255毫秒自旋(Spin)时间就是指由
于部分线程已经走到了安全点,但还有一些特别慢的线程并没有到,所以垃圾收集线程无法开始工
作,只能空转(自旋)等待。
解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX:
+SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的
时间超过2000毫秒时就认定为超时,这样就会输出导致问题的线程名称,得到的日志如下所示:
# SafepointSynchronize::begin: Timeout detected:
# SafepointSynchronize::begin: Timed out while spinning to reach a safepoint.
# SafepointSynchronize::begin: Threads which did not reach the safepoint:
# "RpcServer.listener,port=24600" #32 daemon prio=5 os_prio=0 tid=0x00007f4c14b22840
nid=0xa621 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
# SafepointSynchronize::begin: (End of list)从错误日志中顺利得到了导致问题的线程名称为“RpcServer.listener,port=24600”。
安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的,所以方法调用、循环跳转、异常跳
转这些位置都可能会设置有安全点,但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环
还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小
的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted
Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环
(Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不
单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。
HotSpot原本提供了-XX:+UseCountedLoopSafepoints参数去强制在可数循环中也放置安全点,不
过这个参数在JDK 8下有Bug [2],有导致虚拟机崩溃的风险,所以就不得不找到RpcServer线程里面的
缓慢代码来进行修改。最终查明导致这个问题是HBase中一个连接超时清理的函数,由于集群会有多
个MapReduce或Spark任务进行访问,而每个任务又会同时起多个Mapper/Reducer/Executer,其每一个
都会作为一个HBase的客户端,这就导致了同时连接的数量会非常多。更为关键的是,清理连接的索
引值就是int类型,所以这是一个可数循环,HotSpot不会在循环中插入安全点。当垃圾收集发生时,
如果RpcServer的Listener线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安
全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就
非常简单了,把循环索引的数据类型从int改为long即可,但如果不具备安全点和垃圾收集的知识,这
种问题是很难处理的。