0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

糟糕,被SimpleDateFormat坑到啦!

京东云 来源:jf_75140285 作者:jf_75140285 2024-09-06 09:45 次阅读

1. 问题背景

问题的背景是这样的,在最近需求开发中遇到需要将给定目标数据通过某一固定的计量规则进行过滤并打标生成明细数据,其中发现存在一笔目标数据的时间在不符合现有日期规则的条件下,还是通过了规则引擎的匹配打标操作。故而需要对该错误匹配场景进行排查,定位根本原因所在。

2. 排查思路

2.1 数据定位

在开始排查问题之初,先假定现有的Aviator规则引擎能够对现有的数据进行正常的匹配打标,查询在存在问题数据(图中红框所示)同一时刻进行规则匹配时的数据都有哪些。发现存在五笔数据在同一时刻进行规则匹配落库。

wKgaombaXpCANXfmAAGe5bR-pqY435.png

继续查询具体的匹配规则表达式,发现针对loanPayTime时间区间在[2022-07-16 00:00:00, 2023-05-11 23:59:59]的范围内进行匹配,目标数据的时间为2023-09-19 11:27:29,理论上应该不会被匹配到。

wKgZombaXpGAe-HlAABHT4Fj8qw463.png

但是观测匹配打标的明细数据发现确实打标成功了(如红框所示)。

wKgaombaXpKABZ6IAAGh1Bn0M7E195.png

所以重新回到最初的和目标数据同时落库的五笔数据发现,这五笔数据的loanPayTime时间确实在规则[2022-07-16 00:00:00, 2023-05-11 23:59:59]之内,所以在想有没有可能是在目标数据匹配规则引擎,其它的五笔数据中的其中一笔对该数据进行了修改导致误匹配到了这个规则。顺着这个思路,首先需要确认下Aviator规则引擎在并发场景下是否线程安全的。

wKgZombaXpaAPKazAAG9HT1yXuU440.png

2.2 规则引擎

由于在需求中使用到用于给数据匹配打标的是Aviator规则引擎,所以第一直觉是怀疑Aviator规则引擎在并发的场景中可能会存在线程不安全的情况。

wKgaombaXpeAFAwrAACPrAnrQf0551.png

首先简单介绍下Aviator规则引擎是什么,Aviator是一个高性能的、轻量级java语言实现的表达式求值引擎,主要用于各种表达式的动态求值,相较于其它的开源可用的规则引擎而言,Aviator的设计目标是轻量级和高性能 ,相比于Groovy、JRuby的笨重,Aviator非常小,加上依赖包也才450K,不算依赖包的话只有70K;

当然,Aviator的语法是受限的,它不是一门完整的语言,而只是语言的一小部分集合。其次,Aviator的实现思路与其他轻量级的求值器很不相同,其他求值器一般都是通过解释的方式运行,而Aviator则是直接将表达式编译成Java字节码,交给JVM去执行。简单来说,Aviator的定位是介于Groovy这样的重量级脚本语言和IKExpression这样的轻量级表达式引擎之间。(具体Aviator的相关介绍不是本文的重点,具体可参见)

通过查阅相关资料发现,Aviator中的AviatorEvaluator.execute() 方法本身是线程安全的,也就是说只要表达式执行逻辑和传入的env是线程安全的,理论上是不会出现并发场景下线程不安全问题的。(详见)

2.3 匹配规则引擎的env

wKgZombaXpiAbcE5AABTqU--LnI055.png

通过前面Aviator的相关资料发现传入的env如果在多线程场景下不安全也会导致最终的结果是错误的,故而定位使用的env发现使用的是HashMap,该集合类确实是线程不安全的(具体可详见),但是线程不安全的前提是多个线程同时对其进行修改,定位代码发现在每次调用方式时都会重新生成一个HashMap,故而应该不会是由于这个线程不安全类导致的。

wKgaombaXpmABWGSAADNnSDuIgY560.png

继续定位发现,loanPayTime这个字段在进行Aviator规则引擎匹配前使用SimpleDateFormat进行了格式化,所以有可能是由于该类的线程不安全导致的数据错乱问题,但是这个类应该只是对日期进行格式化处理,难不成还能影响最终的数据。带着这个疑问查询资料发现,emm确实是线程不安全的。

wKgZombaXpqAJhg_AAKEKTD57Ws016.png

好家伙,嫌疑对象目前已经有了,现在就是寻找相关证据来佐证了。

3. SimpleDateFormat 还能线程不安全?

3.1 先写个demo试试

话不多说,直接去测试一下在并发场景下,SimpleDateFormat类会不会对需要格式化的日期进行错乱格式化。先模拟一个场景,对多线程并发场景下格式化日期,即在[0,9]的数据范围内,在偶数情况下对2024年1月23日进行格式化,在奇数情况下对2024年1月22日进行格式化,然后观测日志打印效果。

