讀古今文學網 > Java程序員修煉之道 > 6.4 一個來自於硬件的時間問題 >

6.4 一個來自於硬件的時間問題

你有沒有想過計算機裡的時間存在哪裡以及在哪裡處理?我們都知道硬件最終負責跟蹤時間,但事實可能不像你想的那麼簡單。

為了進行性能調優,你需要對時間如何工作有深刻的認識。為此我們先從底層硬件開始討論,然後探討Java如何與這些子系統集成,最後介紹nanoTime方法的複雜性。

6.4.1 硬件時鐘

在基於x64的機器裡有四種不同的硬件時間源:RTC、8254、TSC以及HPET。

實時時鐘(RTC)基本上和便宜的電子錶(基於石英晶體)裡找到的電子器件一樣,在系統斷電時由主板上的電池供電。系統在啟動時就是從它那裡得到時間的,不過很多機器在OS啟動過程中會通過網絡時間協議(Network Time Protocol,NTP)跟網絡上的時間服務器同步。

所有古董都曾是新東西

實時時鐘這個名字現在看來十分不恰當——在20世紀80年代它剛出現時確實被認為是實時的,但現在它的準確度對於關鍵應用來說已經不夠用了。以「新」或「快」命名的創新經常是這種結局,比如巴黎的Pont Neuf(「新橋」)。它建於1607年,現在已經是巴黎市內最古老的橋了。

8254是可編程計時芯片,也是始祖級的東西。它的時鐘源是一個119.318kHz的晶體,這個頻率是NTSC彩色副載波頻率的三分之一,這也是它返回到CGA圖形系統的原因。它曾經為OS調度器提供定期時點(用於時間片),但現在已經有其他時間源(或者不再需要)了。

下面介紹應用最廣泛的現代計時器——時間戳計時器(TSC)。基本上,這是一個跟蹤CPU運行了多少個週期的CPU計數器。乍看起來它似乎很適合做時鐘。但這個計數器是跟CPU的,並且在運行時可能會受到節能或其他因素的影響。也就是說,不同的CPU會互相偏離,也不能跟鐘錶時間保持一致。

最後還有高精度事件計時器(HPET)。這種計時器是最近幾年才出現的,有助於人們用較老的時鐘硬件更好地計時。HPET使用至少10MHz的計時器,所以其精度至少應該是1μs——但它並不是在所有硬件上都可用,也不是所有操作系統都支持。

如果這些內容看起來有點亂,那是因為它們本來就亂。好在Java平台提供了可以使用它們的工具——它把對硬件和OS支持的依賴隱藏到特定的機器配置裡。然而試圖隱藏依賴項的做法並沒有完全成功。

6.4.2 麻煩的nanoTime

Java中有兩個獲取時間的方法:System.currentTimeMillisSystem.nanoTime,後面一個用於測量比毫秒更精確的時間。表6-1總結了它們兩個的主要差異。

表6-1 Java內置時間獲取方法的比較

currentTimeMillis nanoTime 解析度為毫秒級 納秒級引用 幾乎所有情況下都跟鐘錶時間相符 可能偏離鐘錶時間

如果表6-1中對nanoTime的描述讓它看起來有點像計時器,那就對了,因為如今在大多數操作系統上,它的時間源都是CPU計數鍾——TSC。

nanoTime的輸出是相對於某個固定時間的。也就是說必須用它記錄間隔期,用nanoTime的返回結果減去之前調用得到的返回結果。下面這段代碼來自後面的一個研究案例,恰好表明了這種情況:

long t0 = System.nanoTime;
doLoop1;
long t1 = System.nanoTime;
...
long el = t1 - t0;
  

eldoLoop1執行所用的時間(以納秒為單位)。

要在性能調優中正確使用這些方法,必須對nanoTime的行為有所瞭解。代碼清單6-1輸出了毫秒計時器和納秒計時器(通常由TSC提供)之間的最大偏離。

代碼清單6-1 時間偏離

