JetBrains Platform
Plugin and extension development for JetBrains products.
调查 IntelliJ 平台 UI 冻结

您可能偶尔会看到这样的画面,进而疑惑自己的 IDE 究竟出了什么问题,为何会出现冻结! 这是一个棘手的问题,各类错误与性能问题都有可能导致 UI 冻结。
JetBrains IDE 基于 UI 框架 (Java AWT) 构建,该框架采用单线程模式,即通过事件调度线程 (EDT) 执行绘制操作与处理用户输入事件。
当 IDE 无法在 EDT 上执行操作时,就会发生 UI 冻结,进而导致用户无法与 IDE 进行交互。 这篇博文旨在整合我们关于 UI 冻结原因的相关知识。 您可以参考本文来调查和解决 IDE 插件中的 UI 冻结!
IntelliJ 平台的 UI 架构
由于我们的 UI 框架基于单线程构建,务必要避免 EDT 长时间处于阻塞状态。 EDT 会运行一个处理 AWT 事件的事件循环。 此类事件的典型示例包括用户输入(如打字或移动鼠标)以及来自 Swing 的重绘请求。
事件需要在 16 毫秒内完成处理。 否则,IDE 将无法实现每秒 60 帧的渲染速率。
调查 UI 冻结
在绝大多数情况下,只需查看线程转储,即可获取 UI 冻结的相关信息。 调查应从 AWT-EventQueue-N 线程(即 EDT)开始。 该线程的名称通常应为 AWT-EventQueue-0。 如果您发现后缀中的数字变大,则表示平台可能存在问题。
发生 UI 冻结时,EDT 通常会因某种锁而处于阻塞状态。
读写锁导致的冻结
如果您不熟悉 IntelliJ 平台读写锁的相关概念,可以访问此 Notebook 了解详情。
由于 IntelliJ 平台的架构特性,写入锁通常会在 EDT 上获取。 鉴于写入锁往往无法立即获取,您有时会在 EDT 的堆栈跟踪中看到以下行:
"AWT-EventQueue-0" prio=0 tid=0x0 nid=0x0 waiting on condition java.lang.Thread.State: TIMED_WAITING on com.intellij.openapi.progress.util.EternalEventStealer@3a946cba at java.base@21.0.8/java.lang.Object.wait0(Native Method) ... (!) at com.intellij.platform.locking.impl.NestedLocksThreadingSupport$ComputationState.upgradeWritePermit(NestedLocksThreadingSupport.kt:370) ... at com.intellij.platform.locking.impl.NestedLocksThreadingSupport.runWriteAction(NestedLocksThreadingSupport.kt:921) ... at com.intellij.ide.IdeEventQueue.dispatchEvent(IdeEventQueue.kt:347) ... at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:92)
此处带有 (!) 标记的行是关键行。 此行表明 EDT 正因获取写入锁而处于阻塞状态。 在这种情况下,无需查看上方内容。 如果检测到 UI 冻结,IntelliJ 平台通常会进入应急模式,并运行部分内部过程。
EDT 因写入锁阻塞,表明有一个线程正持有读取锁运行。 此时调查的下一步便是找到此后台线程。 具体可通过在线程转储中搜索子字符串 readAction(不区分大小写)来实现。
例如,我们可能会找到如下线程:
"JobScheduler FJ pool 1/11" prio=0 tid=0x0 nid=0x0 runnable
java.lang.Thread.State: RUNNABLE
at ai.grazie.rules.en.QuantifierNounCompatibility.(QuantifierNounCompatibility.java:38)
at ai.grazie.rules.en.AgreementSet.(AgreementSet.java:42)
at ai.grazie.rules.en.PluralsInCompounds.(PluralsInCompounds.java:21)
at ai.grazie.rules.en.Articles.(Articles.java:250)
...
at ai.grazie.rules.toolkit.LanguageToolkit.allParameters(LanguageToolkit.java:87)
(!!) at com.intellij.grazie.pro.TreeRuleChecker.calcParameters(TreeRuleChecker.java:234)
...
at com.intellij.codeInsight.daemon.impl.AnnotatorRunner$Lambda/0x00000070047e8000.run(Unknown Source)
...
(!) at com.intellij.platform.locking.impl.NestedLocksThreadingSupport.tryRunReadAction(NestedLocksThreadingSupport.kt:826)
...
at java.base@21.0.8/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:187)
此线程取自 ForkJoinPool,并且我们可以观察到它当前正运行读取操作(位于 (!) 处)。 同时,我们在堆栈跟踪中发现了 AnnotatorRunner,这意味着此线程当前正在运行高亮显示任务。 高亮显示过程中的读取操作会在存在待处理的写入操作时被取消,这意味着此次 UI 冻结的原因是相关代码未检查 ProgressManager.checkCanceled。 在代码块的中间部分,我们能看到来自 Grazie 插件的部分跟踪信息(始于 (!!))。 至此,我们的调查可以得出结论。 我们已找到问题根源,即 Grazie 检查 checkCanceled 的频率不足,进而导致了 UI 冻结。
后台写入操作导致的冻结
IntelliJ 平台正逐步将写入操作迁移至后台线程,但此功能目前尚不稳定,可能会导致额外的 UI 冻结与死锁问题。
尽管写入操作可以在后台运行,EDT 仍会经常获取写入意图锁,从而产生如下堆栈跟踪:
"AWT-EventQueue-0" #91 [119043] prio=6 os_prio=31 cpu=71985.87ms elapsed=1065.05s tid=0x00000001610c4c00 nid=119043 sleeping [0x0000000398429000] java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep0(java.base@21.0.8/Native Method) ... at com.intellij.platform.locking.impl.NestedLocksThreadingSupport$ComputationState.acquireWriteIntentPermit(NestedLocksThreadingSupport.kt:416) ... at java.awt.EventDispatchThread.run(java.desktop/EventDispatchThread.java:92)
这表明 EDT 无法立即获取写入意图锁。 下一步是搜索写入操作。 在本例中,该操作可能出现在协程转储中。 例如:
- "RefreshQueue pool":StandaloneCoroutine{Active}, state: SUSPENDED [Kernel@lumrbnr1s12qelije7p5, Rete(abortOnError=false, commands=capacity=2147483647,data=[onReceive], reteState=kotlinx.coroutines.flow.StateFlowImpl@6d763516, dbSource=ReteDbSource(reteState=kotlinx.coroutines.flow.StateFlowImpl@6d763516)), DbSourceContextElement(kernel Kernel@lumrbnr1s12qelije7p5), ComponentManager(ApplicationImpl@106897812), com.intellij.codeWithMe.ClientIdContextElementPrecursor, Dispatchers.Default.limitedParallelism(1)]
at com.intellij.core.rwmutex.WriteIntentPermitImpl.acquireWriteActionPermit(RWMutexIdea.kt:263)
...
at com.intellij.openapi.vfs.newvfs.RefreshQueueImpl.processEventsSuspending(RefreshQueueImpl.kt:191)
...
at com.intellij.openapi.vfs.newvfs.RefreshQueueImpl$queueAsyncSessionWithCoroutines$3$1.invokeSuspend(RefreshQueueImpl.kt:177)
我们在这里可以看到,一个协程在获取写入锁时处于 SUSPENDED 状态。 具体而言,VFS 刷新无法继续执行,这意味着存在一个活跃的读取操作。 事实确实如此:
"JobScheduler FJ pool 7/9" #201 [175107] daemon prio=6 os_prio=31 cpu=153605.15ms elapsed=1039.57s tid=0x0000000142955a00 nid=175107 runnable [0x00000003610e7000] java.lang.Thread.State: RUNNABLE at com.intellij.psi.impl.file.impl.MultiverseFileViewProviderCache.get(MultiverseFileViewProviderCache.kt:67) ... at org.jetbrains.kotlin.idea.codeInsight.lineMarkers.KotlinRecursiveCallLineMarkerProvider.collectSlowLineMarkers(KotlinRecursiveCallLineMarkerProvider.kt:35) ... at com.intellij.openapi.application.impl.ApplicationImpl.tryRunReadAction(ApplicationImpl.java:1206) ... at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21.0.8/ForkJoinWorkerThread.java:187)
在此我们再次得出结论:由于读取操作的存在,某些代码无法被取消。 解决此类冻结问题的流程与我们上文讨论的内容类似。
SuvorovProgress
有时,EDT 会在名为 SuvorovProgress 的类中阻塞:
"AWT-EventQueue-0" prio=0 tid=0x0 nid=0x0 waiting on condition
java.lang.Thread.State: TIMED_WAITING
on com.intellij.openapi.progress.util.EternalEventStealer@215bb938
at java.base@21.0.7/java.lang.Object.wait0(Native Method)
at java.base@21.0.7/java.lang.Object.wait(Object.java:366)
at com.intellij.openapi.progress.util.EternalEventStealer.dispatchAllEventsForTimeout(SuvorovProgress.kt:261)
at com.intellij.openapi.progress.util.SuvorovProgress.processInvocationEventsWithoutDialog(SuvorovProgress.kt:125)
at com.intellij.openapi.progress.util.SuvorovProgress.dispatchEventsUntilComputationCompletes(SuvorovProgress.kt:73)
at com.intellij.openapi.application.impl.ApplicationImpl.lambda$postInit$14(ApplicationImpl.java:1434)
at com.intellij.openapi.application.impl.ApplicationImpl$Lambda/0x000076ace059cff8.invoke(Unknown Source)
at com.intellij.platform.locking.impl.RunSuspend.await(NestedLocksThreadingSupport.kt:1517)
at com.intellij.platform.locking.impl.NestedLocksThreadingSupportKt.runSuspendWithWaitingConsumer(NestedLocksThreadingSupport.kt:1472)
...
at com.intellij.platform.locking.impl.NestedLocksThreadingSupport.runWriteAction(NestedLocksThreadingSupport.kt:921)
...
at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:105)
at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:92)
不过,SuvorovProgress 只是 UI 冻结的一种表现。 它表明 IntelliJ 平台正处于应急模式,在此模式下,平台仍能运行部分可信的 AWT 事件,或绘制冻结弹出窗口 UI。
这种情况极少会引发冻结,因此问题很可能出在其他地方。
线程饥饿导致的冻结
IntelliJ 平台大量使用 Kotlin 协程库。 在协程中,存在两个主要的线程池:Dispatchers.Default 和 Dispatchers.IO。 二者均为有界线程池,Dispatchers.Default 包含的线程数与您计算机的核心数一致,而 Dispatchers.IO 则包含 64 个线程。 如果这些线程全部因某项操作而被阻塞,IDE 便会遭遇线程饥饿,即由于线程池中所有线程均处于阻塞状态,协程机制无法继续推进。
线程饥饿的常见表现是协程陷入 Cancelling 状态。 这些协程无法进入 Cancelled 状态,因为它们无法在各自的线程池中执行清理操作。
要调查 Default 调度器的线程饥饿问题,您需要找到所有包含 runDefaultDispatcherTask 的阻塞线程。
例如:
"DefaultDispatcher-worker-9@28483" daemon prio=5 tid=0xa0 nid=NA waiting java.lang.Thread.State: WAITING at jdk.internal.misc.Unsafe.park(Unsafe.java:-1) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269) ... at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:48) at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source:1) (!!) at com.intellij.platform.pluginManager.frontend.BackendUiPluginManagerController.awaitForResult(BackendUiPluginManagerController.kt:293) ... at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:610) (!) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runDefaultDispatcherTask(CoroutineScheduler.kt:882) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:906) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:775) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:762)
如果此类线程的数量与您计算机的核心数一致(位于 (!) 处),则表明存在线程饥饿的迹象。 同理,如果排查的是 IO 调度器的线程饥饿问题,您会发现 64 个名为 DefaultDispatcher-worker-N 的阻塞线程。
注意:仅仅查看所有名为 DefaultDispatcher-worker-N 的线程不足以判定 Default 调度器存在线程饥饿。 由于协程调度器会在 Default 和 IO 调度器之间共享物理线程,如果不实际查看源代码,很难判断某一线程属于哪个调度器。
要解决线程饥饿问题,需要将阻塞操作移出 Default 调度器。 在这里我们可以看到,在带有 (!!) 的行中使用了 runBlocking,这部分代码需要迁移到其他调度器中。
服务初始化导致的冻结
在 IntelliJ 平台中,另一个容易引发锁问题的场景便是服务初始化。 由于服务仅会初始化一次,尝试访问某个服务的线程可能会在等待另一线程完成初始化的过程中陷入阻塞。
以下为此场景的具体示例:
"AWT-EventQueue-0" #59 [128003] prio=6 os_prio=31 cpu=3011.23ms elapsed=172.40s tid=0x000000012c07ae00 nid=128003 waiting on condition [0x000000039b0ae000] java.lang.Thread.State: TIMED_WAITING (parking) at jdk.internal.misc.Unsafe.park(java.base@21.0.7/Native Method) ... (!) at com.intellij.serviceContainer.ComponentManagerImplKt.runBlockingInitialization$lambda$9(ComponentManagerImpl.kt:1660) ... at com.intellij.serviceContainer.ComponentManagerImpl.getService(ComponentManagerImpl.kt:672) (!!) at com.intellij.xdebugger.XDebuggerManager.getInstance(XDebuggerManager.java:32) ... (!!!) at com.intellij.platform.locking.impl.NestedLocksThreadingSupport.runWriteAction(NestedLocksThreadingSupport.kt:939) ... at java.awt.EventDispatchThread.run(java.desktop/EventDispatchThread.java:92)
我们可以看到,在带有 (!) 标记的行中,EDT 因某个服务的初始化而阻塞。 该服务是 XDebuggerManager(位于 (!!) 处),因此其内部可能存在问题。 此外,我们还注意到此过程是在写入操作(位于 (!!!) 处)下运行的,这一发现在后续排查中会很有用。
通过搜索 XDebuggerManager,我们可以找到以下线程:
"DefaultDispatcher-worker-4" #55 [130051] daemon prio=5 os_prio=31 cpu=1537.10ms elapsed=172.41s tid=0x000000012b883c00 nid=130051 in Object.wait() [0x000000036b607000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait0(java.base@21.0.7/Native Method) ... (!) at com.intellij.openapi.application.impl.ApplicationImpl.runReadAction(ApplicationImpl.java:1028) at com.intellij.xdebugger.impl.breakpoints.XBreakpointManagerImpl.loadState(XBreakpointManagerImpl.java:536) at com.intellij.xdebugger.impl.XDebuggerManagerImpl.loadState(XDebuggerManagerImpl.java:388) (!!) at com.intellij.xdebugger.impl.XDebuggerManagerImpl.loadState(XDebuggerManagerImpl.java:81) ... at com.intellij.configurationStore.ComponentStoreWithExtraComponents.initComponentBlocking(ComponentStoreWithExtraComponents.kt:43) ... at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:765)
(!!) 处的服务实例初始化尝试获取读取锁(位于 (!) 处)。 问题很明显,EDT 上的写入操作因服务初始化过程中的锁而阻塞,而服务初始化又因获取读取锁而阻塞。 这是由于两种锁的获取顺序错误导致的死锁。
修正此问题的最佳方式是将读取操作移出服务初始化过程。 如果无法做到这一点,您可以在读取操作中预加载服务。 这样,服务初始化将继承读取权限。
如何处理 runBlocking
有时,您可能会发现线程在 runBlocking 中阻塞。 这意味着该线程正在尝试以同步方式执行协程。
例如:
"JobScheduler FJ pool 6/15" prio=0 tid=0x0 nid=0x0 waiting on condition
java.lang.Thread.State: TIMED_WAITING
on kotlinx.coroutines.BlockingCoroutine@41813fd4
at java.base@21.0.8/jdk.internal.misc.Unsafe.park(Native Method)
...
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$BuildersKt__BuildersKt(Builders.kt:85)
...
(!) at com.intellij.openapi.progress.CoroutinesKt.runBlockingCancellable(coroutines.kt:117)
at com.intellij.grazie.text.CheckerRunner.run(CheckerRunner.kt:53)
...
(!!) at com.intellij.openapi.application.impl.ApplicationImpl.tryRunReadAction(ApplicationImpl.java:1206)
...
at java.base@21.0.8/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:187)
首先,我们需要确认是否存在 runBlockingCancellable(位于 (!) 处)。 这是 runBlocking 的特殊平台版本,可与我们的取消机制良好配合。
由于此线程运行读取操作(位于 (!!) 处),我们现在需要弄清楚它为何无法被取消。 要做到这一点,我们需要查看协程转储,并搜索以 BlockingCoroutine(即与 runBlocking 对应的协程名称)为起点的协程树。 我们的发现如下:
- JobImpl{Active}
- BlockingCoroutine{Active}@41813fd4, state: SUSPENDED [ModalityState.NON_MODAL, ComputationState(level=0,thisLevelLock=com.intellij.core.rwmutex.RWMutexIdeaImpl@1c825ccf,isParallelizedRead=true), BlockingEventLoop]
at com.intellij.grazie.text.CheckerRunner$run$1.invokeSuspend(CheckerRunner.kt:69)
- DeferredCoroutine{Active}, state: SUSPENDED [ModalityState.NON_MODAL, ComputationState(level=0,thisLevelLock=com.intellij.core.rwmutex.RWMutexIdeaImpl@1c825ccf,isParallelizedRead=true), BlockingEventLoop]
at com.intellij.ml.grazie.pro.SentenceBatcher$forSentences$1.parseAsync(SentenceBatcher.kt:96)
at com.intellij.ml.grazie.pro.CloudOrLocalBatchParser.parseAsync(CloudOrLocalBatchParser.kt:20)
at com.intellij.ml.grazie.pro.ParsedSentence$Companion.getSentences(ParsedSentence.kt:104)
at com.intellij.ml.grazie.pro.AsyncTreeRuleChecker.checkExternally$suspendImpl(AsyncTreeRuleChecker.kt:18)
at com.intellij.ml.grazie.pro.AsyncTreeRuleChecker$Style.checkExternally(AsyncTreeRuleChecker.kt:48)
at com.intellij.grazie.text.CheckerRunner$run$1$deferred$1$1.invokeSuspend(CheckerRunner.kt:56)
我们可以看到,此协程运行于 com.intellij.grazie.text.CheckerRunner 中,这意味着此 BlockingCoroutine 与上述跟踪信息中的 runBlocking 相对应。 现在我们可以检查这个协程树,尝试找出冻结的原因。
由于该协程的状态为 DeferredCoroutine{Active},它并未被取消。 这意味着平台的取消机制存在问题,因为按照设计,平台在接收到写入操作时,应当取消所有正在进行的读取操作;因此,所有协程也应随之被取消。 正常情况下,我们在此处应看到 BlockingCoroutine{Cancelling}。
要解决此 UI 冻结问题,我们需要查明该协程为何无法转换为 {Cancelled} 状态。 最可能的原因是,代码调用 ProgressManager.checkCanceled() 的频率不够高。
结论
在这篇博文中,我们探讨了基于 IntelliJ 平台的 IDE 中常见的 UI 冻结来源。 多数情况下,这类冻结是由于插件代码与 IntelliJ 平台之间协作不足所致。 通过使用 IntelliJ 平台中用于执行可取消操作的基元,我们能够显著提升 JetBrains IDE 的响应速度。
本博文英文原作者: