最難發現的異常

轉載請註明出處 http://www.blogjava.net/killme2008/archive/2011/07/10/354062.html

    上週在線上系統發現了兩個bug,值得記錄下查找的過程和原因。以後如果還有查找bug比較有價值的經歷,我也會繼續分享。
    第一個bug的起始,是在線上日誌發現一個頻繁打印的異常——java.lang.ArrayIndexOutOfBoundsException。但是卻沒有堆棧,只有一行一行的ArrayIndexOutOfBoundsException。沒有堆棧,不知道異常是從什麼地方拋出來的,也就不能找到問題的根源,更談不上解決。題外,工程師在用log4j記錄錯誤異常的時候,我看到很多人這樣用(假設e是異常對象):

log.error("發生錯誤:"+e);
或者:
log.error("發生錯誤:"+e.getMessage());
    這樣的寫法是不對,只記錄了異常的信息,而沒有將堆棧輸出到日誌,正確的寫法是利用error的重載方法:
log.error("xxx發生錯誤",e);

    這樣才能在日誌中完整地輸出異常堆棧來。如何寫好日誌是另一個話題,這裏不展開。繼續我們的找bug經歷。剛纔提到,我們線上日誌一直出現一行錯誤信息ArrayIndexOutOfBoundsException卻沒有堆棧,是我們沒有正確地寫日誌嗎?檢查代碼不是的,這個問題其實是跟JDK5引入的一個新特性有關,對於一些頻繁拋出的異常,JDK爲了性能會做一個優化,在JIT重新編譯後會拋出沒有堆棧的異常。在使用server模式的時候,這個優化是開啓的,我們的服務器跑在server模式下並且jdk版本是6,因此在頻繁拋出ArrayIndexOutOfBoundsException異常一段時間後優化開始起作用,只拋出沒有堆棧的異常信息了。

    那麼怎麼解決這個問題呢?因爲這個優化是在JIT重新編譯後才起作用,因此一開始拋出異常的時候還是有堆棧的,所以可以查看較舊的日誌,尋找有完整堆棧的異常信息。但是由於我們的日誌太大,會定期刪除,我們的服務器也啓動了很長時間,因此查找日誌不是很靠譜的方法。
    另一個解決辦法是暫時禁用這個優化,強制要求每次都要拋出有堆棧的異常。幸好JDK提供了選項來關閉這個優化,配置JVM參數
-XX:-OmitStackTraceInFastThrow
    就可以禁止這個優化(注意選項中的減號,加號是啓用)。

    我們找了臺機器,配置了這個參數並重啓一下。過了一會就找到問題所在,堆棧類似這樣
Caused by: java.lang.ArrayIndexOutOfBoundsException: -1831238
    at sun.util.calendar.BaseCalendar.getCalendarDateFromFixedDate(BaseCalendar.java:
436)
    at java.util.GregorianCalendar.computeFields(GregorianCalendar.java:
2081)
    at java.util.GregorianCalendar.computeFields(GregorianCalendar.java:
1996)
    at java.util.Calendar.setTimeInMillis(Calendar.java:
1109)
    at java.util.Calendar.setTime(Calendar.java:
1075)
    at java.text.SimpleDateFormat.format(SimpleDateFormat.java:
876)
    at java.text.SimpleDateFormat.format(SimpleDateFormat.java:
869)
    at java.text.DateFormat.format(DateFormat.java:
316)

    讀者肯定猜到了,這個問題是由於SimpleDateFormat的誤用引起的。SimpleDateFormatjavadoc中有這麼句話:
Date formats are not synchronized. It is recommended to create separate format instances for each thread. 
If multiple threads access a format concurrently, it must be 
synchronized externally. 
    但是很悲劇的是這句話是放在整個doc的最後面,在我看來,這句話應該放在最前面纔對。簡單來說就是SimpleDateFormat不是線程安全的,你要麼每次都new一個來用,要麼做加鎖來同步使用。

    出問題的代碼就是由於工程師認爲SimpleDateFormat的創建代價很高,然後搞了個map做緩存,所有線程共用這個instance做format,同時沒有做同步。悲劇就誕生了。
    這裏就引出我想提到的第二點問題,在使用一個類或者方法的時候,最好能詳細地看下該類的javadoc,JDK的javadoc是做的非常好的,javadoc除了做說明之外,通常還會給示例,並且會點出一些關鍵問題,如線程安全性和平臺移植性。

    最後,我將做個測試,到底在使用SimpleDateFormat怎麼做纔是最好的方式?假設我們要實現一個formatDate方法將日期格式化成"yyyy-MM-dd"的格式。
    第一個方法是每次使用都創建一個instance,並調用format方法:
publicstatic String formatDate1(Date date) {
        SimpleDateFormat format 
=new SimpleDateFormat("yyyy-MM-dd");
return format.format(date);
    }

    第二個方法是隻創建一個instance,但是在調用方法的時候做同步:
privatestaticfinal SimpleDateFormat formatter =new SimpleDateFormat("yyyy-MM-dd");

publicstaticsynchronized String formatDate2(Date date) {
return formatter.format(date);
    }

    第三個方法比較特殊,我們爲每個線程都緩存一個instance,存放在ThreadLocal裏,使用的時候從ThreadLocal裏取就可以了:
privatestatic ThreadLocal<SimpleDateFormat> formatCache =new ThreadLocal<SimpleDateFormat>() {

        @Override
protected SimpleDateFormat initialValue() {
returnnew SimpleDateFormat("yyyy-MM-dd");
        }

    };

publicstatic String formatDate3(Date date) {
        SimpleDateFormat format 
= formatCache.get();
return format.format(date);
    }

    然後我們測試下三個方法併發調用下的性能並做一個比較,併發100個線程循環調用1000萬次,記錄耗時。我們設置了JVM參數:
-Xmx512m -XX:CompileThreshold=10000
    設置堆最大爲512M,設置當一個方法被調用1萬次的時候就被JIT編譯。測試的結果如下:
第1次測試
第2次測試
第3次測試
formatDate1
50545
49365
53532
formatDate2
10895
10761
10673
formatDate31038699199527
(單位:毫秒)

    從結果來看,方法1最慢,方法3最快,但是就算是最慢的方法1也可以達到每秒鐘200 20萬次的調用量,很少有系統能達到這個量級。這個點很難成爲你係統的瓶頸所在。從我的角度出發,我會建議你用方法1或者方法2,如果你追求那麼一點性能提升的話,可以考慮用方法3,也就是用ThreadLocal做緩存。

    總結下本文找bug經歷想表達的幾點想法:
(1)正確地打印錯誤日誌
(2)在server模式下,最好都設置-XX:-OmitStackTraceInFastThrow
(3)使用類或者方法的時候,最好能詳細閱讀下javadoc,很多問題都能找到答案
(4)使用SimpleDateFormat的時候要注意線程安全性,要麼每次new,要麼做同步,兩者的性能有差距,但是這個差距很難成爲你的性能瓶頸。

    下篇文章我再分享另一個bug的查找經歷,也是比較有趣,可以看到一些工具的使用。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章