查看原文
其他

Java程序陷入时间裂缝:探索代码深处的神秘停顿|得物技术

财神 得物技术
2024-12-05

目录

一、初探

二、安全点

三、停顿细节分析

四、停顿类型一览

五、停顿分类

    1. JVMTI&调试

    2. GC

    3. 偏向锁的问题

    4. 探测工具

    5. 虚拟机内部运行时

    6. 逆优化

    7. 其他

        7.1 内部调试

        7.2 废弃类型-ThreadStop

六、No vm operation

    1. 从输出入手

    2. 匿名的停顿类型 

    3. 虚拟机的方案

七、非counted loop的循环

八、总结

我们来想一个问题:当你的Java程序偶然发生短暂的停顿,你会觉得是什么造成了这种现象?

绝大多数的同学立刻会回答是GC导致的STW(stop-the-world)。没错,GC确实是可以立刻先怀疑的方向。但是实际上,Java程序发生短暂停顿有非常多的可能性,我们今天来聊聊这个话题。

初探

我们首先需要知道,我们当前自己的Java应用的停顿细节是怎么样的?是否有一些我们还不知道的异常卡顿?这里需要修改一下自己的应用启动脚本,引入下列启动参数项:
-XX:+UnlockDiagnosticVMOptions-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCApplicationConcurrentTime-XX:+PrintSafepointStatistics-XX:PrintSafepointStatisticsCount=1-XX:-DisplayVMOutput-XX:+LogVMOutput-XX:LogFile=/logs/vm.log-Xloggc:/logs/gc.log
简单来说,以上的启动参数打开了虚拟机诊断调试选项,并在gc.log中输出了当前应用的停顿概要。同时将应用中涉及到的停顿明细信息输出在了vm.log中。
我们先来看下gc.log的输出:
2024-03-07T20:43:34.027+0800: 549.045: Total time for which application threads were stopped: 0.0021832 seconds, Stopping threads took: 0.0003043 seconds2024-03-07T20:43:34.078+0800: 549.096: Total time for which application threads were stopped: 0.0022472 seconds, Stopping threads took: 0.0002551 seconds2024-03-07T20:43:34.081+0800: 549.099: Total time for which application threads were stopped: 0.0020218 seconds, Stopping threads took: 0.0002485 seconds2024-03-07T20:43:34.082+0800: 549.101: Total time for which application threads were stopped: 0.0015735 seconds, Stopping threads took: 0.0001436 second
这里关注两个关键数值的输出:application threads were stopped和Stopping threads took。前者表示当前应用在此时实际停顿的时间,后者表示使所有线程到达安全点(safepoint)的时间。

安全点

上面提到一个概念叫安全点(safepoint)。Java开发同学相信对这个概念都不会陌生。所有涉及虚拟机停顿的逻辑基本上都和这个概念有关,稍微官方一点的定义为:A point in program where the state of execution is known by the VM
当Java程序在运行过程中需要执行某些需要线程同步的操作,比如垃圾回收、线程挂起、代码优化等,为了确保这些操作的安全性和一致性,虚拟机会在一些特定的位置设置安全点。在这些安全点上,所有活动的线程都会被暂停,以便进行必要的操作
可以将安全点比喻为Java程序中的一个交通指示灯,当到达这个指示灯时,所有线程都必须停止,以确保交通可以安全地进行(即程序执行)。在这个停顿的时刻,虚拟机可以执行一些关键的任务,比如清理不再使用的内存(垃圾回收)、优化代码、线程状态检查等。
那么肯定有同学会问了,虚拟机会在一些特定的位置设置安全点,那么这些特定的位置是什么呢?比较常见的有两种场景:
  • 解释模式下,当线程感知到safepoint已开启时,会在当前字节码执行完成之后停下来(此时字节码已经被替换为safepoint标记);
  • Compiled模式下,当线程感知到safepoint已开启时,会就近在方法返回或者非counted loop的循环的返回之前停下来(此时会尝试访问指定内存,若安全点已开启则此内存不可读进一步触发SIGSEGV信号捕获停止)。
