使用 Date 和 SimpleDateFormat 会遇到的坑

1,123 阅读2分钟

坑爹的年月日

我们从 Date 对象中获取年月日,代码如下。

val date = Date()

println(date) // Wed Mar 30 17:12:34 CST 2022
println(date.year) // 122
println(date.month) // 2
println(date.date) // 30

what?年份是 122 年,这什么鬼?月份返回 2,明明是三月呀,这又是什么鬼?还好 30 号这个日期是对的。

让我们来看一下 getMonth()getYear() 这两个方法的 Javadoc。

/**
  * Returns a number representing the month that contains or begins with the instant in 
  * time represented by this Date object. The value returned is between 0 and 11, with
  * the value 0 representing January.
 */
public int getMonth() {
    return normalize().getMonth() - 1; // adjust 1-based to 0-based
}

/**
  * Returns a value that is the result of subtracting 1900 from the year that contains 
  * or begins with the instant in time represented by this Date object, as interpreted 
  * in the local time zone.
 */
public int getYear() {
    return normalize().getYear() - 1900;
}

尼玛,原来 2022 - 1900 = 122是这么来的。月份,竟然从0开始,这是学的谁呢?

初始化日期时间问题

如果要初始化一个 2021 年 12 月 31 日 12 点 23 分 59 秒这样的时间,可以使用下面的代码吗?

val date = Date(2021, 12, 31, 12, 23, 59)
println(date) // Tue Jan 31 12:23:59 CST 3922

可以看到,输出的时间是 3922 年 1 月 31 日 12 点 23 分 59 秒。

于是,我们再来看看 Date(year, month, date, hrs, min, sec) 这个方法的 Javadoc。

/**
  * Allocates a Date object and initializes it so that it represents the instant at the 
  * start of the second specified by the year, month, date, hrs, min, and sec arguments 
  * in the local time zone.
*/
public Date(int year, int month, int date, int hrs, int min, int sec) {
    int y = year + 1900;
    // month is 0-based. So we have to normalize month to support Long.MAX_VALUE.
    if (month >= 12) {
        y += month / 12;
        month %= 12;
    } else if (month < 0) {
        y += CalendarUtils.floorDivide(month, 12);
        month = CalendarUtils.mod(month, 12);
    }
    BaseCalendar cal = getCalendarSystem(y);
    cdate = (BaseCalendar.Date) cal.newCalendarDate(TimeZone.getDefaultRef());
    cdate.setNormalizedDate(y, month + 1, date).setTimeOfDay(hrs, min, sec, 0);
    getTimeImpl();
    cdate = null;
}

方法的注释并没有告诉我们:为什么年份返回 3922 年? 为什么月份返回 1 月?

于是看方法实现,得到了如下的一个代码逻辑:

image.png

原来,3922 年 1 月 31 日 12 点 23 分 59 秒 是这么来的。

所以,正确的初始化应该是:

val date = Date(2021 - 1900, 12 - 1, 31, 23, 59)

哎,代码也太不易读了。

时区问题

因为日期时间的特殊性,不同的国家地区在同一时刻(时间戳)显示的日期时间应该是不一样的,但 Date 做不到,因为它底层代码是这样的:

image.png

也就是说它表示的是一个具体时刻(时间戳),这个数值放在全球任何地方都是一模一样的,也就是说 Date()System.currentTimeMillis() 没啥两样。

JDK 提供了 TimeZone 表示时区的概念,但它在 Date 里并无任何体现,只能使用在格式化器上,这种设计着实让人再一次看不懂了。

日期时间计算问题

关于日期时间的计算,有一个常踩的坑。就是直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 new Date().getTime() 方法得到的时间戳加 30 天对应的毫秒数,也就是 (30 天 * 24 小时 * 60 分钟 * 60 秒 * 1000 毫秒):

val now = Date()
val nextMonth = Date(now.time + 30 * 24 * 60 * 60 * 1000)
println(now) // Wed Mar 30 20:08:31 CST 2022
println(nextMonth) // Fri Mar 11 03:05:43 CST 2022

得到的日期居然比当前日期还要早,根本不是晚 30 天的时间。

