ThreadLocal 应用及源码分析

使用

 

用 ThreadLocal 包装的对象,对于每一个线程,都会保留被包装对象的副本,从一定程度上保证共享变量的线程安全性
 
ThreadLocal 非常适合需要线程安全的全局变量,也常应用于各类上下文
 
我们以 Sprig Security 的应用场景为例,用户的每次请求都会携带上 Cookie,Sprig Security 会去解析 Cookie,得到一个用户对象,而这个用户对象往往会在这次请求(线程)中被反复获取和使用。为了实现线程在多个方法中都可以获取到同一个用户对象,而不使用方法参数传递, Sprig Security 使用了 ThreadLocal
 
简单看下 Sprig Security 是怎么做的,首先,我们通过下面的代码来获取用户对象
 
Authentication user = SecurityContextHolder.getContext().getAuthentication();

 

进入到获取上下文的方法 getContext() 中,可以发现 contextHolder 就是一个 ThreadLocal,内部封装了 SecurityContext 对象,这样在这个请求的任意方法中都可以通过这个上下文来获取用户对象,而不需要通过方法参数传递,像这样的做法非常常见,在很多框架中都有用到
 
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>();

public SecurityContext getContext() {
   SecurityContext ctx = contextHolder.get();

   if (ctx == null) {
      ctx = createEmptyContext();
      contextHolder.set(ctx);
   }

   return ctx;
}

 

源码分析

 

来看下 ThreadLocal 是如何实现对象和线程绑定的
 
我们先猜测下,既然 ThreadLocal 是通过为每个线程保留一份数据,第一时间想到的就是使用 Map ,即 Map 的 key 保存线程 ID,value 保存变量的值,这样,我们通过线程 ID 就可以获取到想要的值。事实上,很久以前确实是这么做的。但 1.8 却并非如此,这里通过 set、get、remove 三个方法来一窥究竟
 

Set 方法

 
进入 set 方法,首先是获取当前线程,然后调用 getMap 方法
 
getMap 方法会返回 threadLocals 变量,位于 Thread 类中
 
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

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

ThreadLocal.ThreadLocalMap threadLocals = null;

 

再来看下 ThreadLocalMap ,他是 ThreadLocal 的内部类,这里截取该类的部分属性做以说明

 

static class ThreadLocalMap {

    /** 存放了 ThreadLocal 和 设置进去的值, 作用类似 HashMap 的 Entry */
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** ThreadLocal 中 set 进去的对象 */
        Object value;

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

    /** table 的初始大小 */
    private static final int INITIAL_CAPACITY = 16;

    /** Entry 数组 */
    private Entry[] table;

     /** 用于扩容 */
    private int threshold; // Default to 0

 

分析到这里,大致可以得到下图的对应关系:
 

 

1)每个 Thread 中都有一个 ThreadLocalMap
2)ThreadLocalMap 中有个 Entry[] 数组
3)Entry 中包含了 ThreadLocal 对象和 Value 值
 
回到 set 方法的代码中,getMap 方法返回后会进行空判断,这里会分两种情况
 

getMap 返回不为空

 
进到 map.set(this, value) 中
 
方法先是通过 hash 算法计算出 context 对象对应 Entry 数组中的下标,注意这个算法在后面还会出现
 
然后遍历 Entry 数组,如果能找到传入的 key,直接赋值即可;否则 new 一个 Entry 对象,将 ThreadLocal 和 Value 放入其中,然后将这个 Entry 放到数组中,最后重新计算 size 和判断是否需要扩容
 
private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

 

getMap 返回为空

 

进到 createMap(t, value) 中,逻辑很简单就不分析了
 
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

 

为何如此设计

 
个人认为,采取将 ThreadLocal 分散到各个线程中而不是将线程都保存到 ThreadLocal 的设计的原因是因为线程在执行完之后往往就被销毁了,如果 ThreadLocal 保存了线程,那么就需要将线程从 ThreadLocal 中移除,否则 ThreadLocal 会一直持有线程的引用,导致线程无法被回收,复杂度会更高
 

Get 方法

 
明白了 set 方法后,get 方法也就很好理解了
 
getMap 方法在 set 中已经讲过了,我们继续看 getEntry 方法
 
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();
}

 

进入 getEntry 
 
第一行代码就是一个 hash 计算,和 set 方法中的算法一模一样,因为每一个 ThreadLocal 的 threadLocalHashCode 是固定的,所以就算出的下标 i 也一样(除非扩容导致 table.length 出现变化,不过在扩容时也会重新 hash ,所以实际上还是一样的),然后去判断 Entry 中的 ThreadLocal 是否就是当前的 ThreadLocal ,是的话就直接返回这个 Entry,进一步就可以拿到 Value

 
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

 

Remove 方法

 

remove 方法整体逻辑也很简单,就是将 Entry 中的 ThreadLocal 设置为 null
 
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

public void clear() {
    this.referent = null;
}

 

上下文混乱问题

 

这里举一个案例,谈谈上下文数据混乱问题以及特定情况下 remove 方法的必要性
 
我们知道,在 web 项目中,每个请求, tomcat 默认都会指派一个线程来处理。为了防止线程过多和减少新建、销毁线程的开支,往往会使用线程池技术,也就是说在不同时间段内的两次请求可能使用的是线程池中的同一个线程,明白了这点后,再来看下方代码

 

private final ThreadLocal<String> context = new ThreadLocal<>();

private void set(String setting) {
    if (flag) {
        context.set(setting);
    }
}

private void update() {
    if (context.get() != null) {
        methodA(context.get());
    } else {
        methodB();
    }
}

 

 

上方代码的执行顺序是先 set 然后 update,关键点是会根据 flag 来判断是否需要设置上下文,根据是否存在上下文来执行不同的逻辑
 
在两次请求都使用的同一个线程的前提下(请求 A 和请求 B 都用的同一个线程),执行顺序如下:
 
1)请求 A 调用 set 方法,flag 为 true ,设置上下文,随后执行 update,成功调用 methodA,但直到结束之前都没有删除上下文
2)请求 B 也同样调用 set 方法,但它的 flag 为 false,所以进到 update 时预想的是调用 methodB,但因为请求 A 没有删除上下文,会调用 methodA,导致程序逻辑出错
 
解决:如果请求 A 在使用完之后调用 remove 方法就可以避免此类情况

posted @ 2021-09-27 11:49  happyhbao  阅读(128)  评论(0)    收藏  举报