背景
因为历史业务逻辑设计不合理,系统上的很多直播录播听课记录以及对应学员听课时长的统计不正确,因此需要重新将全部的直播录播听课数据都拉取回来,并且重新计算正确的学员听课时长。
因为只使用了一段时间的展示互动直播系统,所以数据量并不大,数据量大概三百多万。
正常来说单线程跑完这点数据都很快,但因为展示互动的接口限制分页大小最大只能200条,因而考虑使用多线程拉取。按照日期去分启动线程,确保不同线程处理的数据不会有交叉。
在查询数据库记录是否存在的时候,因为展示互动的直播观看记录并没有提供唯一标识,因此只能通过sdkid+手机号+进入房间时间来判断一条记录是否已经存在于系统中。因为数据库存储的进入房间时间不包含毫秒部分,而系统的date对象包含毫秒部分,因而使用工具类将时间格式的字段的毫秒部分去掉,在实际运行的时候出现了数据错乱.
问题发现与分析
通过打断点+查看数据,最终定位到工具类的一个方法。
出错的工具类方法实现如下,通过两次转换来达到将毫秒部分丢弃的目的。以往在单线程场景下使用并没有出现问题,但这次使用过程中却出现了问题。
public class DateUtils extends PropertyEditorSupport {
public static final SimpleDateFormat datetimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static Date getFormatDate(Date date) throws ParseException {
String format = datetimeFormat.format(date);
return datetimeFormat.parse(format);
}
}
如下,在经过转换之后,转换前后时间差过大。偶尔还会出现转换完之后还会出现格式不正确的数据。
原本以为是因为这个方法是一个静态方法导致的,本着验证一下的想法,我换了一种实现该效果的静态方法,却发现新方法并不会出现上述数据异常的情况。
新方法如下:
public class DateUtils extends PropertyEditorSupport {
public static Date getFormatDate(Date date) throws ParseException {
Calendar instance = Calendar.getInstance();
instance.setTime(date);
instance.set(Calendar.MILLISECOND,0);
return instance.getTime();
}
}
后面忽然想到,调用一个方法本质上就是将方法里面的代码插入到调用的地方。按照这个思路,新方法插入到该处之后,新方法的各种变量依旧全部是局部变量,局部变量自然没有线程安全问题。也就是出现异常和调用静态方法没有关系,第一个静态方法里面出现静态的SimpleDateFormat对象才是罪魁祸首。因为静态变量是属于类维度的,也就是多线程共用一个SimpleDateFormat对象。
在查看了SimpleDateFormat的源码之后找到了问题所在。如下,在格式化之前,会先将传入的日期对象设置到SimpleDateFormat类里面的Calendar字段,然后再去进行后续的操作;但因为这个静态SimpleDateFormat对象是静态的,多线程情况下就出现多个线程同时设置这个SimpleDateFormat里面的Calendar字段,前面的线程还没来得及格式化数据就被后面的线程更改了,也就出现了线程安全问题。
都看了SimpleDateFormat源码,新方法里面用到的Calendar.getInstance()也顺便看看,看看这个方法创建出来的对象是不是同一个。
从下图可以看出,每次创建的时候都是采用new的方式创建对象,因此每次返回的确实都是新的对象。
总结
知道在多线程场景下使用共享变量要注意,没留意到工具类里面也可能会有隐藏的坑,下次使用多线程的时候得更加注意才行。