Bootstrap

java多线程_SimpleDateFormat类线程不安全分析

SimpleDateFormat 使用说明

在JAVA的江湖世界,虽然日期转换工具类如此纷繁复杂;但是作为初入世事的JAVA程序员,了解到的第一款日期格式化武器毫无疑问是 “SimpleDateFormat”; 毫无疑问的是 SimpleDateFormat 帮助我们解决了太多太多的日期转换问题。在此向它致敬;

也许在某个深夜,你在研读一些JAVA的经典著作,忽然一行文字引入眼帘——“SimpleDateFormat 线程不安全”,你不禁打了个寒颤,my god。或许,你上线了无数个不安全的 “SimpleDateFormat”,然而它并没有对你的生产环境造成任何实质性的影响。如果是这样,我想你是幸运的,因为你的系统的并发能力还不至于重现 “SimpleDateFormat” 的不安全的场景。

然而作为一个有志的JAVA程序员。你必须要避免任何导致程序异常的情况发生。

所以我们要做的是:
第一步:重现 SimpleDateFormat 多线程问题。
第二步: why? SimpleDateFormat 为什么不安全。
第三步:怎么解决这个问题。

ok,现在让我们来开始探讨这个问题。

SimpleDateFormat 多线程问题重现

好的,我们通过线程池构建一个多线程访问环境,来看看SimpleDateFormat究竟会发生那些问题?

代码如下:

package com.java.thread.demo003;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 *
 * SimpleDateFormat 线程不安全问题重现
 * */
public class Demo003_a {

    private static SimpleDateFormat simpleDateFormat
            =  new SimpleDateFormat("yyyy-MM-dd");

    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i=0; i< 10; i++){
            executors.execute(()->{

                try {
                    simpleDateFormat.parse("2023-02-08");
                    System.out.println(Thread.currentThread().getName() + " 日期格式化成功");
                } catch (ParseException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }catch (NumberFormatException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }

            });
        }

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executors.shutdown();
        System.out.println("所有线程格式化日期成功!!");
    }
}

运行结果如下所示,当然失败的姿势多种多样。如果你运气好的话,也有可能运行成功。

"D:\Program Files\Java\jdk1.8.0_181\bin\java.exe" "-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.3\lib\idea_rt.jar=49240:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.3\bin" -Dfile.encoding=UTF-8 -classpath "D:\Program Files\Java\jdk1.8.0_181\jre\lib\charsets.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\deploy.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\access-bridge-64.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\cldrdata.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\dnsns.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jaccess.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jfxrt.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\localedata.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\nashorn.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunec.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunjce_provider.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunmscapi.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunpkcs11.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\zipfs.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\javaws.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\jce.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\jfr.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\jfxswt.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\jsse.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\management-agent.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\plugin.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\resources.jar;D:\Program Files\Java\jdk1.8.0_181\jre\lib\rt.jar;D:\study_note\Code_JavaStudy\Java\target\classes;D:\repository\org\antlr\antlr4-runtime\4.10.1\antlr4-runtime-4.10.1.jar;D:\repository\org\openjdk\jol\jol-core\0.14\jol-core-0.14.jar;D:\repository\org\projectlombok\lombok\1.18.0\lombok-1.18.0.jar" com.java.thread.demo003.Demo003_a
pool-1-thread-1 解析失败
pool-1-thread-2 日期格式化成功
pool-1-thread-3 解析失败
pool-1-thread-4 日期格式化成功
pool-1-thread-5 日期格式化成功
pool-1-thread-6 日期格式化成功
pool-1-thread-7 日期格式化成功
pool-1-thread-8 日期格式化成功
pool-1-thread-9 日期格式化成功
pool-1-thread-10 日期格式化成功
java.lang.NumberFormatException: For input string: "2200022333.E2200022333E44"
	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.java.thread.demo003.Demo003_a.lambda$main$0(Demo003_a.java:24)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: For input string: "2200022333.E2200022333E44"
	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.java.thread.demo003.Demo003_a.lambda$main$0(Demo003_a.java:24)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

Process finished with exit code 1

为什么parse方法解析的对象不是合法的日期字符串类型,而是一些奇奇怪怪的东西了?为了找到相应的原因,我们必须进入到SimpleDateFormat的源代码来进行分析。

SimpleDateFormat 多线程问题分析

首先我们进入SimpleDateFormat的parse方法,然后查看parse()接近最后的部分

