ToB企服应用市场:ToB评测及商务社交产业平台

标题: SimpleDateFormat 线程安全问题修复方案 [打印本页]

作者: 立聪堂德州十三局店    时间: 2023-8-29 18:40
标题: SimpleDateFormat 线程安全问题修复方案
问题介绍

在日常的开发过程中,我们不可避免地会使用到 JDK8 之前的 Date 类,在格式化日期或解析日期时就需要用到 SimpleDateFormat 类,但由于该类并不是线程安全的,所以我们常发现对该类的不恰当使用会导致日期解析异常,从而影响线上服务可用率。
以下是对 SimpleDateFormat 类不恰当使用的示例代码:
  1. package com.jd.threadsafe;
  2. import java.text.SimpleDateFormat;
  3. import java.util.concurrent.atomic.AtomicBoolean;
  4. /**
  5. * @Date: 2023/7/25 10:47
  6. * @Desc: SimpleDateFormat 线程安全问题复现
  7. * @Version: V1.0
  8. **/
  9. public class SimpleDateFormatTest {
  10.     private static final AtomicBoolean STOP = new AtomicBoolean();
  11.     private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-M-d"); // 非线程安全
  12.     public static void main(String[] args) {
  13.         Runnable runnable = () -> {
  14.             int count = 0;
  15.             while (!STOP.get()) {
  16.                 try {
  17.                     FORMATTER.parse("2023-7-15");
  18.                 } catch (Exception e) {
  19.                     e.printStackTrace();
  20.                     if (++count > 3) {
  21.                         STOP.set(true);
  22.                     }
  23.                 }
  24.             }
  25.         };
  26.         new Thread(runnable).start();
  27.         new Thread(runnable).start();
  28.     }
  29. }
复制代码
以上代码模拟了多线程并发使用 SimpleDateFormat 实例的场景,此时可观察到如下异常输出:
  1. java.lang.NumberFormatException: For input string: ""
  2.         at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
  3.         at java.lang.Long.parseLong(Long.java:601)
  4.         at java.lang.Long.parseLong(Long.java:631)
  5.         at java.text.DigitList.getLong(DigitList.java:195)
  6.         at java.text.DecimalFormat.parse(DecimalFormat.java:2082)
  7.         at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
  8.         at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  9.         at java.text.DateFormat.parse(DateFormat.java:364)
  10.         at com.jd.threadsafe.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:21)
  11.         at java.lang.Thread.run(Thread.java:750)
  12. java.lang.NumberFormatException: For input string: ""
  13.         at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
  14.         at java.lang.Long.parseLong(Long.java:601)
  15.         at java.lang.Long.parseLong(Long.java:631)
  16.         at java.text.DigitList.getLong(DigitList.java:195)
  17.         at java.text.DecimalFormat.parse(DecimalFormat.java:2082)
  18.         at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
  19.         at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  20.         at java.text.DateFormat.parse(DateFormat.java:364)
  21.         at com.jd.threadsafe.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:21)
  22.         at java.lang.Thread.run(Thread.java:750)
  23. java.lang.NumberFormatException: multiple points
  24.         at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
  25.         at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
  26.         at java.lang.Double.parseDouble(Double.java:538)
  27.         at java.text.DigitList.getDouble(DigitList.java:169)
  28.         at java.text.DecimalFormat.parse(DecimalFormat.java:2087)
  29.         at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
  30.         at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  31.         at java.text.DateFormat.parse(DateFormat.java:364)
  32.         at com.jd.threadsafe.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:21)
  33.         at java.lang.Thread.run(Thread.java:750)
  34. java.lang.NumberFormatException: multiple points
  35.         at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
  36.         at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
  37.         at java.lang.Double.parseDouble(Double.java:538)
  38.         at java.text.DigitList.getDouble(DigitList.java:169)
  39.         at java.text.DecimalFormat.parse(DecimalFormat.java:2087)
  40.         at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
  41.         at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  42.         at java.text.DateFormat.parse(DateFormat.java:364)
  43.         at com.jd.threadsafe.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:21)
  44.         at java.lang.Thread.run(Thread.java:750)
  45. java.lang.NumberFormatException: For input string: ""
  46.         at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
  47.         at java.lang.Long.parseLong(Long.java:601)
  48.         at java.lang.Long.parseLong(Long.java:631)
  49.         at java.text.DigitList.getLong(DigitList.java:195)
  50.         at java.text.DecimalFormat.parse(DecimalFormat.java:2082)
  51.         at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
  52.         at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
  53.         at java.text.DateFormat.parse(DateFormat.java:364)
  54.         at com.jd.threadsafe.SimpleDateFormatTest.lambda$main$0(SimpleDateFormatTest.java:21)
  55.         at java.lang.Thread.run(Thread.java:750)
