深入理解Java虚拟机-第五章 调优案例分析与实战

第五章 调优案例分析与实战

本来是不想写这一章的,因为讲的都是案例分析。而 Eclipse 调优对我本身用的是 IDEA 也仅仅是只有参考作用,所以感觉没有写的必要。可是读着读着感觉还是要开一章写一下自己的思考过程,有助于后面的进步。

5.1 概述:

5.2 案例分析

5.2.1 高性能硬件上的程序部署策略

书中讲述了一个 15 万 PV / 天 左右的在线文档类型网站更换了硬件系统,新的硬件为 4 个 CPU 、16 G 物理内存,操作系统为 64 位 CentOS 5.4,Resin 作为 Web 服务器。但是升级后网站经常不定期出现长时间失去响应的情况。
针对这种情况,我会第一时间查看日志看是否有定时任务存在。在网站发生停顿的时候是否抛出异常并打印日志。看书中的描述很明显没报错。那么下一步就要自然而然的去想停顿的产生原因,如果不是机器网络波动的话第一反应肯定是 STW 。果不其然,书中继续分析是因为 GC 停顿导致的,虚拟机运行在 Server 模式,默认使用吞吐量优先收集器,回收 12G 的内存一次 Full GC 要停顿14秒。而且由于程序设计的关系,访问文档时要把文档从磁盘中提到内存里,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代而没在 Minor GC 中清理掉。这个时候我们会想到 进入老年代的三个条件:

  • 对象年龄过大,多次 Minor GC 都没有清理掉
  • 对象很大,大于设定的参数而直接分配进入老年代
  • 对象年龄不大,但是 S 区中小于此年龄的大小总和占一定比例。

对于这些信息收集到后,我的治理办法有以下三点:

  • 调整垃圾收集器,由吞吐量优先的收集器改为 CMS 或 G1 这种为缩短停顿时间而设计的收集器。因为吞吐量优先的收集器是将停顿时间延后考虑的,他首先考虑的是是否能够高效运用 CPU 和内存。这类收集器更适用与计算类服务器而不是文中的页面响应服务器。
  • 缩小整个堆的大小来减少每次回收的停顿时间。因为我们知道,垃圾收集器(不管是 CMS 还是 G1)主要停顿是在 第一次标记和重标记(最终标记)的时候。而像 CMS 的重标记会扫描整个堆,缩小堆的大小就会减少扫描时间,就能减少停顿时间。
  • 提高年轻代大小,让大对象尽量在年轻代就被回收掉不要进入老年代(避免大对象直接分配进老年代)。这样虽然 Minor GC 耗时可能长一些,但是相比把所有压力都放在老年代要好很多。

我以为这样就万事大吉了,可是我忽略了两点。用户之所以会出现这样的问题根本原因是因为用户觉得系统太慢而升级扩容,而我为解决问题又给他退回去了,不单浪费了升级后的机器资源,而且并没有达到用户“升级加速”的目的**。
书中给的解法,是使用逻辑集群。最后的部署方案调整为建立 5 个 JDK 的逻辑集群,每个按照固定内存计算。另外建立一个 Apache 服务作为前端均衡代理访问门户。考虑到用户对响应速度较关心且文档服务的主要压力集中在磁盘和内存访问,CPU 资源敏感度较低,于是更换为 CMS 收集器进行垃圾回收。
对比我的解决方案,书中给的部署方案显然较合理。不过我认为针对单独的虚拟机仍可以采用我的优化方案(这里欢迎理智来喷哈,我就是针对我当前的理解这么一说)来降低 GC 停顿时间。

5.2.2 集群间同步导致的内存溢出

案例中讲述了一个集群,通过JBossCache构建了一个全局缓存,而使用一段时间后却频发内存溢出。定位问题就只好在发生 OOM 的时候记录 heapdump 。加入启动参数 -XX:+HeapDumpOnOutOfMemoryError 。然后发现在 OOM 时,内存中充斥着大量的 NAKACK 对象,我对 JBossCache 存在知识盲区,不好定位怎么判断于直接看到后文。发现是因为网络波动时,重发数据在内存中不断的堆积,很快就 OOM 了。

5.2.3 堆外内存导致的溢出错误

案例中讲述了一个小型的学校考试系统,运行在一台 I5 、4G内存 、32位 Windows 系统 的机器上。系统使用了逆向 AJAX技术(又称 Coment 或 Server Side Push,其实就是主动推送,服务器主动推给页面端,页面可以实时的反应服务器信息)来实时获取考试数据。选用的是 CometD 1.1.1 作为服务器推送框架。测试期间发现服务器不定时抛出 OOM 。
即使知道标题是堆外内存,到目前为止也比较迷茫这里究竟哪里出了错。要我来就先看日志,能否找到 OOM 是在哪个区域抛出来的(比如堆、栈、方法区),再加上参数看报错的 heapdump。
再往下读,管理员将堆大小设置为最大,但是32位系统加到 1.6G 基本就没法往上加了。而且作者的确加了参数想观察 OOM 时的 dump。令我意外的是竟然 OOM 的时候啥文件也没产生。那就只能看日志、记录 jstat 信息。结果是在 OOM 后的系统日志中发现了如下报错堆栈,看到第一句我就瞬间懂了。

