大道至简,知易行难
广阔天地,大有作为

JDK1.8中线程安全地获取微秒/纳秒时间戳及锁的公平性问题

我司核心系统在高并发情况下,原毫秒时间戳分辨率不足,需要获取分辨率更高的微秒时间戳,且生产系统几乎不具备将目前正在使用的JDK1.8进行升级的可能。

一、Java8获取高精度时间戳的限制

由于交易流水号的存在,之前用到高精度时间戳的场景不多。一阵搜索后发现:Java8中获取到的当前时刻仅限于毫秒级,尽管java.time中的类可以【保存】以纳秒为单位的值,但却只能以毫秒为单位【获取】当前时间。这种限制是由于java.time.Clock的底层实现决定的,在Java9及更高版本中,新的java.time.Clock实现可以以更精细的分辨率获取当前时刻,具体取决于主机硬件和操作系统的限制。

使用如下代码初步测试:

在JDK 1.8上编译为class文件,然后分别在Java8和Java11下测试:

Java8获取时间戳的几种方式

Java8获取时间戳的几种方式

同样的class文件在Java11上执行:

Java11获取时间戳的几种方式

Java11获取时间戳的几种方式

可见,在Java8中获取到的时间到毫秒后就直接截断了,而在Java11中则输出到了微秒级别。而在java.time中的几个类也正在逐渐取代原有的java.util.Date:

接着大概看一下上述用到的几个函数。

System.nanoTime()是一个native函数,JDK源码注释如下:

需要注意,System.nanoTime()与System.currentTimeMillis()显著不同,System.currentTimeMillis()我们肯定非常熟悉了,返回的是:

the difference, measured in milliseconds, between the current time and midnight, January 1, 1970 UTC

而System.nanoTime()根据文档所述,返回的则是当前虚拟机距离某个固定但随机的起始时间(这个起始时间可能是未来的时间,因此System.nanoTime()可能返回负数)的纳秒数,该起始时间在同一个虚拟机内部是相同的,但在不同的虚拟机之间则是不同的。因此,单独获取System.nanoTime()并没有什么意义,因为该值是随机的,通常只能用于多次获取System.nanoTime()之差来测量逝去的时间,而与任何其它系统或者真实的时间概念无关

在某些文章中曾不断提到“System.nanoTime()非线程安全”的说法,如参考文档1中2018年这类较新的文章中还是错的:

这个说法实际是错误的,根据Stack Overflow中的说法,自Java7以后System.nanoTime()就是线程安全的了:

LocalDateTime.now()返回是:

the current date-time from the system clock in the default time-zone

根据文档,LocalDateTime.now()的返回值也是java.time.LocalDateTime类型的:

正如前面的表格所示,java.time.LocalDateTime及其内部的成员变量java.time.LocalDate和java.time.LocalTime都不带时区信息。

java.time.Instant是JDK1.8后引入的一个类,下面是其JDK1.8源码中的注释及两个关键方法:

从中我们可以总结出:

  1. Instant是不可变且线程安全的;
  2. Instant内部有两个long类型的成员,分别保存了自1970-01-01T00:00:00Z后的秒数,及该秒对应的纳秒(大于0,小于999,999,999);
  3. Instant.now()调用的是Clock.systemUTC().instant(),因此也就对应上前述受限于Clock的问题。从JDK1.8(Oracle)的源码中我们可以看到:

可见在JDK1.8上,Instant确实最多也就只能达到毫秒的级别。接下来让我们看下JDK1.9(OpenJDK)的源码:

很明显,JDK1.9上的Clock在返回Instant时增加了对毫秒的处理,getNanoTimeAdjustment是一个native方法,就是用于获取纳秒的:

Instant.now().toString()输出的是ISO-8601格式的UTC时间戳,Java8及以下不输出微秒,Java9以上输出到微秒级别。

二、NanoClock方案

由于升级JDK版本需要大量测试,目前的条件下基本是不切实际的,因此需要寻求替代方案。

