【OOM】記錄一次生產上的OutOfMemory解決過程


一.項目架構

  SpringCloud  Dalston.SR1 + SpringBoot 1.5.9 + Mysql +Redis + RabbitMQ

  所有的業務模塊的應用服務都部署在同一個服務器,且單實例部署,服務器配置4核32G,

 

二. 原因分析:

  自己所負責的data模塊這兩天OOM較多,導致服務重啟;

  data服務主要業務是報表相關,數倉對接的業務以及多個外部數據相關的小程序的后台,與數據庫的交互比較多,業務邏輯相對其他模塊較為簡單,

  第一次:2月25日OOM情況:

    由於Redis反序列化失敗導致的OOM

  

 

   第二次:2月26日的OOM情況:

    由於GC無法回收對象導致

   

 

   第一次發生OOM時,覺得可能就是由於Redis序列化器和反序列化器不一致,原有的JVM參數僅設置時-Xmx:512m -Xms:512m, 老年代:年輕代=2:1 ,老年代大概分配有300M內存

    時候排查問題時,發現Redis的使用都是用自己用RedisTemplate封裝的工具類,按道理說不會出現什么問題,並未過多關注;

   第二次發生OOM時,與第一次相距的時間僅為1天,當時就覺得問題不對了,

    1.首先使用jmap -histo:live pid 查看 服務內存活的對象,發現 [C 類型的數組和ConcurrentHashMap對象都存活較多;

         檢查代碼后發現並未有顯示的使用該兩類類型,懷疑時String字符串過多導致的;

    2.其次使用JDK自帶的分析工具:jmap -dump:format=b,file=文件名 [pid] 導出OOM時的dump日志;

      導出時間非常慢,且占用線上系統的CPU,導致CPU達到100%

    3.使用jstat -gc pid /jstat -gcutil pid 查看gc的狀況

      發現gc和fgc的都非常多,特別是fgc已經達到1000多次;

   

  

   初步解決方案:(2月26日)

     最后仍然是重啟服務,-添加參數Xmx1024m -Xms:1024m

     然后添加JVM參數(使用jinfo -flag可以在生產環境上直接添加)

     jinfo -flag +HeapDumpBeforeFullGC pid

     jinfo -flag +HeapDumpAfterFullGC pid

     jinfo -flag +HeapDumpOnOutOfMemoryError pid

     jinfo -flag +HeapDumpPath=/home/xxx/xxx pid 添加dump日志的目錄(需要提前建好)

     jinfo -flag -XX:+PrintGCDetails pid      開啟gc日志

     jinfo -flag -XX:+PrintGCDateStamps -Xloggc:/xxx/xxx  設置gc日志的目錄

     修改完成后第二天根據fgc產生的dump日志,加載到jvisualVM里面之后發現也是[C占用內存較多

     下午 2點左右,監控線上服務時發現Old老年代的內存占用為300M,總大小為700M,經過一次FGC之后占用70M,這就比較正常了;

  

  重點來了:

    在2月26日添加完成JVM參數后,第二天同樣的接口,FGC之前終於拿到了dump文件,大小是1.4G,接下來就是分析dump文件了,這里我選擇了兩個工具:

    MAT與Jvisualvm

      在使用體驗來說JDK自帶的Jvisualvm真的很垃圾,文件打開都要半個小時,果斷放棄,轉而使用MAT

    導入dump文件以后如圖

    

 

 

    這里主要是看Leak Suspects:其他的幾個指標在此也說明一下:

      1. Histogram可以列出內存中的對象,對象的個數以及大小。
      2. Dominator Tree可以列出那個線程,以及線程下面的那些對象占用的空間。
      3.Top consumers通過圖形列出最大的object。
      4.Leak Suspects通過MA自動分析泄漏的原因。

   打開Leak Suspects后可以看到線程堆棧如圖

     

 

 

     再繼續找,找到是否有我們的業務代碼。找到如圖

     

 

 

   這里其實已經定位到具體的業務代碼了,但是MAT的強大之處就是可以定位究竟是什么大對象,

  如圖,這里已經可以看到了6W多個HashMap被Object[]引用,這里是內存占用的主要原因

   OK,接下來可以取看業務代碼了

    

 

 

         

 

 

   業務代碼如下,只展示關鍵代碼,這個接口是報表數據導出的接口,查詢mysql后使用HashMap去接收結果集,

   Object[]用於是用於寫入報表工具類的入參;

  查看服務器日志后,發現這條SQL有6W多條數據,而且在一分鍾之內有人操作導出了兩次,導致數據封裝到HashMap里面,發生FGC

    

 

 

 

三   最終解決方案: 

  1.加大堆內存 原來由512擴大到1024M;

  2.HashMap改為JavaBean對象去封裝結果集,因為HashMap底層是數組,還有其他的引用成員變量,更加有頻繁的擴容,

    查資料后發現HashMap在數據量的情況下內存占用比Java對象要大;

  3.導出接口添加限流注解,防止在短時間內多次請求;

  以下是限流代碼:使用Guava的限流組件實現,當然也可以基於Redis的實現,或者自己實現一套

  4.由於EasyExcel內存占用少,可以將poi換成阿里的EasyExcel,實現多條數據分頁導出;

/**
 * @author: Gabriel
 * @date: 2020/2/18 12:03
 * @description 自定義接口限流注解
 */
@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimitAnno {

    /** 每秒放入令牌桶中的token */
    double limitNum() default 20;
}


/**
 * @author: Gabriel
 * @date: 2020/2/18 12:07
 * @description
 */
@Slf4j
@Aspect
@Component
public class RateLimitAspect {

    /**
     * 用來存放不同接口的RateLimiter(key為接口名稱,value為RateLimiter)
     */
    private ConcurrentHashMap<String, RateLimiter> map = new ConcurrentHashMap<>();

    private RateLimiter rateLimiter;

    @Autowired
    private static ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private HttpServletResponse httpServletResponse;

    @Pointcut("@annotation(com.gabriel.stage.annotation.RateLimitAnno)")
    public void rateLimit() {
    }

    /**
     * 環繞通知
     *
     * @param joinPoint
     * @return
     * @throws Exception
     */
    @Around("rateLimit()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object obj = null;
        //獲取攔截的方法簽名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Object target = joinPoint.getTarget();
        //獲取注解信息
        Method method = target.getClass().getMethod(signature.getName(), signature.getParameterTypes());
        RateLimitAnno annotation = method.getAnnotation(RateLimitAnno.class);
        double limitNum = annotation.limitNum();
        //獲取方法名
        String functionName = signature.getName();
        //獲取類名
        String className = signature.getDeclaringTypeName();
        signature.getDeclaringTypeName();
        if (StringUtils.isNotBlank(className)) {
            className = StringUtils.substringAfterLast(className, ".");
        }
        //拼接類名和方法名,保證key唯一
        String joinName = StringUtils.join(functionName, className);

        //獲取rateLimiter
        if (map.containsKey(joinName)) {
            rateLimiter = map.get(joinName);
        } else {
            map.put(joinName, RateLimiter.create(limitNum));
            rateLimiter = map.get(joinName);
        }

        if (rateLimiter.tryAcquire()) {
                obj = joinPoint.proceed();
        } else {
            System.err.println("接口限流,請求降級。。。。。。。。。。。。。。。。。");
            throw new BusinessException(ResultCode.SERVER_ERROR);
        }
        return obj;
    }

 

  

   


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM