System.currentTimeMillis() 竟然存在性能問題?
- 2019 年 10 月 31 日
- 筆記
來源:https://dwz.cn/M1NXgypa
在之前的文章中就提到了,System.currentTimeMillis()並非最佳實踐。但是令人沒想到的是,除了精度問題,竟還存在性能問題。
System.currentTimeMillis()是極其常用的基礎Java API,廣泛地用來獲取時間戳或測量程式碼執行時長等,在我們的印象中應該快如閃電。
但實際上在並發調用或者特別頻繁調用它的情況下(比如一個業務繁忙的介面,或者吞吐量大的需要取得時間戳的流式程式),其性能表現會令人大跌眼鏡。直接看下面的Demo。
public class CurrentTimeMillisPerfDemo { private static final int COUNT = 100; public static void main(String[] args) throws Exception { long beginTime = System.nanoTime(); for (int i = 0; i < COUNT; i++) { System.currentTimeMillis(); } long elapsedTime = System.nanoTime() - beginTime; System.out.println("100 System.currentTimeMillis() serial calls: " + elapsedTime + " ns"); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(COUNT); for (int i = 0; i < COUNT; i++) { new Thread(() -> { try { startLatch.await(); System.currentTimeMillis(); } catch (InterruptedException e) { e.printStackTrace(); } finally { endLatch.countDown(); } }).start(); } beginTime = System.nanoTime(); startLatch.countDown(); endLatch.await(); elapsedTime = System.nanoTime() - beginTime; System.out.println("100 System.currentTimeMillis() parallel calls: " + elapsedTime + " ns"); } }
執行結果如下圖。

可見,並發調用System.currentTimeMillis()一百次,耗費的時間是單執行緒調用一百次的250倍。如果單執行緒的調用頻次增加(比如達到每毫秒數次的地步),也會觀察到類似的情況。
實際上在極端情況下,System.currentTimeMillis()的耗時甚至會比創建一個簡單的對象實例還要多,看官可以自行將上面執行緒中的語句換成new HashMap<>之類的試試看。
為什麼會這樣呢?
來到HotSpot源碼的hotspot/src/os/linux/vm/os_linux.cpp
文件中,有一個javaTimeMillis()方法,這就是System.currentTimeMillis()的native實現。
jlong os::javaTimeMillis() { timeval time; int status = gettimeofday(&time, NULL); assert(status != -1, "linux error"); return jlong(time.tv_sec) * 1000 + jlong(time.tv_usec / 1000); }
挖源碼就到此為止,因為已經有國外大佬深入到了彙編的級別來探究,簡單來講就是:
- 調用gettimeofday()需要從用戶態切換到內核態;
- gettimeofday()的表現受Linux系統的計時器(時鐘源)影響,在HPET計時器下性能尤其差;
- 系統只有一個全局時鐘源,高並發或頻繁訪問會造成嚴重的爭用。
HPET計時器性能較差的原因是會將所有對時間戳的請求串列執行。
TSC計時器性能較好,因為有專用的暫存器來保存時間戳。缺點是可能不穩定,因為它是純硬體的計時器,頻率可變(與處理器的CLK訊號有關)。
另外,可以用以下的命令查看和修改時鐘源。
~ cat /sys/devices/system/clocksource/clocksource0/available_clocksource tsc hpet acpi_pm ~ cat /sys/devices/system/clocksource/clocksource0/current_clocksource tsc ~ echo 'hpet' > /sys/devices/system/clocksource/clocksource0/current_clocksource
如何解決這個問題?
最常見的辦法是用單個調度執行緒來按毫秒更新時間戳,相當於維護一個全局快取。其他執行緒取時間戳時相當於從記憶體取,不會再造成時鐘資源的爭用,代價就是犧牲了一些精確度。
具體程式碼如下:
public class CurrentTimeMillisClock { private volatile long now; private CurrentTimeMillisClock() { this.now = System.currentTimeMillis(); scheduleTick(); } private void scheduleTick() { new ScheduledThreadPoolExecutor(1, runnable -> { Thread thread = new Thread(runnable, "current-time-millis"); thread.setDaemon(true); return thread; }).scheduleAtFixedRate(() -> { now = System.currentTimeMillis(); }, 1, 1, TimeUnit.MILLISECONDS); } public long now() { return now; } public static CurrentTimeMillisClock getInstance() { return SingletonHolder.INSTANCE; } private static class SingletonHolder { private static final CurrentTimeMillisClock INSTANCE = new CurrentTimeMillisClock(); } }
使用的時候,直接CurrentTimeMillisClock.getInstance().now()就可以了。
不過,在System.currentTimeMillis()的效率沒有影響程式整體的效率時,就不必忙著做優化,這只是為極端情況準備的。