Skip to main content

探索java中复杂的时区

作草分茶About 14 min

探索java中复杂的时区

一些常见的时间(时区)概念

GMT

又称格林尼治平均时间或格林尼治标准时间,旧译格林威治标准时间。
GMT是根据地球的自转和公转来计算时间,指位于英国伦敦郊区的皇家格林尼治天文台的标准时间,因为本初子午线被定义在通过那里的经线

UTC

UTC 全称 Universal Time Coordinated ,中文名协调世界时间,又称世界统一时间、世界标准时间、国际协调时间。是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林尼治标准时间。
UTC是根据原子钟来计算时间。

CST 和 CTT

北京时间,China Standard Time,中国标准时间,在时区划分上,属东八区,比协调世界时早8小时,记为UTC+8。
不过这个CST这个缩写比较纠结的是它可以同时代表四个不同的时间:

  1. Central Standard Time (USA) UT-6:00
  2. Central Standard Time (Australia) UT+9:30
  3. China Standard Time UT+8:00
  4. Cuba Standard Time UT-4:00

所以为了避免歧义,现在有些地方用 CTT 来指代中国时区。

ISO

ISO 是一种时间格式,现在用的是 ISO 8601 版本,格式如下;

YYYY-MM-DDThh:mm:ss[.mmm]TZD

Timestamp

Timestamp 中文名是时间戳,是指格林威治时间1970年01月01日00时00分00秒起至当下的总秒数。是一个绝对时间

Asia/Shanghai

Asia/Shanghai是已地区命名的地区标准时,在中国叫CST。 这个地区标准时会兼容历史各个时间节点。
中国1986-1991年实行夏令时,夏天和冬天差1个小时,Asia/Shanghai会兼容这个时间段。

看看 Java 里的时间秘密

以下测试代码都是以 JDK17 运行的。

时间戳和 Date 长什么样子

@Test  
public void test1() {  
    TimeZone timeZone = TimeZone.getDefault();  
    long timestamp = System.currentTimeMillis();  
    Date now = new Date();  
    System.out.println("当前时区:" + timeZone);  
    System.out.println("当前时间戳:" + timestamp);  
    System.out.println("当前 Date:" + now);  
    System.out.println("时间戳和 Date 比较:" + (timestamp == now.getTime()));  
}

运行结果如下:

当前时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
当前时间戳:1691847447812
当前 Date:Sat Aug 12 21:37:27 CST 2023
时间戳和 Date 比较:true

可以看到当前 JVM 默认时区是Asia/Shanghai,从 Date 中获取的时间戳和从系统System.currentTimeMillis获取的时间戳是一致的。
看看 Date 的代码:

private transient long fastTime;

public Date() {  
    this(System.currentTimeMillis());  
}

public Date(long date) {  
    fastTime = date;  
}

public String toString() {  
    // "EEE MMM dd HH:mm:ss zzz yyyy";  
    BaseCalendar.Date date = normalize();  
    StringBuilder sb = new StringBuilder(28);  
    int index = date.getDayOfWeek();  
    if (index == BaseCalendar.SUNDAY) {  
        index = 8;  
    }  
    convertToAbbr(sb, wtb[index]).append(' ');                        // EEE  
    convertToAbbr(sb, wtb[date.getMonth() - 1 + 2 + 7]).append(' ');  // MMM  
    CalendarUtils.sprintf0d(sb, date.getDayOfMonth(), 2).append(' '); // dd  
  
    CalendarUtils.sprintf0d(sb, date.getHours(), 2).append(':');   // HH  
    CalendarUtils.sprintf0d(sb, date.getMinutes(), 2).append(':'); // mm  
    CalendarUtils.sprintf0d(sb, date.getSeconds(), 2).append(' '); // ss  
    TimeZone zi = date.getZone();  
    if (zi != null) {  
        sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz  
    } else {  
        sb.append("GMT");  
    }  
    sb.append(' ').append(date.getYear());  // yyyy  
    return sb.toString();  
}

Date 中存放了一个时间戳的成员变量,默认的构造方法就是当前时间戳。由于计算机运行很快,所以两次获取的当前时间戳是一致的。
再看看 Date 的 toString 方法,该方法是将 Date 对象格式化为EEE MMM dd HH:mm:ss zzz yyyy字符串,中文含义为星期 月份
当月天数 小时 分钟 秒 时区 年份

为什么打印的 Date 时区是 CST,而当前时区却是 Asia/Shanghai
呢?其实这里做了一层转换,就是代码zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)
,只是展示的名字不同罢了,其实都是一样的。

不同时区对 Date 有什么影响

@Test  
public void test2() {  
    TimeZone timeZone = TimeZone.getDefault();  
    System.out.println("以下是默认时区:" + timeZone);  
    System.out.println("当前 Date:" + new Date());  
    System.out.println("--------------");  
  
    // 修改时区为 UTC    
    TimeZone utcTimeZone = TimeZone.getTimeZone("UTC");  
    TimeZone.setDefault(utcTimeZone);  
    System.out.println("以下是 UTC 时区:" + utcTimeZone);  
    System.out.println("当前 UTC Date:" + new Date());  
    System.out.println("--------------");  
  
    // 修改时区为 UTC+8    
    TimeZone utc8TimeZone = TimeZone.getTimeZone("UTC+8"); 
    TimeZone.setDefault(utc8TimeZone);  
    System.out.println("以下是 UTC+8(错误的)时区" + utc8TimeZone);  
    System.out.println("当前 UTC+8 Date:" + new Date());  
    System.out.println("--------------");  
  
    // 修改时区为 GMT    
    TimeZone gmtTimeZone = TimeZone.getTimeZone("GMT");  
    TimeZone.setDefault(gmtTimeZone);  
    System.out.println("以下是 GMT 时区:" + gmtTimeZone);  
    System.out.println("当前 GMT Date:" + new Date());  
    System.out.println("--------------");  
  
    // 修改时区为 GMT+8    
    TimeZone gmt8TimeZone = TimeZone.getTimeZone("GMT+8"); 
    TimeZone.setDefault(gmtTimeZone);  
    System.out.println("以下是 GMT+8 时区:" + gmt8TimeZone);  
    System.out.println("当前 GMT+8 Date:" + new Date());  
}

上面的代码运行出来的结果如下:

以下是默认时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
当前 Date:Sat Aug 12 22:21:48 CST 2023
--------------
以下是 UTC 时区:sun.util.calendar.ZoneInfo[id="UTC",offset=0,dstSavings=0,useDaylight=false,transitions=0,lastRule=null]
当前 UTC Date:Sat Aug 12 14:21:48 UTC 2023
--------------
以下是 UTC+8(错误的)时区::sun.util.calendar.ZoneInfo[id="GMT",offset=0,dstSavings=0,useDaylight=false,transitions=0,lastRule=null]
当前 UTC+8 Date:Sat Aug 12 14:21:48 GMT 2023
--------------
以下是 GMT 时区:sun.util.calendar.ZoneInfo[id="GMT",offset=0,dstSavings=0,useDaylight=false,transitions=0,lastRule=null]
当前 GMT Date:Sat Aug 12 14:21:48 GMT 2023
--------------
以下是 GMT+8 时区:sun.util.calendar.ZoneInfo[id="GMT+08:00",offset=28800000,dstSavings=0,useDaylight=false,transitions=0,lastRule=null]
当前 GMT+8 Date:Sat Aug 12 22:21:48 GMT+08:00 2023

这个结果就很有意思了,我们先确定几个常识或者说前提:

  1. 东八区的时间是比 0 时区快 8 个小时的;
  2. 时间是个有绝对和相对的概念,绝对时间以 0 时区时间为准,在不同时区有着不同的叫法,但是表示的是同一时刻;

然后在这个基础上我们分析上面的结果:

  1. 默认时区是 Asia/Shanghai,当前时间为2023-08-12 22:21:48,CST 中国时区,北京时间,没有问题;
  2. 修改时区为 UTC,当前时间为2023-08-12 14:21:48,UTC 时区,比北京时间晚八个小时,也是对的;
  3. 修改时区为 UTC+8,当前时间为2023-08-12 14:21:48,GMT 时区,比北京时间晚八个小时。这里就有问题了,为什么设置了
    UTC+8,但是时区却是 GMT 时区?这个问题先记下后面再看;
  4. 修改时区为 GMT,当前时间为2023-08-12 14:21:48,GMT 时区,比北京时间晚八个小时,也是对的;
  5. 修改时区为 GMT+8,当前时间为2023-08-12 22:21:48,GMT+8 时区,也是对的;

那么现在来分析为什么设置 UTC+8,但是时区却是 GMT,看看代码:

private static TimeZone getTimeZone(String ID, boolean fallback) {  
    TimeZone tz = ZoneInfo.getTimeZone(ID);  
    if (tz == null) {  
        tz = parseCustomTimeZone(ID);  
        if (tz == null && fallback) {  
            tz = new ZoneInfo(GMT_ID, 0);  
        }  
    }  
    return tz;  
}
  1. 第一步获取默认的时区枚举,很遗憾,默认的时区枚举根本没有 UTC+8,默认的可用时区可以通过 TimeZone.getAvailableIDs() 获取;
  2. 第二步是解析自定义的时区,还是很遗憾,自定义的时区只支持 GMT 开头的,所以返回默认的时区 GMT;

Jackson 时间序列化盲点

准备一个 java 模型类

@Getter  
@Setter  
public class Model {  
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")  
    private Date created;  
    private Date modified;  
  
    private void setDate(Date date) {  
        System.out.println("设置时间为:" + date);  
        this.created = date;  
        this.modified = date;  
    }  
}

该类有两个成员变量,一个加了 JsonFormat 注解,另一个没有加,时间默认为当前时间。

将对象转 String

@Test  
public void test1() throws JsonProcessingException {  
    System.out.println("当前时区:" + TimeZone.getDefault());  
    Model model = new Model();  
    ObjectMapper objectMapper = new ObjectMapper();  
    System.out.println(objectMapper.writeValueAsString(model));  
}

运行结果如下:

当前时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
设置时间为:Sun Aug 13 11:15:14 CST 2023
输出结果:{"created":"2023-08-13 03:15:14","modified":1691896514868}

从输出结果中可以看到两个问题:

  1. 可以看到当前时区为东八区,加了 JsonFormat 注解的字段 created 按格式输出了,但是结果却是 0 时区的时间,而不是当前时区的时间;
  2. 没有加注解的 modified 输出了时间戳。

探索一下,可以在 JsonFormat 注解中发现一个参数 timezone,注释如下:

TimeZone to use for serialization (if needed). Special value of DEFAULT_TIMEZONE can be used to mean "just use the default", where default is specified by the serialization context, which in turn defaults to system default (UTC) unless explicitly set to another timezone.

翻译过来就是这个参数可以指定序列化的时区,如果没有指定,默认是 UTC 时区(这里不是用的 Jvm 中的 TimeZone),这个默认值可以修改。

第二个问题是因为 modified 字段没有指定序列化的格式,所以默认加上了参数SerializationFeature.WRITE_DATES_AS_TIMESTAMPS,将
Date 输出为时间戳。代码看 DateTimeSerializerBase
5cCZEC

oMsKHF
但是 Jackson 可以指定默认 Date 默认的序列化格式

那么试一下改过后的代码:

@Getter  
@Setter  
public class ModelA {  
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+7")  
    private Date created;  
    private Date modified;  
  
    private void setDate(Date date) {  
        System.out.println("设置时间为:" + date);  
        this.created = date;  
        this.modified = date;  
    }  
}

@Test  
public void test2() throws JsonProcessingException {  
    System.out.println("当前时区:" + TimeZone.getDefault());  
    ModelA model = new ModelA();  
    ObjectMapper objectMapper = new ObjectMapper();  
    // 指定格式化样式
    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");  
    objectMapper.setDateFormat(dateFormat);  
    // 设置时区为东八区
    objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));  
    System.out.println("输出结果:" + objectMapper.writeValueAsString(model));  
    System.out.println("-------------");  
    System.out.println("设置时区为 0 时区");  
    // 设置时区为 0 时区
    TimeZone.setDefault(TimeZone.getTimeZone("GMT"));  
    objectMapper.setTimeZone(TimeZone.getTimeZone("GMT"));  
    System.out.println("输出结果:" + objectMapper.writeValueAsString(model));  
}

这里指定了字段 created 的序列化格式为yyyy-MM-dd HH:mm:ss,并且指定了时区为 GMT+7(东七区,比北京时间早一个小时),然后设置了
Date 默认的序列化格式为yyyy/MM/dd HH:mm:ss,可以看一下结果:

当前时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
设置时间为:Sun Aug 13 11:16:37 CST 2023
输出结果:{"created":"2023-08-13 10:16:37","modified":"2023/08/13 11:16:37"}
-------------
设置时区为 0 时区
输出结果:{"created":"2023-08-13 10:16:37","modified":"2023/08/13 03:16:37"}
  1. 当前时区还是东八区,时间为 2023-08-13 11:16:37
  2. 单独指定了序列化格式和时区的字段比当前时间早一个小时,而默认的时间 modified 按默认格式和时区输出了;
  3. 设置 0 时区后,加了注解的字段序列化没有变化,序列化配置以注解为准,但是 modified 以 0 时区序列化了;

将 String 转对象

@Test  
public void test3() throws JsonProcessingException {  
    System.out.println("当前时区:" + TimeZone.getDefault());  
    String json = "{\"created\":\"2023-08-12 12:56:46\",\"modified\":\"2023/08/12 12:56:46\"}";  
    System.out.println("原字符串:" + json);  
    ObjectMapper objectMapper = new ObjectMapper();  
    System.out.println("--------------");  
    objectMapper.setDateFormat(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"));  
    // 按 0 时区区算  
    System.out.println("设置按 0 时区序列化");  
    objectMapper.setTimeZone(TimeZone.getTimeZone("GMT"));  
    Model model2 = objectMapper.readValue(json, Model.class);  
    TimeZone.setDefault(TimeZone.getTimeZone("GMT"));  
    System.out.println("输出 created:" + model2.getCreated());  
    System.out.println("输出 modified:" + model2.getModified());  
    System.out.println("--------------");  
    // 按东八区算  
    System.out.println("设置按东八区序列化");  
    objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));  
    Model model = objectMapper.readValue(json, Model.class);  
    TimeZone.setDefault(TimeZone.getTimeZone("GMT+8"));  
    System.out.println("输出 created:" + model.getCreated());  
    System.out.println("输出 modified:" + model.getModified());  
}

定义一个字符串,字符串里的时间只是一个符号,并没有任何时区的概念,所以转换为 Date 的时候,这个概念会变成某时区的某时间,运行结果如下:

当前时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
原字符串:{"created":"2023-08-12 12:56:46","modified":"2023/08/12 12:56:46"}
--------------
设置按 0 时区序列化
输出 created:Sat Aug 12 12:56:46 GMT 2023
输出 modified:Sat Aug 12 12:56:46 GMT 2023
--------------
设置按东八区序列化
输出 created:Sat Aug 12 20:56:46 GMT+08:00 2023
输出 modified:Sat Aug 12 12:56:46 GMT+08:00 2023

分析结果:

  1. 当前时区东八区,created 加了注解未指定时区,modified 未加注解,字符串时间为2023-08-12 12:56:46
  2. 设置默认反序列化时区为 0 时区,转为对象之后,都是0 时区的 2023-08-12 12:56:46
  3. 设置默认反序列化时区为东八区,转为对象之后,created 是东八区的 2023-08-12 20:56:46,也就是 0
    时区的2023-08-12 12:56:46,这个时间明显跟期望不一致,我们期望得到的是东八区的2023-08-12 12:56:46,*
    这个问题我们用下一个测试用例验证一下*。modified 是东八区的2023-08-12 12:56:46,这个是正确的;

将上面的代码改变一下,设置东八区序列化重新 new 一个 ObjectMapper:

