Java 多线程(7): ThreadLocal 的应用及原理

在涉及到多线程须要共享变量的时候,通常有两种方法:其一就是使用互斥锁,使得在每一个时刻只能有一个线程访问该变量,好处就是便于编码(直接使用 synchronized 关键字进行同步访问),缺点在于这增长了线程间的竞争,下降了效率;其二就是使用本文要讲的 ThreadLocal。若是说 synchronized 是以“时间换空间”,那么 ThreadLocal 就是 “以空间换时间” —— 由于 ThreadLocal 的原理就是为每一个线程都提供一个这样的变量,使得这些变量是线程级别的变量,不一样线程之间互不影响,从而达到能够并发访问而不出现并发问题的目的。数据库


首先咱们来看一个客观的事实:当一个可变对象被多个线程访问时,可能会获得非预期的结果 —— 因此先让咱们来看一个例子。在讲到并发访问的问题的时候,SimpleDateFormat 老是会被拿来当成一个绝好的例子(从这点看感谢 JDK 提供了这么一个有设计缺陷的类方便咱们当成反面教材 :) )。由于 SimpleDateFormatformatparse 方法共享从父类 DateFormat 继承而来的 Calendar 对象:
DateFormat 的 Calendar 对象数组

而且在 formatparse 方法中都会改变这个 Calendar 对象:安全

  • format 方法片断:

format 方法片断

  • parse 方法片断:

parse 方法片断

就拿 format 方法来讲,考虑以下的并发情景:多线程

  • 线程A 此时调用 calendar.setTime(date1),而后 线程A 被中断;
  • 接着 线程B 执行,而后调用 calendar.setTime(date2),而后 线程B 被中断;
  • 接着又是 线程A 执行,可是此时的 calendar 已经和以前的不一致了,因此便致使了并发问题。

因此由于这个共享的 calendar 对象,SimpleDateFormat 并非一个线程安全的类,咱们写一段代码来测试下。并发

(1)定义 DateFormatWrapper 类,来包装对 SimpleDateFormat 的调用:app

public class DateFormatWrapper {

    private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return SDF.format(date);
    }

    public static Date parse(String str) throws ParseException {
        return SDF.parse(str);
    }
    
}

(2)而后写一个 DateFormatTest,开启多个线程来使用 DateFormatWrapperide

public class DateFormatTest {

    public static void main(String[] args) throws Exception {
        ExecutorService threadPool = Executors.newCachedThreadPool(); // 建立无大小限制的线程池

        List<Future<?>> futures = new ArrayList<>();

        for (int i = 0; i < 9; i++) {
            DateFormatTask task = new DateFormatTask();
            Future<?> future = threadPool.submit(task); // 将任务提交到线程池

            futures.add(future);
        }

        for (Future<?> future : futures) {
            try {
                future.get();
            } catch (ExecutionException ex) { // 运行时若是出现异常则进入 catch 块
                System.err.println("执行时出现异常:" + ex.getMessage());
            }
        }

        threadPool.shutdown();
    }

    static class DateFormatTask implements Callable<Void> {

        @Override
        public Void call() throws Exception {
            String str = DateFormatWrapper.format(
                    DateFormatWrapper.parse("2017-07-17 16:54:54"));
            System.out.printf("Thread(%s) -> %s\n", Thread.currentThread().getName(), str);

            return null;
        }

    }
}

某次运行的结果:
某次运行的结果测试

能够发现,SimpleDateFormat 在多线程共享的状况下,不只可能会出现结果错误的状况,还可能会因为并发访问致使运行异常。固然,咱们确定有解决的办法:this

  1. DateFormatWrapperformatparse 方法加上 synchronized 关键字,坏处就是前面提到的这会加大线程间的竞争和切换而下降效率;
  2. 不使用全局的 SimpleDateFormat 对象,而是每次使用 formatparse 方法都新建一个 SimpleDateFormat 对象,坏处也很明显,每次调用 format 或者 parse 方法都要新建一个 SimpleDateFormat,这会加大 GC 的负担;
  3. 使用 ThreadLocalThreadLocal<SimpleDateFormat> 能够为每一个线程提供一个独立的 SimpleDateFormat 对象,建立的 SimpleDateFormat 对象个数最多和线程个数相同,相比于 (1),使用ThreadLocal不存在线程间的竞争;相比于 (2),使用ThreadLocal建立的 SimpleDateFormat 对象个数也更加合理(不会超过线程的数量)。