复制代码
以上异常的根本原因是因为 SimpleDateFormat 是有状态的,如 SimpleDateFormat 类中含有非线程安全的 NumberFormat 成员变量:
  1. /**
  2. * The number formatter that DateFormat uses to format numbers
  3. * in dates and times.  Subclasses should initialize this to a number format
  4. * appropriate for the locale associated with this DateFormat.
  5. * @serial
  6. */
  7. protected NumberFormat numberFormat;
复制代码
从 NumberFormat 的 Java Doc 中能看到如下描述:
Synchronization Number formats are generally 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.
从 SimpleDateFormat 的 Java Doc 中能看到如下描述:
Synchronization 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.
修复方案一:加锁(不推荐)
  1. package com.jd.threadsafe;
  2. import java.text.SimpleDateFormat;
  3. import java.util.concurrent.atomic.AtomicBoolean;
  4. /**
  5. * @Date: 2023/7/25 10:47
  6. * @Desc: SimpleDateFormat 线程安全修复方案:加锁
  7. * @Version: V1.0
  8. **/
  9. public class SimpleDateFormatLockTest {
  10.     private static final AtomicBoolean STOP = new AtomicBoolean();
  11.     private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-M-d"); // 非线程安全
  12.     public static void main(String[] args) {
  13.         Runnable runnable = () -> {
  14.             int count = 0;
  15.             while (!STOP.get()) {
  16.                 try {
  17.                     synchronized (FORMATTER) {
  18.                         FORMATTER.parse("2023-7-15");
  19.                     }
  20.                 } catch (Exception e) {
  21.                     e.printStackTrace();
  22.                     if (++count > 3) {
  23.                         STOP.set(true);
  24.                     }
  25.                 }
  26.             }
  27.         };
  28.         new Thread(runnable).start();
  29.         new Thread(runnable).start();
  30.     }
  31. }
复制代码
首先我们能想到的最简单的解决线程安全问题的修复方案即加锁,如以上修复方案,使用 synchronized 关键字对 FORMATTER 实例进行加锁,此时多线程进行日期格式化时退化为串行执行,保证了正确性牺牲了性能,不推荐。
修复方案二:栈封闭(不推荐)

如果按照文档中的推荐用法,可知推荐为每个线程创建独立的 SimpleDateFormat 实例,一种最简单的方式就是在方法调用时每次创建 SimpleDateFormat 实例,以实现栈封闭的效果,如以下示例代码:
  1. package com.jd.threadsafe;
  2. import java.text.SimpleDateFormat;
  3. import java.util.concurrent.atomic.AtomicBoolean;
  4. /**
  5. * @Date: 2023/7/25 10:47
  6. * @Desc: SimpleDateFormat 线程安全修复方案:栈封闭
  7. * @Version: V1.0
  8. **/
  9. public class SimpleDateFormatStackConfinementTest {
  10.     private static final AtomicBoolean STOP = new AtomicBoolean();
  11.     public static void main(String[] args) {
  12.         Runnable runnable = () -> {
  13.             int count = 0;
  14.             while (!STOP.get()) {
  15.                 try {
  16.                     new SimpleDateFormat("yyyy-M-d").parse("2023-7-15");
  17.                 } catch (Exception e) {
  18.                     e.printStackTrace();
  19.                     if (++count > 3) {
  20.                         STOP.set(true);
  21.                     }
  22.                 }
  23.             }
  24.         };
  25.         new Thread(runnable).start();
  26.         new Thread(runnable).start();
  27.     }
  28. }
