注:本人原创,首发于https://mp.weixin.qq.com/s/oMQ–gLOGMXpblM4g8TnZA。转载请注明出处。
计算机科学是建立在现实物理世界的基础上的,要尽量匹配地球自转公转的结果,同时要匹配一系列人为规定的概念(如时区、夏令时)。这就带来了一系列问题:计算机如何描述及存储时间点和时间段、如何匹配不同时区和计时方式、如何转换时间的表示方法、如何获取当前时间、如何控制时间精度、如何感知时间流逝等一系列问题。本篇文章尽笔者能力清晰深入地探究这个问题。
时区是地球上的同一块区域使用的同一个时间定义。世界各个国家位于地球不同位置上,因此不同国家,特别是东西跨度大的国家日出、日落时间必定有所偏差。这些偏差就是所谓的时差。
所谓“夏令时”(Daylight Saving Time,简称D.S.T.),是指在夏天太阳升起的比较早时,将时钟拨快一小时,以提早日光的使用。这个构想于1784年由美国班杰明·富兰克林提出来,1915年德国成为第一个正式实施夏令日光节约时间的国家,以削减灯光照明和耗电开支。 进夏令时时间要拨快一小时,出夏令时时间再拨回来。但这跟UTC或GMT完全没有关系,完全是人为行为。
时间的表示可以分为时间点和时间段。时间点又可以分为“相对时间”和“绝对时间”(不是相对论里那个),人们一般理解表述的“现在几点”、“挂钟上显示什么时间”是“相对时间”,即本地时间,是没有时区属性的。但是如果要表示一个客观发生的时间点就要用到“绝对时间”,这个时间点在每个时区的挂钟上显示的都不同。
时间的表示还关系到精度问题,如精确到天、秒还是毫秒、纳秒,都有不同的表示方法。
下面以Java为例,较为详细地介绍关于日期时间的获取、表示及格式转换方法。
这是我们最常用的获取当前时间的方法,静态方法System.currentTimeMillis() 返回UTC时间从1970年1月1日00:00到现在的总毫秒数,返回类型为long。我们所有需要做的就是一行代码:
Long time = System.currentTimeMillis();
ps:为什么是从1970年1月1日开始?
Unix是1969年发布的雏形,最早是基于硬件60Hz的时间计数。1971年底出版的《Unix Programmer’s Manual》里定义的Unix Time是以1971年1月1日00:00:00作为起始时间,每秒增长60。之后考虑到32位整数的范围,如果每秒60个数字,则两年半就会循环一轮。于是改成了以秒为计数单位。这个循环周期有136年之长,就不在乎起始时间是1970还是1971年了,于是就改成了人工记忆、计算比较方便的1970年。
“The date was programmed into the system sometime in the early 70s only because it was convenient to do so, according to Dennis Ritchie, one the engineers who worked on Unix at Bell Labs at its inception.”
趣闻:32位Unix时间戳的范围是 1971年1月1日00:00:00 ~ 2038年1月19日03:14:07(UTC),超过这一范围则会越界。2016年出现过苹果用户将手机时间设为1971年之前,然后iPhone变砖了。现在iPhone的解决方法是不允许手动设置年份
注意,java.lang包在该方法的注释中提到,当返回值的时间单位是毫秒时,值的粒度取决于底层操作系统,可能粒度会大于1ms。同时高并发场景下要小心该方法的性能消耗。为什么会这样?什么时候会出现这种情况?下篇会从该方法的源码入手深入探究。
Java7的API文档中说明:该方法返回正在运行的Java虚拟机的高分辨率时间源的当前值,以纳秒为单位。此方法只能用于测量经过的时间,与系统或钟表时间等任何其他概念无关。在同一个Java虚拟机实例中,此方法的所有调用都使用相同的时间原点,其他虚拟机实例可能使用不同的时间原点。此方法提供纳秒级精度,但不一定是纳秒级分辨率,但是最少和 currentTimeMillis() 方法的分辨率一样高。
也就是说,nanoTime() 方法返回的数字绝对值没有意义,仅当计算在Java虚拟机的同一实例中获得的两个此值之间的差异时,此方法返回的值才有意义。常用的方法是:
Long startTime = System.nanoTime();
doSomething();
Long estimatedTime = System.nanoTime() - startTime;
那所谓的“随机起点”在不同平台上是如何实现的?System.nanoTime() 和 System.currentTimeMillis() 有没有什么关系?也会在下篇中一并提及。
Date是Java最早提供的用来封装日期时间的类,由于不易于国际化且很多参数计算不符合日常认知或不正确(具体可以见源码),很多获取年、月、日、小时等数据的方法都过时了不推荐使用(@Deprecated),被Calendar类的方法代替。这里选一些还在使用的关键字段和方法进行说明。
Date类有两个关键的成员变量:
// 记录当前时间戳
private transient long fastTime;
/*
* cdate对象是 BaseCalendar.Date类,继承自sun.util.calendar.CalendarDate。
* 包含很多已计算好的日期时间相关变量,如 dayOfWeek(所在星期的第几天)、leapYear(是否是闰年)等。
* 如果 cdate 对象为空,用 fastTime 变量代表精确到毫秒的时间。
* 如果 cdate.isNormalized() 方法返回 true,则 fastTime 和 cdate 已经同步过。
* 如果 cdate.isNormalized() 方法返回 false,则忽略 fastTime 的值,使用 cdate 代表时间。
*/
private transient BaseCalendar.Date cdate;
Date类提供的两个构造函数,看源码清晰明了:
// 无参构造方法,创建当前时间的Date类
public Date() {
this(System.currentTimeMillis());
}
// 传入一个Unix时间戳,创建特定时间的Date类
public Date(long date) {
fastTime = date;
}
// 其他通过年月日创建的构造方法已被 Calendar.set() 和 DateFormat.parse() 等方法替代,不再展示
Date类型存储日期时间实际存储的是Unix时间戳,所以可以表示绝对时间,支持绝对时间的比较。典型的Date类型数据结构如下图:
一个小问题:上文我们看到构造方法中并没有赋值 cdate 变量,那么调试的时候显示的 cdate 是如何被初始化的呢?
答案是:IDE调试的时候为了显示变量值,调用了 toString 方法,至于为什么会初始化,参考该类 toString() 方法源码。
Date类还有很多常用的成员方法,可以用 long getTime( ) 和 void setTime(long time) 进行该Date对象日期时间的获取和设定(毫秒级别);可以用 boolean after(Date date)、boolean before(Date date)、int compareTo(Date date)、boolean equals(Object date)等方法比较两个日期时间的先后顺序。具体的比较简单,不展开详述。
java.sql.Date、java.sql.Time 和 java.sql.Timestamp 都继承自 java.util.Date 类,是专门用于数据库连接的。由于继承关系,从数据结构来看和它们的父类区别不大。最主要的区别在于 Timestamp 类可以表示至纳秒级,其 fastTime 字段从秒之后被截掉,毫秒至纳秒精度保存在特有的 nanos 字段中。可参考下图:
但是要注意 Timestamp 类的纳秒精度可能是“假的”,构造方法源码如下:
public Timestamp(long time) {
super((time/1000)*1000);
nanos = (int)((time%1000) * 1000000);
if (nanos < 0) {
nanos = 1000000000 + nanos;
super.setTime(((time/1000)-1)*1000);
}
}
可以看出,在将 fastTime 字段强行截掉之后,进行 毫秒值直接乘1,000,000 的操作后赋给了 nanos 字段,成为了“只能表示到毫秒的纳秒级精确度”。当然,还可以通过 setNanos(int n) 方法给纳秒数赋精确值。
虽然数据结构看来没什么特别,但是如果涉及到Timestamp类的父子类型转换或时间的比较,就要小心一些“坑”。
The Timestamp.equals(Object) method never returns true when passed an object that isn’t an instance of java.sql.Timestamp, because the nanos component of a date is unknown. As a result, the Timestamp.equals(Object) method is not symmetric with respect to the java.util.Date.equals(Object) method. Also, the hashCode method uses the underlying java.util.Date implementation and therefore does not include nanos in its computation.
意为:传递一个不是java.sql.Timestamp实例的对象时,Timestamp.equals(Object)方法永远不会返回true,因为日期的nanos组件是未知的。因此,Timestamp.equals(Object)方法与java.util.Date.equals(Object)方法不对称。此外,hashCode方法使用底层的java.util.Date实现,因此在其计算中不包括nanos。
equals() 源码如下:
public boolean equals(java.lang.Object ts) {
if (ts instanceof Timestamp) {
return this.equals((Timestamp)ts);
} else {
// 非Timestamp类型直接返回false
return false;
}
}
// Timestamp类型的equals判断
public boolean equals(Timestamp ts) {
if (super.equals(ts)) {
if (nanos == ts.nanos) {
return true;
} else {
return false;
}
} else {
return false;
}
}
public boolean after(Date when) {
return getMillisOf(this) > getMillisOf(when);
}
java.sql.Timestamp 类没有重写 after(Date d) 方法,只写了after(Timestamp t) 方法,如下:
public boolean after(Timestamp ts) {
return compareTo(ts) > 0;
}
所以上图传参为 java.util.Date 类,程序走的是父类的 after() 方法,而 java.sql.Timestamp 类也没有重写 getMillisOf() 方法,所以也是使用父类的:
static final long getMillisOf(Date date) {
if (date.cdate == null || date.cdate.isNormalized()) {
return date.fastTime;
}
BaseCalendar.Date d = (BaseCalendar.Date) date.cdate.clone();
return gcal.getTime(d);
}
上文有提到,java.util.Date 会对 fastTime 和 cdate 进行同步,由于 Timestamp 类在其继承父类的 fastTime 和 cdate 变量中不存储毫秒数据,所以调用父类的 after() 方法时, 只有毫秒差异的时间调用 getMillisOf() 方法返回的结果是相同的。所以,java.sql.Timestamp 向父类 java.util.Date转型时会丢失毫秒。
JDK文档中对此的说明为:
Due to the differences between the Timestamp class and the java.util.Date class mentioned above, it is recommended that code not view Timestamp values generically as an instance of java.util.Date. The inheritance relationship between Timestamp and java.util.Date really denotes implementation inheritance, and not type inheritance.
意为:建议代码不要将 Timestamp 值一般视为java.util.Date的实例。 Timestamp 和 java.util.Date 之间的继承关系实际上表示实现继承,而不是类型继承。
如果不确定类型的情况下要进行时间的比较,尽量使用 compareTo() 方法,可以保证正确性。
Calendar类是一个日历抽象类,提供了一组对年月日时分秒星期等日期信息的操作的函数,并针对不同国家和地区的日历提供了相应的子类,即本地化。比如公历 GregorianCalendar ,佛历(泰国使用)BuddhistCalendar,日本历 JapaneseImperialCalendar 等(没有中国农历太不友好了=_=)。从JDK1.1版本开始,在处理日期和时间时系统推荐使用Calendar类进行实现。在设计上,Calendar类的功能要比Date类强大很多,而且在实现方式上也比Date类要复杂一些。
首先我们来直观地看一下Calendar类能表示些什么,打印一个新建的Calendar实例:
// 代码:
Calendar calendar = Calendar.getInstance();
System.out.println(calendar);
// 打印结果,字段含义都是字面意思:
java.util.GregorianCalendar[
time=1564912275912,
areFieldsSet=true,
areAllFieldsSet=true,
lenient=true,
zone=sun.util.calendar.ZoneInfo[
id="Asia/Shanghai",
offset=28800000,
dstSavings=0,
useDaylight=false,
transitions=19,
lastRule=null
],
firstDayOfWeek=1,
minimalDaysInFirstWeek=1,
ERA=1,
YEAR=2019,
MONTH=7,
WEEK_OF_YEAR=32,
WEEK_OF_MONTH=2,
DAY_OF_MONTH=4,
DAY_OF_YEAR=216,
DAY_OF_WEEK=1,
DAY_OF_WEEK_IN_MONTH=1,
AM_PM=1,
HOUR=5,
HOUR_OF_DAY=17,
MINUTE=51,
SECOND=15,
MILLISECOND=912,
ZONE_OFFSET=28800000,
DST_OFFSET=0
]
Calendar类可以通过静态工厂方法或new子类的方式来获得实例:
Calendar calendar = new GregorianCalendar();
Calendar类可以实现带时区的年月日时分秒星期等对Unix时间戳的转换,内部通过子类复杂的 computeTime() 方法进行计算。可以使用 getTime() 方法返回 java.util.Date 类型的时间,可以使用 getTimeInMillis() 方法返回当前Unix时间戳,也可以通过 get(int field) 方法获取其他年月日等单独信息,部分可用 field 列表如下:
常量 | 含义 |
---|---|
Calendar.YEAR | 年份 |
Calendar.MONTH | 月份 |
Calendar.DATE | 日期 |
Calendar.DAY_OF_MONTH | 日期,和上面的字段意义完全相同 |
Calendar.HOUR | 12小时制的小时 |
Calendar.HOUR_OF_DAY | 24小时制的小时 |
Calendar.MINUTE | 分钟 |
Calendar.SECOND | 秒 |
Calendar.DAY_OF_WEEK | 星期几 |
Calendar.DAY_OF_YEAR | 今年的第几天 |
也可以通过多个 set 重载方法设定各种值。
同时, add() 方法支持对单个值的加减,从而实现时间推移的计算,传入负数即为减,示例如下:
GregorianCalendar 对象可以直接使用 isLeapYear(int year) 接口判断是否闰年。
要注意两个设定上的问题:在 Calendar 中 MONTH 这个域并不是从1到12的,而是0表示一月,11表示十二月。 DAY_OF_WEEK 域星期天是1,星期一是2,依次类推。为了避免用错,Calendar 类已经为我们定义好了常量,如一月可以直接 Calendar.JANUARY
。
SimpleDateFormat 是一个以语言环境敏感的方式来格式化和分析日期的类。SimpleDateFormat 允许选择任何用户自定义的日期时间格式来运行。如:
还有更多可表示的模式,对应符号不在此给出。
值得一提的是,在后端接口开发时,接口返回的日期时间格式可能是和框架序列化方式有关的。如 springboot 中使用 jackson 作为默认的 json 工具,不同版本 jackson 对于日期时间的默认序列化方式不同。1.5.10.RELEASE 版本的 springboot 默认 2.8.10 版本的 jackson,Date类返回的默认格式是Unix时间戳;2.0.5.RELEASE 版本的 springboot 默认 2.9.6 版本的 jackson,Date类返回的默认格式类似 “2019-08-04T13:43:21.535+0000” 。如果想规定返回格式可以在 spring 中配置,或直接使用 SimpleDateFormat 格式化成 String 后再返回。
症状如下图,开多个线程使用同一个 SimpleDateFormat 实例,会出现解析失败:
说明在多线程场景下 SimpleDateFormat 是有线程安全问题的。究其原因,SimpleDateFormat 类继承自 DateFormat 类,DateFormat 实例中维护了一个 Calendar 对象,parse() 方法会调用 Calendar 对象的方法去根据给定格式设置属性值,而 Calendar 对象的 fields、time、zone 等表示字段都是线程不安全的。如果 SimpleDateFormat 是单例,Calendar 对象一定也是多线程共用一个的。
解决方法:
public class Main {
private static ThreadLocal sdfThreadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static void main(String[] args) {
for (int i = 0; i < 100; ++i) {
Thread thread = new Thread(() -> {
try {
System.out.println(sdfThreadLocal.get().parse("2019-08-04 22:17:27"));
} catch (Exception e) {
System.out.println("解析失败");
}
});
thread.start();
}
}
}
由于旧版 Java 中的日期时间 API 存在线程不安全、某些设计不符合日常直觉、时区处理复杂等问题,Java8 中提供了一些新的 API。包括Instant、LocalDate、LocalTime、LocalDateTime、ZonedDateTime、Period、Duration、DateTimeFormatter等。
首先直观看一下这些类里都有什么:
Instant,中文可译为“瞬间”,表示了时间线上一个确切的点,可以表示纳秒级别的时刻(虽然 now() 构造方法得出的纳秒数和 java.sql.Timestamp 类一样也是“假的”,是从 System.currentTimeMillis() 得来的)。Instant是时区无关的,如何理解这个“时区无关”?即始终是对标协调世界时(UTC)即格林尼治零时区的,个人觉得可以理解为“Unix时间戳的更精确表示形式”。
Instant 类有四种实例化方法:
由上上图可知,Instant 对象中保存了 seconds(距离初始时间的秒数)和 nanos(当前秒的第几纳秒),可以通过以下get开头的方法获取,传入 field 也可以获取毫秒、微秒级的时间。
字面含义,LocalDate 表示本地日期,LocalTime 表示本地时间,LocalDateTime 表示日期加时间。Java8中支持日期和时间的分别表示。
API都较为简单,来讲两个需要理解的注意点:
以 LocalDate 为例说明API,剩余两个类大同小异。
LocalDate 可以通过三种方法创建实例:
可以通过各种get方法得到日期相关字段,如字面意思:
可以增减字段值:
以及一些原来要很复杂代码的操作,现在可以简化:
还可以获取指定时区的当前日期时间,或添加时区属性,转化成下面要介绍的 ZonedDateTime,注意这里没有进行时间的时区变换,而是仅仅添加了时区属性,更印证了上文说的“Local”的含义。拿 LocalDateTime 举例:
ZonedDateTime 可以被理解为 LocalDateTime 的外层封装,它的内部存储了一个 LocalDateTime 的实例,专门用于普通的日期时间处理,此外它还定义了 ZoneId 实例和 ZoneOffset 实例来描述时区的概念。调试信息显示如下:
产生 ZonedDateTime 实例的几种方法如下,如字面意思较好理解:
public static ZonedDateTime now();
public static ZonedDateTime now(ZoneId zone);
public static ZonedDateTime of(LocalDate date, LocalTime time, ZoneId zone)
public static ZonedDateTime of(LocalDateTime localDateTime, ZoneId zone)
public static ZonedDateTime ofInstant(Instant instant, ZoneId zone)
public static ZonedDateTime of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone)
其他方法操作和 LocalDateTime 类似,不多赘述。
DateTimeFormatter 类作为 Java8 中用于表示日期时间的类,与原有DateFormat 类最大的不同就在于它是线程安全的,其他使用上的操作基本类似。举例如下:
Java8 添加了处理时间差的功能,用 Period 处理两个日期之间的差值,用 Duration 处理两个时间之间的差值。between() 方法等大大简化了计算两个日期时间之间差值的操作,举例如下:
简单介绍了 Java8 的一些处理日期时间的新API,可以说对比之前的版本是有很大的改进的。
讲完表示再来看日期时间的存储方法。以MySQL数据库为例,介绍数据存储的方式,以及与Java程序的交互。
将MySQL提供的几种日期时间数据结构列表如下:
类型名称 | 占用空间 | 展示格式 | 表示范围 |
---|---|---|---|
YEAR | 1 bytes | YYYY | 1901——2155 |
DATE | 4 bytes | YYYY-MM-DD | 1000-01-01——9999-12-31 |
TIME | 3 bytes | HH:MM:SS | -838:59:59——838:59:59 |
DATETIME | 8 bytes | YYYY-MM-DD HH:MM:SS | 1000-01-01 00:00:00——9999-12-31 23:59:59 |
TIMESTAMP | 4 bytes | YYYY-MM-DD HH:MM:SS | 1970-01-01 00:00:01——2038-01-19 03:14:07 (UTC) |
1.1. YEAR 类型用于表示年份,默认是4位,可以直接插入4位数字或字符串。由于YEAR类型占用空间很小,如果只想表示年份,并在其表示范围内,不失是一种很好的选择。
1.2. DATE 类型用于表示日期,以 YYYY-MM-DD 格式显示。指“日历页上的日期”,没有时区概念,类似于 Java8 中的 LocalDate。
1.3. TIME 类型用于表示时间,以 HH:MM:SS 格式显示,精度为秒。指“挂钟显示的时间”,没有时区概念,类似于 Java8 中的 LocalTime。
1.4. DATETIME 类型是 DATE 和 TIME 的结合,占8位,它把日期和时间封装到格式为 “YYYYMMDDHHMMSS” 的整数中,可以记录较 TIMESTAMP 更长的时间。没有时区概念,类似于 Java8 中的 LocalDateTime。
1.5. TIMESTAMP 类型也是表示日期加时间,但是表示的时间较短,和32位 Unix 时间戳相同。TIMESTAMP 类型表示的时间与时区有关,MySQL服务器、操作系统、客户端连接等都有时区设置,插入日期时会先转换为本地时区后再存放,查询日期时会将日期转换为本地时区后再显示。如果插入时没有指定 TIMESTAMP 列的值,则系统默认设置为 ‘0000-00-00 00:00:00’,也可以手动设置为添加当前时间。
YEAR、DATE、TIME 三种类型都功能不同,YEAR 存年份,DATE 存日期,TIME 存时间,按业务需求进行挑选即可。
主要比较 DATETIME 和 TIMESTAMP 类型:
还有一种可选项:每次涉及日期时间时全部用Unix时间戳表示,Java中用long,MySQL中用INT类型,详见如何正确地处理时间-廖雪峰。好处是体现了“存储与显示分离”的原则,且易于比较。但是肉眼无法快速识别时间戳确实带来了很大的麻烦,况且Java和MySQL开发出那么多类型就是为了方便使用(不然上文全都白讲了),也可以解决大多数问题,所以个人并不推荐这种做法(也可能是开发经验不够,没有理解到廖老师这个点的精髓)。
笔者自己总结了Java 和 MySQL 日期时间数据类型的一种映射关系:
Java类型 | MySQL映射 |
---|---|
java.sql.Date | DATE |
java.sql.Time | TIME |
java.sql.Timestamp | TIMESTAMP |
java.time.LocalDate | DATE |
java.time.LocalTime | TIME |
java.time.LocalDateTime | DATETIME |
java.time.ZonedDateTime | TIMESTAMP |
java.time.Instant | TIMESTAMP |
在MySQL数据库创建表包含各种类型的字段用于测试:
CREATE TABLE `test_time` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`time1` date DEFAULT NULL,
`time2` time DEFAULT NULL,
`time3` year(4) DEFAULT NULL,
-- 长度为3精确到毫秒
`time4` datetime(3) DEFAULT NULL,
-- 长度为6精确到微秒
`time5` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=latin1;
需要时区转换的时间一定不是“挂钟上的时间”,而是时间轴上确定的一个“绝对时间”。所以时区转换分为两个方面:由被展示的字符串添加某时区信息后转为Java对象,或由固定时区的Java对象转换时区后展示。下面各种方式实现这两个转换:
根据目标时区和原时区的时差直接加减,“硬核转换”,极不推荐。
如下图(注意,转为Date对象的时候自动变为了系统时区):或者更简单的利用“z”这个域:
用各种方法得到该时间点的时间戳,然后转化为Java对象,添加时区信息,输出。
按照上述MySQL与Java交互中所述,将MySQL存储的时间转换为Java对象,然后按照2,3方法转换即可。
本篇文章全面贴近实际开发,首先从日常代码遇到的问题出发,介绍了一些常识和会遇到的问题。
随后介绍了Java中日期时间的获取、数据格式表示及格式转换方法。其中深入源码详细介绍了Java7中的日期时间数据结构,拆解了可能会遇到的线程安全问题及解决办法,并在使用层面介绍了Java8中日期时间新API及其优点,源码中的复杂计算方法有待今后研究。
接着在存储方面介绍了MySQL的日期时间类型及如何选择的建议,并给出了与Java各种日期时间类型的转换示例。
最后根据时区转换的需求给出各种数据结构的时区转换操作方法。
本篇为上篇-应用篇,下篇中会详细解释一些底层日期时间的处理,如为什么不同操作系统获取当前时间的速度有数量级差异;高并发场景用 System.currenTimeMillis() 会出现什么问题及怎么解决;Linux中有哪些时间相关系统调用及他们的区别;系统对于类似 Thread.sleep(long millis) 的“时间段”长度是如何控制的;以上这些底层问题如何影响我们的程序设计等。