线程副本ThreadLocal机制和源码

283 阅读8分钟

[toc]

1: 内容提要

本文涵盖以下内容:threadlocal的使用案例和忘记remove造成的一个坑,threadlocal的源码,关于threadlocal的一点感受。全文总计2616字 ,预计需要10分钟。

2: 案例:白名单用户权限状态校验 用于灰度发布

使用场景:前端header中带一个白名单的版本标示A,同时后端在nacos中保存一个版本标示B。请求到达后端接口时候比较两个版本标示,当A和B相等的时候,代表进入灰度测试逻辑。当灰度测试完毕以后,后端版本B+1,此时与前端header不再匹配,灰度发布的逻辑不再起效。标示灰度发布逻辑是否有效的flag变量就是一个threadLocal。

看下具体代码实现:

  • 构造一个线程副本标记是否启用灰度逻辑。
/**
 * 线程副本
 *
 * @author yanghaolei
 * @date 2020-04-21 下午19:32
 */

public class SwitchUserFlagHolder {

    private final static ThreadLocal<Boolean> SWITCH_USER_FLAG = new ThreadLocal<Boolean>() {
        @Override
        protected Boolean initialValue() {
            return Boolean.FALSE;
        }
    };

    public static Boolean get() {
        return SWITCH_USER_FLAG.get();
    }

    public static void set(Boolean status) {
        SWITCH_USER_FLAG.set(status);
    }

    public static void remove() {
        SWITCH_USER_FLAG.remove();
    }
}

  • 写一个注释,标记在需要的接口上
/**
 * 灰度用户切换
 *
 * @author yanghaolei
 * @date 2020/05/21 下午8:21
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SwitchUser {

	/**
	 * 是否AOP统一处理
	 *
	 * @return false, true
	 */
	boolean value() default false;

}
  • Aspect逻辑:比较前后端版本标示。当前后端版本标示一致时,修改线程副本的boolean等于true,接口中的灰度逻辑开始生效。这里有一个关键点是切面结束以后一定要清除掉线程副本里的值。
/**
 * 灰度用户切换
 *
 * @author yanghaolei
 * @date 2020/05/21 下午8:21
 */
@Slf4j
@Aspect
@Component
@AllArgsConstructor
public class SwitchUserAspect {
    private final HttpServletRequest request;

    @SneakyThrows
    @Around(value = "@annotation(switchUser)")
    public Object around(ProceedingJoinPoint point, SwitchUser switchUser) {
        String version = request.getHeader(SecurityConstants.VERSION);
        String maVersion = flowWxConfig.getByType(FlowTypeConstants.PICBOOK).getMa().getVersion();

        // 如果前端传递的参数和版本一致
        if (maVersion.equals(version)) {
            SwitchUserFlagHolder.set(Boolean.TRUE);
        }

        return point.proceed();
    }

