正确理解和转换时区

正确理解和转换时区

Java的时区问题困扰了我很久,最近我又一次遇到了这个问题,而这次在ChatGPT的协助下我最终搞清楚发生了什么,它其实是一个很简单的问题,但如果没有搞清楚里面的细节的话,就会非常棘手。

时区

时区是一个和地理位置相关的概念,同一个时刻在不同的地区会有所不同,以一个特定的标准让各地的时间能以一定的规则统一,让它们表达的含义在各地通行,这就是时区的目的。

以格林尼治天文台为基础,每隔15经度划分为一个时区,每一个时区具备一个小时的时差,格里尼治天文台所在的时区也就是0时区,这种时区的表达方式被称为“格林尼治时间”也就是“GMT”。

通常情况下以格里尼治天文台所在的时区为基础,东经为“GMT+N”,西经为“GMT-N”,例如北京时间就是GMT+8,需要注意的是,存在一种以西经为“GMT+N”,东经为“GMT-N”的表示方法,它以西方为正,也就是POSIX标准,它们会在GMT时区加上前缀Etc。

也就是说,北京时间在GMT时区标准下,可以写为“GMT+8”也可以记作“Etc/GMT-8”。

虽然格林尼治时间很好用,但现在使用的更多的是基于“协调世界时”——“UTC”,这二者的差别主要是计量方式的不同,格林尼治时间以追踪地球自转计时方式,而协调世界时使用的是原子钟计时,但在大多数情况下,我们可以认为它们是等同的,除非涉及到经度极高的计时和运算,例如某些时间非常敏感的科研。

协调世界时UTC的0时区,也是格林尼治天文台的时区,同样的,东经为“UTC+N”,西经为“UTC-N”,例如北京时间可以表示为“UTC+8”,与GMT不同,它没有所谓的反向前缀,所有UTC时间都是东正西负,非常标准。

除了“GMT”和“UTC”,还有一种很常见的时区表示法,那就是偏移表示法,这种表示方法的标准格式是两位数的小时,冒号加上两位数的分钟,同样的,东经使用“+”,西经使用“-”,北京时间在这种表示法中被记为“+08:00”,小时和分钟必须是两位,零是不能省略的,零时区既可以写作“+00:00”,也可以是“-00:00”。

另外,格林尼治天文台所在被规定为0经度。

在计算机中表示日期和时间,我们通常使用时间戳,在当下,时间戳是一个Long型的长整数,它的含义是从UTC时区的1970年1月1日0点整到某个特定时间点的差值,时间戳为0则表示它是1970年1月1日0点整,时间戳的计量单位是毫秒,时区起到的作用正是把这种时间戳转换为特定地区的事件,也能把不含时区的本地时间转换为以UTC为基准的时间戳。

Java中常见日期时间对象

在早期的Java版本中,虽然也有时区的概念,但是它的API存在种种问题,此时最常用的时区处理方法,是通过第三方类库来进行的,也就是那个非常常用的Joda-time了,不过Java8开始,Java自身就提供了相对统一的日期时间API,所以这里我就不继续关注Joda类库,而是专注于使用Java提供的API。

Java提供的日期和时间现在相当丰富,最常用到的是以下这些:

LocalDateTime

这种类型的日期和时间不具备时区——当然,记录它的设备是有时区的,只是这个对象不会记载它,因此如果需要使用这种类型的日期和时间,就必须了解自己是在什么时区下记录了它。

这就像是我们把某一个时刻写在了纸上,通常情况下,只有日期和时间而不会刻意记录时区一样。

LocalDate & LocalTime

这两种记录的是单纯的日期或者单纯的时间,也是没有时区信息的,通常使用的也不算很多。

ZonedDateTime

这种类型的日期和时间,在记录了日期和时间的同时,也记录了时区信息,时区信息表明了这个日期和时间是在哪里被记录的。

OffsetDateTime & OffsetTime

以时间偏移的形式表示时区的日期和时间,这种时间偏移表达的东西当然就是时区了。

时区转换

如果想要正确的显示一个日期和时间,就必然需要处理时区,这里有两种思路:

如果日期和时间没有记录时区,在使用的时候需要为它赋予一个时区,然后才能格式化为各种形式的日期字符串,由于这个日期和时间本身没有记录时区,所以需要自己为它添加一个,时区并不是一个易变的东西,大多数时候它就是当前设备的默认时区。

对于LocalDate:

// 这里有一个LocalDate。
LocalDate date = LocalDate.now();
// 通过赋予时间属性转换为LocalDateTime
LocalDateTime dateTime = date.atTime(0,0,0);
// 标记它的时区,日期和时间变成了特定时区下的日期和时间。
ZonedDateTime zonedDateTime = LocalDateTime.atZone(ZoneId.systemDefault());

这样一个LocalDate就变成了ZonedDateTime,它表示date在0时0分0秒的系统默认时区下的本地日期时间,此时该日期时间已经处于指定时区。

也可以是这样:

// 这里有一个本地日期
LocalDate date = LocalDate.now();
// 这里有一个本地时间
LocalTime time = LocalTime.now();
// 添加时间,转换为本地日期和时间
LocalDateTime dateTime = date.atTime(time);
// 标记时区。
ZonedDateTime zonedDateTime = LocalDateTime.atZone(ZoneId.systemDefault());

这一点必须重点强调,atZone方法只是为当前的本地日期时间设定了一个时区它不会对日期和时间本身做出修改,之前的日期还是那个日期,时间还是那个时间,它们的值都是本来的值,不会因为使用了atZone而被时区影响这个方法的含义是赋予一个时区,表示这个日期和时间是在这个时区记录的

那么,转换时区要做的是什么呢?有两种解释:

其一,将这个时间时刻正确的表达为另一个时区的地方时间,也就是说目前记录的时刻是没有问题的,我们想要得到的是它在另一个时区的表达,例如,将当前的时间从“UTC+8”转换为“UTC+0”,时刻本身不变,但是切换这一时刻的时区,获取另一个时区下,这一时刻是哪天,那个小时,那一分钟,这代表我们将会把同一时刻换算为另一个时区的时间,看看在当地这一时刻是什么时候,对于这种情况,正确的做法是:

// 这将会把时区从本地时区切换到GMT时区,此时,日期和时间会以GMT时区表达。
zoneDateTime.withZoneSameInstant(ZoneId.of("GMT"))

其二,目前的日期和时间是正确的,但它的时区有问题,它不应该以现在的时区被记载,例如:北京的早上8点被记载为格林尼治的早上8点,此时我们需要做的是更换这一日期和时间的时区,让日期和时间维持原状,日期还是那个日期,早上8点还是早上8点,而改变的仅仅是时区,从格林尼治时区变为北京时区,从格林尼治的早上8点变为北京的早上8点。

// 这将会把时区从本地时区更换到GMT时区,此时,日期和时间保持不变,但是时区变为GMT。
zoneDateTime.withZoneSameLocal(ZoneId.of("GMT"))

另外,时区的不同会影响日期和时间的字符串形式,包括使用的ZonedDateTime和OffsetDateTime在格式化时都会对最终生成的字符串有影响。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Fantastic Soft

风铃之书是个人的工作和生活的总结和分享的站点,欢迎来访和留言,有时也会提供自家软件的发布版本和开源项目。