专栏首页做不甩锅的后端Java中的时间和日期(一):有关java时间的哪些坑

Java中的时间和日期(一):有关java时间的哪些坑

从一开始学习java到现在,我们都一直在使用java.util.Date这个对象来表示时间和日期。使用也很方便:

Date date = new Date();
System.out.println(date.toString());

这样很容易的就得到了一个基于当前时间的字符串输出:

Wed Aug 05 10:47:21 CST 2020

另外结合系统中的一些列日期的工具类,我们可以完成很多基于时间的操作。利用Calendar实现指定时间设置,通过SimpleDateFormat来实现日期的格式化等等。但是使用的过程中,经常会出现各种各样的错误。

1.容易混淆的日期格式字符串

如下例子所示,我们希望将2020-12-29日通过格式化输出:

Calendar calendar = Calendar.getInstance();
calendar.set(2020,Calendar.DECEMBER,29);
SimpleDateFormat format = new SimpleDateFormat("YYYY-MM-dd");

结果却不是我们希望的那样:

2021-12-29

结果变成了2021年。这是因为,大写的Y表示 Week year。即本周所在的年份。2020年12月29日位于2021年的第一周,那么自然时间就变成了2021年。

实际上应该用小写的y来表示。也就是说,这个时间格式字符串,大小写有不同的意义。月份是大写的MM,而不是小写的m。自然,这个情况在新版本的阿里规范中也有说明:

专门有两条进行说明,可见,这也是在日常编码过程中容易出BUG的地方。

2.static的SimpleDateFormat

这是一个非常著名的坑,问题在于,SimpleDateFormat并不是线程安全的。如果static在多线程场景下容易导致并发问题。我们可以用如下代码测试:

private final static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");

public static void main(String[] args) {

	ExecutorService pool = new ThreadPoolExecutor(10, 10,
			0L, TimeUnit.MILLISECONDS,new ArrayBlockingQueue<>(20));

	IntStream.range(0,10).forEach((i) -> {
		pool.execute(() -> {
			IntStream.range(0,20).forEach((j) -> {
				try {
					System.out.println(simpleDateFormat.parse("2020-08-05"));
				} catch (ParseException e) {
					e.printStackTrace();
				}
			});
		});
	});
}

创建一个10个线程的threadPool,之后,提交10个任务循环对simpleDateFormat对象 parse相同的时间。 可以看到结果中:

Wed Aug 05 00:00:00 CST 2020
Thu Sep 24 00:00:00 CST 2020
Wed Aug 05 00:00:00 CST 2020
Thu Sep 24 00:00:00 CST 2020
Wed Aug 05 00:00:00 CST 2020
Wed Aug 05 00:00:00 CST 2020
java.lang.NumberFormatException: For input string: "E.800088E22.800088E2"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$0(SimpleDateFormatTest.java:21)
	at java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:110)
	at java.util.stream.IntPipeline$Head.forEach(IntPipeline.java:559)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$1(SimpleDateFormatTest.java:19)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: "E.800088E22"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$0(SimpleDateFormatTest.java:21)
	at java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:110)
	at java.util.stream.IntPipeline$Head.forEach(IntPipeline.java:559)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$1(SimpleDateFormatTest.java:19)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: "E.800088E22"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$0(SimpleDateFormatTest.java:21)
	at java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:110)
	at java.util.stream.IntPipeline$Head.forEach(IntPipeline.java:559)
	at com.dhb.date.test.SimpleDateFormatTest.lambda$null$1(SimpleDateFormatTest.java:19)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
Sun Dec 05 00:00:00 CST 7999

有的地方转换时间日期变成了错误的值,有的地方还造成了异常。导致数据无法转换。造成这个问题的根源就是SimpleDateFormat不是线程安全的。 我们可以看看其源码: SimpleDateFormat 继承了DateFormat。parse对外都是使用的抽象类的方法。但是实际上有一个抽象方法:

public Date parse(String source) throws ParseException
{
    ParsePosition pos = new ParsePosition(0);
    Date result = parse(source, pos);
    if (pos.index == 0)
        throw new ParseException("Unparseable date: \"" + source + "\"" ,
            pos.errorIndex);
    return result;
}
    
public abstract Date parse(String source, ParsePosition pos);

这个抽象方法再有具体的实现类来实现。 而在这个具体的方法中,有一段关键的代码:

  Date parsedDate;
    try {
        parsedDate = calb.establish(calendar).getTime();
        // If the year value is ambiguous,
        // then the two-digit year == the default start year
        if (ambiguousYear[0]) {
            if (parsedDate.before(defaultCenturyStart)) {
                parsedDate = calb.addYear(100).establish(calendar).getTime();
            }
        }
    }

estahlish方法依赖于calendar这个成员变量:

    protected Calendar calendar;

但是不幸这个成员变量本身没有做任何保护措施。这就导致在多线程的情况下,第一个线程可能还没来得及处理完,第二个线程就将这个值就行了修改。这也是并发问题产生的根源。

    Calendar establish(Calendar cal) {
        boolean weekDate = isSet(WEEK_YEAR)
                            && field[WEEK_YEAR] > field[YEAR];
        if (weekDate && !cal.isWeekDateSupported()) {
            // Use YEAR instead
            if (!isSet(YEAR)) {
                set(YEAR, field[MAX_FIELD + WEEK_YEAR]);
            }
            weekDate = false;
        }

        cal.clear();
        // Set the fields from the min stamp to the max stamp so that
        // the field resolution works in the Calendar.
        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;
                }
            }
        }