@Override
public Date parse(String text, ParsePosition pos)
{
    Date parsedDate;
    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();
            }
        }
    }
    // An IllegalArgumentException will be thrown by Calendar.getTime()
    // if any fields are out of range, e.g., MONTH == 17.
    catch (IllegalArgumentException e) {
        pos.errorIndex = start;
        pos.index = oldStart;
        return null;
    }

    return parsedDate;
}
    

最后的返回值是通过 calb.establish(calendar).getTime() 获取到的;

接下来我们分析一下CalendarBuilder的这个方法

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;
}

可以看到,在establish中先调用了cal.clear(),后调用了cal.set(),cal 是Calendar的对象,是一个
成员实例。

不幸的是Calendar是线程不安全的,当多个线程同时操作一个SimpleDateFormat的时候,会引起cal的混乱。

SimpleDateFormat 多线程问题解决

从上面我们验证的SimpleDateFormat的线程问题,并且简单的分析了造成线程不安全问题的原因。

那么我们如何在代码编写工作中去规避这个问题了?

当然解决问题的方式永远是多种多样的,下面只是简单的列举了众多解决方法中的几种,如果你有更加好的解决办法,请及时留言,我将感谢万分!!!

局部变量法

package com.java.thread.demo003;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 *
 * SimpleDateFormat 线程不安全问题重现
 * */
public class Demo003_b {


    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i=0; i< 10; i++){
            executors.execute(()->{

                SimpleDateFormat simpleDateFormat
                        =  new SimpleDateFormat("yyyy-MM-dd");
                try {
                    simpleDateFormat.parse("2023-02-08");
                    System.out.println(Thread.currentThread().getName() + " 日期格式化成功");
                } catch (ParseException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }catch (NumberFormatException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }

            });
        }

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executors.shutdown();
        System.out.println("所有线程格式化日期成功!!");
    }
}

很显然这样能够规避 SimpleDateFormat 线程不安全的问题,但是这对于高并发场景并不友好;因为其会创建很多的SimpleDateFormat对象。

synchronized 锁方式

package com.java.thread.demo003;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 *
 * SimpleDateFormat 线程不安全问题重现
 * */
public class Demo003_c {

    private static SimpleDateFormat simpleDateFormat
            =  new SimpleDateFormat("yyyy-MM-dd");

    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i=0; i< 10; i++){
            executors.execute(()->{

                try {
                    synchronized (simpleDateFormat) {
                        simpleDateFormat.parse("2023-02-08");
                    }
                    System.out.println(Thread.currentThread().getName() + " 日期格式化成功");
                } catch (ParseException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }catch (NumberFormatException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }

            });
        }

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executors.shutdown();
        System.out.println("所有线程格式化日期成功!!");
    }
}

这种方式虽然不用像局部变量法那样创建很多的对象,但是会造成其它线程的等待,同样对高并发场景不太友好。

ThreadLocal方式

package com.java.thread.demo003;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 *
 * SimpleDateFormat 线程不安全问题重现
 * */
public class Demo003_d {

    private static final ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i=0; i< 10; i++){
            executors.execute(()->{

                try {
                    threadLocal.get().parse("2023-02-08");
                    System.out.println(Thread.currentThread().getName() + " 日期格式化成功");
                } catch (ParseException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }catch (NumberFormatException e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }

            });
        }

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executors.shutdown();
        System.out.println("所有线程格式化日期成功!!");
    }
}

使用ThreadLocal存储每个线程拥有的SimpleDateFormat的对象的副本,能够有效的避免线程不安全的问题。

这种方式运行效率比较高,推荐在高并发业务场景使用。

DateTimeFormatter方式

DateTimeFormatter 是java8新提供的日期格式化武器;是线程安全的,且适合高并发场景。

package com.java.thread.demo003;

import java.text.ParseException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 *
 * SimpleDateFormat 线程不安全问题重现
 * */
public class Demo003_e {


    private static DateTimeFormatter formatter =
            DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i=0; i< 10; i++){
            executors.execute(()->{

                try {
                    LocalDate.parse("2023-02-08",formatter);
                    System.out.println(Thread.currentThread().getName() + " 日期格式化成功");
                } catch (Exception e) {
                    System.out.println(Thread.currentThread().getName() + " 解析失败");
                    e.printStackTrace();
                    System.exit(1);
                }

            });
        }

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executors.shutdown();
        System.out.println("所有线程格式化日期成功!!");
    }
}

参考

<<深入理解高并发编程>>

;