Java Review - SimpleDateFormat线程不安全原因的源码分析及解决办法

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: Java Review - SimpleDateFormat线程不安全原因的源码分析及解决办法

195d03d17afc4a928bc581f313b01dfe.png


概述


SimpleDateFormat是Java提供的一个格式化和解析日期的工具类,在日常开发中经常会用到,但是由于它是线程不安全的,所以多线程共用一个SimpleDateFormat实例对日期进行解析或者格式化会导致程序出错。


这里来揭示它为何是线程不安全的,以及如何避免该问题。


复现问题


import java.text.ParseException;
import java.text.SimpleDateFormat; 
/**
 * @author 小工匠
 * @version 1.0
 * @description: TODO
 * @date 2021/11/21 14:56
 * @mark: show me the code , change the world
 */
public class SimpleDateFormatTest {
    // 1 创建单例实例
    private static SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd HH:mm:ss");
    public static void main(String[] args) {
        // 2 开启多个线程,并且欧东
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                try {
                    // 3 使用单例日期解析文本
                    System.out.println(sdf.parse("2021-11-19 15:15:00"));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
    }
}

代码(1)创建了SimpleDateFormat的一个实例

代码(2)创建10个线程,每个线程都共用同一个sdf对象对文本日期进行解析。

多运行几次代码就会抛出java.lang.NumberFormatException异常,增加线程的个数有利于复现该问题

Exception in thread "Thread-0" Exception in thread "Thread-2" Exception in thread "Thread-1" Exception in thread "Thread-6" Exception in thread "Thread-4" Exception in thread "Thread-8" Exception in thread "Thread-9" Exception in thread "Thread-5" Exception in thread "Thread-7" java.lang.NumberFormatException: For input string: ""
  at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
  at java.lang.Long.parseLong(Long.java:601)
  at java.lang.Long.parseLong(Long.java:631)
  at java.text.DigitList.getLong(DigitList.java:195)
  at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
  at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
  at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  at java.text.DateFormat.parse(DateFormat.java:364)
  at com.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.ArrayIndexOutOfBoundsException: 20
  at java.text.DigitList.append(DigitList.java:151)
  at java.text.DecimalFormat.subparse(DecimalFormat.java:2278)
  at java.text.DecimalFormat.parse(DecimalFormat.java:2036)
  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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: multiple points
  at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
  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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: ""
  at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
  at java.lang.Long.parseLong(Long.java:601)
  at java.lang.Long.parseLong(Long.java:631)
  at java.text.DigitList.getLong(DigitList.java:195)
  at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
  at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
  at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  at java.text.DateFormat.parse(DateFormat.java:364)
  at com.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: empty String
  at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
  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:2162)
  at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  at java.text.DateFormat.parse(DateFormat.java:364)
  at com.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: empty String
  at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
  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:2162)
  at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  at java.text.DateFormat.parse(DateFormat.java:364)
  at com.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.ArrayIndexOutOfBoundsException: 19
  at java.text.DigitList.append(DigitList.java:151)
  at java.text.DecimalFormat.subparse(DecimalFormat.java:2278)
  at java.text.DecimalFormat.parse(DecimalFormat.java:2036)
  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.artisan.bfzm.chapter11.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:26)
  at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: multiple points
  at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
  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 
  .....


源码分析


为了便于分析,首先来看SimpleDateFormat的类图结构


f08d4a1d06124149b0bf54115ff3fd04.png


可以看到,每个SimpleDateFormat实例里面都有一个Calendar对象,后面我们就会知道,SimpleDateFormat之所以是线程不安全的,就是因为Calendar是线程不安全的。


Calendar之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如fields、time等。


下面从代码层面来看下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;
    }
  @Override
    public Date parse(String text, ParsePosition pos)
    {
        .......
    // 1 解析日期字符串,将解析好的数据放入CalendarBuilder对象中
        CalendarBuilder calb = new CalendarBuilder();
         .......
         .......
    Date parsedDate;
        try {
          // 2 使用calb中解析好的日期数据设置calendar
            parsedDate = calb.establish(calendar).getTime();
        } 
        catch (IllegalArgumentException e) {
            .......
          .......
            return null;
        }
     .......
         .......
        return parsedDate;
    }
  • 代码(1)的主要作用是解析日期字符串并把解析好的数据放入 CalendarBuilder的实例calb中。CalendarBuilder是一个建造者模式,用来存放后面需要的数据。
  • 代码(2)使用calb中解析好的日期数据设置calendar。


