问题陈述
当您深入研究时,即使是最基本的问题也会变得很有趣。 今天,我想深入研究一下Java时间。 我们将从Java API的最基础知识开始,然后逐步降低堆栈:通过OpenJDK源代码glibc一直到Linux内核。 我们将研究各种环境下的性能开销,并尝试对结果进行推理。
我们将探索经过时间的度量:从某个活动的开始事件到结束事件所经过的时间。 这对于性能改进,操作监控和超时执行很有用。
以下伪代码是我们几乎可以在任何代码库中看到的常见用法:
START_TIME = getCurrentTime() executeAction() ELAPSED_TIME = getCurrentTime() - START_TIME
有时它不太明确。 我们可以使用面向方面的编程原则来避免本质上与操作有关的污染我们的业务代码,但是它仍然以一种或另一种形式存在。
Java中经过的时间
Java提供了两个用于测量时间的基本原语: System.currentTimeMillis()
和System.nanoTime()
。 这两个调用之间有几个区别,让我们对其进行分解。
1.起点的稳定性
System.currentTimeMillis()
返回自Unix纪元开始(1970年1月1日UTC)以来的毫秒数。 另一方面, System.nanoTime()
返回自过去某个任意点以来的纳秒数。
这立即告诉我们currentTimeMillis()
的最佳粒度为1毫秒。 它使得不可能测量任何短于1ms的东西。 currentTimeMillis()
使用1970年1月1日UTC作为参考点的事实是好事。
为什么好呢? 我们可以比较两个不同的JVM甚至两个不同的计算机返回的currentTimeMillis()
值。
为什么不好? 当我们的计算机没有同步时间时,比较将不会很有用。 典型服务器场中的时钟未完全同步,并且始终会有一些差距。 如果我要比较两个不同系统的日志文件,这仍然可以接受:如果时间戳记不能完全同步,则可以。 但是,有时这种差距可能导致灾难性的结果,例如,当将其用于分布式系统中的冲突解决时。
2.时钟单调性
另一个问题是,不能保证返回值会单调增加。 这是什么意思? 当您连续两次调用currentTimeMillis()
,第二个调用返回的值可能小于第一个。 这是违反直觉的,并且可能导致无意义的结果,例如经过时间为负数。 显然, currentTimeMillis()
不是衡量应用程序内部经过时间的好选择。 那nanoTime()
呢?
System.nanoTime()
不使用Unix纪元作为参考点,而是过去的一些未指定点。 在执行单个JVM的过程中,问题仍然存在,仅此而已。 因此,甚至比较在同一台计算机上运行的两个不同JVM返回的nanoTime()
值也没有意义,更不用说在单独的计算机上了。 参考点通常与上一次计算机启动有关,但这纯粹是实现细节,我们根本不能依赖它。 这样做的好处是,即使计算机中的挂钟时间由于某种原因而倒退,也不会对nanoTime()
产生任何影响。 这就是为什么nanoTime()
是一个不错的工具,可以测量单个JVM上两个事件之间的经过时间,但是我们无法比较两个不同JVM上的时间戳。
Java实现
让我们探讨一下Java中如何实现currentTimeMillis()
和nanoTime()
。 我将使用来自OpenJDK 14当前负责人的资源 。 System.currentTimeMillis()
是一种本地方法,因此我们的Java IDE不会告诉我们它是如何实现的。 这个本地代码看起来更好一些:
JVM_LEAF(jlong, JVM_CurrentTimeMillis(JNIEnv *env, jclass ignored)) JVMWrapper( "JVM_CurrentTimeMillis" ); return os::javaTimeMillis(); JVM_END
我们可以看到,这只是委派,因为实现因操作系统而异。 这是Linux的实现 :
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 ); }
该代码委托给Posix函数gettimeofday()
。 此函数返回一个简单的结构:
struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */ };
该结构包含自该纪元以来的秒数和给定秒数内的微秒数。 currentTimeMillis()
的约定将返回自该纪元以来的毫秒数,因此它必须进行简单的转换: jlong(time.tv_sec) * 1000 + jlong(time.tv_usec / 1000)
函数gettimeofday()
由glibc实现,它最终会调用Linux内核。 稍后我们将更深入地了解。
让我们看看nanoTime()
的实现方式:事实并没有太大不同System.nanoTime()
也是一种本地方法: public static native long nanoTime();
和jvm.cpp
委托给特定于操作系统的实现:
JVM_LEAF(jlong, JVM_NanoTime(JNIEnv *env, jclass ignored)) JVMWrapper( "JVM_NanoTime" ); return os::javaTimeNanos(); JVM_END
javaTimeNanos()的Linux实现非常有趣:
jlong os::javaTimeNanos() { if (os::supports_monotonic_clock()) { struct timespec tp; int status = os::Posix::clock_gettime(CLOCK_MONOTONIC, &tp); assert (status == 0 , "gettime error" ); jlong result = jlong(tp.tv_sec) * ( 1000 * 1000 * 1000 ) + jlong(tp.tv_nsec); return result; } else { timeval time; int status = gettimeofday(&time, NULL); assert (status != - 1 , "linux error" ); jlong usecs = jlong(time.tv_sec) * ( 1000 * 1000 ) + jlong(time.tv_usec); return 1000 * usecs; } }
有两个分支:如果操作系统支持单调时钟,它将使用它,否则它将委托给我们的老朋友gettimeofday()
。 Gettimeofday()
与Posix调用的System.currentTimeMillis()
相同! 显然,随着nanoTime()
粒度更高,转换看起来有些不同,但这是相同的Posix调用! 这意味着在某些情况下, System.nanoTime()
使用Unix纪元作为参考,因此它可以回到过去! 换句话说:它不能保证是单调的!
好消息是,据我所知,所有现代Linux发行版都支持单调时钟。 我认为该分支是为了与早期版本的kernel / glibc兼容。 如果您对HotSpot如何检测操作系统是否支持单调时钟的详细信息感兴趣,请参见此代码 。 对我们大多数人来说,重要的是要知道OpenJDK实际上总是调用Posix函数clock_gettime()
,该函数在glibc和Linux内核的glibc委托中实现。
基准I –本地笔记本电脑
至此,我们对如何实现nanoTime()
和currentTimeMillis()
有了一些直觉。 让我们看看他们是快闪还是慢速。 这是一个简单的JMH基准:
@BenchmarkMode (Mode.AverageTime) @OutputTimeUnit (TimeUnit.NANOSECONDS) public class Bench { @Benchmark public long nano() { return System.nanoTime(); } @Benchmark public long millis() { return System.currentTimeMillis(); } }
当我在装有Ubuntu 19.10的笔记本电脑上运行此基准测试时,得到以下结果:
基准测试 | 模式 | 碳纳米管 | 得分了 | 错误 | 单位 |
---|---|---|---|---|---|
板凳 | 平均 | 25 | 29.625 | ±2.172 | ns / op |
Benchnano | 平均 | 25 | 25.368 | ±0.643 | ns / op |
每个调用System.currentTimeMillis()
大约需要29纳秒,而System.nanoTime()
大约需要25纳秒。 不好,不可怕。 这意味着使用System.nano()
测量花费少于几十纳秒的任何东西可能是不明智的,因为我们仪器的开销会高于所测量的间隔。 我们还应该避免在紧密的循环中使用nanoTime()
,因为延迟会Swift增加。 另一方面,使用nanoTime()
来衡量例如来自远程服务器的响应时间或昂贵的计算时间似乎是明智的。
基准II – AWS
在便携式计算机上运行基准测试很方便,但不是很实用,除非您愿意放弃便携式计算机并将其用作应用程序的生产环境。 相反,让我们在AWS EC2中运行相同的基准测试。
让我们使用Ubuntu 16.04 LTS启动一台c5.xlarge机器,并使用出色的SDKMAN工具安装由AdoptOpenJDK项目上的精湛人员构建的Java 13:
板凳
板凳
结果如下:
基准测试 | 模式 | 碳纳米管 | 得分了 | 错误 | 单位 |
---|---|---|---|---|---|
板凳 | 平均 | 25 | 28.467 | ±0.034 | ns / op |
Benchnano | 平均 | 25 | 27.331 | ±0.003 | ns / op |
这几乎与笔记本电脑上的一样,还不错。 现在让我们尝试c3.large实例。 它是较老的一代,但仍经常使用:
基准测试 | 模式 | 碳纳米管 | 得分了 | 错误 | 单位 |
---|---|---|---|---|---|
板凳 | 平均 | 25 | 362.491 | ±0.072 | ns / op |
Benchnano | 平均 | 25 | 367.348 | ±6.100 | ns / op |
这看起来一点都不好! c3.large是一个较旧的较小实例,因此预计会有所降低,但这太多了! currentTimeMillis()
和nanoTime()
都慢一个数量级。 起初360 ns听起来可能还不错,但是请考虑一下:要仅测量一次经过时间,您需要两次调用。 因此,每次测量花费大约0.7μs。 如果您有10个探针测量不同的执行阶段,则您的时间为7μs。 透视一下:40gbit网卡的往返行程约为10μs。 这意味着向我们的热路径添加一堆探针可能会对延迟产生非常大的影响!
一点内核调查
为什么C3实例比笔记本电脑或C5实例慢得多? 事实证明,这与Linux时钟源有关,更重要的是与glibc-kernel接口有关。 我们已经知道,每次调用nanoTime()
或currentTimeMillis()
调用OpenJDK中的本地代码,该本地代码调用glibc,后者又调用Linux内核。
有趣的部分是glibc-Linux内核转换:通常,当进程调用Linux内核函数(也称为syscall)时,它涉及从用户模式切换到内核模式,然后再返回。 此转换是一个相对昂贵的操作,涉及许多步骤:
- 将CPU寄存器存储在内核堆栈中
- 使用实际功能运行内核代码
- 将结果从内核空间复制到用户空间
- 从内核堆栈恢复CPU寄存器
- 跳回用户代码
这从来都不是便宜的操作,并且随着边信道安全攻击和相关缓解技术的出现,它变得越来越昂贵。
对性能敏感的应用程序通常会尽力避免用户到内核的转换。 Linux内核本身提供了一些非常频繁的系统调用的捷径,称为vDSO –虚拟动态共享对象 。 它实质上导出了一些功能,并将它们映射到进程的地址空间。 用户进程可以调用这些函数,就像它们是普通共享库中的常规函数一样。 结果, clock_gettime()
和gettimeofday()
都实现了这样的快捷方式,因此,当glibc调用clock_gettime()
,它实际上只是跳转到内存地址,而无需执行昂贵的用户到内核转换。
所有这些听起来像是一个有趣的理论,但是并不能解释为什么System.nanoTime()
在c3实例上这么慢。
实验时间
我们将使用另一个出色的Linux工具来监视系统调用的数量: perf
。 我们可以做的最简单的测试是启动基准测试并计算操作系统中的所有系统调用。 perf
语法很简单:
sudo perf stat -e raw_syscalls:sys_enter -I 1000 -a
这将为我们提供每秒的系统调用总数。 一个重要的细节:它将仅向我们提供真正的系统调用,以及完整的用户模式-内核模式转换。 vDSO调用不计在内。 这是在c5实例上运行时的外观:
板凳
您可以看到每秒大约有130个系统调用。 鉴于我们基准测试的每次迭代都少于30 ns,因此很明显,该应用程序使用vDSO绕过了系统调用。
这是在c3实例上的外观:
板凳
每秒超过1,300,000个系统调用! 同样, nanoTime()
和currentTimeMillis()
的延迟也大约翻了一番,达到700ns /操作。 这是一个相当有力的指示,每个基准测试迭代都会调用一个真实的系统调用!
让我们使用另一个perf
命令来收集其他证据。 此命令将计算5秒钟内调用的所有系统调用并按名称分组:
sudo perf stat -e 'syscalls:sys_enter_*' -a sleep 5
在c5实例上运行时,没有任何异常情况。 但是,在c3实例上运行时,我们可以看到以下内容:
板凳
这是我们的吸烟枪! 非常有力的证据表明,当基准测试在c3框上运行时,它将进行真正的gettimeofday()
系统调用! 但为什么?
这是 4.4内核(在Ubuntu 16.04中使用) 的相关部分 :
板凳
当Java调用System.currentTimeMillis()
时,它是映射到用户内存并由glibc调用的函数。 它调用do_realtime()
,该struct tv
使用当前时间填充struct tv
,然后返回给调用者。 重要的是所有这些操作都在用户模式下执行,而没有任何缓慢的系统调用。 好吧,除非do_realtime()
返回VCLOCK_NONE
。 在这种情况下,它将调用vdso_fallback_gtod()
,这将执行缓慢的系统调用。
为什么c3实例进行回退做系统调用而c5不做? 好吧,这与虚拟化技术的变化有关! 自成立以来,AWS一直在使用Xen虚拟化 。 大约2年前, 他们宣布从Xen过渡到KVM虚拟化 。 C3实例使用Xen虚拟化,较新的c5实例使用KVM。 对我们而言重要的是,每种技术都使用Linux Clock的不同实现。 Linux在/sys/devices/system/clocksource/clocksource0/current_clocksource
显示当前时钟源。
这是c3:
板凳
这是c5:
板凳
原来,KVM-时钟实现套vclock_mode
到VCLOCK_PVCLOCK
这意味着慢回退分支以上不采取。 Xen时钟源根本没有设置此模式 ,而是停留在VCLOCK_NONE
。 这将导致跳入vdso_fallback_gtod()
函数,该函数最终将启动实际的系统调用!
板凳
关于Linux的好处是它具有高度的可配置性,并且经常给我们足够的绳索来吊死自己。 我们可以尝试更改c3上的时钟源并重新运行基准测试。 可通过$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
xen tsc hpet acpi_pm$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
xen tsc hpet acpi_pm
TSC代表时间戳记计数器 ,它是一种非常快速的来源,并且对我们而言重要的是适当的vDSO实施。 让我们将c3实例中的时钟源从Xen切换到TSC:
板凳
检查它是否真的被切换:
板凳
看起来不错! 现在,我们可以重新运行基准测试:
基准测试 | 模式 | 碳纳米管 | 得分了 | 错误 | 单位 |
---|---|---|---|---|---|
板凳 | 平均 | 25 | 25.558 | ±0.070 | ns / op |
Benchnano | 平均 | 25 | 24.101 | ±0.037 | ns / op |
数字看起来不错! 实际上比具有kvm-clock的c5实例更好。 每秒系统调用数与c5实例处于同一级别:
板凳
有些人甚至在使用Xen虚拟化时也建议将时钟源切换为TSC。 我对它可能产生的副作用知之甚少,但是显然,即使是一些大公司也在生产中做到了这一点。 显然,这并不证明它是安全的,但这表明它对某些人有效。
最后的话
我们已经看到了底层实现细节如何对普通Java调用的性能产生重大影响。 这不仅仅是在微基准测试中可见的理论问题, 实际系统也会受到影响 。 您可以直接在Linux内核源代码树中阅读有关vDSO的更多信息。
没有我在Hazelcast的出色同事,我将无法进行调查。 这是一支世界一流的团队,我从他们那里学到了很多东西! 我要感谢布伦丹·格雷格(Brendan Gregg)收集的各种技巧 ,我的记忆力一直很差,布伦丹创造了一个出色的备忘单。
最后但并非最不重要的一点:如果您对性能,运行时或分布式系统感兴趣,请关注我 !
翻译自: https://www.javacodegeeks.com/2019/12/measuring-time-from-java-to-kernel-and-back.html