gitbook/Java业务开发常见错误100例/docs/224240.md
2022-09-03 22:05:03 +08:00

584 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑
你好,我是朱晔。今天,我来和你说说恼人的时间错乱问题。
在Java 8之前,我们处理日期时间需求时,使用Date、Calender和SimpleDateFormat,来声明时间戳、使用日历处理日期和格式化解析日期时间。但是,这些类的API的缺点比较明显,比如可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题。
因此,Java 8推出了新的日期时间类。每一个类功能明确清晰、类之间协作简单、API定义清晰不踩坑,API功能强大无需借助外部工具类即可完成操作,并且线程安全。
但是,Java 8刚推出的时候,诸如序列化、数据访问等类库都还不支持Java 8的日期时间类型,需要在新老类中来回转换。比如,在业务逻辑层使用LocalDateTime,存入数据库或者返回前端的时候还要切换回Date。因此,很多同学还是选择使用老的日期时间类。
现在几年时间过去了,几乎所有的类库都支持了新日期时间类型,使用起来也不会有来回切换等问题了。但,很多代码中因为还是用的遗留的日期时间类,因此出现了很多时间错乱的错误实践。比如,试图通过随意修改时区,使读取到的数据匹配当前时钟;再比如,试图直接对读取到的数据做加、减几个小时的操作,来“修正数据”。
今天,我就重点与你分析下时间错乱问题背后的原因,看看使用遗留的日期时间类,来处理日期时间初始化、格式化、解析、计算等可能会遇到的问题,以及如何使用新日期时间类来解决。
## 初始化日期时间
我们先从日期时间的初始化看起。如果要初始化一个2019年12月31日11点12分13秒这样的时间,可以使用下面的两行代码吗?
```
Date date = new Date(2019, 12, 31, 11, 12, 13);
System.out.println(date);
```
可以看到,输出的时间是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改造之后,初始化时年参数直接使用当前年即可,不过月需要注意是从0到11。当然,你也可以直接使用Calendar.DECEMBER来初始化月份,更不容易犯错。为了说明时区的问题,我分别使用当前时区和纽约时区初始化了两次相同的日期:
```
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);
```
我得到的是1970年1月1日8点。因为我机器当前的时区是中国上海,相比UTC时差+8小时:
```
Thu Jan 01 08:00:00 CST 1970
Asia/Shanghai:8
```
对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
* 方式一,以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());
```
可以看到,把2020-01-02 22:00:00这样的时间表示,对于当前的上海时区和纽约时区,转化为UTC时间戳是不同的时间:
```
Thu Jan 02 22:00:00 CST 2020:1577973600000
Fri Jan 03 11:00:00 CST 2020:1578020400000
```
这正是UTC的意义,并不是时间错乱。对于同一个本地时间的表示,不同时区的人解析得到的UTC时间一定是不同的,反过来不同的本地时间可能对应同一个UTC。
**第二类问题是**,格式化后出现的错乱,即同一个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时间才会正确;读的时候,也只有正确设置本地时区,才能把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));
```
可以看到,相同的时区,经过解析存进去和读出来的时间表示是一样的(比如最后一行);而对于不同的时区,比如上海和纽约,最后输出的本地时间不同。+9小时时区的晚上10点,对于上海是+8小时,所以上海本地时间是晚上9点;而对于纽约是-5小时,差14小时,所以是早上8点:
```
Asia/Shanghai2020-01-02 21:00:00 +0800
America/New_York2020-01-02 08:00:00 -0500
+09:002020-01-02 22:00:00 +0900
```
到这里,我来小结下。要正确处理国际化时间问题,我推荐使用Java 8的日期时间类,即使用ZonedDateTime保存时间,然后使用设置了ZoneId的DateTimeFormatter配合ZonedDateTime进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。
接下来,我们继续看看对于日期时间的格式化和解析,使用遗留的SimpleDateFormat,会遇到哪些问题。
## 日期时间格式化和解析
每到年底,就有很多开发同学踩时间格式化的坑,比如“这明明是一个2019年的日期,**怎么使用SimpleDateFormat格式化后就提前跨年了**”。我们来重现一下这个问题。
初始化一个Calendar,设置日期时间为2019年12月29日,使用大写的YYYY来初始化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());
```
得到的输出却是2020年12月29日:
```
defaultLocale:zh_CN
格式化: 2020-12-29
weekYear:2020
firstDayOfWeek:1
minimalDaysInFirstWeek:1
```
出现这个问题的原因在于,这位同学混淆了SimpleDateFormat的各种格式化模式。JDK的[文档](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)中有说明:小写y是年,而大写Y是week year,也就是所在的周属于哪一年。
一年第一周的判断方式是,从getFirstDayOfWeek()开始,完整的7天,并且包含那一年至少getMinimalDaysInFirstWeek()天。这个计算方式和区域相关,对于当前zh\_CN区域来说,2020年第一周的条件是,从周日开始的完整7天,2020年包含1天即可。显然,2019年12月29日周日到2020年1月4日周六是2020年第一周,得出的week year就是2020年。
如果把区域改为法国:
```
Locale.setDefault(Locale.FRANCE);
```
那么week yeay就还是2019年,因为一周的第一天从周一开始算,2020年的第一周是2019年12月30日周一开始,29日还是属于去年:
```
defaultLocale:fr_FR
格式化: 2019-12-29
weekYear:2019
firstDayOfWeek:2
minimalDaysInFirstWeek:4
```
这个案例告诉我们,没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非 “Y”。
除了格式化表达式容易踩坑外,SimpleDateFormat还有两个著名的坑。
第一个坑是,**定义的static的SimpleDateFormat可能会出现线程安全问题。**比如像这样,使用一个100线程的线程池,循环20次把时间格式化任务提交到线程池处理,每个任务中又循环10次解析2020-01-01 11:12:13这样一个时间表示:
```
ExecutorService threadPool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 20; i++) {
//提交20个并发解析时间的任务到线程池,模拟并发环境
threadPool.execute(() -> {
for (int j = 0; j < 10; j++) {
try {
System.out.println(simpleDateFormat.parse("2020-01-01 11:12:13"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
}
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
```
运行程序后大量报错,且没有报错的输出结果也不正常,比如2020年解析成了1212年:
![](https://static001.geekbang.org/resource/image/3e/27/3ee2e923b3cf4e13722b7b0773de1b27.png)
SimpleDateFormat的作用是定义解析和格式化日期时间的模式。这,看起来这是一次性的工作,应该复用,但它的解析和格式化操作是非线程安全的。我们来分析一下相关源码:
* SimpleDateFormat继承了DateFormat,DateFormat有一个字段Calendar;
* SimpleDateFormat的parse方法调用CalendarBuilder的establish方法,来构建Calendar;
* establish方法内部先清空Calendar再构建Calendar,整个操作没有加锁。
显然,如果多线程池调用parse方法,也就意味着多线程在并发操作一个Calendar,可能会产生一个线程还没来得及处理Calendar就被另一个线程清空了的情况:
```
public abstract class DateFormat extends Format {
protected Calendar calendar;
}
public class SimpleDateFormat extends DateFormat {
@Override
public Date parse(String text, ParsePosition pos)
{
CalendarBuilder calb = new CalendarBuilder();
parsedDate = calb.establish(calendar).getTime();
return parsedDate;
}
}
class CalendarBuilder {
Calendar establish(Calendar cal) {
...
cal.clear();//清空
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
for (int index = 0; index <= maxFieldIndex; index++) {
if (field[index] == stamp) {
cal.set(index, field[MAX_FIELD + index]);//构建
break;
}
}
}
return cal;
}
}
```
format方法也类似,你可以自己分析。因此只能在同一个线程复用SimpleDateFormat,比较好的解决方式是,通过ThreadLocal来存放SimpleDateFormat:
```
private static ThreadLocal<SimpleDateFormat> threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
```
第二个坑是,**当需要解析的字符串和格式不匹配的时候,SimpleDateFormat表现得很宽容**,还是能得到结果。比如,我们期望使用yyyyMM来解析20160901字符串:
```
String dateString = "20160901";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
System.out.println("result:" + dateFormat.parse(dateString));
```
居然输出了2091年1月1日,原因是把0901当成了月份,相当于75年:
```
result:Mon Jan 01 00:00:00 CST 2091
```
对于SimpleDateFormat的这三个坑,我们使用Java 8中的DateTimeFormatter就可以避过去。首先,使用DateTimeFormatterBuilder来定义格式化字符串,不用去记忆使用大写的Y还是小写的Y,大写的M还是小写的m:
```
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();
```
其次,DateTimeFormatter是线程安全的,可以定义为static使用;最后,DateTimeFormatter的解析比较严格,需要解析的字符串和格式不匹配时,会直接报错,而不会把0901解析为月份。我们测试一下:
```
//使用刚才定义的DateTimeFormatterBuilder构建的DateTimeFormatter来解析这个时间
LocalDateTime localDateTime = LocalDateTime.parse("2020/1/2 12:34:56.789", dateTimeFormatter);
//解析成功
System.out.println(localDateTime.format(dateTimeFormatter));
//使用yyyyMM格式解析20160901是否可以成功呢?
String dt = "20160901";
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMM");
System.out.println("result:" + dateTimeFormatter.parse(dt));
```
输出日志如下:
```
2020/1/2 12:34:56.789
Exception in thread "main" java.time.format.DateTimeParseException: Text '20160901' could not be parsed at index 0
at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949)
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1777)
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.better(CommonMistakesApplication.java:80)
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
```
到这里我们可以发现,使用Java 8中的DateTimeFormatter进行日期时间的格式化和解析,显然更让人放心。那么,对于日期时间的运算,使用Java 8中的日期时间类会不会更简单呢?
## 日期时间的计算
关于日期时间的计算,我先和你说一个常踩的坑。有些同学喜欢直接使用时间戳进行时间计算,比如希望得到当前时间之后30天的时间,会这么写代码:直接把new Date().getTime方法得到的时间戳加30天对应的毫秒数,也就是30天\*1000毫秒\*3600秒\*24小时:
```
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
```
得到的日期居然比当前日期还要早,根本不是晚30天的时间:
```
Sat Feb 01 14:17:41 CST 2020
Sun Jan 12 21:14:54 CST 2020
```
出现这个问题,**其实是因为int发生了溢出**。修复方式就是把30改为30L,让其成为一个long:
```
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
```
这样就可以得到正确结果了:
```
Sat Feb 01 14:17:41 CST 2020
Mon Mar 02 14:17:41 CST 2020
```
不难发现,手动在时间戳上进行计算操作的方式非常容易出错。对于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功能强大很多**。
第一,可以使用各种minus和plus方法直接对日期进行加减操作,比如如下代码实现了减一天和加一天,以及减一个月和加一个月:
```
System.out.println("//测试操作日期");
System.out.println(LocalDate.now()
.minus(Period.ofDays(1))
.plus(1, ChronoUnit.DAYS)
.minusMonths(1)
.plus(Period.ofMonths(1)));
```
可以得到:
```
//测试操作日期
2020-02-01
```
第二,还可以通过with方法进行快捷时间调节,比如:
* 使用TemporalAdjusters.firstDayOfMonth得到当前月的第一天;
* 使用TemporalAdjusters.firstDayOfYear()得到当前年的第一天;
* 使用TemporalAdjusters.previous(DayOfWeek.SATURDAY)得到上一个周六;
* 使用TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)得到本月最后一个周五。
```
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)));
```
输出如下:
```
//本月的第一天
2020-02-01
//今年的程序员日
2020-09-12
//今天之前的一个周六
2020-01-25
//本月最后一个工作日
2020-02-28
```
第三,可以直接使用lambda表达式进行自定义的时间调整。比如,为当前时间增加100天以内的随机天数:
```
System.out.println(LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
```
得到:
```
2020-03-15
```
除了计算外,还可以判断日期是否符合某个条件。比如,自定义函数,判断指定日期是否是家庭成员的生日:
```
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操作和计算日期时间虽然方便,但计算两个日期差时可能会踩坑:**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解决这个问题:
```
//计算日期差
11
P2M11D
72
```
从日期时间的时区到格式化再到计算,你是不是体会到Java 8日期时间类的强大了呢?
## 重点回顾
今天,我和你一起看了日期时间的初始化、时区、格式化、解析和计算的问题。我们看到,使用Java 8中的日期时间包Java.time的类进行各种操作,会比使用遗留的Date、Calender和SimpleDateFormat更简单、清晰,功能也更丰富、坑也比较少。
如果有条件的话,我还是建议全面改为使用Java 8的日期时间类型。我把Java 8前后的日期时间类型,汇总到了一张思维导图上,图中箭头代表的是新老类型在概念上等价的类型:
![](https://static001.geekbang.org/resource/image/22/33/225d00087f500dbdf5e666e58ead1433.png)
这里有个误区是,认为java.util.Date类似于新API中的LocalDateTime。其实不是,虽然它们都没有时区概念,但java.util.Date类是因为使用UTC表示,所以没有时区概念,其本质是时间戳;而LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一个时间点。
因此,在把Date转换为LocalDateTime的时候,需要通过Date的toInstant方法得到一个UTC时间戳进行转换,并需要提供当前的时区,这样才能把UTC时间转换为本地日期时间(的表示)。反过来,把LocalDateTime的时间表示转换为Date时,也需要提供时区,用于指定是哪个时区的时间表示,也就是先通过atZone方法把LocalDateTime转换为ZonedDateTime,然后才能获得UTC时间戳:
```
Date in = new Date();
LocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault());
Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
```
很多同学说使用新API很麻烦,还需要考虑时区的概念,一点都不简洁。但我通过这篇文章要和你说的是,并不是因为API需要设计得这么繁琐,而是UTC时间要变为当地时间,必须考虑时区。
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 我今天多次强调Date是一个时间戳,是UTC时间、没有时区概念,为什么调用其toString方法会输出类似CST之类的时区字样呢?
2. 日期时间数据始终要保存到数据库中,MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗,它们是否包含时区信息呢?
对于日期和时间,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。