記錄一次生產環境 JVM 內存持續增長的排查過程,核心原因是動態腳本編譯產生的類未被回收,導致 Metaspace / PermGen 區塊耗盡。
運維發現應用發布後 JVM 內存佔用回落,隨後持續升高。對 Heap Dump 進行分析,發現大量 GroovyClassLoader 加載的類對象佔據內存。
publicT getScriptedObject(String scriptName, String scriptSource, Class cls) { if (StringUtils.isEmpty(scriptSource)) throw new RuntimeException("服務腳本" + scriptName + "為空"); GroovyObject goo = null; Class clz = null; try { // 每次調用都解析腳本源碼,生成新的 Class clz = groovyClassLoader.parseClass(scriptSource); goo = (GroovyObject) clz.newInstance(); // 關鍵問題:自動裝配導致 Bean 成為多例 if (null != beanFactory) { beanFactory.autowireBeanProperties(goo, 1, true); // AutowireCapableBeanFactory.AUTOWIRE_BY_NAME } } catch (UnsatisfiedDependencyException ex) { // 忽略異常 } catch (Exception ex) { logger.error("腳本{}異常:{}", scriptName, ex); throw new RuntimeException(ex); } if (cls.isAssignableFrom(goo.getClass())) { return (T) goo; } else { throw new RuntimeException("腳本" + scriptName + "錯誤"); } }
parseClass 都會生成一個新的 Class 對象,由 GroovyClassLoader 加載。若腳本內容不變,仍重複編譯,導致類數量無限增長。beanFactory.autowireBeanProperties(goo, 1, true) 將動態生成的對象納入 Spring 容器管理,但每次調用都創建新實例,且未註冊為單例 Bean,實際上產生了大量「多例」對象,佔用 Heap 內存。GroovyClassLoader 及其加載的類長期被引用,無法被 GC 回收(除非類加載器本身不可達)。引入腳本緩存層:使用 Map
private final Map> scriptClassCache = new ConcurrentHashMap<>(); public T getScriptedObject(String scriptName, String scriptSource, Class cls) { // 1. 從緩存獲取或編譯類 Class> clz = scriptClassCache.computeIfAbsent(scriptName, k -> { try { return groovyClassLoader.parseClass(scriptSource); } catch (Exception e) { throw new RuntimeException("腳本編譯失敗: " + scriptName, e); } }); // 2. 實例化(每次調用仍可創建新實例,或根據需求緩存實例) try { GroovyObject goo = (GroovyObject) clz.newInstance(); if (beanFactory != null) { beanFactory.autowireBeanProperties(goo, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, true); } return cls.cast(goo); } catch (Exception e) { throw new RuntimeException("腳本實例化失敗: " + scriptName, e); } }
緩存清理機制:為保持腳本修改後生效,需提供緩存刷新功能:
scriptClassCache.clear()。autowireBeanProperties 會將對象納入容器生命周期管理,若對象頻繁創建且非單例,會造成內存壓力。此案例再次驗證:「不了解框架內部實現,輕易調用底層 API,必踩坑。」