说起这个非counted loop的循环有点意思,已经碰到过很多次有人踩这个坑了,不同的技术公众号也看到过好几次。我们后面再聊。
这里很明显会发现一个问题, 那就是Java线程是逐渐开始进入安全点的。那肯定存在一个先后顺序的问题,即大家都要等最后一个线程完成阻塞,才算完全进入安全点,有点木桶原理的意味。以下图为例,其实最大暂停时间=安全点期间的时间+最晚到达安全点的时间,也即图中的Worst Pause Time这部分。这个细节大家可以先品一品。

停顿细节分析

书接上回。我们上面看到gc.log的输出看到了比较宽泛的耗时信息,更进一步的需要去看vm.log的输出:
我们在这里看到了相对比较复杂的日志输出格式。这里两行为一组数据,其中第一行为标题,第二行为该项的具体数值。我们以截图中前两行的输出为例:
  • 626.369:应用已经运行的时间,以秒为单位,精确到毫秒
  • vmop(RevokeBias):触发本次虚拟机停顿的VM操作,这里RevokeBias代表的是一个偏向锁撤销的操作
  • threads:此时线程明细情况
    • total(1538):本次停顿时,虚拟机中的线程数量
    • initially_running(0):停顿发生时,仍在运行的线程数
    • wait_to_block(4):虚拟机开始操作执行时阻塞的线程数
  • times:本次停顿消耗的时间明细
    • spin(0):到达安全点期间因线程自旋耗时的时间
    • block(0):到达安全点期间因线程阻塞耗时的时间
    • sync(0):一般为spin和block之和,可以理解为线程到达安全点的时间
    • cleanup(1):本次暂停虚拟机内部层面耗时
    • vmop(0):本次暂停花费在实际操作上的时间,比如前面的偏向锁撤销(RevokeBias)
  • page_trap_count:已访问JIT的pollling page的线程数量
虽然上面这一串数值的输出看起来非常麻烦,但大多数情况下我们只用关注3项。
  • vmop:看是具体哪个操作导致虚拟机要进行安全点停顿
  • times(sync):是否有部分线程导致所有线程迟迟无法到达安全点
  • times(vmop):到达安全点后,当前这个操作具体的耗时过程

停顿类型一览

如上所说,我们现在知道特定的vmop会导致Java进程陷入停顿。现在我们肯定想知道,这些vmop都有哪些不同的情况或者分类。
ThreadStop,ThreadDump,PrintThreads,FindDeadlocks,ForceSafepoint,ForceAsyncSafepoint,Deoptimize,DeoptimizeFrame,DeoptimizeAll,ZombieAll,UnlinkSymbols,Verify,PrintJNI,HeapDumper,DeoptimizeTheWorld,CollectForMetadataAllocation,GC_HeapInspection,GenCollectFull,GenCollectFullConcurrent,GenCollectForAllocation,ParallelGCFailedAllocation,ParallelGCSystemGC,CGC_Operation,CMS_Initial_Mark,CMS_Final_Remark,G1CollectFull,G1CollectForAllocation,G1IncCollectionPause,EnableBiasedLocking,RevokeBias,BulkRevokeBias,PopulateDumpSharedSpace,JNIFunctionTableCopier,RedefineClasses,GetOwnedMonitorInfo,GetObjectMonitorUsage,GetCurrentContendedMonitor,GetStackTrace,GetMultipleStackTraces,GetAllStackTraces,GetThreadListStackTraces,GetFrameCount,GetFrameLocation,ChangeBreakpoints,GetOrSetLocal,GetCurrentLocation,EnterInterpOnlyMode,ChangeSingleStep,HeapWalkOperation,HeapIterateOperation,ReportJavaOutOfMemory,JFRCheckpoint,Exit,LinuxDllLoad,Terminating
如上图,在JDK8(虽然JDK22已经有了但是我们依然选择JDK8懂的都懂)中,一共有56种导致Java程序停顿的可能性(感兴趣的可以访问jdk/hotspot/src/share/vm/runtime/vm_operations.hpp at jdk8-b116 · openjdk/jdk)。虽然看起来很多,但稍加归类,会发现其实并不复杂。 

停顿分类

JVMTI&调试

JVMTI(Java Virtual Machine Tool Interface)是Java虚拟机工具接口的缩写。它是一组用于开发和调试工具的API,允许开发人员创建各种类型的工具,以监视和管理正在运行的Java应用程序。它的简易架构描述可见下图。更多细节可见官方文档 https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html#architecture。
简单来说,JVMTI为开发人员提供了一种与Java虚拟机进行交互的方式,使他们能够实现诸如性能分析、调试、代码检查等工具。通过JVMTI,开发人员可以访问Java虚拟机的内部状态,监控和操作线程、堆栈、对象等信息,从而帮助他们更好地理解和优化Java应用程序的运行情况。
和JVMTI相关的停顿类型如下(加粗部分为常见停顿分类,其余章节同):
  • GetOrSetLocal
  • ChangeBreakpoints
  • HeapWalkOperation
  • GetOwnedMonitorInfo
  • GetCurrentContendedMonitor
  • GetCurrentLocation
  • EnterInterpOnlyMode
  • ChangeSingleStep
  • GetObjectMonitorUsage
  • GetStackTrace
  • GetMultipleStackTraces
  • GetAllStackTraces
  • GetThreadListStackTraces
  • GetFrameCount
  • GetFrameLocation
  • HeapIterateOperation
  • JNIFunctionTableCopier
乍一看,这都什么玩意儿,完全没见过没听过啊,这些也算常见分类?但实际上这些停顿和我们工作中常见的一个能力息息相关,那就是Debug。
当前Debug的功能已经十分强大,通过小小的调试窗,我们可以完成很多复杂的功能,比如:
当你断点调试时,其实是利用了ChangeBreakpoints(设置断点)和ChangeSingleStep(逐行调试)配合来完成;
当你断点调试想看具体某个变量的值,其实是利用了GetOrSetLocal(获取/设置变量)来完成;
当你断点调试想看某个对象被谁引用了,其实是利用了HeapWalkOperation(堆访问)来完成;
当你获取线程锁使用明细时,其实是GetOwnedMonitorInfo&GetCurrentContendedMonitor(锁&竞争信息)在底层默默的配合来完成;
特别的,这里最高频的其实是GetOrSetLocal这个停顿。由于每次Debug,我们会实时在调试窗看到多个字段或者对象及其内嵌的值,这里的每一次数据都会触发GetOrSetLocal。在逻辑比较复杂的时候,一次Debug可能要触发数十或上百次。所以有时候我们感觉Debug好像很卡,其实很有可能是和我们在虚拟机底层做了太多次停顿有关。

一次简单的debug引发的大量GetOrSetLocal

GC

  • G1CollectFull
  • G1CollectForAllocation
  • G1IncCollectionPause
  • GenCollectFull
  • GenCollectFullConcurrent
  • GenCollectForAllocation
  • ParallelGCFailedAllocation
  • ParallelGCSystemGC
  • CollectForMetadataAllocation
  • CGC_Operation
  • CMS_Initial_Mark
  • CMS_Final_Remark
垃圾收集是Java虚拟机中的核心功能,负责管理内存并回收不再使用的对象,以避免内存泄漏和提高程序性能。在垃圾收集的过程中,上述停顿扮演着重要角色,代表了不同垃圾收集器的具体操作。比如G1CollectForAllocation表示G1收集器下内存分配失败触发了GC,GenCollectFull表示因FGC而导致的GC等。
GC是我们日常最为熟悉的停顿类型,也是我们遇到可疑停顿优先怀疑的目标。但除常规的GC之外,还有两个地方我们需要稍加留意。

System.gc()的显式调用