复制代码
即将共用的 SimpleDateFormat 实例调整为每次创建新的实例,该修复方案保证了正确性但每次方法调用需要创建 SimpleDateFormat 实例,并未复用 SimpleDateFormat 实例,存在 GC 损耗,所以并不推荐。
修复方案三:ThreadLocal(推荐)

如果日期格式化操作是应用里的高频操作,且需要优先保证性能,那么建议每个线程复用 SimpleDateFormat 实例,此时可引入 ThreadLocal 类来解决该问题:
  1. package com.jd.threadsafe;
  2. import java.text.SimpleDateFormat;
  3. import java.util.concurrent.atomic.AtomicBoolean;
  4. /**
  5. * @Date: 2023/7/25 10:47
  6. * @Desc: SimpleDateFormat 线程安全修复方案:ThreadLocal
  7. * @Version: V1.0
  8. **/
  9. public class SimpleDateFormatThreadLocalTest {
  10.     private static final AtomicBoolean STOP = new AtomicBoolean();
  11.     private static final ThreadLocal<SimpleDateFormat> SIMPLE_DATE_FORMAT_THREAD_LOCAL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-M-d"));
  12.     public static void main(String[] args) {
  13.         Runnable runnable = () -> {
  14.             int count = 0;
  15.             while (!STOP.get()) {
  16.                 try {
  17.                     SIMPLE_DATE_FORMAT_THREAD_LOCAL.get().parse("2023-7-15");
  18.                 } catch (Exception e) {
  19.                     e.printStackTrace();
  20.                     if (++count > 3) {
  21.                         STOP.set(true);
  22.                     }
  23.                 }
  24.             }
  25.         };
  26.         new Thread(runnable).start();
  27.         new Thread(runnable).start();
  28.     }
  29. }
复制代码
执行上述代码,不会再观察到异常输出,因为已为每个线程创建了独立的 SimpleDateFormat 实例,即在线程维度复用了 SimpleDateFormat 实例,在线程池等池化场景下相比上方栈封闭的修复方案降低了 GC 损耗,同时也规避了线程安全问题。
以上使用 ThreadLocal 在线程维度复用非线程安全的实例可认为是一种通用的模式,可在 JDK 及不少开源项目中看到类似的模式实现,如在 JDK 最常见的 String 类中,对字符串进行编解码所需要用到的 StringDecoder 及 StringEncoder 即使用了 ThreadLocal 来规避线程安全问题:
  1. /**
  2. * Utility class for string encoding and decoding.
  3. */
  4. class StringCoding {
  5.     private StringCoding() { }
  6.     /** The cached coders for each thread */
  7.     private final static ThreadLocal<SoftReference<StringDecoder>> decoder =
  8.         new ThreadLocal<>();
  9.     private final static ThreadLocal<SoftReference<StringEncoder>> encoder =
  10.         new ThreadLocal<>();
  11.     // ...
  12. }
复制代码
参考:JDK8 - StringCoding
在 Dubbo 的 ThreadLocalKryoFactory 类中,在对非线程安全类 Kryo 的使用中,也使用了 ThreadLocal 类来规避线程安全问题:
  1. package org.apache.dubbo.common.serialize.kryo.utils;
  2. import com.esotericsoftware.kryo.Kryo;
  3. public class ThreadLocalKryoFactory extends AbstractKryoFactory {
  4.     private final ThreadLocal<Kryo> holder = new ThreadLocal<Kryo>() {
  5.         @Override
  6.         protected Kryo initialValue() {
  7.             return create();
  8.         }
  9.     };
  10.     @Override
  11.     public void returnKryo(Kryo kryo) {
  12.         // do nothing
  13.     }
  14.     @Override
  15.     public Kryo getKryo() {
  16.         return holder.get();
  17.     }
  18. }
