推荐学习
- 面试终败“高并发”,25天苦心钻研,居然整出一份并发宝典?
- 三次阿里二面挂,Java+并发+JVM+网络+数据库+算法,我还能说啥?
又是风和日丽的一天,我正在快乐的写着bug,突然感觉到背后一阵凉风吹过,我感觉肯定有大事发生,我转头一看,果然,小明笑嘻嘻的站在我身后,边笑边说:哥,忙吗?不忙的话帮我看个问题呗!每次他的问题都十分诡异,不过身为同事,还是应该相互帮助的,我决定与他一起看看他的问题。
SimpleDateFormat诡异bug
SimpleDateFormat应该是我们开发中使用比较多的工具类了吧,小明也在项目中使用到了,但就是这个工具类让小明痛苦了好长一段时间,为什么呢?那是因为测试工程师的小哥哥们在辛劳的做着接口性能测试,但是发现有些接口返回的日期时间是错乱的,不符合实际结果,这个时候,禅道中就多出了一道美丽的风景线,那就是bug:在并发接口测试中,日期返回的数据偶尔错误。 就是这么一个bug,让小明郁闷了好长一段时间,实在找不到解决方案,这才找到了我,我来看了他的业务代码之后,发现他使用了SimpleDateFormat这个工具类来格式化时间, 并且还是静态的,这个时候我就知道为什么平时做功能测试的时候没有问题,在做性能测试的时候bug就出来了,小明也是刚毕业不久,对多线程这一块不是怎么的熟悉,所以这个也不能怪他,我们现在使用demo来复现一下SimpleDateFormat的诡异bug吧。
复现SimpleDateFormat诡异bug
我这里将使用demo的形式来复现一下SimpleDateFormat存在的bug。
字符串日期转Date日期(parse)
package com.ymy.test;import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.Date;public class SimpleDateFormatBugTest { private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private static Date parse(String date){ Date parse = null; try { return sdf.parse(date); } catch (ParseException e) { e.printStackTrace(); } return null; } public static void main(String[] args) { Thread t1 = new Thread(() -> { Date parse = parse("2020-12-12 12:12:12"); System.out.println("当前日期:" + parse); }); Thread t2 = new Thread(() -> { Date parse = parse("2020-12-12 12:12:12"); System.out.println("当前日期:" + parse); }); Thread t3 = new Thread(() -> { Date parse = parse("2018-10-10 10:10:10"); System.out.println("当前日期:" + parse); }); t1.start(); t2.start(); t3.start(); try { t1.join(); t2.join(); t3.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("线程执行完毕"); }}
执行结果
Exception in thread "Thread-2" Exception in thread "Thread-0" java.lang.NumberFormatException: For input string: ".1102E.1102E22"at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)at java.lang.Double.parseDouble(Double.java:538)at java.text.DigitList.getDouble(DigitList.java:169)at java.text.DecimalFormat.parse(DecimalFormat.java:2089)at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)at java.text.DateFormat.parse(DateFormat.java:364)at com.ymy.test.SimpleDateFormatBugTest.parse(SimpleDateFormatBugTest.java:33)at com.ymy.test.SimpleDateFormatBugTest.lambda$main$2(SimpleDateFormatBugTest.java:56)at java.lang.Thread.run(Thread.java:748)java.lang.NumberFormatException: For input string: ".1102E.1102E22"at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)at java.lang.Double.parseDouble(Double.java:538)at java.text.DigitList.getDouble(DigitList.java:169)at java.text.DecimalFormat.parse(DecimalFormat.java:2089)at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)at java.text.DateFormat.parse(DateFormat.java:364)at com.ymy.test.SimpleDateFormatBugTest.parse(SimpleDateFormatBugTest.java:33)at com.ymy.test.SimpleDateFormatBugTest.lambda$main$0(SimpleDateFormatBugTest.java:45)at java.lang.Thread.run(Thread.java:748)当前日期:Sat Dec 12 12:12:12 CST 2020线程执行完毕Process finished with exit code 0
Date日期转String类型(format)
package com.ymy.test;import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.Date;public class SimpleDateFormatTest { private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private static Date d1 = null; private static Date d2 = null; private static Date d3 = null; static { try { d1 = sdf.parse("2020-12-12 12:12:12"); d2 =sdf.parse("2019-11-11 11:11:11"); d3 =sdf.parse("2018-10-10 10:10:10"); } catch (ParseException e) { e.printStackTrace(); } } public static void main(String[] args) { Thread t1 = new Thread(() -> { String parse = sdf.format(d1); System.out.println("当前日期:" + parse); }); Thread t2 = new Thread(() -> { String parse = sdf.format(d2); System.out.println("当前日期:" + parse); }); Thread t3 = new Thread(() -> { String parse = sdf.format(d3); System.out.println("当前日期:" + parse); }); t1.start(); t2.start(); t3.start(); try { t1.join(); t2.join(); t3.join(); } catch (InterruptedException e) { e.printStackTrace(); } }}
使用三个线程分别对2020-12-12 12:12:12、2019-11-11 11:11:11、2018-10-10 10:10:10的Date格式转字符串格式的操作,我们在静态代码块中初始化了Date格式的数据,然后使用三个线程分别对他们进行格式转换。
第一次:
当前日期:2020-12-12 12:12:12当前日期:2018-11-11 11:11:11当前日期:2019-11-11 11:11:11
第二次:
当前日期:2018-10-10 10:10:10当前日期:2018-10-10 10:10:10当前日期:2019-11-11 11:11:11Process finished with exit code 0
第三次:
当前日期:2020-10-10 10:10:10当前日期:2018-10-10 10:10:10当前日期:2018-10-10 10:10:10Process finished with exit code 0
我们发现String转Date的时候有两个线程直接报错,Date转String虽然不会报错,但是日期格式全部错乱,为什么平时使用的时候不会出现问题,一到性能测试的时候就会发生这种问题,小明表示快要崩溃了。
SimpleDateFormat出现bug的原因
其实了解过多线程的人都知道SimpleDateFormat是线程不安全的,但是为什么是线程不安全的,大家都知道吗?我们一起来看一下。
首先parse源码分析:
public Date parse(String source) throws ParseException { ParsePosition pos = new ParsePosition(0); Date result = parse(source, pos); if (pos.index == 0) throw new ParseException("Unparseable date: "" + source + """ , pos.errorIndex); return result; }
进入:parse(source, pos);,然后注意到下面这段代码
try { parsedDate = calb.establish(calendar).getTime(); // If the year value is ambiguous, // then the two-digit year == the default start year if (ambiguousYear[0]) { if (parsedDate.before(defaultCenturyStart)) { parsedDate = calb.addYear(100).establish(calendar).getTime(); } } }Calendar establish(Calendar cal) { boolean weekDate = isSet(WEEK_YEAR) && field[WEEK_YEAR] > field[YEAR]; if (weekDate && !cal.isWeekDateSupported()) { // Use YEAR instead if (!isSet(YEAR)) { set(YEAR, field[MAX_FIELD + WEEK_YEAR]); } weekDate = false; } cal.clear(); // Set the fields from the min stamp to the max stamp so that // the field resolution works in the Calendar. for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) { for (int index = 0; index <= maxFieldIndex; index++) { if (field[index] == stamp) { cal.set(index, field[MAX_FIELD + index]); break; } } } if (weekDate) { int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1; int dayOfWeek = isSet(DAY_OF_WEEK) ? field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek(); if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) { if (dayOfWeek >= 8) { dayOfWeek--; weekOfYear += dayOfWeek / 7; dayOfWeek = (dayOfWeek % 7) + 1; } else { while (dayOfWeek <= 0) { dayOfWeek += 7; weekOfYear--; } } dayOfWeek = toCalendarDayOfWeek(dayOfWeek); } cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek); } return cal; }
我们深入到calb.establish(calendar).getTime();中,发现 establish() 中存在着一行比较秀的代码:
cal.clear();
public final void clear() { for (int i = 0; i < fields.length; ) { stamp[i] = fields[i] = 0; // UNSET == 0 isSet[i++] = false; } areAllFieldsSet = areFieldsSet = false; isTimeSet = false; }
由于calendar这个参数是由调用方传递进来的,而调用方parse()拿的是当前类的成员变量。
protected Calendar calendar;
然而我们都知道,成员变量在多线程情况下如果没有锁的加持,是很容易出现线程安全问题的,他这里是先执行的clear,在重新获取时间
public final Date getTime() { return new Date(getTimeInMillis()); }public long getTimeInMillis() { if (!isTimeSet) { updateTime(); } return time; }ressWarnings("ProtectedField") protected long time;
getTime最终返回的是成员变量,这个是之前已经设置了值的属性,然而当开启多线程的时候,很有可能导致第一个线程执行了getTime之后第二个线程有执行了clear,由于这些变量都是成员变量,所以他们是共享的,bug就发生了。 format日期错乱的原因和这个类似,这里就不做过多说明了,感兴趣的小哥哥小姐姐可以翻开源码撸一下。
如何解决SimpleDateFormat多线程安全问题
局部变量
既然成员变量会发生线程安全问题,那将SimpleDateFormat设置成为局部变量那不就没问题了吗,却是是这样,但如果需要修改格式化的模式,改动量是非常大,因为你需要将所有涉及到的局部变量都修改一遍,而单例只需要修改一次,这个看情况而定
使用SimpleDateFormat方法时加锁
这也是一种解决的思路,比如:synchronized,大家都知道,加锁会降低程序的效率,除非必要情况,否者时不建议直接使用锁来解决的。
使用ThreadLocal
ThreadLocal不知道大家了解过没有,他是一种解决多线程并发问题简单有效的方式,通过线程和变量绑定的方式,让线程与线程之间不存在变量共享的问题,自然而然就解决了多线程并发的问题。
ThreadLocal介绍
JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序,ThreadLocal并不是一个Thread,而是Thread的局部变量。
举个生活中的例子,人有三急,而厕所只有一坑位,所以公司的上百人都会争先恐后的争夺那一个坑位的使用权,并且在争夺过程中还有可能出现事故,公司老总有一次也想蹲坑,但是发现已经有很多人正在为了那一个坑位抢的死去活来,导致他不能及时排泄而。。。。。。
公司老总整理了情绪之后,于时吩咐秘书:你赶紧去安排一下,厕所的坑位增加到一百个,每人一个,看还有没有人抢。
这个例子就有点类似与我们程序中的多线程,很多线程都在抢同一个资源,在抢来抢去的时候难免发生意外情况,也就是程序中的线程安全问题,所以有没有一种给每个线程都分配对应的变量,让他们不用抢来抢去?这个时候ThreadLocal站了出来,我是土豪,我给你们每个线程都分配一个只属于你们自己的资源,省的你们抢来抢去,打扰我泡妞。
ThreadLocal使用demo
package com.ymy.test;import java.util.Random;public class MyLocalThread { private static Random random = new Random(); private static ThreadLocal t = ThreadLocal.withInitial( ()->random.nextInt(10)+1 ); private static Integer get(){ return t.get(); } public static void main(String[] args) { Thread t1 = new Thread(() -> { Integer num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" "+ num); num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" 第二次获取 "+ num); }); Thread t2 = new Thread(() -> { Integer num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" "+ num); num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" 第二次获取 "+ num); }); Thread t3 = new Thread(() -> { Integer num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" "+ num); num = get(); System.out.println("线程名:"+Thread.currentThread().getName()+" 第二次获取 "+ num); }); t1.start(); t2.start(); t3.start(); try { t1.join(); t2.join(); t3.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("执行完毕"); }}
上面的代码很简单,ThreadLocal为三个线程生成三个随机数,然后三个线程分别去获取生成的随机数,看看会发生什么结果?
线程名:Thread-0 3线程名:Thread-0 第二次获取 3线程名:Thread-1 1线程名:Thread-2 10线程名:Thread-1 第二次获取 1线程名:Thread-2 第二次获取 10执行完毕Process finished with exit code 0
我们来看一下三个线程分别生成的随机数:
第一个线程(Thread-0):3
第二个线程(Thread-1):1
第三个线程(Thread-2):10
我们看到三个线程生成的数据:3、1、10,都是随机的,但是这并不能说明他就是线程安全的,所以我这里还特意在这三个线程中重复获取了一次随机数,我们我们发现:
第一个线程第二次获取(Thread-0):3
第二个线程第二次获取(Thread-1):1
第三个线程第二次获取(Thread-2):10
你们是不是发现什么了?没错,那就是每个线程的第二次获取的数据和第一次是相同的,而且不会和其他线程发生任何错乱,这就是ThreadLocal的神奇之处。
ThreadLocal源码探索
我们先看看ThreadLocal是如何被创建的
private static ThreadLocal t = ThreadLocal.withInitial(()->random.nextInt(10)+1);
这行代码是上面demo中ThreadLocal创建方式,通过ThreadLocal.withInitial来创建,我们一起来看一下withInitial方法。
/** * Creates a thread local variable. The initial value of the variable is * determined by invoking the {@code get} method on the {@code Supplier}. * * @param the type of the thread local's value * @param supplier the supplier to be used to determine the initial value * @return a new thread local variable * @throws NullPointerException if the specified supplier is null * @since 1.8 */ public static ThreadLocal withInitial(Supplier extends S> supplier) { return new SuppliedThreadLocal<>(supplier); }
这是jdk1.8才支持的创建方式,以前的版本请不要这么使用,这个方法表示:
创建线程局部变量。变量的初始值是通过调用get()方法确定的。
那我们在一起来看看get()方法
/** * Returns the value in the current thread's copy of this * thread-local variable. If the variable has no value for the * current thread, it is first initialized to the value returned * by an invocation of the {@link #initialValue} method. * * @return the current thread's value of this thread-local */ public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }
大致意思:
返回当前线程中该线程局部变量的副本中的值。如果变量没有当前线程的值,则首先将其初始化为调用{@link #initialValue}方法返回的值。
/** * Variant of set() to establish initialValue. Used instead * of set() in case user has overridden the set() method. * * @return the initial value */ private T setInitialValue() { T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); return value; }
大致意思:将此线程局部变量的当前线程副本设置为指定的值。大多数子类将不需要重写这个方法,仅仅依靠{@link #initialValue}方法来设置线程局部变量的值,其中看到ThreadLocalMap 了没有?之前是不是一直有一个疑惑,那就是ThreadLocal到底是怎么存储我们线程变量的,ThreadLocalMap 就是ThreadLocal给每一个线程都分配一个独立资源的王牌,我们一起看看ThreadLocalMap的内部结构
static class Entry extends WeakReference> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal> k, Object v) { super(k); value = v; } }/** * The table, resized as necessary. * table.length MUST always be a power of two. */ private Entry[] table;
里面也有很多的方法,这里就不全贴出来了,占用太多空间,Entry 存放的就是线程对应的存储对象,我们来梳理一下整个过程,当线程调用get()的时候,首先会判断Thread类中的ThreadLocal.ThreadLocalMap threadLocals变量,通过线程名获取
如果存在,接着获取当前线程的存储的对象T,如果没有找到,那么将会执行初始化过程,也就是setInitialValue()方法,在setInitialValue()方法方法中又会判断ThreadLocal.ThreadLocalMap是否为空,如果不为空,赋值,为空,创建ThreadLocal.ThreadLocalMap
void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }
然后返回当前线程的变量对象,大致流程就是这样,Thread类中维护着一个ThreadLocalMap,它负责存储线程的与线程对应的资源对象,当线程调用get()的时候会判断ThreadLocalMap中是否存在当前线程,如果没有,创建在返回,否者直接返回。
ThreadLocal注意事项
不知道你们注意到没有,存储线程变量的ThreadLocalMap属于Thread,并不属于ThreadLocal,你知道为什么吗?
1:我们知道,ThreadLocal只是一个简单的工具类,而ThreadLocalMap里面的数据都是和线程相关,所以存放在Thread中。
2:ThreadLocalMap存放在Thread中不容易发生内存泄漏,为什么这么说呢?那是因为ThreadLocalMap中对线程Thread有着引用关系,由于ThreadLocal的生命周期可能是和程序共存亡的,如果将ThreadLocalMap存放到ThreadLocal中,就算线程的生命周期结束了,ThreadLocalMap也不会被回收,因为ThreadLocal一直存在,如果存放在Thread中就不一样了,我们来看一下之前看过的源码
static class Entry extends WeakReference> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal> k, Object v) { super(k); value = v; } }
看到WeakReference没有,它表示这ThreadLocalMap对ThreadLocal是一种弱引用,只要Thread生命周期结束,ThreadLocalMap也会跟着一起消失,所以考虑内存泄漏方面,存在Thread类中更合理。
那ThreadLocal会发生内存泄漏问题吗?答案是肯定的,刚刚我们说到,线程被回收,ThreadLocalMap也会一起被回收,但是有一种情况线程是同程序共生死的,那就是线程池,所这个时候就可能会发生内存泄露问题了,然而我们我们在项目开发中线程池是使用比较多的,那我们如何解决这个问题呢?
其实很简单,java的gc机制做不到,不代表我们自己做不到,我们在自己的逻辑处理中自己释放,想必很多人都想到了,try/finally,没错就是它,只需要在finally中假如这行代码即可。
t.remove();//t:ThreadLocal实例
ThreadLocal介绍的差不多了,下面我们来使用ThreadLocal解决一下SimpleDateFormat多线程的安全问题吧。
使用ThreadLocal解决SimpleDateFormat线程安全问题
上修改之后的代码
package com.ymy.test;import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.Date;public class SimpleDateFormatBugTest { private static ThreadLocal t = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); private static SimpleDateFormat getSimpleDateFormat() { return t.get(); } private static Date parse(String date){ try { return getSimpleDateFormat().parse(date); } catch (ParseException e) { e.printStackTrace(); } return null; } public static void main(String[] args) { Thread t1 = new Thread(() -> { Date parse = parse("2020-12-12 12:12:12"); System.out.println("当前日期:" + parse); }); Thread t2 = new Thread(() -> { Date parse = parse("2019-11-11 11:11:11"); System.out.println("当前日期:" + parse); }); Thread t3 = new Thread(() -> { Date parse = parse("2018-10-10 10:10:10"); System.out.println("当前日期:" + parse); }); t1.start(); t2.start(); t3.start(); try { t1.join(); t2.join(); t3.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("线程执行完毕"); }}
第一次运行
当前日期:Wed Oct 10 10:10:10 CST 2018当前日期:Mon Nov 11 11:11:11 CST 2019当前日期:Sat Dec 12 12:12:12 CST 2020线程执行完毕Process finished with exit code 0
第二次运行
当前日期:Wed Oct 10 10:10:10 CST 2018当前日期:Sat Dec 12 12:12:12 CST 2020当前日期:Mon Nov 11 11:11:11 CST 2019线程执行完毕Process finished with exit code 0
第三次运行
当前日期:Wed Oct 10 10:10:10 CST 2018当前日期:Sat Dec 12 12:12:12 CST 2020当前日期:Mon Nov 11 11:11:11 CST 2019线程执行完毕Process finished with exit code 0
完美的解决了SimpleDateFormat在多线程中并发问题。
总结
问题终于解决了,小明也可以继续快乐的写bug了,充实的一天就这么过去了,小明收获了知识,而我,带着小明走了一遍ThreadLocal的源码,也对ThreadLocal加深了一遍印象,小明因为解决了这个bug,也不用加班了,脸上流露了激动的神情,也正好到下班时间了,然后小明就带着我去撸串了。。。。。。。。。。。。。。。。。。。。
作者:卖托儿索的小火柴
原文链接:https://blog.csdn.net/qq_33220089/article/details/105216878