多线程知识梳理(5),当我们谈到ThreadLocal的时候,我们在谈什么?

一、ThreadLocal是什么

从名称来看ThreadLocal的直接翻译就是线程本地,可以粗糙的理解成当前现成的本地数据,是不和其他线程共享的数据。但是这么理解是不是太片面呢,这里我们看一下JDK源码对ThreadLocal的注释是什么吧。

1. JDK源码说明

/**
 * 
 * 这个类提供线程局部变量。这些变量与普通的变量不同,因为每个访问的线程(通过其get或set方法)都有
 * 自己的独立初始化的变量副本。ThreadLocal实例通常是希望将状态与线程关联的类中的私有静态
 * (private static)字段(例如:一个用户ID或事务ID)。
 * 
 * 只要线程存活并且ThreadLocal实例可以访问,每个线程都保存对其线程局部变量副本的隐含引用; 
 * 线程结束之后,线程本地实例的所有副本都将被垃圾回收(除非存在对这些副本的其他引用)。
 * @author  Josh Bloch and Doug Lea
 * @since   1.2
 */

2. 个人理解

根据Jdk源码注释,我们可以得到以下理解:

  1. 每一个线程都在Threadlocal中单独存储,将ThreadLocal对象作为key,将存储的类型作为Value直接存储到ThreadLocalMap中;
  2. 由于是按照线程进行区分的,各个线程之间的变量不会互相影响;
  3. 因为ThreadLocal是和线程绑定的,如果是使用线程池的话,如果之前的线程没有在使用结束的时候执行remove操作,等到线程池再轮循到这个线程的时候,可能会读取到脏数据;
  4. 一个ThreadLocal在同一个线程中只能存储一个对象,如果多次执行set操作,后面存储的对象会覆盖前面存储的对象
  5. 由于这种kv的数据结构,我们可以粗略的将ThreadLocal理解成一个HashMap,只不过key是Threadlocal本身而已,有趣的时候,ThreadLocal在执行set的时候,也会执行自己的Hash寻址算法,这点和Hashmap很像。

3. 使用场景

如果你希望构造这样一个对象,将这个对象设置为共享变量,并统一设置初始值。但是你还希望每个线程对这个值的修改都是互相的独立的。那么这个对象就是ThreadLocal

4. ThreadLocal和Thread的关系

ThreadLocal有一个静态内部类叫ThreadLocalMap,它还有一个静态内部类Entry,在Thread中的ThreadLocalMap属性的赋值是在ThreadLocal类中的createMap中进行的。ThreadLocal和ThreadLocalMap有三组对应的方法:get、set和remove,在ThreadLocal中对它们只做校验和判断,最终的实现会落在ThreadLocalMap上。Entry继承自WeakReference,没有方法,只有一个value成员变量,它的key是threadLocal对象。

// 静态内部类Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}


// Thread类中的Thread'Local'Map
/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

简单梳理一下他们的关系:

  1. 1个Thread有且仅有一个ThreadLocalMap对象;
  2. 1一个Entry对象的Key弱引用指向1个ThreadLocal对象;
  3. 1个ThreadLocalMap对象可以存储多个Entry
  4. 1个ThreadLocal对象可以被多个线程所共享
  5. ThreadLocal对象不持有value,value由线程的entry对象持有

所有Entry对象都被ThreadLocalMap类实例化对象threadLocals持有。当线程对象执行完毕时,线程对象内的实例属性均会被垃圾回收。但是,ThreadLocal对象经常被设置为私有静态变量使用,那么其生命周期至少不会随着线程结束而结束。

二、ThreadLocal怎么用

1. API

返回值 方法名 备注
void set 存储
void remove 删除
T get 获取

2. 在多线程情况下的Demo

启动类

public class ThreadDemo {

	// 声明一个ThreadLocal,存储类型为Integer
    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();

