JVM常用参数
GC 性能衡量指标
吞吐量:这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于95%
停顿时间:指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。
垃圾回收频率:多久发生一次垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
JVM内存的分配策略
对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC 。
大对象直接进入老年代: 当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代,最典型的大对象有长字符串和大数组。可以设置JVM参数 -XX:PretenureSizeThreshold ,大于此值的对象直接在老年代分配。
长期存活的对象进入老年代: 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
动态对象年龄判定: 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
空间分配担保: 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次Minor GC是有风险的;(也就是说,会把原先新生代的对象挪到老年代中) ;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC 。
串行垃圾收集器结论
- 当Eden区存储不下新分配的对象时,会触发minorGC。
- GC之后,还存活的对象,按照正常逻辑,需要存入到Survivor区(幸存区)。
- 当无法存入到幸存区时,此时会触发担保机制
- 发生内存担保时,需要将Eden区GC之后还存活的对象放入老年代。后来的新对象或者数组放入Eden区。
下面解释一下空间分配担保时的 “冒险”是冒了什么风险
新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。但前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁
Full GC 的触发条件
对于 Minor GC,其触发条件比较简单,当 Eden 空间满时,就将触发一次 Minor GC。
而 Full GC 触发条件相对复杂,有以下情况会发生 full GC:
- 调用 System.gc(): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败: 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
- JDK 1.7 及以前的永久代空间不足: 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError 。
那么如何进行JVM调优呢?
降低 Minor GC 频率
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。
扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?
我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。那么正常情况下,Minor GC 的时间为:T1+T2。通常在虚拟机中,复制对象的成本要远高于扫描成本。
假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么此时就会有一次复制存活对象,
当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,
所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。
- 如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。
- 如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。并且能减少GC的频率
因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
也就是说GC 后存活对象的数量多的话,不应该增加年轻代空间。
降低 Full GC 频率
JVM调优的另一大重点就是防止频繁FullGc
因为我们知道,在发生FULL GC的时候,意味着JVM会暂停所有正在执行的线程(Stop The World)来回收内存空间,在这个时间段内,所有除了回收垃圾的线程外,其他有关JAVA的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢,卡机等状态。
因此,GC优化的核心思路在于:尽可能让对象在新生代中分配和回收,尽量避免过多对象进入老年代,导致对老年代频繁进行垃圾回收,同时给系统足够的内存减少新生代垃圾回收次数
增加新生代内存
大多数优化目的都是为了让短期存活的对象尽量都留在Survivor区,不进入老年代,即可在minor gc时被回收,就不会因进入老年代而导致的full gc了
动态年龄判断
比如说如果新生代内存较小,那么有可能会因为动态年龄判断导致 对象还没达到最大最大存活年龄就被放到老年代了,这样一来,有可能只存活很短时间的对象也被放入老年代,导致内存占满,频繁Full GC
高并发场景
如高并发也要增加年轻代内存。因为并发的对象 大都是朝生夕死的,年轻代给大点 可能防止 突然的高并发把年轻代占满,使这些生命短暂的对象进到老年代,造成频繁full gc.
比如秒杀接口(假设这个接口运行时间是1s) 瞬间5000qps 代表1s会创建5000个订单对象,显然这些订单对象,在秒杀接口跑完就应该回收。假设年轻代很小,这个5000个对象一来,直接放不下,就只能去老年代了,但下一秒秒杀接口跑完,这些对象其实就已经可以回收了。所以一般高并发操作也要增加年轻代内存
小结
如果我们看年轻代的内存使用率处在高位,导致频繁的 Minor GC,而频繁 GC 的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。
但是新生代内存也不是越大越好,年轻代设置过大:
- 生命周期长的对象会长时间停留在年轻代,在S0和S1来回复制,增加复制开销。
- 新生代内存太大,老年代内存占比有所降低,会更频繁地触发Full GC
- 年轻代太大会增加Y GC每次停顿的时间,因为要清理的对象更多。不过通过根节点遍历,OopMap,old scan等优化手段这一部分的开销其实比较少。
- 浪费内存。内存也是钱啊虽然现在租的很便宜
如果我们看年老代的内存使用率处在高位,导致频繁的 Full GC, 这样分两种情况:
- 如果每次 Full GC 后年老代的内存占用率没有下来,可以怀疑是内存泄漏;
- 如果 Full GC 后年老代的内存占用率下来了,说明不是内存泄漏,我们要考虑调大老年代。
但是如果老年代设置过大,回收频率会降低,导致单次回收时间过长,因为需要回收的对象更多,导致GC stop the world时间过长,卡顿明显,导致请求无法及时处理。
设置对象移动到老年代的分代年龄阈值:
当然也不是都要让对象都尽量存活在新生代好
比如一个场景一次minor gc要间隔二三十秒,而这个场景中大多数对象一般在几秒内就变为垃圾,因此可以将默认的15岁改小,例如改成5岁,即对象经过5次minor gc才会进入老年代,完整时间为一两分钟。若对象一两分钟都还存活,则可认为该对象为存活周期较长的对象,可将其移至老年代,而不是继续占用Survivor区空间。
OOM场景及解决方案
Java heap space
JVM 无法在堆中分配对象时,会抛出这个异常,导致这个异常的原因可能有三种:
- 内存泄漏。Java 应用程序一直持有 Java 对象的引用,导致对象无法被 GC 回收,比如对象池和内存池中的对象无法被 GC 回收。
- 配置问题。有可能是我们通过 JVM 参数指定的堆大小(或者未指定的默认大小),对于应用程序来说是不够的。解决办法是通过 JVM 参数加大堆的大小。
- finalize 方法的过度使用。如果我们想在 Java 类实例被 GC 之前执行一些逻辑,比如清理对象持有的资源,可以在 Java 类中定义 finalize 方法,这样 JVM GC 不会立即回收这些对象实例,而是将对象实例添加到一个叫“java.lang.ref.Finalizer.ReferenceQueue”的队列中,执行对象的 finalize 方法,之后才会回收这些对象。Finalizer 线程会和主线程竞争 CPU 资源,但由于优先级低,所以处理速度跟不上主线程创建对象的速度,因此ReferenceQueue 队列中的对象就越来越多,最终会抛出 OutOfMemoryError。解决办法是尽量不要给 Java 类定义 finalize 方法。
GC overhead limit exceeded
出现这种 OutOfMemoryError 的原因是,垃圾收集器一直在运行,但是 GC 效率很低。
比如 Java 进程花费超过 98%的 CPU 时间来进行一次 GC,但是回收的内存少于 2%的 JVM堆,并且连续 5 次 GC 都是这种情况,就会抛出 OutOfMemoryError。
解决办法是查看 GC 日志或者生成 Heap Dump,确认一下是不是内存泄漏,如果不是内存泄漏可以考虑增加 Java 堆的大小。当然还可以通过参数配置来告诉 JVM 无论如何也不要抛出这个异常,方法是配置-XX:-UseGCOverheadLimit,但是并不推荐这么做,因为这只是延迟了 OutOfMemoryError 的出现。
Requested array size exceeds VM limit
从错误消息我们也能猜到,抛出这种异常的原因是“请求的数组大小超过 JVM 限制”,应用程序尝试分配一个超大的数组。比如应用程序尝试分配 512MB 的数组,但最大堆大小为256MB,则将抛出OutOfMemoryError,并且请求的数组大小超过 VM 限制。
通常这也是一个配置问题(JVM 堆太小),或者是应用程序的一个 Bug,比如程序错误地计算了数组的大小,导致尝试创建一个大小为 1GB 的数组。
MetaSpace
如果 JVM 的元空间用尽,则会抛出这个异常。我们知道 JVM 元空间的内存在本地内存中分配,但是它的大小受参数 MaxMetaSpaceSize 的限制。当元空间大小超过MaxMetaSpaceSize 时,JVM 将抛出带有 MetaSpace 字样的 OutOfMemoryError。解决办法是加大 MaxMetaSpaceSize 参数的值。
Request size bytes for reason. Out of swap space
当本地堆内存分配失败或者本地内存快要耗尽时,Java HotSpot VM 代码会抛出这个异常,VM 会触发“致命错误处理机制”,它会生成“致命错误”日志文件,其中包含崩溃时线程、进程和操作系统的有用信息。如果碰到此类型的 OutOfMemoryError,需要根据JVM 抛出的错误信息来进行诊断;或者使用操作系统提供的 DTrace 工具来跟踪系统调用,看看是什么样的程序代码在不断地分配本地内存
Unable to create native threads
线程内存溢出:某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行,这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常
抛出这个异常的过程大概是这样的:
- Java 程序向 JVM 请求创建一个新的 Java 线程。
- JVM 本地代码(Native Code)代理该请求,通过调用操作系统 API 去创建一个操作系统级别的线程 Native Thread。
- 操作系统尝试创建一个新的 Native Thread,需要同时分配一些内存给该线程,每一个Native Thread 都有一个线程栈,线程栈的大小由 JVM 参数-Xss决定。
- 由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。
5.JVM 抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错
误。
因此关键在于第四步线程创建失败,JVM 就会抛出 OutOfMemoryError,那具体有哪些因素会导致线程创建失败呢?
1.内存大小限制:Java 创建一个线程需要消耗一定的栈空间,并通过-Xss参数指定。请注意的是栈空间如果过小,可能会导致 StackOverflowError,尤其是在递归调用的情况下;但是栈空间过大会占用过多内存。32位系统,假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。
32 位系统的内核空间占用 1G ,位于最高处,剩下的 3G 是用户空间;
64 位系统的内核空间和用户空间都是 128T ,因此不会因为线程栈过大而耗尽虚拟地址空间。但是请你注意,64 位的 Java 进程能分配的最大内存数仍然受物理内存大小的限制
// 调整创建线程时分配的栈空间大小,比如调整为 512k:
ulimit -s 512
// 一个进程能创建的最大线程数
ulimit -u 65535
2.当然,还需要从程序本身找找原因,看看是否真的需要这么多线程,有可能是程序的 Bug 导致创建过多的线程。
常用的JVM调优工具
- Jconsole:jdk自带,功能简单,但是可以在系统有一定负荷的情况下使用。对垃圾回收算法有很详细的跟踪。
- jProfile:商业软件,需要付费
- VisualVM:JDK自带,功能强大,与JProfiler类似
JVM报OOM后排查步骤
当Java虚拟机(JVM)发生了内存溢出错误(OutOfMemoryError,OOM)时,通常需要进行一系列的排查步骤来定位和解决问题。以下是一些常见的排查步骤:
- 查看错误信息:
- 首先,查看错误消息以获取关于哪种类型的内存溢出发生以及在哪个部分的详细信息。不同类型的OOM错误会提供不同的错误消息,例如java.lang.OutOfMemoryError: Java heap space表示堆内存溢出,java.lang.OutOfMemoryError: PermGen space表示永久代内存溢出(在Java 7及以下版本中),而java.lang.OutOfMemoryError: Metaspace表示元空间内存溢出(在Java 8及以上版本中)。
- 分析堆转储文件:
- 如果可用,分析堆转储(Heap Dump)文件,以查看内存溢出时堆中的对象信息。可以使用工具如Eclipse Memory Analyzer(MAT)或VisualVM进行分析。这将有助于识别内存泄漏或者哪些对象占用了大量内存。
- 检查代码和内存使用:
- 仔细审查代码,尤其是内存密集型操作,以查找可能导致内存泄漏或内存使用不当的地方。确保释放不再使用的对象的引用,以便垃圾收集器能够回收它们。
- 监视内存使用:
- 使用性能监控工具监视应用程序的内存使用情况。可以使用工具如JVisualVM、JConsole、Prometheus等来监控堆内存、非堆内存、线程、GC活动等指标,以便及时发现内存问题。
- 调整JVM参数:
- 考虑调整JVM参数以增加堆内存大小或Metaspace大小,以满足应用程序的需求。这可以通过-Xmx和-XX:MaxMetaspaceSize等参数来完成。
- 请谨慎增加内存大小,以免过度分配内存导致系统变得不稳定。
- 优化代码和数据结构:通过优化算法、数据结构和代码,以减少内存占用。确保使用资源有效并且不浪费内存。
- 检查第三方库:
- 如果应用程序使用第三方库或框架,确保这些库的版本是最新的,因为一些旧版本可能存在内存泄漏或性能问题。
- 分析日志和异常堆栈:
- 分析应用程序的日志和异常堆栈,以查找潜在的问题。查看是否有重复的异常或错误,这可能会指示问题的发生。
- 使用内存分析工具:
- 使用专业的内存分析工具,如MAT、VisualVM、YourKit等,来深入分析内存使用情况,查找对象引用关系,并识别内存泄漏。
- 进行压力测试:
- 在开发环境中进行性能和内存压力测试,以模拟生产环境中的工作负载,以便更早地发现潜在的内存问题。
在排查内存溢出问题时,常常需要综合考虑多个因素,并可能需要多次迭代来找到问题的根本原因。一旦定位到问题,就可以采取相应的措施来解决内存溢出错误。