调优案例分析与实战

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。


一、调优策略

  • 找出问题后,就可以进行调优了,下面介绍几种常用的 GC 调优策略。

降低 Minor GC 频率

  • 通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。
  • 可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。
  • 我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为:T1+T2。
  • 当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生Minor GC 的时间为:两次扫描新生代,即 2T1。
  • 可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。
  • 如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。

降低 Full GC 的频率

  • 通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。我们可以使用哪些方法来降低 Full GC 的频率呢?
  • 减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。
  • 我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
    增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。

选择合适的 GC 收集器

  • 假设我们有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 收集器,CMS(Concurrent Mark Sweep)收集器和 G1 收集器都是不错的选择。
  • 而当我们的需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 收集器来提高系统的吞吐量。

二、案例:调优案例分析

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

  • 场景描述

访问文档时要把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代,没有在 Minor GC 中清理掉。于是产生网站经常不定期出现长时间失去响应的情况,这个问题显然是过大的堆内存进行回收时带来的长时间的停顿。(问题背景:从 32 位系统 1.5G 的堆内存,升级为 64 位系统 12G 的堆内存,升级前只感觉到使用网站比较缓慢,升级后却出现了十分明显的停顿)

  • 两种策略
    • 通过 64 位 JDK 来使用大内存。
    • 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。
  • 策略 1 要考虑的问题
    • 对于用户交互性强、对停顿时间敏感的系统,可以给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制的足够低,至少要低到不会影响用户使用,而控制 Full GC 频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应该太长
    • 内存回收导致长时间停顿
    • 现阶段,64 位 JDK 性能可能不如 32 位
    • 需要保证程序稳定,因为这种程序一旦发生溢出几乎无法产生堆转储快照,转储快照会过于庞大而几乎无法分析
    • 程序在 64 位 JDK 消耗的内存比 32 位要大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
  • 策略 2 要考虑的问题
    • 在逻辑集群的环境下,要尽量避免节点竞争全局的资源,比如磁盘竞争,各个节点如果同时访问磁盘,会很容易导致 IO 异常
    • 很难最高效率地使用资源池,比如连接池,因为一般是在各个节点建立独立的连接池,这样可能导致一部分节点满了,另一部分没满
    • 各个节点会受到 32 位的内存限制
    • 大量使用本地缓存的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点都有一份缓存,可以考虑把本地缓存换为集中式缓存

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

  • 场景描述

集群节点之间数据共享,由于读写频繁竞争很激烈,性能影响较大,于是使用了 JBossCache 构建了一个全局缓存。全局缓存使用后,服务正常使用了一段较长的时间,但最近却不定期地出现了多次的内存溢出问题。

  • 问题分析
    • 在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器(负责安全校验的全局 Filter)导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
    • 这一类被挤群共享的数据要使用类似 JBossCache 这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但不应当有过于频繁的写操作,那样会带来很大的网络同步的开销

堆外内存导致的溢出错误

  • 场景描述

大量的 NIO 操作需要使用到 Direct Memory 内存(直接内存),而直接内存的垃圾收集不像新生代老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后 Full GC,然后“顺便地”帮它清理掉内存的废弃对象。(出现该问题的背景:1.6G 内存给了 Java 堆,直接内存只能在剩下的 0.4G 内存中分配一部分)

  • 从实践经验的角度出发,除了 Java 堆和永久代之外,我们注意到下面区域还会占用较多的内存
    • Direct Memory:可通过 -XX:MaxDirectMemorySize 来调整大小,内存不足时抛出 OutOfMemoryError。
    • 线程堆栈:可通过 -Xss 来调整大小,内存不足时抛出 StackOverflowError 或 OutOfMemoryError。
    • Socket 缓存区:每个 Socket 连接都有 Receive 和 Send 两个缓存区,分别占大小 37KB 和 25KB,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出 IOException: Too many open files 异常。
    • JNI 代码:如果代码中使用 JNI 调用本地库,那本地库使用的内存也不在堆中。
    • 虚拟机和 GC:虚拟机、GC 的代码执行也要消耗一定的内存。

外部命令导致虚拟机缓慢

  • 每个用户请求的处理都需要执行一个外部 shell 脚本来获得系统的一些信息,执行这个脚本是通过 Java 的 Runtime.getRuntime().exec() 方法来调用的,这种调用方式很消耗资源。
  • Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会非常大,不仅是 CPU,内存负担也很重。

服务器 JVM 进程崩溃

  • 由于 MIS 系统的用户多,待办事项变化很快,为了不被 OA 系统速度拖累,使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等,时间越长就累积了越多 Web 服务没有调用完成,导致在等待的线程和 Socket 连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。
  • 解决方法:通知 OA 门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。

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

  • 业务上需要每十分钟加载一个约 80MB 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long, Long> Entry,在这段时间里面 Minor GC 就会造成 500 毫秒的停顿,这是难以接受的。
  • 处理方案就是要修改程序,因为这里问题产生的根本原因是用 HashMap<Long, Long> 结构来存储数据文件空间效率太低。

三、实战:Eclipse 运行速度调优

永久代溢出

  • 问题分析:JDK1.6 没有指定设置永久代最大容量 -XX:MaxPermSize=256M
  • 解决方案:指定 -XX:MaxPermSize=256M

类加载时间过长

  • 问题分析:字节码验证需要占用大量时间
  • 解决方案:关闭字节码验证,使用参数 -Xverify:none

编译时间过长

  • 问题分析:JDK1.2 以后,虚拟机内设了两个运行时编译器,如果方法被调用的次数达到一定程度,就会被判定为热代码,从而交给 JIT 编译器及时编译为本地代码,这回消耗程序正常运行的时间,即编译时间
  • 解决方案:使用 -Xint 禁止编译器运作,但这也会让给 Eclipse 启动时间变长

调整内存设置控制垃圾收集频率

  • 新生代 GC 频繁发生,很明显是由于虚拟机分配给新生代的空间太小而导致的,因此很有必要使用 -Xmn 参数调整新生代的大小
  • 对于老年代 GC,大多数是由于老年代容量扩展而导致的,为了避免这些扩展所带来的性能浪费,我们可以把 -Xms 和 -XX:PermSize 参数值设置为 -Xmx 和 -XX:MaxPermSize 参数值一样,这样就强制虚拟机在启动的时候把老年代和永久代的容量固定下来,避免运行时自动扩展。

四、排查步骤

平时排查内存性能瓶颈时,我们往往需要用到一些 Linux 命令行或者 JDK 工具来辅助我们监测系统或者虚拟机内存的使用情况

Linux 命令行工具之 top 命令

top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。
top
除了简单的 top 之外,我们还可以通过 top -Hp pid 查看具体线程使用系统资源情况:
线程

JDK 工具之 jstat 命令

jstat 可以监测 Java 应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息。我们可以运行 jstat -help 查看一些关键参数信息:
jstat
再通过 jstat -option 查看 jstat 有哪些操作:
jstat

JDK 工具之 jmap 命令

使用过 jmap 查看堆内存初始化配置信息以及堆内存的使用情况。那么除了这个功能,我们其实还可以使用 jmap 输出堆内存中的对象信息,包括产生了哪些对象,对象数量多少等。

还可以通过 jmap 命令把堆内存的使用情况 dump 到文件中;再使用 jhat 命令分析 dump 文件,或者使用一些 MAT 可视化工具分析。

JDK 工具之 jstack 堆栈跟踪工具

jstack 命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过 jstack 来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事,或者等待着什么资源。

关于各 JDK 命令详情请参考:虚拟机性能监控与故障处理工具

五、实战演练

平时遇到的内存溢出问题一般分为两种,一种是由于大峰值下没有限流,瞬间创建大量对象而导致的内存溢出;另一种则是由于内存泄漏而导致的内存溢出。

先查看日志,发现有 OOM 异常,首先可以通过 Linux 系统命令查看进程在整个系统中内存的使用率是多少,可以使用 top 命令。
top
从 top 命令查看进程的内存使用情况,可以发现在机器只有 8G 内存且只分配了 4G 内存给 Java 进程的情况下,Java 进程内存使用率已经达到了 55%,再通过 top -Hp pid 查看具体线程占用系统资源情况
top
再通过 jstack pid 查看具体线程的堆栈信息,可以发现该线程一直处于 TIMED_WAITING 状态,此时 CPU 使用率和负载并没有出现异常,我们可以排除死锁或 I/O 阻塞的异常问题了。
jstack
再通过 jmap 查看堆内存的使用情况,可以发现,老年代的使用率几乎快占满了,而且内存一直得不到释放:
jmap
通过以上堆内存的情况,我们基本可以判断系统发生了内存泄漏。下面我们就需要找到具体是什么对象一直无法回收,什么原因导致了内存泄漏。我们需要查看具体的堆内存对象,看看是哪个对象占用了堆内存,可以通过 jstat 查看存活对象的数量:
jstat
Byte 对象占用内存明显异常,说明代码中 Byte 对象存在内存泄漏,我们在启动时,已经设置了 dump 文件,通过 MAT 打开 dump 的内存日志文件,我们可以发现 MAT 已经提示了 byte 内存异常。

通过 MAT 工具可以知道这个对象是在哪里产生的:
分析


笔记来源:《深入理解Java虚拟机》第五章 调优案例分析与实战(P132)。

  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

发飙的蜗牛咻咻咻~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值