    public static void main(String[] args) throws InterruptedException {
		// 创建一个线程池大小为1的线程池
        Executor executor = Executors.newFixedThreadPool(1);
		// 执行四个线程,这四个线程分别会先执行get在执行set操作
        for (int i = 0; i < 4; i++) {
            executor.execute(new RunnableDemo());
        }

        while (true) {
//            System.out.println(threadLocal.get());
        }
    }

}

线程类

public class RunnableDemo implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " get num : " + ThreadDemo.threadLocal.get());
        Integer num = new Random().nextInt();
        System.out.println(Thread.currentThread().getName() + " out num : " + num);
        ThreadDemo.threadLocal.set(num);
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果:

pool-1-thread-1 get num : null
pool-1-thread-1 out num : 162662825
    
pool-1-thread-1 get num : 162662825
pool-1-thread-1 out num : -168394526
    
pool-1-thread-1 get num : -168394526
pool-1-thread-1 out num : 842018131
    
pool-1-thread-1 get num : 842018131
pool-1-thread-1 out num : 1188266731

结论:

由于我们的线程对象中没有对ThreadLocal执行remove方法,当线程池第二次轮询到这个线程的时候,直接执行threadLocal.get()方法获取到的还是上一次执行的结果。这种情况是要尽量避免,因为有可能因为没有执行remove操作,而导致第二次获取到的数据是错误的。

3. ThreadLocal无法解决共享对象的更新问题

如例子所示:

public class ThreadLocalDemo {
    private static final StringBuilder INIT_VALUE = new StringBuilder("init");
    private static final ThreadLocal<StringBuilder> builder = new ThreadLocal<StringBuilder>() {
        @Override
        protected StringBuilder initialValue() {
            return INIT_VALUE;
        }
    };

    private static class AppendStringThread extends Thread {
        @Override
        public void run() {
            StringBuilder inThread = builder.get();
            for (int i = 0; i < 10; i++) {
                inThread.append("-").append(i);
            }
            System.out.println(Thread.currentThread().getName() + inThread.toString());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new AppendStringThread().start();
        }
        TimeUnit.SECONDS.sleep(10);
    }
}

输出结果:

Thread-1init-0-1-2-3-4-5-6-7-8-9
Thread-3init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-0init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-2init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9

可以看到输出的结构是乱序不可控的,所以使用讴歌引用来操作共享对象时,依然需要进行线程同步。

现在我们将AppendStringThread类中的intThread计算来做上锁来保证线程之间的同步机制,其他方法不边。AppendStringThread具体代码如下:

private static class AppendStringThread extends Thread {
    @Override
    public void run() {
        StringBuilder inThread = builder.get();
        ReentrantLock lock = new ReentrantLock(true);
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                inThread.append("-").append(i);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
        System.out.println(Thread.currentThread().getName() + inThread.toString());
    }
}

输出结果:

Thread-0init-0-1-2-3-4-5-6-7-8-9
Thread-1init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-2init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-4init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-6init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-7init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-8init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-9init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-3init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9
Thread-5init-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9-0-1-2-3-4-5-6-7-8-9

三、ThreadLocal 源码分析

1. set方法

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

源码解析

set方法内的东西看起来比较少,甚至我们不用看具体实现就能大概知道他想做什么。执行逻辑:

  1. 获取当前线程;
  2. 获取当前线程的ThreadLocalMap内部类对象;
  3. 判断ThreadLocalMap内部类是否为空;
  4. 如果不为空则执行set操作,将当前ThreadLocal作为key,value作为值存储到ThreadLocalMap中;
  5. 如果ThreadMap是null,则执行新建操作,并且将给定的ThreadLocal作为key,传入的对象作为value写入ThreadLocal中;

具体是不是这样的呢,我们可以看一下代码。Thread t = Thread.currentThread();这段代码就不用解析了,因为不涉及到其他方法。我们直接来看第二行ThreadLocalMap map = getMap(t);

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

可以看到返回的当前线程的threadLocals,那我们追过去看一下threadLocals是个什么东西呢?

ThreadLocal.ThreadLocalMap threadLocals = null;

从这里我们可以看到所谓t.threadLocals本质上是指向了ThreadLocal类的一个内部类ThreadLocalMap,我们可以看一下ThreadLocalMap内部类中的属性

 		/**
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;

        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

        /**
         * The number of entries in the table.
         */
        private int size = 0;

        /**
         * The next size value at which to resize.
         */
        private int threshold; // Default to 0

是不是看起来似曾相识,是不是有点像HashMap。我们这边先暂时按下对ThreadLocalMap这个内部类的好奇之心,继续往下看。ThreadLocal.set方法后面发生了什么,它开始判断这个ThreadLocalMap是不是null,刚才我们看源码得到ThreadLocal.ThreadLocalMap threadLocals = null;可以得知,第一次访问的时候这个值一定是null,那么它就会触发createMap(t, value);方法。

createMap(t, value);方法显然是一个初始化方法,我们看一下它做了什么:

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

将当前线程的threadLocals的成员变量指向了一个新的ThreadMap对象,将当前ThreadLocal对象作为key,存储的对象作为value对ThreadLocalMap进行初始化

接下来我们看一下ThreadLocalMap的构造器

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 将ThreadLocalMap的table属性初始化为一个长度为16的数组
    table = new Entry[INITIAL_CAPACITY];
    // 根据threadlocal对象进行hashcode和长度-1进行于预算,用来获取这个threadlocal放在数组中的位置,其实本质上这一步就是一个寻址算法,而且这寻址算法和hashmap的寻址算法及其相似,Hashmap中的寻址算法源码是这样的tab[i = (n - 1) & hash],为什么直接取模,是因为对于计算机来说这样的运算效率更高。
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 根据寻址算法找到的位置,将ThreadLocal作为key,存储的对象作为value封装成Entry对象存储到数组中
    table[i] = new Entry(firstKey, firstValue);
    // 将ThreadLocalMap中包含的元素个数修改为1
    size = 1;
    // 这个值类似于Hashmap中的threshold,是阈值,如果当前数组的大小大于它的时候就会触发rehash操作
    setThreshold(INITIAL_CAPACITY);
}
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

到现在为止,第一次初始化ThreadLocalMap的代码逻辑就已经全部梳理完了,现在我们看一下,第二次乃至第N次存储数据的时候,ThreadLocal是如何处理的

if (map != null) // 第二次、第三次、第N次存储的时候,map肯定不是null,就会触发ThreadLocalMap的set方法
    map.set(this, value);
private void set(ThreadLocal<?> key, Object value) {

  	// 获取当前Thread'Local'Map的table对象,将其赋值给局部变量tab
    Entry[] tab = table;
    // 获取长度
    int len = tab.length;
    // 获取当前ThreadLocal应该存储到的下表位置
    int i = key.threadLocalHashCode & (len-1);
	// 进行循环,根据寻址算法给定的下表位置获取坐标,如果不是就继续循环下一个
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        // 获取entry的key,判断key是否和当前传入的key是同一个,如果是就覆盖value并结束set方法
        ThreadLocal<?> k = e.get();
        if (k == key) {
            e.value = value;
            return;
        }
		// 如果当前的entry还没有初始化过值
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	// 将tab下标为i的对象赋值为new Entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 判断是否需要扩容(见1.2) 判断增加这个entry之后,是否比阈值要大,如果比阈值要大就会进行rehash算法
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

注1.2

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        // 判断i+1是否超过了tab的长度,如果超过了返回0,否则返回i+1
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 如果entry不是null,但是没有Treadlocal作为key存储进去
        // 将长度传入的n设置为当前tab的长度
        // 将removed设置为ture
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

2. get方法

public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    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;
        }
    }
    // 如果当前线程的ThreadLocalMap为null或者当前的这个threadLocal对象在map中不存在,直接初始化
    return setInitialValue();
}

threadLocal的方法非常简单,寥寥几行。大概的意思就是判断一下当前线程的threadLocalMap是否为null,如果不是null,再判断当前这个threadlocal对象是否存储过数据,如果存储过就直接返回存储的数据,如果没有存储过。再执行初始化操作setInitialValue

