阅读本文大概需要 3.2 分钟。
前言
日常开发中,我们经常需要使用时间相关类,想必大家对 SimpleDateFormat 并不陌生。主要是用它进行时间的格式化输出和解析,挺方便快捷的,但是 SimpleDateFormat 并不是一个线程安全的类。在多线程情况下,会出现异常,想必有经验的小伙伴也遇到过。
下面我们就来分析分析 SimpleDateFormat 为什么不安全?是怎么引发的?以及多线程下有那些 SimpleDateFormat 的解决方案?
先看看《阿里巴巴开发手册》对于 SimpleDateFormat 是怎么看待的
问题复现
一般我们在使用 SimpleDateFormat 的时候会把它定义为一个静态变量,避免频繁创建它们的对象实例,代码如下:
打印一下结果:
是不是感觉没什么毛病?相信大多数人都是这样使用的,也包括我。在单线程下自然没毛病了,但是运用到多线程下就有大问题了。
测试下:
控制台打印结果:
你看结果,发现了什么?直接崩了,部分线程获取的时间不对,部分线程报 java.lang.NumberFormatException:multiple points 错,线程直接挂死了。还有部分线程报 empty String 错,值有问题。
多线程不安全原因
因为我们把 SimpleDateFormat 定义为静态变量,那么多线程下 SimpleDateFormat 的实例就会被多个线程共享,B 线程会读取到 A 线程的时间,就会出现时间差异和其它各种问题。SimpleDateFormat 和它继承的 DateFormat 类也不是线程安全的。
来看看 SimpleDateFormat 的 format() 方法的源码:
注意,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 源码上作者也给过我们提示:
翻译过来的意思就是:
日期格式未同步。
建议为每个线程创建单独的格式实例。如果多个线程同时访问格式,则必须在外部同步
解决方案
只在需要的时候创建新实例,不用 static 修饰。
如上代码,仅在需要用到的地方创建一个新的实例,就没有线程安全问题,不过也加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。
采用 Synchronized 方式
简单粗暴,synchronized 往上一套也可以解决线程安全问题,缺点自然就是并发量大的时候会对性能有影响,线程阻塞。
ThreadLocal
ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。
基于 JDK1.8 的 DateTimeFormatter
也是《阿里巴巴开发手册》给我们的解决方案,对之前的代码进行改造:
运行结果就不贴了,不会出现报错和时间不准确的问题。
DateTimeFormatter 源码上作者也加注释说明了,他的类是不可变的,并且是线程安全的。
OK,现在是不是可以对你项目里的日期工具类进行一波优化了呢?
知识扩展
在上述代码中,我们通过创建一个线程池,来实现多线程循环打印日期的操作,但是我们创建方式你有没有留意。
ExecutorService executorService = Executors.newFixedThreadPool(100);
当你 IDEA 安装了阿里巴巴的代码规范检查插件时,使用 Executors 来创建线程池的话,会出现提示让你手动创建线程池。
因此,我们可以将创建线程池的代码改成:
ExecutorService executorService = new ThreadPoolExecutor(100, 100,0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
但是又会有提示,建议要为线程池中的线程设置名称:
改造之后的代码为:
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
关于线程名称
再次简单进一步解读下:
newFixedThreadPool 和 newSingleThreadExecutor 由于最后一个参数即工作队列是
链表类型的阻塞队列,而我们看其构造函数发现,默认队列大小是整数的最大值!!!
所以如果请求太多,队列很可能就耗费内存非常大导致 OOM。
但是他们的线程数是固定的,而且一般不会太大,所以不会因为创建过多线程而导致 OOM。
再来看下 newCachedThreadPool 和 newScheduledThreadPool
其中第最大线程池大小是整数的最大值,因此线程可能不断创建,乃至到整数的最大值个线程,很容易导致 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·
程序员的成长之路
路虽远,行则必至
本文原发于 同名微信公众号「程序员的成长之路」,回复「1024」你懂得,给个赞呗。
微信 ID:cxydczzl
往期精彩回顾
程序员接私活的 7 大平台利器
教你一招用 IDE 编程提升效率的骚操作!
大学期间的副业赚钱之道
一个对话让你明白架构师是做什么的?
作为程序员的你,一年看几本技术相关的书
5 个相见恨晚的 Linux 命令
为啥程序员下班后只关显示器从不关电脑?
送给程序员们的经典电子书大礼包
面试时如何优雅地自我介绍?
支撑百万并发的数据库架构如何设计?