美文网首页Java
Java中的时间和日期(一):有关java时间的哪些坑

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

作者: 冬天里的懒喵 | 来源:发表于2020-08-16 09:10 被阅读0次

    @[toc]
    从一开始学习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年。


    image.png

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


    image.png

    专门有两条进行说明,可见,这也是在日常编码过程中容易出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。这样会把之前的结果删除。这就导致了出现的各种错误。
    对于这个情况,在阿里规范中也有约定:


    image.png

    建议配合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();


      image.png

    这是因为,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
      对于这几个类,我们一般也接触得比较少,阿里规范是不建议使用的。


      image.png
    • 关于年、月的天数,不应该在程序中固定,应该通过Calendar计算


      image.png
    • 使用枚举值来指代月份。如果使用数字,注意Date,Calendar等日期相关类的月份month取值在0-11之间。
      在Calendar中,月份是从0开始计数的。


      image.png

    相关文章

      网友评论

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

        本文链接:https://www.haomeiwen.com/subject/hvqtjktx.html