出现这个问题,其实是因为 int 发生了溢出。修复方式就是把 1000 改为 1000L,让其成为一个 long:

val now = Date()
val nextMonth = Date(now.time + 30 * 24 * 60 * 60 * 1000L)
println(now) // Wed Mar 30 20:09:48 CST 2022
println(nextMonth) // Fri Apr 29 20:09:48 CST 2022

这样就可以得到正确结果了。不难发现,手动在时间戳上进行计算操作的方式非常容易出错。

使用 Java 8 的日期时间类型,可以直接进行各种计算,更加简洁和方便:

val localDateTime = LocalDateTime.now()
println(localDateTime.plusDays(30)) // 2022-04-29T20:11:39.432906

跨年问题

每到年底,就有很多开发同学踩时间格式化的坑,比如“这明明是一个 2021 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了”。我们来重现一下这个问题。

初始化一个 Calendar,设置日期时间为 2020 年 12 月 29 日,使用大写的 YYYY 来初始化 SimpleDateFormat

Locale.setDefault(Locale.SIMPLIFIED_CHINESE)
println(Locale.getDefault()) // zh_CN

val calendar = Calendar.getInstance()
calendar.set(2020, Calendar.DECEMBER, 29, 0, 0 ,0)
val simpleDateFormat = SimpleDateFormat("YYYY-MM-dd")

println(simpleDateFormat.format(calendar.time)) // 2021-12-29
println(calendar.weekYear) // 2021

出现这个问题的原因在于,这位同学混淆了 SimpleDateFormat 的各种格式化模式。JDK 的文档中有说明:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。

没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非 “Y”。

线程安全问题

定义的 staticSimpleDateFormat 可能会出现线程安全问题。比如像这样,使用一个 100 线程的线程池,循环 20 次把时间格式化任务提交到线程池处理,每个任务中又循环 10 次解析 2020-01-01 11:12:13 这样一个时间表示:

val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

val threadPool = Executors.newFixedThreadPool(100)
for (i in 0..19) {
    //提交20个并发解析时间的任务到线程池,模拟并发环境
    threadPool.execute {
        for (j in 0..9) {
            try {
                println(simpleDateFormat.parse("2020-01-01 11:12:13"))
            } catch (e: ParseException) {
                e.printStackTrace()
            }
        }
    }
}

threadPool.shutdown()
threadPool.awaitTermination(1, TimeUnit.HOURS)

运行程序后大量报错,且没有报错的输出结果也不正常,比如 2020 年解析成了 1111 年:

image.png

SimpleDateFormat 的作用是定义解析和格式化日期时间的模式。这,看起来这是一次性的工作,应该复用,但它的解析和格式化操作是非线程安全的。我们来分析一下相关源码:


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;
    }
}

通过源码我们可以发现:

  • SimpleDateFormat 继承了 DateFormatDateFormat 有一个字段 Calendar
  • SimpleDateFormat 的 parse 方法调用 CalendarBuilderestablish 方法,来构建 Calendar
  • establish 方法内部先清空 Calendar 再构建 Calendar,整个操作没有加锁。

显然,如果多线程池调用 parse 方法,也就意味着多线程在并发操作一个 Calendar,可能会产生一个线程还没来得及处理 Calendar 就被另一个线程清空了的情况。

format 方法也类似,可以自己分析。因此只能在同一个线程复用 SimpleDateFormat,比较好的解决方式是,通过 ThreadLocal 来存放 SimpleDateFormat

private static ThreadLocal<SimpleDateFormat> threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

时间格式不匹配问题

当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,还是能得到结果。比如,我们期望使用 yyyyMM 来解析 20210901 字符串:

val dateStr = "20210901"
val simpleDateFormat = SimpleDateFormat("yyyyMM")
println(simpleDateFormat.parse(dateStr)) // Sun Jan 01 00:00:00 CST 2096

居然输出了 2096 年 1 月 1 日,原因是把 0901 当成了月份,相当于 75 年。

对于上面说的 SimpleDateFormat 的这三个坑,我们使用 Java 8 中的 DateTimeFormatter 就可以都避过去。