import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadSafeDateFormatDemo {
    static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");


    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        LocalDateTime startDateTime = LocalDateTime.now();
        Date date = new Date();
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            executor.submit(() - > {
                try {
                    if (finalI % 2 == 0) {

                        String formattedDate = dateFormat.format(date);
                        //第一种
//                        String formattedDate = DateUtil.formatDate(date);
                        //第二种
//                        String formattedDate = DateSyncUtil.formatDate(date);
                        //第三种
//                        String formattedDate = ThreadLocalDateUtil.formatDate(date);
                        System.out.println("线程 " + Thread.currentThread().getName() + " 时间为: " + formattedDate + " 偶数i:" + finalI);
                    } else {
                        Date now = new Date();
                        now.setTime(now.getTime() - TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS));
                        String formattedDate = dateFormat.format(now);
                        //第一种
//                        String formattedDate = DateUtil.formatDate(now);
                        //第二种
//                        String formattedDate = DateSyncUtil.formatDate(now);
                        //第三种
//                        String formattedDate = ThreadLocalDateUtil.formatDate(now);
                        System.out.println("线程 " + Thread.currentThread().getName() + " 时间为: " + formattedDate + " 奇数i:" + finalI);
                    }

                } catch (Exception e) {
                    System.err.println("线程 " + Thread.currentThread().getName() + " 出现了异常: " + e.getMessage());
                }
            });
        }

        executor.shutdown();
        try {
            executor.awaitTermination(30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 计算总耗时
        LocalDateTime endDateTime = LocalDateTime.now();
        Duration duration = Duration.between(startDateTime, endDateTime);
        System.out.println("所有任务执行完毕,总耗时: " + duration.toMillis() + " 毫秒");
    }
}

具体demo代码如上所示,执行结果如下,理论上来说应该是2024年1月23日2024年1月22日打印日志的次数各5次。实际结果发现在偶数的场景下仍然会出现打印格式化2024年1月22日的场景。明显出现了数据错乱赋值的问题,所以到这里大概可以基本确定就是SimpleDateFormat类在并发场景下线程不安全导致的

wKgZombaXp2Acjb2AAEwnhsTG2I866.png

3.2 SimpleDateFormat为什么线程不安全?

查询相关资料发现,从SimpleDateFormat类提供的接口来看,实在让人看不出它与线程安全有什么关系,进入SimpleDateFormat源码发现类上面确实存在注释提醒:意思就是, SimpleDateFormat中的日期格式不是同步的。推荐(建议)为每个线程创建独立的格式实例。如果多个线程同时访问一个格式,则它必须保持外部同步。

wKgaombaXp6ARHuCAAGXORfBsp0840.png

继续分析源码发现,SimpleDateFormat线程不安全的真正原因是继承了DateFormat,DateFormat中定义了一个protected属性的 Calendar类的对象:calendar。由于Calendar类的概念复杂,牵扯到时区与本地化等等,jdk的实现中使用了成员变量来传递参数,这就造成在多线程的时候会出现错误。

wKgZombaXqCALSSGAAGEsqLatYw884.png

注意到在format方法中有一段如下代码:

 public StringBuffer format(Date date, StringBuffer toAppendTo,
                               FieldPosition pos)
    {
        pos.beginIndex = pos.endIndex = 0;
        return format(date, toAppendTo, pos.getFieldDelegate());
    }

    // Called from Format after creating a FieldDelegate
    private StringBuffer format(Date date, StringBuffer toAppendTo,
                                FieldDelegate delegate) {
        // Convert input date to time field list
        calendar.setTime(date);

        boolean useDateFormatSymbols = useDateFormatSymbols();

        for (int i = 0; i < compiledPattern.length; ) {
            int tag = compiledPattern[i] >>> 8;
            int count = compiledPattern[i++] & 0xff;
            if (count == 255) {
                count = compiledPattern[i++] < < 16;
                count |= compiledPattern[i++];
            }

            switch (tag) {
            case TAG_QUOTE_ASCII_CHAR:
                toAppendTo.append((char)count);
                break;

            case TAG_QUOTE_CHARS:
                toAppendTo.append(compiledPattern, i, count);
                i += count;
                break;

            default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
                break;
            }
        }
        return toAppendTo;
    }

calendar.setTime(date)这条语句改变了calendar,稍后,calendar还会用到(在subFormat方法里),而这就是引发问题的根源。

