重学《深入理解java虚拟机》の调优案例分析与实战

大内存硬件上的程序部署策略

一个15万PV/日左右的在线文档类型网站服务器硬件由分配1.5GJVM内存+32位操作系统升级成16G+64位操作系统,虚拟机的内存限制-Xmx以及-Xms都是固定12G,但是发现服务器的运行效果不理想,网站经常不定期出现长时间失去响应。

监控服务器运行状况后发现网站失去响应是由垃圾收集停顿所导致的, 在该系统软硬件条件下,HotSpot虚拟机是以服务端模式运行, 默认使用的是吞吐量优先收集器, 回收12GB的Java堆, 一次FullGC的停顿时间就高达14秒。 由于程序设计的原因, 访问文档时会把文档从磁盘提取到内存中, 导致内存中出现很多由文档序列化产生的大对象, 这些大对象大多在分配时就直接进入了老年代, 没有在Minor GC中被清理掉。 这种情况下即使有12GB的堆, 内存也很快会被消耗殆尽, 由此导致每隔几分钟出现十几秒的停顿。

控制Full GC频率的关键是老年代的相对稳定, 这主要取决于应用中绝大多数对象能否符合“朝生夕灭”的原则, 即大多数对象的生存时间不应当太长, 尤其是不能有成批量的、 长生存时间的大对象产生, 这样才能保障老年代空间的稳定。

堆外内存导致的溢出错误

垃圾收集进行时, 虚拟机虽然会对直接内存进行回收, 但是直接内存却不能像新生代、 老年代那样, 发现空间不足了就主动通知收集器进行垃圾回收, 它只能等待老年代满后Full GC出现后, “顺便”帮它清理掉内存的废弃对象。

从实践经验的角度出发,除了java堆和方法区之外,注意下面这些区域还会占用较多的内存,但是所有的内存总和受到操作系统进程最大内存的限制:

  • 直接内存:可通过-XX: MaxDirectMemorySize调整大小,内存不足时抛出OutOf-MemoryError或者OutOfMemoryError: Direct buffer memory。
  • 线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(如果线程请求的栈深度大于虚拟机所允许的深度)或者OutOfMemoryError(如果Java虚拟机栈容量可以动态扩展, 当栈扩展时无法申请到足够的内存)
  • Socket缓存区: 每个Socket连接都Receive和Send两个缓存区, 分别占大约37KB和25KB内存, 连接多的话这块内存占用也比较可观。 如果无法分配, 可能会抛出IOException: Too many open files异常。
  • JNI代码: 如果代码中使用了JNI调用本地库, 那本地库使用的内存也不在堆中, 而是占用Java虚拟机的本地方法栈和本地内存的
  • 虚拟机和垃圾收集器: 虚拟机、 垃圾收集器的工作也是要消耗一定数量的内存的。

外部命令导致系统缓慢

通过Java的Runtime.getRuntime().exec()方法调用外部脚本或外部命令,这种调用方式在java虚拟机中是非常消耗资源的操作,即使外部命令本身能够很快执行完毕,频繁调用时创建进程的开销也会非常可观。 Java虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程, 再用这个新的进程去执行外部命令, 最后再退出这个进程。 如果频繁执行这个操作, 系统的消耗必然会很大, 而且不仅是处理器消耗, 内存负担也很重。

服务器虚拟机进程崩溃

当系统通过网络调用第三方系统的时候,如果两边的服务不对等,调用方的速度远远大于被调用方的处理速度,时间越长就会积累越多的web服务没有调用完成,导致等待的线程和Socket连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃,可以将异步调用改为生产者/消费者模式的消息队列实现。

由安全点导致长时间停顿

使用G1垃圾收集器,设置参数-XX:MaxGCPauseMillis=500毫秒,运行一段时间后发现垃圾收集的停顿时间经常达到3秒以上,而且实际垃圾收集器进行回收的动作就只占了其中的几百毫秒,现象见下方日志截图:

image.png
  • user:进程执行用户态代码所耗费的处理器时间
  • sys:进程执行核心态代码所耗费的处理器时间
  • real:执行动作从开始到结束耗费的时钟时间

注意user和sys是处理器时间,real是时钟时间,它们的区别是处理器时间代表的是线程占用处理器一个核心的耗时计数, 而时钟时间就是现实世界中的时间计数。 如果是单核单线程的场景下, 这两者可以认为是等价的, 但如果是多核环境下, 同一个时钟时间内有多少处理器核心正在工作, 就会有多少倍的处理器时间被消耗和记录下来。

在垃圾收集调优时, 我们主要依据real时间为目标来优化程序, 因为最终用户只关心发出请求到得到响应所花费的时间, 也就是响应速度, 而不太关心程序到底使用了多少个线程或者处理器来完成任务。

日志显示垃圾收集一共花费0.14秒,但是用户线程足足停顿了2.26秒,两者差距远远超出了征程的TTSP(Time To Safepoint)耗时的范畴,所以猜测部分用户线程进入安全点太慢,导致的现象。安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的, 所以方法调用、 循环跳转、 异常跳转这些位置都可能会设置有安全点, 但是HotSpot虚拟机为了避免安全点过多带来过重的负担, 对循环还有一项优化措施, 认为循环次数较少的话, 执行时间应该也不会太长, 所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。 这种循环被称为可数循环(Counted Loop) , 相对应地, 使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop) , 将会被放置安全点。 通常情况下这个优化措施是可行的, 但循环执行的时间不单单是由其次数决定, 如果循环体单次执行就特别慢, 那即使是可数循环也可能会耗费很多的时间。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容