咱们使用 ThreadLocal 来对 DateFormatWrapper 进行修改,使得每一个线程使用单独的 SimpleDateFormat编码

public class DateFormatWrapper {

    private static final ThreadLocal<SimpleDateFormat> SDF = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }

    };

    public static String format(Date date) {
        return SDF.get().format(date);
    }

    public static Date parse(String str) throws ParseException {
        return SDF.get().parse(str);
    }

}

若是使用 Java8,则初始化 ThreadLocal 对象的代码能够改成:

private static final ThreadLocal<SimpleDateFormat> SDF
            = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

而后再运行 DateFormatTest,便始终是预期的结果:
正确的结果


咱们已经看到了 ThreadLocal 的功能,那 ThreadLocal 是如何实现为每一个线程提供一份共享变量的拷贝呢?

在使用 ThreadLocal 时,当前线程访问 ThreadLocal 中包含的变量是经过 get() 方法,因此首先来看这个方法的实现:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

经过代码能够猜想:

  • 在某个地方(其实就是在 ThreadLocal 的内部),JDK 实现了一个相似于 HashMap 的类,叫 ThreadLocalMap,该 “Map” 的键类型为 ThreadLocal<T>,值类型为 T
  • 而后每一个线程都关联着一个 ThreadLocalMap 对象,而且能够经过 getMap(Thread t) 方法来得到 线程t 关联的 ThreadLocalMap 对象;
  • ThreadLocalMap 类有个以 ThreadLocal 对象为参数的 getEntry(ThreadLocal) 的方法,用来得到当前 ThreadLocal 对象关联的 Entry 对象。一个 Entry 对象就是一个键值对,键(key)是 ThreadLocal 对象,值(value)是该 ThreadLocal 对象包含的变量(即 T)。

查看 getMap(Thread) 方法:
getMap(Thread)

直接返回的就是 t.threadLocals,原来在 Thread 类中有一个就叫 threadLocalsThreadLocalMap 的变量:
 Thread 的 threadLocals 变量

因此每一个 Thread 都会拥有一个 ThreadLocalMap 变量,来存放属于该 Thread 的全部 ThreadLocal 变量。这样来看的话,ThreadLocal就至关于一个调度器,每次调用 get 方法的时候,都会先找到当前线程的 ThreadLocalMap,而后再在这个 ThreadLocalMap 中找到对应的线程本地变量。

ThreadLocal 的 get() 方法的流程

而后咱们来看看当 mapnull(即第一次调用 get())时调用的 setInitialValue() 方法:

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

该方法首先会调用 initialValue() 方法来得到该 ThreadLocal 对象中须要包含的变量 —— 因此这就是为何使用 ThreadLocal 是须要继承 ThreadLocal 时并覆写 initialValue() 方法,由于这样才能让 setInitialValue() 调用 initialValue() 从而获得 ThreadLocal 包含的初始变量;而后就是当 map 不为 null 的时候,将该变量(value)与当前ThreadLocal对象(this)在 map 中进行关联;若是 mapnull,则调用 createMap 方法:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

createMap 会调用 ThreadLocalMap 的构造方法来建立一个 ThreadLocalMap 对象:
ThreadLocalMap 的构造方法

能够看到该方法经过一个 ThreadLocal 对象(firstKey)和该 ThreadLocal 包含的对象(firstValue)构造了一个 ThreadLocalMap 对象,使得该 map 在构造完毕时候就包含了这样一个键值对(firstKey -> firstValue)。