[org.eclipse.jetty.util.log]handle failed java.lang.OutOfMemoryError:null 
	at sun.raise.Unsafe.allocateMemory (Native Method)
	at java.nio.DirectByteBuffer.<init> (DirectByteBuffer.java :99)
	at java.nio.ByteBuffer.allocateDirect (ByteBuffer.java :28 )
	at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
...

不知道大家还记不记得第二章我们讲过直接内存这个区域,那时还专门提过这个 allocateMemory 报错,原因就是直接内存过大,加上堆内存的总和超过了物理内存。就会抛错。
众所周知 32位 Windows 操作系统可以使用的最大内存也就 2G 。而这里给堆就分了个 1.6 G,刚巧 CometD 1.1.1 框架大量的NIO操作需要使用到直接内存。报错了~
这里再次说下,虽然 直接内存也会清理,但不过是发生 Full GC 的时候顺手给他清一下,他自己是不会清的。
除了 Java 堆以外,还有很多区域会占用较多的内存,引用原文的话列举一下:

从实践经验的角度出发,除了Java堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。

  • Direct Memory : 可通过-XX : MaxDirectMemorySize调整大小,内存不足时拋出OutOfMemoryError或者OutOfMemoryError : Direct buffer memory。
  • 线程堆栈:可通过-Xss调整大小,内存不足时拋出StackOverflowError (纵向无法分配, 即无法分配新的栈帧)或者OutOfMemoryError : unable to create new native thread (横向无法分配 ,即无法建立新的线程)。
  • Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会拋出IOException : Too many open files异常。
  • JNI代码 :如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中。
  • 虚拟机和GC:虚拟机、GC的代码执行也要消耗一定的内存。
5.2.4 外部命令导致系统缓慢

网络案例,讲的是一个数字校园应用系统,运行在一个 4 CPU 的 Solaris 10 系统上,中间件为 GlassFish 服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢 ,通过操作系统的mpstat工具发现CPU使用率很高 ,并且系统里占用绝大多数CPU资源的程序并不是应用系统本身。这就很奇怪,按理说用户应用的 CPU 应该占主要部分才对。看到这里我并没有排查思路,于是接着往下看。后面发现最消耗 CPU 资源的竟然是 “fork”系统调用。这个 fork 往往是 Linux 用来产生新的进程的。为什么运行的时候会产生这么多进程啊,Java 程序运行一般都只会产生线程才对。看完才知道,Java中调用了 Runtime.getRuntime().exec() 的方式来执行本地命令了。而这个命令在 Linux 中就会调用一次创建进程。所以这个命令不要频繁调用

5.2.5 服务器 JVM 进程崩溃

环境与 5.2.2 中一致,现象是正常运行一段时间 JVM 就宕机,并留下一个 hs_err_pid###.log 的文件。最终原因就是因为集成了别的系统,而因异步调用的操作过多,时间长积累了很多未调用完成的 Web 调用。导致在等待的线程和 Socket 越来越多,最终虚拟机崩溃。解决方法:通知集成方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。
完全没有思路,一点点跟着书上发现的,所以不细写了。

5.2.6 不恰当数据结构导致内存占用过大

提前科普几个参数(引用自 @蜜汁蛋总 的博客,原文戳此):

  • -Xss:设置每个线程可使用的内存大小,即栈的大小。在相同物理内存下,减小这个值能生成更多的线程,当然操作系统对一个进程内的线程数还是有限制的,不能无限生成。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。
  • -Xms:堆内存的最小大小,默认为物理内存的1/64
  • -Xmx:堆内存的最大大小,默认为物理内存的1/4
  • -Xmn:堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx 减去 -Xmn

本案例环境:

  • 64位虚拟机
  • 内存配置为 -Xms4g -Xmx8g -Xmn1g
  • 使用ParNew+CMS的收集器组合。

背景:平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每 10 分钟加载一个约 80MB 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long,Long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿。GC 日志如下:
GC 日志
这里原书分析的太玄学了,直接原文致敬:

观察这个案例,发现平时的Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的, 在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。而在分析数据文件期间,800MB的Eden空间很快被填满从而引发GC ,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确就成为一个沉重的负担,因此导致GC暂停时间明显变长。

如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑将Survivor空间去掉(加入参数-XX : SurvivorRatio=65536、 -XX : MaxTenuringThreshold=0或者-XX :+AlwaysTenure ) , 让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序 ,因为这里的问题产生的根本原因是用HashMap< Long,Long> 结构来存储数据文件空间效率太低。

下面具体分析一下空间效率。在HashMap<Long,Long> 结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16B ( 2x8B ) 。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的MarkWord、8B的Klass指针 ,在加8B存储数据的long值。在这两个Long对贏组成Map.Entry之后 ,又多了 16B的对象头,然后一个8B的next字段和4B的int型的hash字段 ,为了对齐,还必须添加4B的空白填充,最后还有HashMap中对这个Entry的8B的引用 ,这样增加两个长整型数字,实际耗费的内存为 (Long(24B)x2)+Entry(32B)+HashMap Ref(8B)=88B,空间效率为16B/88B=18%,实在太低了。

5.3 实战:Eclipse 运行速度调优

仅起参考用,不做分析(后面可能会写篇文章去优化下 IDEA 的启动,敬请期待~)。请阅读原文思考。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值