使用System.gc()方法可以显式地向虚拟机发出垃圾回收的请求,但并不保证会立即执行回收所有的垃圾对象。一般情况下,我们的职业操守也制约着我们不会显式的调用该方法。但...我们不会不代表别人不会啊。在比较早之前这种事情在一些比较出名的框架里(比如Tomcat)都挺常见,为了杜绝这种现象,所以会有人建议在系统启动命令行设置-XX:+DisableExplicitGC来忽略掉这种手工触发的GC。详见:tomcat每隔一小时full gc的问题 | 夜行船(https://hongjiang.info/tomcat-full-gc-every-hour/)。

Metasapce区域的危机

大多数情况下,我们更多关注的是Heap区域。但对于单独的Metaspace区域可能并不是很在意。但实际上Metaspace内存不足也很常见,动态类加载问题便是这里的常客。
动态类加载一般出现在以下场景:
  • 字节码工具如Cglib、ByteBuddy、ASM等动态生成类;
  • Nashorn引擎执行Javascript脚本。
这里可以看下这个Case,便是由于动态加载了过多的类导致的频繁GC最终OOM问题。(https://www.aqhi.net/archives/oom-tracing-nashorm)

偏向锁的问题

涉及偏向锁相关停顿的共3个vmop。
  • RevokeBias
  • BulkRevokeBias
  • EnableBiasedLocking
偏向锁在现在看来已经是个比较古老的概念了,但在出现的初期确实有它存在的意义。它的核心思想是,当一个线程获取到锁之后,如果在之后的执行过程中没有其他线程来竞争这把锁,那么这个锁就会进入偏向模式。在偏向模式下,当这个线程再次请求锁时,无需重新竞争,可以直接获取到锁,从而节省了竞争的成本进而提高了程序的性能。
但在线程竞争比较频繁的场景下,如果在偏向模式下出现了其他线程来竞争锁的情况,那么虚拟机内部会尝试进行偏向锁撤销操作,表示资源不在偏向于该线程。不幸的是,在当前的电商场景的大流量下,偏向锁撤销这个动作会变得愈发的频繁。

频繁发生的偏向锁撤销有一定的性能风险

所以在很多中间件系统是建议系统部署时关闭偏向锁的(-XX:-UseBiasedLocking),比如RocketMQ在官方最佳实践中就明确建议关闭偏向锁(https://rocketmq.apache.org/zh/docs/bestPractice/04JVMOS/)。
而从JDK15开始,偏向锁终于退出历史的舞台了(细节可见https://openjdk.org/jeps/374),他会被标记为过期且在虚拟机中默认不会开启。官方给出的理由主要是两点:
  • The performance gains seen in the past are far less evident today.(很明显大家也都这么认为)
  • Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well.(有种改不动的感觉)

探测工具

  • FindDeadlocks
  • PrintThreads
  • PrintJNI  
  • GC_HeapInspection
  • HeapDumper
  • ThreadDump
探测工具是Java提供的用来排查问题的利器。精巧的可执行命令背后,很多时候背后的虚拟机其实在做着较重的操作。比如:
当执行jmap -histo命令,实际会触发GC_HeapInspection停顿进行整个堆的探测;
当执行jstack命令,实际会触发FindDeadlocks、PrintThreads、PrintJNI三次停顿。在这三次停顿里分别会去查找死锁、线程堆栈打印、JNI调用打印这三个动作;
当执行jmap -dump:live,format=b,file=/path命令,实际会触发HeapDumper停顿来完成堆的dump。

一次jstack操作之后带来的3次停顿

所以很多时候我会建议慎重在生产环境使用JDK提供的相关命令(真要使用最好完成流量摘除),否则命令背后的停顿可能会让我们付出惨痛的代价。
还需要注意的是,获取全部线程堆栈java.lang.Thread#getAllStackTraces也是一个非常重的行为。它对应着上面的threadDump这个操作,有些应用后台会起个定时任务定时执行该方法,来获取线程的瞬时状态,这种调用方式也尽可能的要避免。

虚拟机内部运行时

RedefineClassesForceSafepoint(safepoint, vm)ForceAsyncSafepoint(safepoint, vm)PopulateDumpSharedSpace(jvm)ExitLinuxDllLoad ReportJavaOutOfMemory
这部分提到的是和JVM内部运行机制有关的类型。比如ReportJavaOutOfMemory服务于启动参数-XX:OnOutOfMemoryError,Exit对应着进程结束的一些操作,PopulateDumpSharedSpace对应着JDK9开始的CDS(类数据共享)功能,LinuxDllLoad对应着Linux环境下的动态链接库文件的加载。这些非核心场景我们都可以先放一放。我们这里来重点就看1个类型。

RedefineClasses

JDK1.5之后推出了java.lang.instrument.Instrumentation接口,可以动态修改一个类的字节码,我们常用的Arthas、Greys,Byteman都是基于Instrumentation提供了强大的多维度的能力。而这个修改的过程是需要将所有线程挂起,这个挂起就对应着RedefineClasses这个停顿类型。
但滥用Instrumentation对于生产环境的稳定性有较大的侵害。比如跟踪Set接口下指定方法的调用明细:
[arthas@84852]$ trace java.util.Set clear
会得到这样的结果:
Set下实现了clear方法的类一共44个,本次停顿合计33ms。如果不小心监控了一个实现有上百个的超级接口,那带来的停顿会是灾难性的,此时你会发现整个应用短时间无法对外响应。

逆优化

  • Deoptimize
  • DeoptimizeFrame
  • DeoptimizeAll
  • DeoptimizeTheWorld
在 Java 虚拟机中,JIT编译器负责将字节码转换为本地机器代码。C1 编译器主要用于快速编译代码,适用于启动时和热点代码。C2 编译器则会对热点代码进行更深层次更激进的优化,生成更高效的本地代码。整个过程如下图所示:
通俗的讲,逆优化其实就是JVM将JIT编译优化的代码变回未优化的代码。在Java虚拟机中,JIT编译器将字节码动态编译为本地机器代码以提高程序性能。但当JIT编译器优化后的代码无法继续有效执行(比如优化前提条件不再成立)时,就会触发deoptimize(逆优化)机制,即将已经编译的代码恢复成最开始的解释模式(Interpreted),以避免出现错误。
面对逆优化这种场景的停顿,其实我们做不了太多事情。主要是首先出现概率实在是太小了(相比偏向锁这种)。其次和业务代码的关系并不大,所以基本上如果真碰到这种问题且有明显的RT的gap,那只能具体问题具体分析。

其他

内部调试

  • ZombieAll
  • UnlinkSymbols
  • Verify
对于JDK的开发者而言,无法避免地会构建一个供自己开发调试用的的JDK。一个简单的构建命令如下:
sh configure--with-debug-level=slowdebug--disable-warnings-as-errors--with-freetype-include=/usr/local/Cellar/freetype/2.9.1/include/freetype2--with-freetype-lib=/usr/local/Cellar/freetype/2.9.1/lib--with-boot-jdk=/Library/Java/JavaVirtualMachines/jdk1.8.0_161.jdk/Contents/Home--with-target-bits=64
这里的--with-debug-level有四种模式:slowdebug、fastdebug、optimized、release。当以slowdebug或者fastdebug这两种模式build出来的JDK,会可以执行一些特定的调试方法。比如:
#ifndef PRODUCTvoid JavaThread::record_jump(address target, address instr, const char* file, int line) {
...}#endif // PRODUCT
这里的ifndef PRODUCT意味着仅当调试模式才会被调用,才会被引入。而诸如ZombieAll、UnlinkSymbols等这些停顿类型便发生在这些类似的场景下。所以我们这里不做进一步关注。 

废弃类型-ThreadStop

该停顿类型对应于Java线程的stop方法。但该方法在JDK1.2版本中就已经被标记为废弃(deprecated)。stop方法被废弃的原因是它可能会导致线程在不安全的状态下终止,可能引发一些不可预测的问题,比如线程没有来得及释放资源,导致资源泄漏等。因此,不推荐使用stop方法来终止线程。所以我们这里也不做进一步关注。 

No vm operation

眼尖的同学或许在停顿细节分析这一张的图里发现了一点点不一样的地方。我们好像看到了一种额外的停顿分类:no vm operation。这听起来让人相当困惑和拗口:一个类型为[没有vm操作]的停顿。我们可以尝试从Hotspot虚拟机源码入手看看为什么会发生这种事情。

从输出入手

之前vm.log中停顿细节的输出的源码可见:jdk/hotspot/src/share/vm/runtime/safepoint.cpp at 180c59d14700b2b7de2da5814f63f9a3d68cec71 · openjdk。
void SafepointSynchronize::print_statistics() { SafepointStats* sstats = _safepoint_stats;
for (int index = 0; index <= _cur_stat_index; index++) { if (index % 30 == 0) { print_header(); } sstats = &_safepoint_stats[index]; tty->print("%.3f: ", sstats->_time_stamp); tty->print("%-26s [" INT32_FORMAT_W(8) INT32_FORMAT_W(11) INT32_FORMAT_W(15) " ] ", sstats->_vmop_type == -1 ? "no vm operation" : VM_Operation::name(sstats->_vmop_type), sstats->_nof_total_threads, sstats->_nof_initial_running_threads, sstats->_nof_threads_wait_to_block); ...}
我们很容易就看到这里的对于停顿类型的输出规则:如果这个停顿的类型有名字,那么就用它的停顿类型的名字VM_Operation::name(sstats->_vmop_type),否则就输出no vm operation。那什么时候才会没有名字呢?

匿名的停顿类型 

一个标准的停顿执行过程大概是这样的:
  • 开启安全点,等待所有线程到达安全点;
  • 执行特定的触发安全点停顿(比如GC、偏向锁、逆优化等);
  • 停顿完成,所有线程恢复执行。
如果用代码来描述,大概是这个样子:
SafepointSynchronize::begin();op->evaluate();SafepointSynchronize::end();
然而凡事总有例外。在虚拟机内部,有一些内部数据的清理动作,他们清理的数据体量或者影响范围并不大,但是又不想因为这点事儿就全局暂停。有点像我们研发的搭车需求(改动量很小,跟随下一个窗口版本上线就好)。比如:
  • 重量级锁降级(deflating idle monitors)
  • 内联调用缓存清理(updating inline caches)
  • JIT方法活性标记(mark nmethods)
  • 内部字符串再hash提高性能(rehashing string table)
这其中,内联调用缓存清理相比其他几个任务略显重要,因为对应的缓存有一定的内存损耗。所谓内联调用缓存指的是方法调用之间的调用关系。简单来说代码被JIT编译为机器码之后,对于一个方法调用(call指令),需要知道call对应的目标(可静态决定、单态、多态)调用方法是谁。当JIT编译完成后,机器代码首次执行到这里虚拟机会去实时查询当前方法应该指向哪里,因为考虑到多线程运行的关系,这里的查询赋值在底层采用一种类似于ThreadLocal的方式完成。当最终完成方法指向后,"ThreadLocal"里的内容应该被清理。这里的"ThreadLocal"中的内容清理就对应着上面的内联调用缓存清理(updating inline caches)。更多细节可参考https://wiki.openjdk.org/display/HotSpot/Overview+of+CompiledIC+and+CompiledStaticCall。

虚拟机的方案

虚拟机给出的解法就很简单,就是把上述所有动作放在了安全点开启SafepointSynchronize::begin()时进行。这样任意停顿发生时,上述清理都会顺手完成。具体源码可见:jdk/hotspot/src/share/vm/runtime/safepoint.cpp at 9a9add8825a040565051a09010b29b099c2e7d49 · openjdk。
这样的做法本身没有问题,其他停顿类型会捎带着把上述这些事情做掉,属于一举两得。但当系统比较“平静”,即没有其他停顿发生的时候,那上述清理就不会发生了。于是虚拟机给了个兜底的要求:
  • 如果当前没有待执行的其他停顿,那么每过1秒钟来轮询
  • 轮询的内容是检查当前是否有内联调用缓存是否有待清理的数据
  • 如果满足,强制触发一次安全点
源码可见:https://github.com/openjdk/jdk/blob/9a9add8825a040565051a09010b29b099c2e7d49/hotspot/src/share/vm/runtime/vmThread.cpp#L456。


while (!should_terminate() && _cur_vm_operation == NULL) { bool timedout = VMOperationQueue_lock->wait(Mutex::_no_safepoint_check_flag, GuaranteedSafepointInterval); if (timedout && (SafepointALot || SafepointSynchronize::is_cleanup_needed())) { MutexUnlockerEx mul(VMOperationQueue_lock, Mutex::_no_safepoint_check_flag); SafepointSynchronize::begin(); SafepointSynchronize::end(); } ...}
而这次安全点强制触发的类型,就是文初说的no vm operation。它不指向任何具体的类型,仅利用安全点的停顿来完成相关数据清理工作。由于no vm operation可能会带来周期性的停顿,于是网上有小伙伴建议设置虚拟机参数来规避这个问题,-XX:GuaranteedSafepointInterval=0。但从个人角度建议是可以维持现状不动。原因是绝大部分情况这个周期性的停顿其实非常快(因为几乎没做什么重的操作),再次大部分情况下我们的性能还不到来抠这个细节的地步,减少一次RPC的调用,聚合多次DB的查询,得到的效果要远远比这个虚拟机参数的引入要好得多。

非counted loop的循环

前面有提这是个蛮有意思的case。如果你有读过JIT相关的书籍或文章的话,可能会看到这样的描述:

HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop),将会被放置安全点。

简单来说,面对这样的循环:
for (int i = 0; i < reps; i++) { ... }*SAFEPOINT*
虚拟机认为他是有限的循环(因为循环的上限是int类型),所以在循环体内不会放置任何安全点,仅会在循环结束即图中代码的*SAFEPOINT*点才会设置安全点。这会导致如果循环本身是个很重的操作的话,处理这段代码的线程迟迟到不了安全点,会整体拉长整个停顿的耗时。这个case已经有很多人碰到过,随便举几个例子,比如:
  • (小米)HBase实战:记一次Safepoint导致长时间STW的踩坑之旅(https://juejin.cn/post/6844903878765314061)
  • (Netty)long循环和int循环的讨论(https://github.com/netty/netty/pull/3969)
需要注意的是,更高版本的虚拟机已经优化了此问题,我们只需简单理解下这个Case发生的背景即可。

总结

从我们的整体摸排来看,能导致虚拟机发生停顿的行为有很多种,我们稍加整理,可以输出如下的约束:
  • 禁止生产环境debug
  • 生产环境禁止使用jmap等相关工具,如果使用建议流量摘除
  • 禁止代码中使用java.lang.Thread#getAllStackTraces
  • 慎重在生产环境使用Arthas等工具,若必须使用需注意单次命令影响类的范围
  • 偏向锁禁用-XX:-UseBiasedLocking
  • 可尝试加上启动参数 -XX:+DisableExplicitGC
  • 激进场景可使用 -XX:GuaranteedSafepointInterval=0
本质上,我们还是通过减少无谓的安全点进入,从而来保证我们系统的性能。但需要注意的是,并不是所有的停顿都和虚拟机本身有关,比如操作系统层面IO利用率100%,TCP连接打满,其他进程CPU占用,宿主机异常行为等都会影响当前虚拟机的稳定性,所以遇到问题还是需要综合起来去观察。
我们经常会被应用莫名其妙的系统停顿耗时所困扰,了解了Java虚拟机层面的所有停顿可能后,对于代码的书写以及问题排查,都会有莫大的帮助。 

往期回顾


1. KubeAI大模型推理加速实践|得物技术
2. Android Fresco调优实践|得物技术
3. 来自快手、得物等无线优化技术话题已集结|干货
4. 非标类型导致Dubbo接口出入参异常的本质 | 得物技术
5. 模型量化与量化在LLM中的应用 | 得物技术


文 / 财神


关注得物技术,每周一、三、五更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:




线下活动推荐

快快点击下方图片报名吧!

继续滑动看下一个
得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存