head first JVM(4)

调优案例分析与实战

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

1626105218415

在高性能硬件上部署程序,目前主要有两种方式:

  • 通过64位JDK来使用大内存。
  • 使用若干个32位虚拟机建立逻辑集群来利用硬件资源。

其中,使用64位JDK来管理大内存,会面临以下问题:

  • 内存回收导致的长时间停顿。
  • 现阶段,64位JDK的性能测试结果普遍低于32位JDK。
  • 需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生十几GB乃至更大的Dump文件),哪怕产生了快照也几乎无法进行分析。
  • 相同程序在64位JDK消耗的内存一般比32位JDK大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。

控制Full GC频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对很少。只要代码写得合理,应当都能实现在超大堆中正常使用而没有Full GC,这样的话,使用超大堆内存时,网站响应速度才会比较有保证。

使用逻辑集群的方式来部署程序,可能会遇到下面一些问题:
  • 尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致IO异常。
  • 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI,但这个有一定复杂性并且可能带来额外的性能开销。
  • 各个节点仍然不可避免地受到32位的内存限制,在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆一般最多只能开到1.5GB。在某些Linux或UNIX系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB(232)内存的限制。
  • 大量使用本地缓存(如大量使用HashMap作为K/V缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。

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

1626105579887

由于信息有传输失败需要重发的可能性,在确认所有注册在GMS(GroupMembership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。而此MIS的服务端中有一个负责安全校验的全局Filter,每当接收到请求时,均会更新一次最后操作时间,并且将这个时间同步到所有的节点去,使得一个用户在一段时间内不能在多台机器上登录。在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。

堆外内存导致的溢出错误

1626105649611

1626105684323

垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后Full GC,然后“顺便地”帮它清理掉内存的废弃对象。本案例中使用的CometD 1.1.1框架,正好有大量的NIO操作需要使用到Direct Memory内存。

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

外部命令导致系统缓慢

每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息。执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。

Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。

服务器JVM进程崩溃

一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP系统,服务器是WebLogic 9.2,正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文件后,进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中可以看出,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常

1626105957986

解决方法:通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。

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

有一个后台RPC服务器,使用64位虚拟机,内存配置为-Xms4g -Xmx8g-Xmn1g,使用ParNew+CMS的收集器组合。平时对外服务的MinorGC时间约在30毫秒以内,完全可以接受。但业务上需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<Long,Long> Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿,对于这个停顿时间就接受不了了。

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

由Windows虚拟内存导致的长时间停顿

例如,有一个带心跳检测功能的GUI桌面程序,每15秒会发送一次心跳检测信号,如果对方30秒以内都没有信号返回,那就认为和对方程序的连接已经断开。程序上线后发现心跳检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完全无日志输出,处于停顿状态。因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是GC导致的程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps-Xloggc:gclog.log后,从GC日志文件中确认了停顿确实是由GC导致的,大部分GC时间都控制在100毫秒以内,但偶尔就会出现一次接近1分钟的GC。

1626106416493

1626106429899

Licensed under CC BY-NC-SA 4.0
Last updated on Mar 23, 2024 06:11 UTC
让过去的过去,给时间点时间
Built with Hugo
Theme Stack designed by Jimmy