深入理解Java虚拟机笔记 – 自动内存管理机制(调优案例分析与实战)
一个部署问题 √
控制 Full GC 频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。
如果读者计划使用 64 位 JDK 来管理大内存,还需要考虑下面可能面临的问题:内存回收导致的长时间停顿;相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
而使用无 Session 复制的亲合式集群是一个相当不错的选择。
将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑了。
如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面一些问题:
- 尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致 IO 异常。
- 很难最高效率地利用某些资源池,各个节点连接池使用率不一致,而如果使用集中式的JNDI,则会带来个一定复杂性并且可能带来额外的性能开销。
- 各个节点仍然不可避免地受到 32 位的内存限制。
- 每个逻辑节点都有一份缓存,造成空间的浪费,可以考虑使用集中式缓存。
考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问, CPU 资源敏感度较低,因此改为 CMS 收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比硬件升级前有较大提升。
例子 √
在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
例子 √
没有考虑到堆外内存占用比较高,32位windows平台对每个进程内存限制2G,程序中使用到了CometD 1.1.1框架,有大量的NIO操作需要用到Direct Memory内存,Direct Memory分配不足导致的内存溢出。
从实践经验的角度出发,除了 Java 堆和永久代之外,我们注意到下面这些区域还会占用较多的内存:
- Direct Memory: 可通过- XX: MaxDirectMemorySize 调整大小(HotSpot VM无此参数:http://rednaxelafx.iteye.com/blog/1098791,http://www.dongliu.net /post/504141),内存不足时抛出 OutOfMemoryError 或者 OutOfMemoryError: Direct buffer memory。
- 线程堆栈:可通过- Xss 调整大小,内存不足时抛出 StackOverflowError( 纵向无法分配,即无法分配新的栈帧)或者 OutOfMemoryError: unable to create >>li:new native thread( 横向无法分配,即无法建立新的线程)。
- Socket 缓存区:每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出 IOException: Too many open files 异常。
- JNI 代码:如果代码中使用 JNI 调用本地库,那本地库使用的内存也不在堆中。
- 虚拟机和 GC: 虚拟机、 GC 的代码执行也要消耗一定的内存。
图片处理相关 √
大并发的时候,通过mpstat工具发现CPU使用率很高。
通过Solaris 10的Dtrace脚本可以查看当前情况下那些系统调用话费最多的CPU资源。
结果是fork,用来产生新进程的,Java中不应该有新的进程的产生。
最终找到了答案:每个用户请求的处理都需要执行一个外部 shell 脚本来获得系统的一些信息。执行这个 shell 脚本是通过 Java 的 Runtime. getRuntime(). exec() 方法来调用的。
Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是 CPU, 内存负担也很重。
跨系统集成的时候,使用到异步方式调用Web服务,由于两边服务速度不读等,导致很多Web服务没有调用完成,在等待的线程和Socket连接越来越多,超过JVM的承受范围后JVM进程就崩溃了。
可以将异步调用改为生产者/消费者模式的消息队列实现。
测试工具:SoapUI
垃圾收集器: ParNew + CMS
在内存中存入了100万个HashMap,就会GC造成停顿。
ParNew 收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到 Survivor 并维持这些对象引用的正确就成为一个沉重的负担,因此导致 GC 暂停时间明显变长。
如果不修改程序,仅从 GC 调优的角度去解决这个问题,可以考虑将 Survivor 空间去掉(加入参数- XX: SurvivorRatio= 65536、- XX: MaxTenuringThreshold= 0 或者- XX:+ AlwaysTenure), 让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序,因为这里的问题产生的根本原因是用 HashMap < Long, Long >结构来存储数据文件空间效率太低。
HashMap<Long,Long>分别具有 8B 的 MarkWord、 8B 的 Klass 指针,在加 8B 存储数据的 long 值。在这两个 Long 对象组成 Map. Entry 之后,又多了 16B 的对象头,然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段,为了对齐,还必须添加 4B 的空白填充,最后还有HashMap 中对这个 Entry 的 8B 的引用,这样增加两个长整型数字,实际耗费的内存为( Long( 24B) × 2)+ Entry( 32B)+ HashMap Ref( 8B)= 88B, 空间效率为 16B/ 88B= 18%, 实在太低了。
程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生 GC 时就有可能因为恢复页面文件的操作而导致不正常的 GC 停顿。
在 Java 的 GUI 程序中要避免这种现象,可以加入参数"- Dsun. awt.keepWorkingSetOnMinimize= true" 来解决。