想象一下,在一个多线程环境下,有两个线程持有了同一个SimpleDateFormat的实例,分别调用format方法: 线程1调用format方法,改变了calendar这个字段。 中断来了。 线程2开始执行,它也改变了calendar。 又中断了。 线程1回来了,此时,calendar已然不是它所设的值,而是走上了线程2设计的道路。

如果多个线程同时争抢calendar对象,则会出现各种问题,时间不对线程挂死等等。 分析一下format的实现,我们不难发现,用到成员变量calendar,唯一的好处,就是在调用subFormat时,少了一个参数,却带来了这许多的问题。

其实,只要在这里用一个局部变量,一路传递下去,所有问题都将迎刃而解。 这个问题背后隐藏着一个更为重要的问题–无状态:无状态方法的好处之一,就是它在各种环境下,都可以安全的调用。衡量一个方法是否是有状态的,就看它是否改动了其它的东西,比如全局变量,比如实例的字段。format方法在运行过程中改动了SimpleDateFormat的calendar字段,所以,它是有状态的。

4. 如何解决?

4.1 每次在需要时新创建实例

在需要进行格式化日期的地方新建一个实例,不管什么时候,将有线程安全问题的对象由共享变为局部私有都能避免多线程问题,不过也加重了创建对象的负担。在一般情况下,这样其实对性能影响比不是很明显的。代码示例如下。

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 20:04
 */


public class DateUtil {

    public static String formatDate(Date date) throws ParseException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.format(date);
    }

    public static Date parse(String strDate) throws ParseException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.parse(strDate);
    }
}​

4.2 同步SimpleDateFormat对象

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 20:04
 */


public class DateSyncUtil {

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

说明:当线程较多时,当一个线程调用该方法时,其他想要调用此方法的线程就要block,多线程并发量大的时候会对性能有一定的影响。

4.3 ThreadLocal

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class ConcurrentDateUtil {

    private static ThreadLocal< DateFormat > threadLocal = new ThreadLocal< DateFormat >() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    public static Date parse(String dateStr) throws ParseException {
        return threadLocal.get().parse(dateStr);
    }

    public static String format(Date date) {
        return threadLocal.get().format(date);
    }
}

另一种写法

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author 
 * @date 2024/1/23 15:44
 * @description 线程安全的日期处理类
 */


public class ThreadLocalDateUtil {
    /**
     * 日期格式
     */
    private static final String date_format = "yyyy-MM-dd HH:mm:ss";
    /**
     * 线程安全处理
     */
    private static ThreadLocal< DateFormat > threadLocal = new ThreadLocal<  >();

    /**
     * 线程安全处理
     */
    public static DateFormat getDateFormat() {
        DateFormat df = threadLocal.get();
        if (df == null) {
            df = new SimpleDateFormat(date_format);
            threadLocal.set(df);
        }
        return df;
    }

    /**
     * 线程安全处理日期格式化
     */
    public static String formatDate(Date date) {
        return getDateFormat().format(date);
    }

    /**
     * 线程安全处理日期解析
     */
    public static Date parse(String strDate) throws ParseException {
        return getDateFormat().parse(strDate);
    }
}

说明:使用ThreadLocal, 也是将共享变量变为独享,线程独享肯定能比方法独享在并发环境中能减少不少创建对象的开销。如果对性能要求比较高的情况下,一般推荐使用这种方法

4.4 抛弃JDK,使用其他类库中的时间格式化类

•使用

Apache commons

里的

FastDateFormat

,宣称是既快又线程安全的SimpleDateFormat, 可惜它

只能

对日期进行format,

不能

对日期串进行解析。•使用

Joda-Time

类库来处理时间相关问题。

5. 性能比较

通过追加时间监控,将原有数据范围扩充到[0,999],线程池保留10个线程不变,观察三种情况下性能情况。

•第一种:耗时40ms

wKgaombaXqCAH1PyAAIHquMz2Lk923.png

•第二种:耗时33ms

wKgZombaXqGAEfNnAAH-Naeabi0246.png

•第三种:耗时30ms

wKgaombaXqOAexakAAIU_W9WkC4323.png

通过性能压测发现4.3中的ThreadLocal性能最优,耗时30ms,4.1每次新创建实例性能最差,需要耗时40ms,当然了在极致的高并发场景下提升效果应该会更加明显。性能问题不是本文探讨的重点,在此不多做赘述。

6. 总结

Ok,以上就是针对本次问题排查的主要思路及流程,这个我刚开始的排查思路也一直局限于规则引擎的线程不安全或者是传入的env(由于使用的是HashMap)线程不安全,还是受到组内大佬的启发和帮助才进一步去分析SimpleDateFormat类可能会存在线程不安全。本次问题排查确实提供一个经验打破常规思路,比如SimpleDateFormat类看起来只是对日期进行格式化,很难和在并发场景下线程不安全会导致数据错乱关联起来。以上。

审核编辑 黄宇

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • JAVA
    +关注