    @After(value = "@annotation(switchUser)")
    public void after(SwitchUser switchUser) {
        SwitchUserFlagHolder.remove();
    }

}
  • 接口逻辑:具体处理业务的接口逻辑,其实就是连接点处理的逻辑(point.proceed())。这里只列举一个用到这个逻辑的接口。
   /**
     * 获取需要邀请的人数
     *
     * @param idXO 活动id
     * @return vo
     */
    @SwitchUser
    public InviteGetProgressVO getProgress(IdXO idXO) {

        // 灰度时候返回还需要邀请人数为-1
        if (Boolean.TRUE.equals(SwitchUserFlagHolder.get())) {
            return new InviteGetProgressVO().setNeedInviteCount(BizConstants.MINUS_ONE);
        }

关于为什么这个设计要用到线程副本: 主要原因还是想配合aop。可以理解成想把状态带入到接口逻辑中。灰度本质上也是一种用户权限校验的场景。aop是经典的权限分离工具了。利用threadLocal可以简洁的将代理外的结果带入到代理中。

关于不remove的坑:如果忘记remove,又加上版本不匹配的时候不会强制set,就会出现不管版本最终有没有匹配,线程副本中的值都是true或者false。原因会在源码之后说。

3: threadLocal的机制和源码: ThreadLocalMap和弱引用key

ThreadLocal是线程thread中的一个属性变量,本质上是一个map。

public class Thread implements Runnable {
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;}

这个map由每一个线程持有。里面记录的是每一个threadLocal的值。举个例子,假设我们的系统有一天不只有灰度测试,还有红度测试,黑度测试【我瞎编的】。 我们声明3个threadLocal来描述是否执行对应的逻辑。true代表执行,false代表不执行。

另外,threadLocal通常要设置成private static final 来表述线程的状态。【 {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).】

private final static ThreadLocal<Boolean> RED = initialValue() -> {return Booelan.TRUE};
private final static ThreadLocal<Boolean> BLACK = initialValue() -> {return Booelan.TRUE};
private final static ThreadLocal<Boolean> GREY = initialValue() -> {return Booelan.FALSE};

那么这个map中的结构就会变成。可以想到当线程变量越来越多的时候,这个map会越来越大。

(RED,TRUE)
(BLACK,TRUE)
(GRAY,FALSE)

ThreadLocalMap真实的结构是下面这样的。

static class ThreadLocalMap {

        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

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

key是弱引用。简单来说就是当我们上面举例的red或者black不用的时候,entry就会被回收,等着去gc。当然这么说其实不准确,因为这个red/black是引用,不用是指他们不再指向某一个对象。当他们不指向某一个对象的时候,JVM会把一个叫作referent的变量置为null。接着gc就会发现他们,并把他们加入回收的队列。注释中表述的entry.get()方法就是下面这个方法

public abstract class Reference<T> {


    private T referent;         /* Treated specially by GC */

   /**
     * Returns this reference object's referent.  If this reference object has
     * been cleared, either by the program or by the garbage collector, then
     * this method returns <code>null</code>.
     *
     * @return   The object to which this reference refers, or
     *           <code>null</code> if this reference object has been cleared
     */
    public T get() {
        return this.referent;
    }

}

当entry.get() == null这个事情发生的时候,这个entry会被当作过期的entry【stale entries】。ThreadLocalMap会在getEntry(),set(),remove()时遍历map,将过期的entry拎出来扔掉。像下面的代码一样:

          if (k == null)
               expungeStaleEntry(i);
     
        /**
         * Expunge a stale entry by rehashing any possibly colliding entries
         * lying between staleSlot and the next null slot.  This also expunges
         * any other stale entries encountered before the trailing null.  See
         * Knuth, Section 6.4
         *
         * @param staleSlot index of slot known to have null key
         * @return the index of the next null slot after staleSlot
         * (all between staleSlot and this slot will have been checked
         * for expunging).
         */
        private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // expunge entry at staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

这样做可以保持线程副本机制长期的使用。【To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys】。简单说,未来可期。

4: threadLocal的机制和源码: Set

看看线程副本怎么赋值:

 /**
     * Sets the current thread's copy of this thread-local variable
     * to the specified value.  Most subclasses will have no need to
     * override this method, relying solely on the {@link #initialValue}
     * method to set the values of thread-locals.
     *
     * @param value the value to be stored in the current thread's copy of
     *        this thread-local.
     */
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

我们已经知道了一个线程持有所有线程副本的键值对map。当key存在时候,是怎么向map中注入值的呢? 当key==null的时候,过期的entry是怎么被发现清除掉的呢? 还有key不存在的时候是不是新建了一个entry呢?

 /**
         * Set the value associated with key.
         *
         * @param key the thread local object
         * @param value the value to be set
         */
        private void set(ThreadLocal<?> key, Object value) {

            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();

                // 存在且不过期 返回value
                if (k == key) {
                    e.value = value;
                    return;
                }

                 // 存在但不过期 替换掉过期的entry[删除旧的entry 换上新的]
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

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

5: threadLocal的机制和源码: 不remove的后果。

不remove又不set的后果:线程会携带上次set的值。由于我们的web容器线程是重复使用的。当一个请求使用完某个线程,该线程会放回线程池被其他请求使用,这就导致一个问题,不同的请求还是有可能会使用到同一个线程。如果是一个请求在使用threadLocal的时候是先get()后set()的话就会有问题。反过来,如果一个请求每次使用threadLocal,都是先set()再get()就不会有问题。不过使用threadLocal最好还是没次使用完就remove()将其删除,避免先get后set。

6: 一点点感受: threadLocal其实不主要是为了并发安全,更重要的是携带状态。

在我才开始写代码的时候总感觉是为了避免线程并发对共享资源的竞争才使用threadLocal。其实后来发现,局部变量一样的线程安全。线程副本还多了gc开销。后来看到这句话才明白线程副本设计的原因:

【This class provides thread-local variables. These variables differ from

  • their normal counterparts in that each thread that accesses one (via its
  • {@code get} or {@code set} method) has its own, independently initialized
  • copy of the variable. {@code ThreadLocal} instances are typically private
  • static fields in classes that wish to associate state with a thread (e.g.,
  • a user ID or Transaction ID).】

本英语小能手来给大家翻译下。that去掉,句子主体是 these variables has its own, independently intiaialized copy of the variable。 意思是他们有独立的初始化过的值的副本; differ from that 是和别的不一样的地方。和谁不一样呢,threadLocal和局部变量需要通过get/set来获取变量。为什么要这么做呢,因为with to associate state with a thread。希望和一个线程的状态发生联系。