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类看起来只是对日期进行格式化,很难和在并发场景下线程不平安会导致数据错乱关联起来。
作者:京东科技 宋慧超
起源:京东云开发者社区 转载请注明起源