关于后端:糟糕被SimpleDateFormat坑到啦-京东云技术团队

40次阅读

共计 8243 个字符,预计需要花费 21 分钟才能阅读完成。

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 类看起来 只是 对日期进行格式化,很难 和在并发场景下线程不平安会导致数据错乱 关联起来

作者:京东科技 宋慧超

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

正文完
 0