每个线程都有自己的ThreadLocalMap,如果map == null,则直接执行setInitialValue。如果map已经创建,则就表示Thread类的threadLocalMap属性已经初始化,如果e == null,依然会执行到setInitialValue。接下来我们看一下这个setInitialValue方法

private T setInitialValue() {
    // 注1 这是一个保护方法,默认返回null,如果需要使用,需要覆写
    T value = initialValue();
    // 获取当前线程,并获取当前线程的ThreadLocalMap,如果为null则创建,否则直接写入
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

注1

protected T initialValue() {
    return null;
}

这个方法默认是返回的null,如果大家希望再初始化value的时候,给定一个不同的值,那么就需要继承ThreadLocal并重写此方法。通常用于匿名内部类中,例如:

	private static final ThreadLocal<Integer> INIT_DEMO = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue() {
            return 1000;
        }
    };
    public static void main(String[] args) throws InterruptedException {
        System.out.println(INIT_DEMO.get());
    }

上面代码中,就没有通过set方法给INIT_DEMO对象赋值,而是通过重写了initialValue方法,在INIT_DEMO对象调用get方法的时候给对象进行赋值的。

3. remove方法

public void remove() {
    // 获取当前线程的threadLocalMap
    ThreadLocalMap m = getMap(Thread.currentThread());
    // 如果map不是null,直接将key为当前threadlocal对象的entry删除掉
    if (m != null)
        // 根据key删除entry
        m.remove(this);
}

remove方法应该是ThreadLocal中最简单的一个方法了,因为他不涉及到大量的方法调用,他就是获取到了当前线程的ThreadLocalMap对象,然后判断一下这个map是否为null,如果不是null,就尝试删除这个map中key为当前threadLocal的entry。下面是remove方法的源码

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    // 寻址定位到这个key对应的下标位置
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        // 找到key了,直接清空这个entry
        if (e.get() == key) {
            e.clear();
            // 将这个key从数组中移除
            expungeStaleEntry(i);
            return;
        }
    }
}

四、ThreadLocal的副作用

1. 脏数据

线程复用会产生咱数据。由于线程池会重用Thread对象,那么与Thread绑定的静态属性ThreadLocal变量也会被宠用。如果在实现的线程的run方法中不显示地调用remove方法清理与线程相关的threadLocal信息,那么倘若下一个线程不调用set设置初始值,就可能get到重用的线程信息,包括threadlocal所关联的线程对象的value值。

我们在【2.在多线程情况下的Demo】中就复现了这个问题,我们创建了一个线程池大小固定为1的线程池。然后将四个线程放入线程池执行。

第一个线程执行完之后将162662825作为value存入threadLocal中,但是在线程的run方法中,并没有显示地调用remove方法。第一个线程执行完毕后,第二个线程开始执行。

第二个线程在执行set之前,先执行了get方法,然后就获取到了上一个线程执行过程中set到threadlocal中的值,于是就出现了如下的结果:

// 线程1
pool-1-thread-1 get num : null
pool-1-thread-1 out num : 162662825
// 线程2
pool-1-thread-1 get num : 162662825
pool-1-thread-1 out num : -168394526

2. 内存泄漏

在源码注释中提示使用static关键字来修饰ThreadLocal。在这个场景下, 寄希望于ThreadLocal对象失去引用后,触发弱引用机制来回收Entry的value就不现实了。在上例中,如果不进行remove操作,那么这个线程回收完之后,通过ThreadLocal对象持有的String对象是不会被释放的。

3. 解决方案

其实以上两个问题解决方法很简单,就是在每次用完ThreadLocal时,必须要及时调用remove方法显示的清理。

五、参考资料

《Java并发编程实战》

《码出高效 Java开发手册》

《JDK1.8源码》

posted @ 2020-05-06 10:33  joimages  阅读(230)  评论(0编辑  收藏  举报