《Effective Java》第二章 创建和销毁对象第5条提到,在开发过程中,需要避免创建不必要的对象,最好能重用对象而非在每次需要的时候就创建一个相同功能的新对象。如果对象是不可变的,它就始终可以被重用,而不用创建新的实例,从而降低内存占用和垃圾回收的成本。例如,创建字符串
// Don't do this! String s = new String("stringette"); // 优化版 String s = "stringette";
第二行代码每次被执行的时候都创建一个新的String实例,实属画蛇添足。传递给String构造器的参数“stringette”本身就是一个String实例,功能等价于构造器创建的所有对象。第四行代码只用了一个String实例,而非每次被创建的时候都创建一个新的实例,并且,对于所有在同一台虚拟机中运行的代码,只要他们包含相同的字符串字面常量,对象“stringette”就会被重用。
但是,对于可变的对象,就不可以这样处理了,如经常使用的时间相关类SimpleDateFormat,主要用它进行时间的格式化输出 (date -> text)和解析(text -> date),方便快捷。为了避免频繁创建对象实例,一般会把SimpleDateFormat定义为一个静态变量,如下文提到的方法DateUtils.format(Date date)和DateUtils.parse(String strDate)。但是SimpleDateFormat并不是一个线程安全的不可变类,在多线程情况下会出现异常,因此,不可以野蛮的套用第5条规则,要因地制宜。下面我们就来分析分析SimpleDateFormat为什么不安全以及多线程下有哪些安全的解决方案。
import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class DateUtils { // private static final Log logger = LogFactory.getLog(DateUtils.class); public static final String YYYYMMDHHMMSS = "yyyy-MM-dd HH:mm:ss"; private static final SimpleDateFormat sdf = new SimpleDateFormat(YYYYMMDHHMMSS);// 臭味道 private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern(YYYYMMDHHMMSS); private static ThreadLocal<SimpleDateFormat> local = new ThreadLocal<SimpleDateFormat>(); /** * 线程不安全 */ public static String format(Date date) throws ParseException { return sdf.format(date); } /** * 线程不安全 * parse 并不是一个原子性的操作 */ public static Date parse(String strDate) throws ParseException { return sdf.parse(strDate); } /** * 线程安全 * 使用 DateTimeFormatter */ public static String safeDtfFormatDate(String dateStr) throws ParseException { LocalDate date1 = LocalDate.parse(dateStr, dtf); return dtf.format(date1); } /** * 线程安全 * 使用 DateTimeFormatter */ public static Date safeDtfParse(String strDate) throws ParseException { return (Date) dtf.parse(strDate); } /** * 线程安全 * 将SimpleDateFormat定义成局部变量 */ public static Date safeParseDate(String strDate, String pattern) { Date date = null; try { if (pattern == null) { pattern = YYYYMMDHHMMSS; } SimpleDateFormat format = new SimpleDateFormat(pattern); date = format.parse(strDate); } catch (Exception e) { // logger.error("parseDate error:" + e); } return date; } public static Date localParse(String str) throws Exception { SimpleDateFormat sdf = local.get(); if (sdf == null) { sdf = new SimpleDateFormat(YYYYMMDHHMMSS, Locale.US); local.set(sdf); } return sdf.parse(str); } public static String localFormat(Date date) throws Exception { SimpleDateFormat sdf = local.get(); if (sdf == null) { sdf = new SimpleDateFormat(YYYYMMDHHMMSS, Locale.US); local.set(sdf); } return sdf.format(date); } /** * 使用 synchronized */ public static String syncFormat(Date date) throws ParseException { synchronized (sdf) { return sdf.format(date); } } public static Date syncParse(String strDate) throws ParseException { synchronized (sdf) { return sdf.parse(strDate); } } private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static String formatDate2(LocalDateTime date) { return formatter.format(date); } public static LocalDateTime parse2(String dateNow) { return LocalDateTime.parse(dateNow, formatter); } }
public class DateFormatTest { private static final Log log = LogFactory.getLog(DateFormatTest.class); public static void main(String[] args) throws Exception { multiThreadFormat(); multiThreadParse(); } private static void multiThreadParse() throws Exception { ExecutorService service = Executors.newFixedThreadPool(100); String myDateStr = "2018-07-02 09:45:56"; for (int i = 0; i < 20; i++) { service.execute(() -> { for (int j = 0; j < 10; j++) { try { Date parsedDate = DateUtils.parse(myDateStr); System.out.println("parsedDate = " + parsedDate); } catch (Exception e) { System.out.println("Error parse,-------------- ," + e); } } }); } log.info("格式化 parse 结束,myDateStr = " + myDateStr); // 等待上述的线程执行完 service.shutdown(); service.awaitTermination(1, TimeUnit.DAYS); } private static void multiThreadFormat() throws Exception { ExecutorService service = Executors.newFixedThreadPool(100); String myDateStr = "2018-07-02 09:45:56"; Date myDate = DateUtils.safeParseDate(myDateStr, null); for (int i = 0; i < 210; i++) { service.execute(() -> { for (int j = 0; j < 20; j++) { try { String formatedDate = DateUtils.format(myDate); if (!myDateStr.equals(formatedDate)) { log.info("格式化失败了,formatedDate = " + formatedDate); } } catch (Exception e) { System.out.println("Error,-------------- ," + e); } } }); } log.info("格式化 format 结束,myDateStr = " + myDateStr); // 等待上述的线程执行完 service.shutdown(); service.awaitTermination(1, TimeUnit.DAYS); } }
问题场景复现
DateUtils.format(Date date)和DateUtils.parse(String strDate在单线程下执行自然没毛病了,但是运用到多线程下就有大问题了。multiThreadParse执行后,控制台打印结果如下:
你看解析日期的时候这不崩了?部分线程获取的时间不对,部分线程直接报java.lang.NumberFormatException 错。
并发问题
SimpleDateFormat和它继承的DateFormat类都是线程不安全的。DateUtils中,我们把SimpleDateFormat定义成static成员变量sdf,那么多个thread之间会共享sdf, 所以Calendar对象也会共享。假设线程A和线程B都进入了parse(text, pos) 方法, 线程B执行到calendar.clear()后,线程A执行到calendar.getTime(), 那么就会有问题。
下面通过format方法的源码分析为什么线程不安全。
@Override 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变量,所以,它是有状态的。
这也同时提醒我们在开发和设计系统的时候注意下以下三点:
1.写公用类的时候,要对多线程调用情况下的结果在注释里进行明确说明;
2.在多线程环境下,对每一个共享的可变变量都要注意其线程安全性;
3.类和方法在做设计的时候,要尽量设计成无状态的。
其实SimpleDateFormat源码上作者也给过我们提示:
Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.
意思就是日期格式化方法不同步,故建议为每个线程创建私有的实例。如果多个线程同时访问一种格式,则必须在外部为该格式加锁。
解决方案
-
使用局部变量
如方法safeParseDate所示,仅在需要用到的地方创建一个新的实例,就没有线程安全问题,不过这加重了创建对象的负担,会频繁地创建和销毁对象,效率较低。
-
使用synchronized
syncFormat和syncParse两个方法简单粗暴,synchronized往上一套同样可以解决线程安全问题,缺点不言自明,并发量大的时候会 导致线程阻塞,限制系统性能。
-
ThreadLocal
ThreadLocal可以确保每个线程都可以得到单独的一个SimpleDateFormat的对象,那么自然也就不存在竞争问题了。用法请参考localParse和localFormat。
-
基于JDK1.8的DateTimeFormatter
jdk 1.8 中新增了 LocalDate 与 LocalDateTime等类以解决日期格式转换问题,同时引入了一个新的类DateTimeFormatter来解决日期格式化问题。LocalDateTime和DateTimeFormatter两个类都没有线程问题,除非把它们创建为共享变量。可以使用Instant代替 Date,LocalDateTime代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat。
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy MM dd"); LocalDate date = LocalDate.parse("2017 06 17", formatter); System.out.println(formatter.format(date));
下面是《阿里巴巴开发手册》给我们的解决方案,对之前的代码进行改造:
public class SimpleDateFormatTest { private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static String formatDate2(LocalDateTime date) { return formatter.format(date); } public static LocalDateTime parse2(String dateNow) { return LocalDateTime.parse(dateNow, formatter); } public static void main(String[] args) throws InterruptedException, ParseException { ExecutorService service = Executors.newFixedThreadPool(100); // 20个线程 for (int i = 0; i < 20; i++) { service.execute(() -> { for (int j = 0; j < 10; j++) { try { System.out.println(parse2(formatDate2(LocalDateTime.now()))); } catch (Exception e) { e.printStackTrace(); } } }); } // 等待上述的线程执行完 service.shutdown(); service.awaitTermination(1, TimeUnit.DAYS); } }
DateTimeFormatter源码上作者也加注释说明了,此类是不可变的,并且是线程安全的。
This class is immutable and thread-safe.
总结
在开发中,复用对象的前提是当且仅当对应的类是不可变类;不可变类本质上是线程安全的,应当尽可能使用。Java 8 提供的不可变时间类是一种解决日期并发编程问题的最佳实践,简单安全。