13 | 用好Java 8的日期时间类,少踩一些“老三样”的坑
初始化日期时间
如果要初始化一个 2019 年 12 月 31 日 11 点 12 分 13秒这样的时间,Date date = new Date(2019, 12, 31, 11, 12, 13);
输出的时间是 3029 年 1 月 31 日 11 点 12 分 13 秒:Sat Jan 31 11:12:13 CST 3920
是新手的低级错误:年应该是和 1900 的差值,月应该是从0 到 11 而不是从 1 到 12。Date date = new Date(2019 - 1900, 11, 31, 11, 12, 13);
但更重要的问题是,当有国际化需求时,需要使用 Calendar 类来初始化时间。
Calendar calendar = Calendar.getInstance();
calendar.set(2019, 11, 31, 11, 12, 13);
System.out.println(calendar.getTime());
Calendar calendar2 = Calendar.getInstance(TimeZone.getTimeZone("America/New_York"));
calendar2.set(2019, Calendar.DECEMBER, 31, 11, 12, 13);
System.out.println(calendar2.getTime());
// 输出显示了两个时间,说明时区产生了作用.对现在输出的日期格式还不满意:
Tue Dec 31 11:12:13 CST 2019
Wed Jan 01 00:12:13 CST 2020
“恼人”的时区问题
全球有 24 个时区,同一个时刻不同时区的时间是不一样的。
关于 Date 类,我们要有两点认识:
- Date 并无时区问题,世界上任何一台计算机使用 new Date() 初始化得到的时间都一样。因为,Date 中保存的是 UTC 时间,UTC 是以原子钟为基础的统一时间,不以太阳参照计时,并无时区划分。
- Date 中保存的是一个时间戳,代表的是从 1970 年 1 月 1 日 0 点(Epoch 时间)到现在的毫秒数。尝试输出 Date(0):
System.out.println(new Date(0));
System.out.println(TimeZone.getDefault().getID() + ":" + TimeZone.getDefault().getRawOffset()/3600000);
国际化的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
- 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳,或 Java 中的 Date 类就是用的这种方式,这也是推荐的方式。
- 以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。只有有了时区信息,我们才能知道这个字面量时间真正的时间点,否则它只是一个给人看的时间表示,只在当前时区有意义。Calendar 是有时区概念的,所以我们通过不同的时区初始化 Calendar,得到了不同的时间。
对于同一个时间表示,比如 2020-01-02 22:00:00,不同时区的人转换成 Date会得到不同的时间(时间戳)
String stringDate = "2020-01-02 22:00:00";
SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 默认时区解析时间表示
Date date1 = inputFormat.parse(stringDate);
System.out.println(date1 + ":" + date1.getTime());
// 纽约时区解析时间表示
inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
Date date2 = inputFormat.parse(stringDate);
System.out.println(date2 + ":" + date2.getTime());
// 对于当前的上海时区和纽约时区,转化为 UTC 时间戳是不同的时间:
Thu Jan 02 22:00:00 CST 2020:1577973600000
Fri Jan 03 11:00:00 CST 2020:1578020400000
格式化后出现的错乱,即同一个 Date,在不同的时区下格式化得到不同的时间表示。比如,在我的当前时区和纽约时区格式化 2020-01-02 22:00:00:
String stringDate = "2020-01-02 22:00:00";
SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//同一Date
Date date = inputFormat.parse(stringDate);
//默认时区格式化输出:
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date));
//纽约时区格式化输出
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date));我当前时区的 Offset(时差)是 +8 小时,对于 -5 小时的纽约,晚上 10 点对应早上 9 点:
[2020-01-02 22:00:00 +0800]
[2020-01-02 09:00:00 -0500]
所以,要正确处理时区,在于存进去和读出来两方面:
存的时候,需要使用正确的当前时区来保存,这样 UTC 时间才会正确;
读的时候,也只有正确设置本地时区,才能把 UTC 时间转换为正确的当地时间。
Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime和 DateTimeFormatter
首先初始化上海、纽约和东京三个时区。我们可以使用 ZoneId.of 来初始化一个标准的时区,也可以使用 ZoneOffset.ofHours 通过一个 offset,来初始化一个具有指定时间差的自定义时区。
对于日期时间表示,LocalDateTime 不带有时区属性,所以命名为本地时区的日期时间;而 ZonedDateTime=LocalDateTime+ZoneId,具有时区属性。因此,LocalDateTime 只能认为是一个时间表示,ZonedDateTime 才是一个有效的时间。在这里我们把 2020-01-02 22:00:00 这个时间表示,使用东京时区来解析得到一个ZonedDateTime。
使用 DateTimeFormatter 格式化时间的时候,可以直接通过 withZone 方法直接设置格式化使用的时区。最后,分别以上海、纽约和东京三个时区来格式化这个时间输出:
//一个时间表示
String stringDate = "2020-01-02 22:00:00";
//初始化三个时区
ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai");
ZoneId timeZoneNY = ZoneId.of("America/New_York");
ZoneId timeZoneJST = ZoneOffset.ofHours(9);
//格式化器
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST);
//使用DateTimeFormatter格式化时间,可以通过withZone方法直接设置格式化使用的时区
DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z");
System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date));
System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date));
System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));
我推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示
日期时间格式化和解析
这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了?
Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
System.out.println("defaultLocale:" + Locale.getDefault());
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 29,0,0,0);
SimpleDateFormat YYYY = new SimpleDateFormat("YYYY-MM-dd");
System.out.println("格式化: " + YYYY.format(calendar.getTime()));
System.out.println("weekYear:" + calendar.getWeekYear());
System.out.println("firstDayOfWeek:" + calendar.getFirstDayOfWeek());
System.out.println("minimalDaysInFirstWeek:" + calendar.getMinimalDaysInFirstWeek());输出:
defaultLocale:zh_CN
格式化: 2020-12-29
weekYear:2020
firstDayOfWeek:1
minimalDaysInFirstWeek:1
这是因为:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。
一年第一周的判断方式是,从 getFirstDayOfWeek() 开始,完整的 7 天,并且包含那一年至少 getMinimalDaysInFirstWeek() 天。这个计算方式和区域相关,对于当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年。
没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非“Y”。
定义的 static 的 SimpleDateFormat 可能会出现线程安全问题
因此只能在同一个线程复用 SimpleDateFormat,比较好的解决方式是,通过 ThreadLocal 来存放 SimpleDateFormat:
private static ThreadLocal threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat(“yyyy-MM-dd HH:mm:ss”));
当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,还是能得到结果
String dateString = "20160901";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
System.out.println("result:" + dateFormat.parse(dateString));result:Mon Jan 01 00:00:00 CST 2091
输出了 2091 年 1 月 1 日,原因是把 0901 当成了月份,相当于 75 年。
对于 SimpleDateFormat 的这三个坑,我们使用 Java 8 中的 DateTimeFormatter 就可以避过去。
private static DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder().appendValue(ChronoField.YEAR) //年.appendLiteral("/").appendValue(ChronoField.MONTH_OF_YEAR) //月.appendLiteral("/").appendValue(ChronoField.DAY_OF_MONTH) //日.appendLiteral(" ").appendValue(ChronoField.HOUR_OF_DAY) //时.appendLiteral(":").appendValue(ChronoField.MINUTE_OF_HOUR) //分.appendLiteral(":").appendValue(ChronoField.SECOND_OF_MINUTE) //秒.appendLiteral(".").appendValue(ChronoField.MILLI_OF_SECOND) //毫秒.toFormatter();
日期时间的计算
有些开发计算30天后的时间,
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);一定要使用30L,不然会int会溢出。
还有更简单的方法。
对于 Java 8 之前的代码,我更建议使用 Calendar:
Calendar c = Calendar.getInstance();
c.setTime(new Date());
c.add(Calendar.DAY_OF_MONTH, 30);
System.out.println(c.getTime());
使用 Java 8 的日期时间类型,可以直接进行各种计算
LocalDateTime localDateTime = LocalDateTime.now();
System.out.println(localDateTime.plusDays(30));
对日期时间做计算操作,Java 8 日期时间 API 会比 Calendar 功能强大很多
减一天和加一天,以及减一个月和加一个月:
System.out.println(LocalDate.now()
.minus(Period.ofDays(1))
.plus(1, ChronoUnit.DAYS)
.minusMonths(1)
.plus(Period.ofMonths(1)));System.out.println("//本月的第一天");
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()));
System.out.println("//今年的程序员日");
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).plusDays(255));
System.out.println("//今天之前的一个周六");
System.out.println(LocalDate.now().with(TemporalAdjusters.previous(DayOfWeek.SATURDAY)));
System.out.println("//本月最后一个工作日");
System.out.println(LocalDate.now().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));
可以直接使用 lambda 表达式进行自定义的时间调整。比如,为当前时间增加 100天以内的随机天数:System.out.println(LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
自定义函数,判断指定日期是否是家庭成员的生日:
public static Boolean isFamilyBirthday(TemporalAccessor date) {int month = date.get(MONTH_OF_YEAR);int day = date.get(DAY_OF_MONTH);if (month == Month.FEBRUARY.getValue() && day == 17)return Boolean.TRUE;if (month == Month.SEPTEMBER.getValue() && day == 21)return Boolean.TRUE;if (month == Month.MAY.getValue() && day == 22)return Boolean.TRUE;return Boolean.FALSE;
}
使用 query 方法查询是否匹配条件:
System.out.println("//查询是否是今天要举办生日");
System.out.println(LocalDate.now().query(CommonMistakesApplication::isFamilyBirthday));
但计算两个日期差时可能会踩坑:Java 8 中有一个专门的类 Period 定义了日期间隔,通过 Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。
比如,计算 2019 年 12 月 12 日和 2019 年 10 月 1 日的日期间隔,很明显日期差是 2 个月零 11 天,但获取 getDays 方法得到的结果只是 11 天,而不是 72 天:
System.out.println("//计算日期差");
LocalDate today = LocalDate.of(2019, 12, 12);
LocalDate specifyDate = LocalDate.of(2019, 10, 1);
System.out.println(Period.between(specifyDate, today).getDays());
System.out.println(Period.between(specifyDate, today));
System.out.println(ChronoUnit.DAYS.between(specifyDate, today));
可以使用 ChronoUnit.DAYS.between 解决这个问题.