private static void runWithSpin(String args) 
  long nowNanos = 0, startNanos = 0;
  long startMillis = System.currentTimeMillis;
  long nowMillis = startMillis;

  while (startMillis == nowMillis) { //將startNanos在毫秒邊界上對齊
    startNanos = System.nanoTime;
    nowMillis = System.currentTimeMillis;
    }

  startMillis = nowMillis;
  double maxDrift = 0;
  long lastMillis;

  while (true) {
    lastMillis = nowMillis;
    while (nowMillis - lastMillis < 1000) {
      nowNanos = System.nanoTime;
      nowMillis = System.currentTimeMillis;
     }
      long durationMillis = nowMillis - startMillis;
      double driftNanos = 1000000 *
   (((double)(nowNanos - startNanos)) / 1000000 - durationMillis);
      if (Math.abs(driftNanos) > maxDrift) {
        System.out.println(\"Now - Start = \"+ durationMillis
    +\" driftNanos = \"+ driftNanos);
        maxDrift = Math.abs(driftNanos);
        }
    }
}
 

這段代碼會輸出可觀測到的最大偏離,並且證明其表現與操作系統的相關度很高。下面是Linux上的一段輸出:

Now - Start = 1000 driftNanos = 14.99999996212864
Now - Start = 3000 driftNanos = -86.99999989403295
Now - Start = 8000 driftNanos = -89.00000011635711
Now - Start = 50000 driftNanos = -92.00000204145908
Now - Start = 67000 driftNanos = -96.0000033956021
Now - Start = 113000 driftNanos = -98.00000407267362
Now - Start = 136000 driftNanos = -98.99999713525176
Now - Start = 150000 driftNanos = -101.0000123642385
Now - Start = 497000 driftNanos = -2035.000012256205//注意driftNanos從-2035到20149出現了一個非常大的跳躍
Now - Start = 1006000 driftNanos = 20149.99999664724
Now - Start = 1219000 driftNanos = 44614.00001309812
  

這裡還有一個裝在相同硬件上的老Solaris上的輸出結果:

Now - Start = 1000 driftNanos = 65961.0000000157    //間隔很平滑
Now - Start = 2000 driftNanos = 130928.0000000399
Now - Start = 3000 driftNanos = 197020.9999999497
Now - Start = 4000 driftNanos = 261826.99999981196
Now - Start = 5000 driftNanos = 328105.9999999343
Now - Start = 6000 driftNanos = 393130.99999981205
Now - Start = 7000 driftNanos = 458913.9999998224
Now - Start = 8000 driftNanos = 524811.9999996561
Now - Start = 9000 driftNanos = 590093.9999992261
Now - Start = 10000 driftNanos = 656146.9999996916   //間隔很平滑
Now - Start = 11000 driftNanos = 721020.0000008626
Now - Start = 12000 driftNanos = 786994.0000000497
  

注意看最大值的增長,在Solaris上很穩定,而在Linux上相當一段時間內看起來都OK,然後出現了大的跳躍。我們在選擇示例代碼時相當認真,盡量避免創建額外的線程,甚至對象,以將平台的干預降到最低(比如說,沒有對象的創建就意味著不會做垃圾收集),但即便如此,我們還是能看到JVM的影響。

最終證實Linux時序上出現的跳躍是由不同CPU上的TSC計數器之間的差異造成的。JVM會定期掛起正在運行的Java線程,並將它遷移到不同核心上。所以程序代碼會見到不同CPU計數器上的差異。

這就是說對於間隔較長的時間,nanoTime基本上是不可信的。只能用它測量較短的時間間隔,較長(宏觀)的時間間隔應該用currentTimeMillis重新校準。

要充分掌握性能調優,即要有紮實的測量理論,還需要知道實現細節。

6.4.3 時間在性能調優中的作用

要做好性能調優,你必須知道該如何解讀代碼運行期間得到的測量記錄,也就是說你必須明白在Java平台上得到的時間測量結果的局限性。