为啥须要使用 Map 呢?由于一个线程可能有多个 ThreadLocal 对象,多是包含 SimpleDateFormat,也多是包含一个数据库链接 Connection,因此不一样的变量须要经过对应的 ThreadLocal 对象来快速查找 —— 那么 Map 固然是最好的方式。


ThreadLocal 还提供了修改和删除当前包含对象的方法,修改的方法为 set,删除的方法为 remove

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

很好理解,若是当前 ThredLocal 尚未包含值,那么就调用 createMap 来初始化当前线程的 ThreadLocalMap 对象,不然直接在 map 中修改当前 ThreadLocalthis)包含的值。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

remove 方法就是得到当前线程的 ThreadLocalMap 对象,而后调用这个 mapremove(ThreadLocal) 方法。查看 ThreadLocalMapremove(ThreadLocal) 方法的实现:
remove(ThreadLocal)

逻辑就是先找到参数(ThreadLocal对象)对应的 Entry,而后调用 Entryclear() 方法,再调用 expungeStaleEntry(i)i 为该 EntrymapEntry 数组中的索引。

(1)首先来看看 e.clear() 作了什么。

查看 ThreadLocalMap 的源代码,咱们能够发现这个 “Map” 的 Entry 的实现以下:
Entry 的实现

能够看到,该 Entry 类继承自 WeakReference<ThreadLocal<?>>,因此 Entry 是一个 WeakReference(弱引用),并且该 WeakReference 包含的是一个 ThreadLocal 对象 —— 于是每一个 Entry 是一个弱引用的 ThreadLocal 对象(又由于 Entry 包括了一个 value 变量,因此该 Entry 构成了一个 ThreadLocal -> Object 的键值对),而 Entryclear() 方法,是继承自 WeakReference,做用就是将 WeakReference 包含的对象的引用设置为 null

clear() 方法

咱们知道对于一个弱引用的对象,一旦该对象再也不被其余对象引用(好比像 clear() 方法那样将对象引用直接设置为 null),那么在 GC 发生的时候,该对象便会被 GC 回收。因此让 Entry 做为一个 WeakReference,配合 ThreadLocalremove 方法,能够及时清除某个 Entry 中的 ThreadLocalEntrykey)。

(2)expungeStaleEntry(i)的做用

先来看 expungeStaleEntry 的前一半代码:

expungeStaleEntry 的前一半代码

expungeStaleEntry 这部分代码的做用就是将 i 位置上的 Entryvalue 设置为 null,以及将 Entry 的引用设置为 null。为何要这作呢?由于前面调用 e.clear(),只是将 Entrykey 设置为 null 而且可使其在 GC 是被快速回收,可是 Entryvalue 在调用 e.clear() 后并不会为 null —— 因此若是不对 value 也进行清除,那么就可能会致使内存泄漏了。所以expungeStaleEntry 方法的一个做用在于能够把须要清除的 Entry 完全的从 ThreadLocalMap 中清除(keyvalueEntry 所有设置为 null)。可是 expungeStaleEntry 还有另外的功能:看 expungeStaleEntry 的后一半代码:

expungeStaleEntry 的后一半代码

做用就是扫描位置 staleSlot 以后的 Entry 数组(直到某一个为 null 的位置),清除每一个 keyThreadLocal) 为 nullEntry,因此使用 expungeStaleEntry 能够下降内存泄漏的几率。可是若是某些 ThreadLocal 变量不须要使用可是却没有调用到 expungeStaleEntry 方法,那么就会致使这些 ThreadLocal 变量长期的贮存在内存中,引发内存浪费或者泄露 —— 因此,若是肯定某个 ThreadLocal 变量已经不须要使用,须要及时的使用 ThreadLocalremove() 方法(ThreadLocalgetset 方法也会调用到 expungeStaleEntry),将其从内存中清除。

相关文章
相关标签/搜索