• SimpleDateFormat线程安全问题排查


    一. 问题现象

    运营部门反馈使用小程序配置的拉新现金红包活动二维码,在扫码后跳转至404页面。

    二. 原因排查

    1. 首先,检查扫码后的跳转链接地址不是对应二维码的实际URL,根据代码逻辑推测,可能是accessToken在微信端已失效导致,检查数据发现,数据库存储的accessToken过期时间为2022-11-29(排查问题当日为2022-10-08),发现过期时间太长,导致accessToken未刷新导致。

    2. 接下来,继续排查造成这一问题的真正原因。排查日志发现更新sql语句对应的的过期时间与数据库记录的一致,推测赋值代码存在问题,如下。

    tokenInfo.setExpireTime(simpleDateFormat.parse(token.getString("expireTime")));
    

    其中,simpleDateFormat在代码中定义是该类的成员变量。

    1. 跟踪代码后发现源码中有明确说明SimpleDateFormat不应该应用于多线程场景下。
    Synchronization
    //SimpleDateFormat中的日期格式化不是同步的。
    Date formats are not synchronized. 
    //建议为每个线程创建独立的格式实例。
    It is recommended to create separate format instances for each thread. 
    //如果多个线程同时访问一个格式,则它必须保持外部同步。
    If multiple threads access a format concurrently, it must be synchronized externally. 
    
    1. 至此,基本可以判断是simpleDateFormat.parse在多线程情况下造成错误的过期时间入库,导致accesstoken无法正常更新。

    三. 原因分析

    1. 接下来写个测试类来模拟:
    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class SimpleDateFormatTest {
    
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
        /**
         * 定义线程池
         **/
        private static final ExecutorService threadPool = new ThreadPoolExecutor(16,
                20,
                0L,
                TimeUnit.MILLISECONDS,
                new LinkedBlockingDeque<>(1024),
                new ThreadFactoryBuilder().setNamePrefix("[线程]").build(),
                new ThreadPoolExecutor.AbortPolicy()
        );
    
        @SneakyThrows
        @Test
        public void testParse() {
            Set results = Collections.synchronizedSet(new HashSet<>());
            // 每个线程都对相同字符串执行“parse日期字符串”的操作,当THREAD_NUMBERS个线程执行完毕后,应该有且仅有一个相同的结果才是正确的
            String initialDateStr = "2022-10-08 18:30:01";
            for (int i = 0; i < 20; i++) {
                threadPool.execute(() -> {
                    Date parse = null;
                    try {
                        parse = simpleDateFormat.parse(initialDateStr);
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "---" + parse);
                });
            }
            threadPool.shutdown();
            threadPool.awaitTermination(1, TimeUnit.HOURS);
        }
    }
    

    运行结果如下:

    [线程]5---Sat Jan 08 18:30:01 CST 2000
    [线程]0---Wed Oct 08 18:30:01 CST 2200
    [线程]4---Sat Oct 08 18:30:01 CST 2022
    Exception in thread "[线程]3" 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.SimpleDateFormatTest.lambda$testParse$0(SimpleDateFormatTest.java:49)
    	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)
    [线程]6---Sat Oct 08 18:30:01 CST 2022
    [线程]11---Wed Mar 15 18:30:01 CST 2045
    Exception in thread "[线程]2" java.lang.ArrayIndexOutOfBoundsException: 275
    	at sun.util.calendar.BaseCalendar.getCalendarDateFromFixedDate(BaseCalendar.java:453)
    	at java.util.GregorianCalendar.computeFields(GregorianCalendar.java:2397)
    	at java.util.GregorianCalendar.computeTime(GregorianCalendar.java:2818)
    	at java.util.Calendar.updateTime(Calendar.java:3393)
    	at java.util.Calendar.getTimeInMillis(Calendar.java:1782)
    	at java.util.Calendar.getTime(Calendar.java:1755)
    	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1532)
    	at java.text.DateFormat.parse(DateFormat.java:364)
    	at com.SimpleDateFormatTest.lambda$testParse$0(SimpleDateFormatTest.java:49)
    	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)
    [线程]6---Fri Oct 01 18:30:01 CST 8202
    [线程]12---Sat Oct 08 18:30:01 CST 2022
    Exception in thread "[线程]1" 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.SimpleDateFormatTest.lambda$testParse$0(SimpleDateFormatTest.java:49)
    	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)
    [线程]0---Sat Oct 08 18:30:01 CST 2022
    [线程]12---Sat Oct 08 18:30:01 CST 2022
    [线程]13---Sat Oct 08 18:30:01 CST 2022
    [线程]18---Sat Oct 08 18:30:01 CST 2022
    [线程]6---Sat Oct 01 18:30:01 CST 2022
    [线程]7---Sat Oct 08 18:30:01 CST 2022
    [线程]10---Sat Oct 08 18:30:01 CST 2022
    [线程]15---Sat Oct 08 18:00:01 CST 2022
    [线程]17---Sat Oct 08 18:30:01 CST 2022
    [线程]14---Sat Oct 08 18:30:01 CST 2022
    预期结果个数 1---实际结果个数7
    

    不仅有的线程结果不正确,甚至还有一些线程还出现了异常!

    1. 为什么SimpleDateFormat类不是线程安全的?

    SimpleDateFormat继承了DateFormat,DateFormat内部有一个Calendar对象的引用,主要用来存储和SimpleDateFormat相关的日期信息。

    SimpleDateFormat对parse()方法的实现。关键代码如下:

     @Override
    public Date parse(String text, ParsePosition pos) {
        ...省略中间代码
        Date parsedDate;
        try {
            ...
            parsedDate = calb.establish(calendar).getTime();
        } catch (IllegalArgumentException e) {
           ...
        }
    
        return parsedDate;
    }
    

    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;
                }
            }
        }
        ...
        return cal;
    }
    

    在多个线程共享SimpleDateFormat时,同时也共享了Calendar引用,在如上代码中,calendar首先会进行clear()操作,然后进行set操作,在多线程情况下,set操作会覆盖之前的值,而且在后续对日期进行操作时,也可能会因为clear操作被清除导致异常。

    四. 解决方案

    1. 将SimpleDateFormat定义成局部变量,每次使用时都new一个新对象,频繁创建对象消耗大,性能影响一些(JDK文档推荐此做法)
        public static Date parse(String strDate) throws ParseException {
             SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
             return sdf.parse(strDate);
        }
    
    1. 维护一个SimpleDateFormat实体,转换方法上使用 Synchronized 保证线程安全:多线程堵塞(并发大系统不推荐)
        private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
          
        public static String formatDate(Date date)throws ParseException{
            synchronized(sdf){
                return sdf.format(date);
            }  
        }
        
        public static Date parse(String strDate) throws ParseException{
            synchronized(sdf){
                return sdf.parse(strDate);
            }
        } 
    
    1. 使用ThreadLocal : 线程独享不堵塞,并且减少创建对象的开销(如果对性能要求比较高的情况,推荐这种方式)。
        public static ThreadLocal threadLocal = ThreadLocal.withInitial(
                () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
        );
        public static Date parse(String strDate) throws ParseException {
            return threadLocal.get().parse(strDate);
        }
    
    1. DateTimeFormatter是Java8提供的新的日期时间API中的类,DateTimeFormatter类是线程安全的,可以在高并发场景下直接使用。
        String dateTimeStr= "2016-10-25 12:00:00";
        DateTimeFormatter formatter02 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStr,formatter02);
        System.out.println(localDateTime);
        String format = localDateTime.format(formatter02);
        System.out.println(format);
    
        2016-10-25T12:00
        2016-10-25 12:00:00
    

    最终,我们根据实际情况公共包DateUtil类提供的strConvertDate方法,原理是按照方案1来解决该问题。

  • 相关阅读:
    Excel数学、工程和科学计算插件:FORMULADESK Studio
    关于mysql本地计算机上的MySQL服务启动后停止。某些服务在未由其他服务或程序使用时将自动停止问题
    uni-app编程checkbox-group获取选中的每个checkbox的value值
    【面试普通人VS高手系列】请说一下你对分布式锁的理解,以及分布式锁的实现
    给STM32装点中国风——华为LiteOS移植
    NVMe SSD 学习总结:04 为什么NVME的SSD越来越受欢迎?
    CTF-XSS
    CentOS7上安装Hadoop2.9.2集群
    确保使用正确的CSI提交HW问题
    Linux 配置Tomcat
  • 原文地址:https://www.cnblogs.com/myshare/p/16869855.html