JAVA8中新的日期时间处理方式到底香不香

307 阅读16分钟

一 前言

对于时间日期处理,平时工作开发当中相信不少人都接触过,经常用在对数据做筛选过滤操作,将服务端下发的时间转格式展示在界面上或者自定义日历控件等,在开发过程中,对于不熟悉这一块知识的同学来讲,经常会遇到一些问题,这些问题一部分是需求设计带来的,而绝大部分还是因为对时间日期处理方式不熟悉而产生的,在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()))

结果一运行 a1.png 果然,试试就是逝世,闪退了,看这报错日志是发生在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()
}

对于同一个时间字符串,我们开十五个线程去对它解析并输出结果,结果会是什么呢?

a2.png 可以看到,啥结果都有,为什么会造成这个情况出现呢,主要是因为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}天")
------------
20202月有29

java8之后推出了一个新的类叫YearMonth,只关心年月,不关心日期,用这个就能获得某一年某一个月有几天,代码如下

val yearMonth = YearMonth.of(2020, Month.FEBRUARY)
println("2020年2月有${yearMonth.lengthOfMonth()}天")
---------
20202月有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,所以我个人还是比较推荐两者兼顾着使用。