System.out.println("当前时区:" + TimeZone.getDefault());  
String json = "{\"created\":\"2023-08-12 12:56:46\",\"modified\":\"2023/08/12 12:56:46\"}";  
System.out.println("原字符串:" + json);  
System.out.println("--------------");  
// 按0 时区区算  
ObjectMapper objectMapper = new ObjectMapper();  
objectMapper.setDateFormat(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"));  
System.out.println("设置按 0 时区序列化");  
objectMapper.setTimeZone(TimeZone.getTimeZone("GMT"));  
Model model2 = objectMapper.readValue(json, Model.class);  
TimeZone.setDefault(TimeZone.getTimeZone("GMT"));  
System.out.println("输出 created:" + model2.getCreated());  
System.out.println("输出 modified:" + model2.getModified());  
System.out.println("--------------");  
// 按东八区算  
System.out.println("设置按东八区序列化");  
ObjectMapper objectMapper2 = new ObjectMapper();  
objectMapper2.setDateFormat(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"));  
objectMapper2.setTimeZone(TimeZone.getTimeZone("GMT+8"));  
Model model = objectMapper2.readValue(json, Model.class);  
TimeZone.setDefault(TimeZone.getTimeZone("GMT+8"));  
System.out.println("输出 created:" + model.getCreated());  
System.out.println("输出 modified:" + model.getModified());

得到结果如下:

当前时区:sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=31,lastRule=null]
原字符串:{"created":"2023-08-12 12:56:46","modified":"2023/08/12 12:56:46"}
--------------
设置按 0 时区序列化
输出 created:Sat Aug 12 12:56:46 GMT 2023
输出 modified:Sat Aug 12 12:56:46 GMT 2023
--------------
设置按东八区序列化
输出 created:Sat Aug 12 12:56:46 GMT+08:00 2023
输出 modified:Sat Aug 12 12:56:46 GMT+08:00 2023

分析一下结果:

  1. 当前时区东八区,created 加了注解未指定时区,modified 未加注解,字符串时间为2023-08-12 12:56:46
  2. 构建一个 ObjectMapper 并设置默认反序列化时区为 0 时区,转为对象之后,都是 0 时区的 2023-08-12 12:56:46
  3. 构建一个 ObjectMapper 并设置默认反序列化时区为东八区,转为对象之后,都是 东八区的 2023-08-12 12:56:46,也就是 0
    时区的2023-08-12 04:56:46,所以问题出现在加了注解的默认时区根本没有改变

其实objectMapper.setTimeZone(TimeZone.getTimeZone("GMT"))这行代码没有生效的原因是因为同一个 ObjectMapper
对象中对同一个类的反序列化做了缓存,设置时区也无法对加了注解的字段生效。此时如果将字符串反序列化到另一个类是可以的,因为没有缓存。

Spring 中的 DateTimeFormat 到底怎么用

很多人不知道 Spring 中的注解 DateTimeFormat 和 Jackson 中的 JsonFormat 到底有什么区别,什么时候该用什么。

其实这个问题很简单,Spring 接收 Http 请求参数有两种方式,一种是基于表单或者 url 上的参数即 params 方式,另一种是 body
方式,这两种方式就决定了 Spring 怎么反序列化入参了。

表单或 Param 方式

这种方式的代码可以查看 org.springframework.beans.AbstractNestablePropertyAccessor
的视线,原理是将每个字段转换为模型对象的属性。这时候就是 DateTimeFormat 生效的时候。

Body 方式

因为 Spring 接收到 Json Body 之后,其实是一串字符串,这时候是不知道哪个字段该映射到哪个模型对象的属性上的,所以需要借助于三方的
Json 序列化工具,比如 Jackson 或者 Fastjson。这时候 JsonFormat 就生效了,这时候的配置就回到了 Jackson ObjectMapper
的配置了,但是 Spring 序列化有自己的 ObjectMapper 实例,可以根据配置文件来进行配置。

同理,因为 http 返回的 Body
是文本,所以这种方式是将模型对象序列化为字符串,这个也是借助于序列化工具来的,所以生效的也是 JsonFormat

接口中的时间字段怎么交互最合理

在接口对接中,经常会有时间字段的接口交互,很多情况下我们是通过字符串来交互的,即格式化成2022-03-09 00:13:00,但是这样的字符串无法标识是哪个时区的时间,即无法标识绝对时间。
这种情况下就需要对接的双方清楚两边的所在时区在哪里,这样很明显是不合理的。有一下几种方式可以解决交互问题:

  1. 双方约定时区,以字符串交互,这种方式成本太大,但是在国内使用时没有问题的;
  2. 以时间戳进行交互,这样输出和输入都是绝对时间,接收方可以将时间戳转换为自己服务时区来进行操作(比如用 Jackson 序列化的时候不要加注解)。
    方式这种对前端交互不够友好,人眼无法一眼看出来时间,可读性太弱。另外提一点,Spring 对 Date 类型的不会默认序列化成时间戳,具体代码看下面,它修改了 Jackson 会默认序列化成时间戳的行为;
    UQVEII
  3. 用带有时区的字符串格式来表示,如OffsetDateTime,这个类里面维护了LocalDateTimeZoneOffset,可以表示时间和时区。这样通过序列化够可以将时间转换为2023-08-18T10:43:00.591+08:00,
    接收方如果在 0 时区,可以将这个字符串反序列化为0 时区的时间,如2023-08-18T02:43:00.591Z。这种方式可读性强,也能标识时间的绝对性,在国际化中使用的较多。