可以看到,每次都会clear。这样会把之前的结果删除。这就导致了出现的各种错误。 对于这个情况,在阿里规范中也有约定:

建议配合ThreadLocal来实现我们期望的这个功能。另外最好是用jdk1.8中新提供的时间对象。 阿里建议的修改方式:

private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {
    @Override
    protected DateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};

在前面对ThreadLocal进行过讨论,我们知道实际上threadLocal的内容在不需要的时候最好是remove。因此如果是jdk1.8的环境,那么我们最好是用jdk新提供的日期工具。后面将专门对这些类进行介绍。

3.格式字符串不匹配的坑

对于SimpleDateFormat,最隐蔽的问题还不是因为格式字符串出错或者线程安全问题。这两类问题都较容易发现。但是还有一类问题,如果出现在生产环境中,将会导致严重问题:

public static void main(String[] args) throws Exception{
	SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
	String dataStr = "20200850";
	System.out.println(format.parse(dataStr));
	dataStr = "202008050";
	System.out.println(format.parse(dataStr));
}

可以看到,我们定义了yyyyMMdd的日期格式字符串,来进行转换,但是我们这个日期格式是有问题的。8月份没有50天。则SimpleDateFormat识别到了dd对应的是50,则在之前2020年8月的基础上加上了50天,这就变成了:

Sat Sep 19 00:00:00 CST 2020
Sat Sep 19 00:00:00 CST 2020

而且更严重的是后面这个错误,SimpleDateFormat不会报错。如果我们在生产环境中处理对生产的数据进行处理的话,这种情况将会非常隐蔽的导致我们处理的结果出错。

4.讨厌的日期计算

个人觉得,涉及到java.util.Date的日期计算绝对不是一个令人可以愉快写代码的事情。如下,如果把一个日期增加30天:

Date date = new Date(System.currentTimeMillis() + 30*24*60*60*1000);
System.out.println(date);

想当然的用这种方式,肯定会得不到想要的结果:

Thu Jul 16 23:35:10 CST 2020

这样会因为后面的int类型时间计算溢出而得不到想要的结果。如果变成long可以解决这个问题,但是并不是一个好办法,还是得用Colander来解决。

Date date1 = new Date(System.currentTimeMillis() + 30L*24*60*60*1000);
System.out.println(date1);
Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
calendar.add(Calendar.DAY_OF_MONTH,30);
System.out.println(calendar.getTime());

这两种方式都能得到相同的结果:

Fri Sep 04 16:47:28 CST 2020
Fri Sep 04 16:47:28 CST 2020

当然,最好的解决办法还是用java8中新引入的时间工具类。这个在后面详细介绍。

5.阿里规范的其他约定

在阿里规范中,除了本文上述问题,还有如下问题:

  • 获取当前毫秒数,用System.currentTimeMillis();

这是因为,new Date()的源码中:

  /**
     * Allocates a <code>Date</code> object and initializes it so that
     * it represents the time at which it was allocated, measured to the
     * nearest millisecond.
     *
     * @see     java.lang.System#currentTimeMillis()
     */
    public Date() {
        this(System.currentTimeMillis());
    }

实际上也是System.currentTimeMillis(),因此new Date会带来额外的内存开销。

  • 不允许在程序任何地方中使用:1)java.sql.Date。 2)java.sql.Time。3)java.sql.Timestamp 对于这几个类,我们一般也接触得比较少,阿里规范是不建议使用的。
  • 关于年、月的天数,不应该在程序中固定,应该通过Calendar计算
  • 使用枚举值来指代月份。如果使用数字,注意Date,Calendar等日期相关类的月份month取值在0-11之间。 在Calendar中,月份是从0开始计数的。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • ArrayList源码分析(基于jdk1.8)(二):subList陷阱补充

    关于ArrayList的subList方法,还会出现另外一个问题就是强引用释放问题。看如下案例:

    冬天里的懒猫
  • java异常体系及1.7中的try-with-resources

    异常指java运行过程出现的错误,在java中,将异常当作对象来处理,java.lang.Throwable是所有异常的超类。其架构如下图:

    冬天里的懒猫
  • 关于禁止使用Executors创建线程池的分析

    线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风...

    冬天里的懒猫
  • java入门

    一、java的运行机制 高级语言的运行机制主要有编译型和解释型两种。 C/C++属于编译型语言,由专门的编译器针对特定的操作系统平台进行翻译,生成可执行代码,特...

    用户1215536
  • 第43节:Java学前要点

    学习Java,有人推荐去培训,有人说没用,其实有钱的,不知道如何学,或者逼不得已去的就可以,也有人自己为了不花这些钱,而选择自学,我觉得也行。

    达达前端
  • 常用的类,包,接口,各5个

    MickyInvQ
  • java(一)基础知识

    绝命生
  • 「大学生学编程系列」如何学习java?

    java目前在编程语言排行中还是稳稳的第一名,生态链系统越来越稳健,java语言已经慢慢步入成熟期,随之带来的是就业门槛的提升,这也是编程发展的一个趋势,未来编...

    程序员互动联盟
  • [有人@我]你的免费10G+Java课程还未领取

    java作为最热门的编程语言,它无处不在。目前全球有着数10亿的设备正在运行着java,全球80%的服务器程序都是用它编写,用以处理每天超过5000w+的数据。

    Java团长
  • Serializable接口心得总结

    可以看到该类的内部实现完全为空,在Java IO体系中仅起一个标记的作用。那么这个标记具体是如何发挥作用的呢?我们测试一下:

    Remember_Ray

扫码关注云+社区

领取腾讯云代金券