    关注

    19

    文章

    2942

    浏览量

    104064
  • hashmap
    +关注

    关注

    0

    文章

    14

    浏览量

    2263
收藏 人收藏

    评论

    相关推荐

    老板了,,,

    今天去广州赛格那买一些元件。。如电阻啊,电容啊,三极管。。。第一次买这些东西,那的老板狂吃,却不知道。。哎,买回来上网一看那些价格。。。果然是交学费了。。。。。
    发表于 11-18 13:10

    方波上下边沿均出现糟糕的抖动

    光电编码器输出方波上下边沿均出现糟糕的抖动?会不会是电机影响?求解?谢谢!
    发表于 04-22 20:57

    赚经验积分

    一分钱难倒英雄好汉这不是吗没积分下载资料求支招~~~~
    发表于 07-08 15:03

    使用闪存最糟糕的环境是什么?

    使用闪存最糟糕的环境是什么? 以上来自于百度翻译 以下为原文What kind of environment is the worst in using flash memory?
    发表于 12-07 14:39

    Linux学习过程踩过的与如何解决踩

    Linux踩记录记录Linux学习过程踩过的与如何解决踩1解决方法:F10进入BIOS使能虚拟化技术
    发表于 11-04 08:44

    烂代码你能忍吗?优秀的代码VS糟糕的代码

    糟糕的代码原来那么不堪一击。
    的头像 发表于 03-30 10:09 4313次阅读
    烂代码你能忍吗?优秀的代码VS<b class='flag-5'>糟糕</b>的代码

    小米华为又苹果,LG才是最大的“亲王”?

    过硬的产品形象,出现多个问题产品。比如,华为手机Mate20 Pro就被LG的不浅,而苹果似乎也马上要成为下一个的对象。那么这到底是怎么回事,为何LG才是最大的“亲王”? 本文
    的头像 发表于 11-17 11:34 515次阅读

    怎样对待水平糟糕的程序员

    这些年遇到了很多糟糕的程序员,其实真正是写程序料的人,普通IT公司大概只占1/3左右吧,其实有2/3的人都太适合当程序员,还不如早点儿改行该干啥就干啥了,其中有1/10的人往往是相对比较糟糕的。
    的头像 发表于 12-28 14:52 1252次阅读

    网购鼠标和键盘时这几个点最容易

    本篇给大家带来的是今年315电商廉价的爹电竞键鼠篇。很多学生党或者预算很低但又喜爱玩电竞游戏的朋友在某宝购买键盘和鼠标时,最容易掉进以下两种键鼠的内。
    的头像 发表于 03-15 10:36 8309次阅读

    购买组装电脑和配件有哪些方法避免

    越来越多的电脑小白入被骗,继而维权困难,只能是哑巴吃黄连有苦说不出。其实网购有决窍,掌握以下几点我们就能避免
    的头像 发表于 12-07 11:23 6318次阅读

    使用Redis时可能遇到哪些「」?

    这篇文章,我想和你聊一聊在使用 Redis 时,可能会踩到的「」。 如果你在使用 Redis 时,也遇到过以下这些「诡异」的场景,那很大概率是踩到「」了: 明明一个 key 设置了过期时间
    的头像 发表于 04-09 11:19 2220次阅读
    使用Redis时可能遇到哪些「<b class='flag-5'>坑</b>」?

    嵌入式Linux踩记录

    Linux踩记录记录Linux学习过程踩过的与如何解决踩1解决方法:F10进入BIOS使能虚拟化技术
    发表于 11-01 17:21 10次下载
    嵌入式Linux踩<b class='flag-5'>坑</b>记录

    Redis分布式锁的10个

    这块代码是有 的,因为setnx和expire两个命令是分开写的,并不是原子操作!如果刚要执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老 ”了,别的线程永远获取不到锁
    的头像 发表于 01-10 10:38 584次阅读

    PCB设计避指南

    本文就重点讲解PCB设计避指南,99%的PCB工程师容易忽略的!点进来避 大家在PCB设计中都踩过哪些,一起来围观这些奇奇怪怪的
    的头像 发表于 03-20 18:20 969次阅读
    PCB设计避<b class='flag-5'>坑</b>指南

    【避指南】电容耐压降额裕量不合理导致电容频繁击穿

    【避指南】电容耐压降额裕量不合理导致电容频繁击穿
    的头像 发表于 11-23 09:04 903次阅读
    【避<b class='flag-5'>坑</b>指南】电容耐压降额裕量不合理导致电容频繁<b class='flag-5'>被</b>击穿