复制代码
参考:Dubbo - ThreadLocalKryoFactory
类似地,在 HikariCP 的 ConcurrentBag 类中,也用到了 ThreadLocal 类来规避线程安全问题,此处不再进一步展开。
修复方案四:FastDateFormat(推荐)

针对 SimpleDateFormat 类的线程安全问题,apache commons-lang 提供了 FastDateFormat 类。其部分 Java Doc 如下:
FastDateFormat is a fast and thread-safe version ofSimpleDateFormat. To obtain an instance of FastDateFormat, use one of the static factory methods:getInstance(String, TimeZone, Locale),getDateInstance(int, TimeZone, Locale),getTimeInstance(int, TimeZone, Locale), orgetDateTimeInstance(int, int, TimeZone, Locale) Since FastDateFormat is thread safe, you can use a static member instance: private static final FastDateFormat DATE_FORMATTER = FastDateFormat.getDateTimeInstance(FastDateFormat.LONG, FastDateFormat.SHORT); This class can be used as a direct replacement toSimpleDateFormatin most formatting and parsing situations. This class is especially useful in multi-threaded server environments.SimpleDateFormatis not thread-safe in any JDK version, nor will it be as Sun have closed the bug/RFE. All patterns are compatible with SimpleDateFormat (except time zones and some year patterns - see below).
该修复方案相对来说代码改造最小,仅需在声明静态 SimpleDateFormat 实例代码处将 SimpleDateFormat 实例替换为 FastDateFormat 实例,示例代码如下:
  1. package com.jd.threadsafe;
  2. import org.apache.commons.lang3.time.FastDateFormat;
  3. import java.util.concurrent.atomic.AtomicBoolean;
  4. /**
  5. * @Date: 2023/7/6 20:05
  6. * @Desc: SimpleDateFormat 线程安全修复方案:FastDateFormat
  7. * @Version: V1.0
  8. **/
  9. public class FastDateFormatTest {
  10.     private static final AtomicBoolean STOP = new AtomicBoolean();
  11.     private static final FastDateFormat FORMATTER = FastDateFormat.getInstance("yyyy-M-d");
  12.     public static void main(String[] args) {
  13.         Runnable runnable = () -> {
  14.             int count = 0;
  15.             while (!STOP.get()) {
  16.                 try {
  17.                     FORMATTER.parse("2023-7-15");
  18.                 } catch (Exception e) {
  19.                     e.printStackTrace();
  20.                     if (++count > 3) {
  21.                         STOP.set(true);
  22.                     }
  23.                 }
  24.             }
  25.         };
  26.         new Thread(runnable).start();
  27.         new Thread(runnable).start();
  28.     }
  29. }
复制代码
执行上述代码,不会再观察到异常输出,因为 FastDateFormat 是线程安全的实现,支持多线程并发调用。
总结

无论使用哪种修复方案,都需要在修改后进行充分的测试,保证修复后不影响原有业务逻辑,如通过单元测试、流量回放等方式来保证本次修复的正确性。
思考

代码里使用 SimpleDateFormat 类的原因是因为日期使用了 Date 类,与 Date 相配套的 JDK 格式化类即 SimpleDateFormat 类,如果我们在处理日期时使用 JDK8 引入的 LocalDateTime 等不可变日期类,那么格式化将使用配套的线程安全的 DateTimeFormatter 类,从根源上规避掉对非线程安全类 SimpleDateFormat 类的使用。
作者:京东物流 刘建设 张九龙 田爽
来源:京东云开发者社区 自猿其说Tech 转载请注明来源

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4