你有没有想过计算机里的时间存在哪里以及在哪里处理?我们都知道硬件最终负责跟踪时间,但事实可能不像你想的那么简单。
为了进行性能调优,你需要对时间如何工作有深刻的认识。为此我们先从底层硬件开始讨论,然后探讨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.currentTimeMillis
和System.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;
el
是doLoop1
执行所用的时间(以纳秒为单位)。
要在性能调优中正确使用这些方法,必须对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.99999996212864Now - Start = 3000 driftNanos = -86.99999989403295Now - Start = 8000 driftNanos = -89.00000011635711Now - Start = 50000 driftNanos = -92.00000204145908Now - Start = 67000 driftNanos = -96.0000033956021Now - Start = 113000 driftNanos = -98.00000407267362Now - Start = 136000 driftNanos = -98.99999713525176Now - Start = 150000 driftNanos = -101.0000123642385Now - Start = 497000 driftNanos = -2035.000012256205//注意driftNanos从-2035到20149出现了一个非常大的跳跃Now - Start = 1006000 driftNanos = 20149.99999664724Now - Start = 1219000 driftNanos = 44614.00001309812
这里还有一个装在相同硬件上的老Solaris上的输出结果:
Now - Start = 1000 driftNanos = 65961.0000000157 //间隔很平滑Now - Start = 2000 driftNanos = 130928.0000000399Now - Start = 3000 driftNanos = 197020.9999999497Now - Start = 4000 driftNanos = 261826.99999981196Now - Start = 5000 driftNanos = 328105.9999999343Now - Start = 6000 driftNanos = 393130.99999981205Now - Start = 7000 driftNanos = 458913.9999998224Now - Start = 8000 driftNanos = 524811.9999996561Now - Start = 9000 driftNanos = 590093.9999992261Now - Start = 10000 driftNanos = 656146.9999996916 //间隔很平滑Now - Start = 11000 driftNanos = 721020.0000008626Now - Start = 12000 driftNanos = 786994.0000000497
注意看最大值的增长,在Solaris上很稳定,而在Linux上相当一段时间内看起来都OK,然后出现了大的跳跃。我们在选择示例代码时相当认真,尽量避免创建额外的线程,甚至对象,以将平台的干预降到最低(比如说,没有对象的创建就意味着不会做垃圾收集),但即便如此,我们还是能看到JVM的影响。
最终证实Linux时序上出现的跳跃是由不同CPU上的TSC计数器之间的差异造成的。JVM会定期挂起正在运行的Java线程,并将它迁移到不同核心上。所以程序代码会见到不同CPU计数器上的差异。
这就是说对于间隔较长的时间,nanoTime
基本上是不可信的。只能用它测量较短的时间间隔,较长(宏观)的时间间隔应该用currentTimeMillis
重新校准。
要充分掌握性能调优,即要有扎实的测量理论,还需要知道实现细节。
6.4.3 时间在性能调优中的作用
要做好性能调优,你必须知道该如何解读代码运行期间得到的测量记录,也就是说你必须明白在Java平台上得到的时间测量结果的局限性。