一 前言
对于时间日期处理,平时工作开发当中相信不少人都接触过,经常用在对数据做筛选过滤操作,将服务端下发的时间转格式展示在界面上或者自定义日历控件等,在开发过程中,对于不熟悉这一块知识的同学来讲,经常会遇到一些问题,这些问题一部分是需求设计带来的,而绝大部分还是因为对时间日期处理方式不熟悉而产生的,在java8以前,我们只能通过查询文档了解原理来修复问题,而java8以后又推出了新的日期处理方式,很大程度上也方便了我们去做一些时间日期相关的操作。
二 熟悉又陌生的三剑客
月份是从0开始设置
首先,还是先回忆下我们之前是怎么处理时间日期的,常用的应该就是那三个类,Calendar,Date,SimpleDateFormat,用法也很简单,比如我想获取当天日期,我们一般会这样做
var date = Calendar.getInstance().time
var format = SimpleDateFormat("yyyy-MM-dd")
println(format.format(date))
-------------------------------
2022-12-21
基本所有日期操作都围绕着这三个类以及他们的api展开,还是比较容易上手的,可是有没有遇到过这些问题,比如我想获取今年五月份所有的订单列表,那肯定要传给服务端2022-05这样一个参数,那我们怎么去生成这样一个参数呢?
var date = Calendar.getInstance()
date.set(Calendar.MONTH,5)
var format = SimpleDateFormat("yyyy-MM")
println(format.format(date.time))
得到的结果是
2022-06
怎么回事,明明设置的是5啊,为什么输出的是6月?因为Calendar中维护着一个域(field)的数组,每一个域比如Calendar.MONTH就是这个数组的下标,每一个下标取值范围不同,我们看下Calendar.MONTH是什么取值范围
/**
* Field number for get and set indicating the
* month. This is a calendar-specific value. The first month of
* the year in the Gregorian and Julian calendars is
* JANUARY which is 0; the last depends on the number
* of months in a year.
*/
public final static int MONTH = 2;
原来如此,这个MONTH第一个月原来是从0开始算的,也就是0表示一月,1表示二月以此类推,所以上面那个例子,我们应该这样改,把5改成4就好了
date.set(Calendar.MONTH,4)
结果自然就得到了
2022-05
虽说这是Calendar类原本就是这么设计的,但是这个细节如果不知道的话,很容易在实际开发当中产生比较严重的bug。
只能格式化Date或者Number对象
我们发现,SimpleDateFormat里面format的重载方法中,可以传入Date也可以传入Any类型,意思是啥都能格式化?这么酷?那我们传个Calendar对象试试
var format = SimpleDateFormat("yyyy-MM")
println(format.format(Calendar.getInstance()))
结果一运行
果然,试试就是逝世,闪退了,看这报错日志是发生在DateFormat类里面
public final StringBuffer format(Object obj, StringBuffer toAppendTo,
FieldPosition fieldPosition)
{
if (obj instanceof Date)
return format( (Date)obj, toAppendTo, fieldPosition );
else if (obj instanceof Number)
return format( new Date(((Number)obj).longValue()),
toAppendTo, fieldPosition );
else
throw new IllegalArgumentException("Cannot format given Object as a Date");
}
很明显了,这个Any类型也不是很Any,必须是Date或者Number的子类,不然就直接抛出异常
非线程安全的parse方法
我们经常使用parse方法将一串日期时间字符串转换成Date对象,从而进行日期操作,但其实parse方法是非线程安全的,这边举个例子来看看
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
for(i in 0 until 15){
val thread = Thread {
println(format.parse("2022-09-23 15:22:12"))
}
thread.start()
}
对于同一个时间字符串,我们开十五个线程去对它解析并输出结果,结果会是什么呢?
可以看到,啥结果都有,为什么会造成这个情况出现呢,主要是因为SimpleDateFormat构造方法里面的initializeCalendar方法生成了一个唯一的Calendar
public SimpleDateFormat(String pattern, Locale locale)
{
if (pattern == null || locale == null) {
throw new NullPointerException();
}
initializeCalendar(locale);
this.pattern = pattern;
this.formatData = DateFormatSymbols.getInstanceRef(locale);
this.locale = locale;
initialize(locale);
}
private void initializeCalendar(Locale loc) {
if (calendar == null) {
assert loc != null;
calendar = Calendar.getInstance(TimeZone.getDefault(), loc);
}
}
然后看parse方法中
public Date parse(String text, ParsePosition pos) {
final TimeZone tz = getTimeZone();
try {
return parseInternal(text, pos);
} finally {
setTimeZone(tz);
}
}
追到parseInternal方法中去,里面一堆代码,咱主要看最后那一段代码
private Date parseInternal(String text, ParsePosition pos)
{
.........
Date parsedDate;
try {
parsedDate = calb.establish(calendar).getTime();
if (ambiguousYear[0]) {
if (parsedDate.before(defaultCenturyStart)) {
parsedDate = calb.addYear(100).establish(calendar).getTime();
}
}
}
catch (IllegalArgumentException e) {
pos.errorIndex = start;
pos.index = oldStart;
return null;
}
return parsedDate;
}
最后解析出来的Date是通过calb.establish(calendar).getTime()生成的,而这个入参calendar就是刚刚在构造方法中通过initializeCalendar方法生成的,是同一个对象,我们继续看establish方法
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;
}
}
}
if (weekDate) {
int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1;
int dayOfWeek = isSet(DAY_OF_WEEK) ?
field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) {
if (dayOfWeek >= 8) {
dayOfWeek--;
weekOfYear += dayOfWeek / 7;
dayOfWeek = (dayOfWeek % 7) + 1;
} else {
while (dayOfWeek <= 0) {
dayOfWeek += 7;
weekOfYear--;
}
}
dayOfWeek = toCalendarDayOfWeek(dayOfWeek);
}
cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
}
return cal;
}
可以看到在对calendar的域做赋值的时候,首先会先将calendar做clear操作,而这个clear操作就相当于是个重置功能
public final void clear()
{
for (int i = 0; i < fields.length; ) {
stamp[i] = fields[i] = 0; // UNSET == 0
isSet[i++] = false;
}
areAllFieldsSet = areFieldsSet = false;
isTimeSet = false;
}
这样就很明显,如果在单线程中,不会出问题,但如果在多线程里面,线程A正在establish这个calendar,而线程B给这个calendar来了个clear操作,那最终出来的结果肯定同预期的不一样.
三 尝试新的方法
说了那么多,我们看看java8里面对时间日期处理做了哪些改进,这边会通过一个个案例来介绍,要注意的是,java8里面对时间日期处理只能针对sdk大于等于26的平台上运行,所以这边会用新老方式都实现一遍,既做个兼容,也做个比较
获取当天日期
老方式
var date = Calendar.getInstance().time
var format = SimpleDateFormat("yyyy-MM-dd")
println(format.format(date))
新方式
println(LocalDate.now())
没错,就只有一行代码,LocalDate类只负责跟日期相关的操作,不会关心时间,时间有另外的类负责,直接调用静态方法now就能把当前日期打印出来,最后出来的结果如下
2022-12-22
获取具体年月日
老方式
val calendar = Calendar.getInstance()
val year = calendar[Calendar.YEAR]
val month = calendar[Calendar.MONTH]+1
val date = calendar[Calendar.DAY_OF_MONTH]
println("年:$year 月:$month 日:$date")
新方式
val today = LocalDate.now()
val year = today.year
val month = today.monthValue
val date = today.dayOfMonth
println("年:$year 月:$month 日:$date")
用老方式,月份那边得留个心眼,得加个1,不然获取的是上个月的月份,而新的方式里面,年月日都有自己的成员变量,获取哪个值可以从命名上很快就能找出来,最终获取的结果是
年:2022 月:12 日:22
获取某一年第几天的具体日期
假设有一个需求,给你年份,再给你个这一年的第几天,让你输出具体具体日期,该怎么做呢,我们用老方式试试
val totalMonth = intArrayOf(
31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31
)
var days = 234
val year = 2019
val calendar = Calendar.getInstance()
for (i in totalMonth.indices) {
days -= if (i == 1) {
if (year % 4 == 0) 29 else totalMonth[i]
} else {
totalMonth[i]
}
if (days <= 0) {
calendar.set(year, i, days+totalMonth[i])
break
}
}
println(SimpleDateFormat("yyyy-MM-dd").format(calendar.time))
思路大概就是,将每个月的最大天数都维护在一个数组当中,下标值当作月份,这一年的第几天当作days,遍历这个数组,每遍历一次,就把days减去这个最大天数,减完的值判断一下是否小于等于0,是就证明已经找到对应的月份,不是则继续减,至于日期,就把减完的值加上当前下标对应的天数就可以了,最终获取的结果是
2019-08-22
费了点脑细胞,可算得到结果了,但是那么长的一段逻辑代码,稍微一疏忽就会产生bug,再看看用LocalDate怎么去实现
println(LocalDate.ofYearDay(2019,234))
对,没了,又是一行代码的事情,用LocalDate里面的静态方法ofYearDay,传入年份跟天数,就得到具体日期,得到的结果很显然也是
2019-08-22
设置具体日期
在一些日历控件上,经常要把之前选过的日期重新带入进去,这个就需要去设置Calendar的日期,比如我想设置2022-12-25这个日期,那么我们用老方式怎么去设置呢
val date = Calendar.getInstance()
date.set(2022,11,25)
println(SimpleDateFormat("yyyy-MM-dd").format(date.time))
没错,月份那边记得减一,才会得到想要的值
2022-12-25
用新的方式又该怎么做呢
println(LocalDate.of(2022,12,25))
使用LocalDate的静态方法of,将年月日传进去,就得到了设置好的日期,很简单!结果同样也是
2022-12-25
判断日期是否相等
在日历上,会经常将今天,明天,后天的日期用中文"今天",“明天”,“后天”展示,代替原有的日期数字,那么判断哪一个日期同今天的日期相同这步操作就必不可少了,Calendar里面,有compareTo方法可以比较,我们看下怎么做的
val todayCalendar = Calendar.getInstance()
val someCalendar = Calendar.getInstance()
someCalendar.set(2019,2,21)
println(if(todayCalendar.compareTo(someCalendar) == 0) "同一天" else "不是同一天")
这个还是比较容易的,compareTo得到0就说明是同一天,我们在看下用LocalDate怎么做的
val today = LocalDate.now()
val someDay = LocalDate.of(2019, 2, 21)
println(if(today.equals(someDay)) "同一天" else "不是同一天")
LocalDate是重写了equals方法,在里面做了日期的比较
判断是否到了某个节日,生日或者纪念日
在日历上,我们还会经常看到,在一些日期上会标有节日的字样,这些日子每一年都会有,属于周期性的事件,所以要判断的话,年份就不能作为判断条件,只能判断月份和日期,假定出生日期是2009-2-21,要判断今天是不是生日,我们用之前的方式看看如何实现
val todayDate = Calendar.getInstance().time
val someCalendar = Calendar.getInstance()
someCalendar.set(2009,2,21)
val someDate = someCalendar.time
val todayDateStr = SimpleDateFormat("MM-dd").format(todayDate)
val someDateStr = SimpleDateFormat("MM-dd").format(someDate)
println(if(todayDateStr.equals(someDateStr)) "生日快乐" else "还没到日子")
---------------
还没到日子
基本就是个字符串比较的操作,我们换种方式,这里就要介绍JAVA8里的另一个类MonthDay,从命名上就能看出,MonthDay只关心月份与日期,不关心年,那么上述例子用MonthDay怎么处理呢
val today = LocalDate.now()
val birthday = LocalDate.of(2009, 12, 22)
val todayMonth = MonthDay.from(today)
val bMonth = MonthDay.of(birthday.monthValue, birthday.dayOfMonth)
println(if(todayMonth.equals(bMonth)) "生日快乐" else "还没到日子")
----------------
生日快乐
同之前的LocalDate一样,也是通过equals方法比较,这里生成MonthDay用到了两个静态方法,from接受一个LocalDate,转换成MonthDay,of方法传入具体月份与日期,生成MonthDay,同LocalDate里面的of方法用法基本一致
获取当前时间
我们看下以前是怎么去获取当前时间的
val date = Calendar.getInstance().time
println("当前时间为:${SimpleDateFormat("HH:mm:ss").format(date)}")
依然是用SimpleDateFormat去格式化Date,我们看下JAVA8以后还可以怎么做,之前介绍了LocalDate可以拿来获取当前日期,那么当前时间呢?对,LocalTime,咱不用看文档就知道类名了,命名还是有一定规律可寻的,我们看下LocalTime怎么获取当前时间的
println("当前时间为:${LocalTime.now()}")
依然还是now方法,获取当前时间,我们后面就能慢慢发现,虽然java8后面在时间日期处理上推出了不少类,但用法基本都很相似,你熟悉了一种,另外几种也都熟悉了
格式化
之前的例子中,用java8之前的方式去打印最终结果,都会经过一步格式化,而java8中的方式,不用格式化也能输出日期时间,但毕竟我们是搞前端的,控件上可不会接受LocalDate这样一个类型的数据去展示时间,那java8之后我们怎么格式化呢
val date = LocalDate.now()
println("格式化后的日期:${date.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"))}")
----------------
格式化后的日期:2022.12.22
使用format方法,里面传入一个DateTimeFormatter对象,使用静态方法ofPattern去生成,ofPattern中传入具体希望格式化的模版,除此之外,DateTimeFormatter还提供一套常用的内置模版,比如上述例子我们还可以这样写
val date = LocalDate.now()
println("格式化后的日期:${date.format(DateTimeFormatter.ISO_LOCAL_DATE)}")
----------------------
格式化后的日期:2022-12-22
修改时间
对于日期时间,我们除了经常会处理某一个点,还会处理一个区间,比如当前时间上加上几个小时,减去几分钟等等,我们以前是这样做的
val calendar = Calendar.getInstance()
calendar.add(Calendar.HOUR, 5)
println(SimpleDateFormat("HH:mm:ss").format(calendar.time))
--------------
19:48:46
这个就是在当前时间上加上五个小时,得到新的时间,那减去五个小时呢?不熟悉api的同学肯定在找minus,decrease之类的方法,可惜没有,只有一个add方法,其实加减操作是通过add方法第二个参数的正负来体现出来的,我们改下上面的例子来获取五个小时前的时间
val calendar = Calendar.getInstance()
calendar.add(Calendar.HOUR, -5)
println(SimpleDateFormat("HH:mm:ss").format(calendar.time))
--------------
09:53:22
这得亏是常量,如果是个变量,我还得拿个0去减一下获取个相反数呗?代码可读性就太差了,我们看看java8之后是怎么操作的
val time = LocalTime.now()
val plusTime = time.plusHours(5)
println(plusTime.format(DateTimeFormatter.ISO_LOCAL_TIME))
-----------
19:58:48.028
直接一个plusHours搞定,同样的,减去五个小时,也有对应的minusHours
val time = LocalTime.now()
val plusTime = time.minusHours(5)
println(plusTime.format(DateTimeFormatter.ISO_LOCAL_TIME))
--------------------
10:03:48.276
可以说职能划分的很明确了,我们不用去关心操作的是哪个域,也不用关心是加还是减,基本想要的操作,都有对应的方法
修改日期
刚刚说了时间的修改,那么日期怎么修改呢,老的api实现方式基本没啥区别,依然是add方法,比如输出当前日期减去五天的日期,这样子去实现
val date = Calendar.getInstance()
date.add(Calendar.DATE,-5)
println(SimpleDateFormat("yyyy-MM-dd").format(date.time))
----------------
2022-12-17
java8之后是这样子实现的
val date = LocalDate.now()
val minusDate = date.minusDays(5)
println("五天前是:${minusDate.format(DateTimeFormatter.ISO_LOCAL_DATE)}")
-----------------
五天前是:2022-12-17
日期也有对应方法去修改时间
计算两个日期之间相差的天数
一些电商平台肯定会有一些预售活动,经常提醒用户还剩几天活动才开始,这也就是两个日期互减得到的结果,在java8之前,我们基本会把两个日期换算成时间戳,然后相减获得区间值,然后在换算成天,代码这样实现
val todayStamp = Calendar.getInstance().time.time
val another = Calendar.getInstance()
another.set(2022,11,29)
val anotherStamp = another.time.time
val diff = anotherStamp - todayStamp
val days = diff / 1000 / 3600 / 24
println("相差${days}天")
-------------------
相差7天
而在java8之后,推出了一个新的类叫Period,专门用来计算两个点之间的区间是多大,上述例子我们再用Period来实现看看
val dateOne = LocalDate.now()
val dateTwo = LocalDate.of(2022, Month.DECEMBER, 29)
val period = Period.between(dateOne, dateTwo)
println("相差${period.days}天")
-------------------
相差7天
更加直观更加方便,不用去刻意转换到时间戳再去计算。但是如果你要计算跨月或者跨年的日期之间的天数,用Period就算不出来了,原因是Period会自动将超过一个月或者一年的天数自动换算成月或者年,我们看下面的例子
val dayOne = LocalDate.of(2022,11,12)
val dayTwo = LocalDate.of(2023,1,22)
val days = Period.between(dayOne,dayTwo)
println("相差${days.days}天")
不同年份,不同月份的日期进行比较,相差几天呢,结果是
相差10天
明显不可能只相差十天,我们顺便把年跟月打印出来就清楚了
println("相差${days.years}年,${days.months}月,${days.days}天")
....
相差0年,2月,10天
现在清楚了,相差两个月零10天,虽然某个方面来讲这个的确蛮方便的,但是如果需求就是要计算出具体天数,难道我们还要每个月加起来吗,当然不用,可以使用下面这个方法
val dayOne = LocalDate.of(2022,11,12)
val dayTwo = LocalDate.of(2023,1,22)
val days = dayTwo.toEpochDay() - dayOne.toEpochDay()
println("相差${days}天")
....
相差71天
结果就出来了,所以可以根据不同需求选取不同的方式
获取某个月的具体天数
由于一年十二个月每个月的天数都不一样,有三十天,有三十一天,二月闰年有29天平年有28天,所以当给你个年份给你个月份,还真是无法通过某一个方法马上得出这个月究竟有多少天,还得通过拿到这个月的头一天跟最后一天,然后相减得出,就像这样
val cal = Calendar.getInstance()
cal.set(Calendar.YEAR, 2020)
cal.set(Calendar.MONTH, 1)
cal.set(Calendar.DAY_OF_MONTH, 1)
val cal2 = Calendar.getInstance()
cal2.set(Calendar.YEAR, 2020)
cal2.set(Calendar.MONTH, 2)
cal2.set(Calendar.DAY_OF_MONTH, 0)
val dis = cal2.time.time - cal.time.time
val day = dis / 1000 / 3600 / 24 + 1
println("2020年2月有${day}天")
------------
2020年2月有29天
java8之后推出了一个新的类叫YearMonth,只关心年月,不关心日期,用这个就能获得某一年某一个月有几天,代码如下
val yearMonth = YearMonth.of(2020, Month.FEBRUARY)
println("2020年2月有${yearMonth.lengthOfMonth()}天")
---------
2020年2月有29天
又是一行代码就能搞定,轻松高效!
从这些例子上就能看出java8对于日期时间处理的细节问题上,还有时区问题上,做了很大一部分优化,之前我们需要花大量代码实现的问题,用新的api用几行甚至一行就能搞定,相信如果团队对代码行数不是特别卷的话,这些新的api的确是可以很受欢迎。
四 线程安全
我们在查看java.time包底下这些java8新推出的类的源码的时候,发现基本所有的类都会有这一段描述
This class is immutable and thread-safe.
不可变并且线程安全的,不可变很明显,所有类都是final的,线程安全体现在什么地方呢,我们这边也举个例子
val date = LocalDate.of(2022,1,1)
for(i in 0 until 15){
val dis = i+1L
val thread = Thread {
date.plusDays(dis)
println(date.format(DateTimeFormatter.ISO_LOCAL_DATE))
}
thread.start()
}
有这么一个日期,2022年1月1日,开启十五个线程,每个线程都修改这个日期,做增加天数的操作,然后将日期打印出来,看看打印出来的结果是啥
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
I 2022-01-01
怎么回事?明明加天数了,怎么感觉跟没加一样,出问题了?我们去plusDays方法里面看看
public LocalDate plusDays(long daysToAdd) {
if (daysToAdd == 0) {
return this;
}
long mjDay = Math.addExact(toEpochDay(), daysToAdd);
return LocalDate.ofEpochDay(mjDay);
}
toEpochDay()计算的是从1970-1-1到被加的日期总共的天数,mjDay是加上daysToAdd后的天数,得出值以后再入参给ofEpochDay方法,我们去看下ofEpochDay里面做了啥
public static LocalDate ofEpochDay(long epochDay) {
long zeroDay = epochDay + DAYS_0000_TO_1970;
......
// convert march-based values back to january-based
int marchMonth0 = (marchDoy0 * 5 + 2) / 153;
int month = (marchMonth0 + 2) % 12 + 1;
int dom = marchDoy0 - (marchMonth0 * 306 + 5) / 10 + 1;
yearEst += marchMonth0 / 10;
// check year now we are certain it is correct
int year = YEAR.checkValidIntValue(yearEst);
return new LocalDate(year, month, dom);
}
里面主要是把入参的天数加上1970年之前的天数,然后逐个换算成年月日,最后返回一个新的LocalDate,而每一个LocalDate里面维护的年月日都是不可变的
/**
* The year.
*/
private final int year;
/**
* The month-of-year.
*/
private final short month;
/**
* The day-of-month.
*/
private final short day;
private LocalDate(int year, int month, int dayOfMonth) {
this.year = year;
this.month = (short) month;
this.day = (short) dayOfMonth;
}
这样才可以保证不管有多少个线程访问这个LocalDate,它的年月日都不会被改变,任何修改的操作,只会生成新的LocalDate,我们回忆下Calendar,它就是因为里面field数组可以随意改动,所以才导致线程不安全。
所以知道了原理,那上面的例子如果想获取修改后的日期,我们可以这样修改
val date = LocalDate.of(2022,1,1)
for(i in 0 until 15){
val dis = i+1L
val thread = Thread {
val plusDate = date.plusDays(dis)
println(plusDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
}
thread.start()
}
最终打印出来的就是我们想要的
I 2022-01-02
I 2022-01-13
I 2022-01-07
I 2022-01-04
I 2022-01-05
I 2022-01-08
I 2022-01-06
I 2022-01-09
I 2022-01-14
I 2022-01-15
I 2022-01-16
I 2022-01-11
I 2022-01-10
I 2022-01-03
I 2022-01-12
五 总结
通过认识java8中java.time包底下的这些新的api,不管从性能,代码的可读性,类的职能划分还是实用操作,都比Calendar和Date这些老api做的要好,对于一些新项目来讲,完全可以用新的api去实现功能,但对于一些大项目或者年代比较久远的项目来讲,替换的成本还是比较高的,因为这些都牵涉到一定的业务功能实现,另外,新的api必须是sdk版本不低于26才能使用,一些低版本设备上,还是得用Calendar和Date这些老api,所以我个人还是比较推荐两者兼顾着使用。