写点什么

糟糕,被 SimpleDateFormat 坑到啦!| 京东云技术团队

  • 2024-02-18
    北京
  • 本文字数:6621 字

    阅读完需:约 22 分钟

1. 问题背景

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

2. 排查思路

2.1 数据定位

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



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



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



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


2.2 规则引擎

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



首先简单介绍下 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


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



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



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

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 类在并发场景下线程不安全导致的


3.2 SimpleDateFormat 为什么线程不安全?

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



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



注意到在 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



•第二种:耗时 33ms



•第三种:耗时 30ms



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

6. 总结

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


作者:京东科技 宋慧超


来源:京东云开发者社区 转载请注明来源

用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
糟糕,被SimpleDateFormat坑到啦!| 京东云技术团队_京东科技开发者_InfoQ写作社区