calb.establish的代码如下

    Calendar establish(Calendar cal) {
        .....
    // 3 重置日期对象cal的属性值
        cal.clear();
     // 4 使用calb中的属性设置cal
        // 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;
                }
            }
        }
        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);
        }
    // 5 返回cal
        return cal;
    }
  • 代码(3)重置Calendar对象里面的属性值,如下所示。
  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;
    }


  • 代码(4)使用calb中解析好的日期数据设置cal对象。
  • 代码(5) 返回设置好的cal对象。


从以上代码可以看出,代码(3)、代码(4)和代码(5)并不是原子性操作。


当多个线程调用parse 方法时,比如线程A执行了代码(3)和代码(4),也就是设置好了cal对象,但是在执行代码(5)之前,线程B执行了代码(3),清空了cal对象。


由于多个线程使用的是一个cal对象,所以线程A执行代码(5)返回的可能就是被线程B清空的对象,当然也有可能线程B执行了代码(4),设置被线程A修改的cal对象,从而导致程序出现错误。


How to Fix ?

每次使用时new一个SimpleDateFormat的实例


每次使用时new一个SimpleDateFormat的实例,这样可以保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其他引用,又需要回收,开销会很大。


加锁


出错的根本原因是因为多线程下代码(3)、代码(4)和代码(5)三个步骤不是一个原子性操作,那么容易想到的是对它们进行同步,让代码(3)、代码(4)和代码(5)成为原子性操作。

可以使用synchronized进行同步,具体如下。


f57c96c27a1841eba8f35eebdbf58cb0.png

进行同步意味着多个线程要竞争锁,在高并发场景下这会导致系统响应性能下降。


使用ThreadLocal


使用ThreadLocal,这样每个线程只需要使用一个SimpleDateFormat实例,这相比第一种方式大大节省了对象的创建销毁开销,并且不需要使多个线程同步。


4654c4229b1f48f49479c713366060b8.png


使用ThreadLocal方式的代码如下

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
/**
 * @author 小工匠
 * @version 1.0
 * @description: TODO
 * @date 2021/11/21 14:56
 * @mark: show me the code , change the world
 */
public class SimpleDateFormatTest {
    // 1 创建ThreadLocal实例
    static ThreadLocal<DateFormat> threadLocal= ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
//    static ThreadLocal<DateFormat> threadLocal2 = new ThreadLocal<DateFormat>(){
//        @Override
//        protected DateFormat initialValue() {
//            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//        }
//    };
    public static void main(String[] args) {
        // 2 开启多个线程,并且欧东
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                try {
                    // 3 使用单例日期解析文本
                    System.out.println(threadLocal.get().parse("2021-11-19 15:15:00"));
                } catch (ParseException e) {
                    e.printStackTrace();
                }finally {
                    // 4 使用完毕,一定要remove
                    threadLocal.remove();
                }
            });
            thread.start();
        }
    }
}


代码(1)创建了一个线程安全的SimpleDateFormat实例


代码(3)首先使用get()方法获取当前线程下SimpleDateFormat的实例。在第一次调用ThreadLocal的get()方法时,会触发其initialValue方法创建当前线程所需要的SimpleDateFormat对象。


另外需要注意的是,在代码(4)中,使用完线程变量后,要进行清理,以避免内存泄漏。


换API - JodaTime or JDK1.8的时间类


Java 8的日期和时间类包含LocalDate、LocalTime、Instant、Duration以及Period,这些类都包含在java.time包中.

新的日期API中提供了一个DateTimeFormatter类用于处理日期格式化操作,它被包含在java.time.format包中,Java 8的日期类有一个format()方法用于将日期格式化为字符串,该方法接收一个DateTimeFormatter类型参数.

    public static void main(String[] args) {
        // 2 开启多个线程,
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                // 3 使用单例日期解析文本
                System.out.println(LocalDateTime.parse("2021-11-19 15:15:00", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            });
            thread.start();
        }
    }

小结


我们这里简单介绍SimpleDateFormat的原理解释了为何SimpleDateFormat是线程不安全的,应该避免在多线程下使用SimpleDateFormat的单个实例