笔者的第一反应是,JDK1.8没戏了。既然JDK层面受限,那么直接通过JNI调用C实现好了,而确实也有人这么做了:

不过,杜老板说:

也就是参考资料3中的实现。不过,这个帖子下面有一个评论:

其中提到所谓的precision不等于accuracy,实际是System.nanoTime()的注释中提到的:

This method provides nanosecond precision, but not necessarily nanosecond resolution (that is, how frequently the value changes) – no guarantees are made except that the resolution is at least as good as that of currentTimeMillis().

不过这应该不影响我们的使用。其完整代码如下:

三、NanoClock线程安全测试

接下来,需要验证多线程情况下获取到的时间戳确实是严格趋势递增的。

首先,从原理上看,NanoClock的本质就是自己记录了开始时刻,并利用System.nanoTime()的特性来实现计算出流逝的纳秒数,其核心逻辑是:

plusNanos内部有一些逻辑,但基本都是线程安全的数学运算。

接下来,写代码实测。直觉上,我们直接使用AtomicInteger依次对NanoClock获取的时间戳计数,丢到线程安全的map里,然后利用TreeMap排序即可,代码如下:

然而,上述的代码却会发现大量乱序的情况出现,例如:

推测可能是由于AtomicInteger为非公平锁导致:

那么,我们换一种方式,改成用LinkedBlockingQueue,利用队列先进先出的特性,代码如下:

结果依然有乱序的情况发生。猜测LinkedBlockingQueue的锁也是非公平的,于是换为可配置锁公平性的ArrayBlockingQueue:

代码如下:

然而,即便使用了公平锁的队列,依然有乱序的情况发生,只不过概率低了一点。

感觉十分诡异,仔细分析代码的执行行为。推测可能是从获取到调用时刻与入队的逻辑有关系:

即假设两个线程A和B同时调用了 Instant.now(clock),线程A先执行System.nanoTime()获取到了纳秒数,线程B后执行System.nanoTime()获取到了纳秒数,由于plusNanos内部还有不少的计算逻辑,因此在发生上下文切换等时,B对 Instant.now(clock)的调用可能反而先于A对 Instant.now(clock)的调用返回,进而导致靠后的时间戳反而先入队列。我们构造如下的代码验证:

这样,为了来规避这个问题,就不能使用队列了。我们依然在获取时间的过程中使用Map,并在最后利用TreeMap排序,不过根据NanoClock的原理,需要以真正获取到System.nanoTime()的顺序为准。此外,我们还必须注意,不能使用诸如:

这样的逻辑,因为先获取到“序号”的线程不一定会真正先调用到System.nanoTime()。调整后的代码:

验证无问题,NanoClock线程安全且严格递增。

三、数据库存储

首先明确,java.util.Data只能保存毫秒精度,因此如果DAO层使用的是此种类型必然将导致精度丢失:

对于MySQL而言,DATETIME和TIMESTAMP(这两者的区别不再赘述)均可以保存到微秒的精度(6位)。文档中提到,即使传入了精度更高的值,值不会被丢弃而是会被保存下来,可见后续应该是能够支持到纳秒的;不过,目前的5.7和8.0均只支持到微秒:

java.sql.Timestamp支持纳秒:

我们可以直接通过如下的方式将java.time.Instant转换为java.sql.Timestamp并用于后续的持久化:

 

参考文档:
1、https://www.geeksforgeeks.org/java-system-nanotime-vs-system-currenttimemillis/
2、https://blog.csdn.net/weixin_29796279/article/details/114247510
3、https://stackoverflow.com/questions/20689055/java-8-instant-now-with-nanosecond-resolution

转载时请保留出处,违法转载追究到底:进城务工人员小梅 » JDK1.8中线程安全地获取微秒/纳秒时间戳及锁的公平性问题

分享到:更多 ()

评论 1

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
  1. #1

    太棒了~

    面壁者3年前 (2021-11-25)回复