前言
日常开发中,我们经常需要使用时间相关类,想必大家对SimpleDateFormat并不陌生。主要是用它进行时间的格式化输出和解析,挺方便快捷的,但是SimpleDateFormat并不是一个线程安全的类。在多线程情况下,会出现异常,想必有经验的小伙伴也遇到过。
下面我们就来分析分析SimpleDateFormat为什么不安全?是怎么引发的?以及多线程下有那些SimpleDateFormat的解决方案?
先看看《阿里巴巴开发手册》对于SimpleDateFormat是怎么看待的
![1699de9099afa9f4?w=728&h=328&f=jpeg&s=38]()
问题复现
一般我们在使用SimpleDateFormat的时候会把它定义为一个静态变量,避免频繁创建它们的对象实例,代码如下:
![1699de90998ce3ef?w=1080&h=679&f=jpeg&s=4]()
打印一下结果:
![1699de9099ca0f65?w=362&h=101&f=png&s=807]()
是不是感觉没什么毛病?相信大多数人都是这样使用的,也包括我。在单线程下自然没毛病了,但是运用到多线程下就有大问题了。
测试下:
![1699de909b1817a1?w=914&h=642&f=jpeg&s=45]()
控制台打印结果:
![1699de909b091eab?w=1080&h=737&f=jpeg&s=8]()
你看结果,发现了什么?直接崩了,部分线程获取的时间不对,部分线程报java.lang.NumberFormatException:multiple points错,线程直接挂死了。还有部分线程报empty String错,值有问题。
多线程不安全原因
因为我们把SimpleDateFormat定义为静态变量,那么多线程下SimpleDateFormat的实例就会被多个线程共享,B线程会读取到A线程的时间,就会出现时间差异和其它各种问题。SimpleDateFormat和它继承的DateFormat类也不是线程安全的。
来看看SimpleDateFormat的format()方法的源码:
![1699de909be957f0?w=592&h=757&f=jpeg&s=39]()
注意, calendar.setTime(date),SimpleDateFormat的format方法实际操作的就是Calendar。
因为我们声明SimpleDateFormat为static变量,那么它的Calendar变量也就是一个共享变量,可以被多个线程访问。
假设线程A执行完calendar.setTime(date),把时间设置成2019-01-02,这时候被挂起,线程B获得CPU执行权。线程B也执行到了calendar.setTime(date),把时间设置为2019-01-03。线程挂起,线程A继续走,calendar还会被继续使用(subFormat方法),而这时calendar用的是线程B设置的值了,而这就是引发问题的根源,出现时间不对,线程挂死等等。
其实SimpleDateFormat源码上作者也给过我们提示:
![1699de90bc3dfd22?w=689&h=137&f=png&s=156]()
翻译过来的意思就是:
日期格式未同步。
建议为每个线程创建单独的格式实例。
如果多个线程同时访问格式,则必须在外部同步
解决方案
只在需要的时候创建新实例,不用static修饰。
![1699de90bc512004?w=854&h=292&f=jpeg&s=29]()
如上代码,仅在需要用到的地方创建一个新的实例,就没有线程安全问题,不过也加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。
采用Synchronized方式
![1699de90be47c023?w=1034&h=435&f=jpeg&s=3]()
简单粗暴,synchronized往上一套也可以解决线程安全问题,缺点自然就是并发量大的时候会对性能有影响,线程阻塞。
ThreadLocal
![1699de90bf36f790?w=919&h=458&f=jpeg&s=36]()
ThreadLocal可以确保每个线程都可以得到单独的一个SimpleDateFormat的对象,那么自然也就不存在竞争问题了。
基于JDK1.8的DateTimeFormatter
也是《阿里巴巴开发手册》给我们的解决方案,对之前的代码进行改造:
![1699de90c07dfbbd?w=792&h=727&f=jpeg&s=48]()
运行结果就不贴了,不会出现报错和时间不准确的问题。
DateTimeFormatter源码上作者也加注释说明了,他的类是不可变的,并且是线程安全的。
![1699de90c43882fa?w=455&h=90&f=png&s=8257]()
OK,现在是不是可以对你项目里的日期工具类进行一波优化了呢?
知识扩展
在上述代码中,我们通过创建一个线程池,来实现多线程循环打印日期的操作,但是我们创建方式你有没有留意。
ExecutorService executorService = Executors.newFixedThreadPool(100);
当你IDEA安装了阿里巴巴的代码规范检查插件时,使用Executors来创建线程池的话,会出现提示让你手动创建线程池。
![1699de90e11a6939?w=824&h=872&f=jpeg&s=68]()
因此,我们可以将创建线程池的代码改成:
ExecutorService executorService = new ThreadPoolExecutor(100, 100,0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
但是又会有提示,建议要为线程池中的线程设置名称:
![1699de90e1620d23?w=952&h=393&f=jpeg&s=37]()
改造之后的代码为:
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("thread-call-runner-%d").build();
ExecutorService executorService = new ThreadPoolExecutor(100, 100,0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
这里会有个问题,ThreadFactoryBuilder()在JDK1.8及之后被去除了,所以如果你的JDK低于1.8即可使用该方法,等于或高于1.8可采取其他方式设置线程名称,也可用其他方式手动创建线程池。
为什么要这样做
我们参考阿里巴巴的Java开发手册内容:
关于Executors
![1699de90e39f45d2?w=731&h=225&f=jpeg&s=33]()
关于线程名称
![1699de90e388fe09?w=574&h=181&f=png&s=304]()
再次简单进一步解读下:
![1699de90e40513c7?w=781&h=190&f=png&s=243]()
链表类型的阻塞队列,而我们看其构造函数发现,默认队列大小是整数的最大值!!!
![1699de90e9fdd9d8?w=794&h=513&f=jpeg&s=37]()
所以如果请求太多,队列很可能就耗费内存非常大导致OOM。
但是他们的线程数是固定的,而且一般不会太大,所以不会因为创建过多线程而导致OOM。
![1699de910016b37b?w=769&h=217&f=jpeg&s=19]()
其中第最大线程池大小是整数的最大值,因此线程可能不断创建,乃至到整数的最大值个线程,很容易导致OOM。其中工作队列使用的是 SynchronousQueue<E>,源码头部的注释中有说明(截取的部分)。
A {@linkplain BlockingQueue blocking queue} in which each insert operation must wait for a corresponding remove operation by another thread, and vice versa.
该类型的阻塞队列每一个插入操作必须等待对应的元素被另一个线程所移除,反之亦然。
因此阻塞队列不会无限拓展而导致OOM。
当我们学习和理解一些原则的同时,多注重源码分析!!!
·END·