相关文章
|
1天前
|
SQL 安全 Java
安全问题已经成为软件开发中不可忽视的重要议题。对于使用Java语言开发的应用程序来说,安全性更是至关重要
在当今网络环境下,Java应用的安全性至关重要。本文深入探讨了Java安全编程的最佳实践,包括代码审查、输入验证、输出编码、访问控制和加密技术等,帮助开发者构建安全可靠的应用。通过掌握相关技术和工具,开发者可以有效防范安全威胁,确保应用的安全性。
11 4
|
27天前
|
Java
Java基础之 JDK8 HashMap 源码分析(中间写出与JDK7的区别)
这篇文章详细分析了Java中HashMap的源码,包括JDK8与JDK7的区别、构造函数、put和get方法的实现,以及位运算法的应用,并讨论了JDK8中的优化,如链表转红黑树的阈值和扩容机制。
20 1
|
1月前
|
安全 Java 编译器
Java 泛型深入解析:类型安全与灵活性的平衡
Java 泛型通过参数化类型实现了代码重用和类型安全,提升了代码的可读性和灵活性。本文深入探讨了泛型的基本原理、常见用法及局限性,包括泛型类、方法和接口的使用,以及上界和下界通配符等高级特性。通过理解和运用这些技巧,开发者可以编写更健壮和通用的代码。
|
2月前
|
安全 Java API
java安全特性
java安全特性
28 8
|
2月前
|
安全 Java API
【性能与安全的双重飞跃】JDK 22外部函数与内存API:JNI的继任者,引领Java新潮流!
【9月更文挑战第7天】JDK 22外部函数与内存API的发布,标志着Java在性能与安全性方面实现了双重飞跃。作为JNI的继任者,这一新特性不仅简化了Java与本地代码的交互过程,还提升了程序的性能和安全性。我们有理由相信,在外部函数与内存API的引领下,Java将开启一个全新的编程时代,为开发者们带来更加高效、更加安全的编程体验。让我们共同期待Java在未来的辉煌成就!
62 11
|
2月前
|
安全 Java API
【本地与Java无缝对接】JDK 22外部函数和内存API:JNI终结者,性能与安全双提升!
【9月更文挑战第6天】JDK 22的外部函数和内存API无疑是Java编程语言发展史上的一个重要里程碑。它不仅解决了JNI的诸多局限和挑战,还为Java与本地代码的互操作提供了更加高效、安全和简洁的解决方案。随着FFM API的逐渐成熟和完善,我们有理由相信,Java将在更多领域展现出其强大的生命力和竞争力。让我们共同期待Java编程新纪元的到来!
93 11
|
3月前
|
安全 算法 Java
java系列之~~网络通信安全 非对称加密算法的介绍说明
这篇文章介绍了非对称加密算法,包括其定义、加密解密过程、数字签名功能,以及与对称加密算法的比较,并解释了非对称加密在网络安全中的应用,特别是在公钥基础设施和信任网络中的重要性。
|
3月前
|
Java
【Java集合类面试十二】、HashMap为什么线程不安全?
HashMap在并发环境下执行put操作可能导致循环链表的形成,进而引起死循环,因而它是线程不安全的。
|
3月前
|
安全 算法 Java
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
这篇文章讨论了Java集合类的线程安全性,列举了线程不安全的集合类(如HashSet、ArrayList、HashMap)和线程安全的集合类(如Vector、Hashtable),同时介绍了Java 5之后提供的java.util.concurrent包中的高效并发集合类,如ConcurrentHashMap和CopyOnWriteArrayList。
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
|
3月前
|
安全 Java 测试技术
深入探讨Java安全编程的最佳实践,帮助开发者保障应用的安全性
在网络安全日益重要的今天,确保Java应用的安全性成为了开发者必须面对的课题。本文介绍Java安全编程的最佳实践,包括利用FindBugs等工具进行代码审查、严格验证用户输入以防攻击、运用输出编码避免XSS等漏洞、实施访问控制确保授权访问、采用加密技术保护敏感数据等。此外,还强调了使用最新Java版本、遵循最小权限原则及定期安全测试的重要性。通过这些实践,开发者能有效提升Java应用的安全防护水平。
47 2