再挖一挖ThreadLocal

关于ThreadLocal源码分析的文章可以说多如牛毛,不仅是由于ThreadLocal源码简洁,还因为它是由Java界的两个大师级的作者编写,Josh Bloch和Doug Lea。Josh Bloch 在 Sun 公司多年为 Java 平台作出了杰出贡献,包括JDK5语言增强、Java集合(Collections)框架,现在 Google 就职,是获奖图书《Effective Java》及《Effective Java: Second Edition》的作者。Doug Lea是JUC包的作者,Java并发编程的泰斗。笔者写这篇博客起初是因为一直对ThreadLocal中的魔数0x61c88647有疑惑,为使这块内容较完整,也就按照通常的内容结构来写了,然而在写的过程中再次感受到,即使再熟悉的东西,仔细思考总结,依然收获满满,推荐小伙伴们也动笔写起来~

一. ThreadLocal做什么的

​ ThreadLocal类可以看作为线程提供局部变量的工具类,也就是说如果定义了一个ThreadLocal,每个线程往这个ThreadLocal中读写是线程隔离的,互相之间不会影响,是线程安全的。与局部变量对应的就是共享变量了,如果多个线程共享一个变量,并发环境下读写共享变量是线程不安全的,为处理这种并发问题,常用做法就是加锁。加锁还是使用局部变量就需要我们根据实际情况去权衡了。

​ 我们先看下ThreadLocal的用法,以下是源码中提供的例子:为每个线程生成唯一id,线程第一次访问ThreadLocal会触发initialValue()方法,因此线程调用ThreadId.get()时得到唯一id:

import java.util.concurrent.atomic.AtomicInteger;

 public class ThreadId {
     // Atomic integer containing the next thread ID to be assigned
     private static final AtomicInteger nextId = new AtomicInteger(0);

     // Thread local variable containing each thread's ID
     private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
             	@Override 
     					protected Integer initialValue() {
              	return nextId.getAndIncrement();
         }
     };

     // Returns the current thread's unique ID, assigning it if necessary
     public static int get() {
         return threadId.get();
     }
 }

二 ThreadLocal设计原理

2.1 ThreadLocal与线程

​ 由表及里,我们先来回顾下ThreadLocal的主要方法:

image-20200704205717523

很简单,是不是?get()方法源码如下,

public T get() {
  // 获得当前线程,然后从当前线程中拿到ThreadLocalMap
  Thread t = Thread.currentThread();
  ThreadLocalMap map = getMap(t);
  
  if (map != null) {
    // 根据当前ThreadLocal引用从ThreadLocalMap中拿到Entry对象,返回其value
    ThreadLocalMap.Entry e = map.getEntry(this);
    if (e != null) {
      @SuppressWarnings("unchecked")
      T result = (T)e.value;
      return result;
    }
  }
  return setInitialValue();
}

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

而线程Thread.java中有ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap:

ThreadLocal.ThreadLocalMap threadLocals = null;

上面的源码很好理解,我们已经可以看出ThreadLocal的精髓是在ThreadLocalMap了,后面我们会细致解读ThreadLocalMap的源码。

2.2 开放寻址与线性探查

​ ThreadLocalMap,名字虽然是map,但却和java.util.Map不一样,我们先来看看其存储结构:

static class ThreadLocalMap {
  	// 继承弱引用,使作为key的ThreadLocal<?> k设置为弱引用
        static class Entry extends WeakReference<ThreadLocal<?>> {
            // ThreadLocal变量存储的值 
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

        // 可以扩容,但table.length必须是2的幂次方
        private Entry[] table;
  
  			// table的初始容量 
        private static final int INITIAL_CAPACITY = 16;

        // table中Entry的数量 
        private int size = 0;

        // 扩容阈值
        private int threshold; // Default to 0
  
  	// 设置阈值,这里的len即使table.length,初始化时是INITIAL_CAPACITY,扩容后则是新的table长度
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }

​ ThreadLocalMap使用散列法,散列函数即是ThreadLocal对象的threadLocalHashCode(我们后面仔细研究)对table.length取模,从而计算出在table的位置i。但由于table.length总是2的幂次方,为提高运算效率,使用位运算来替代取模,即下图所示,这种操作在jdk中非常常见,如HashMap,即使人为初始化的长度不是2的幂次方,也会调整为2的幂次方:

image-20200703215415714

即然是散列,就会有冲突,HashMap中使用链接法(chaining),即冲突的元素存放在链表中,而ThreadLocalMap使用开放寻址法(open addressing),即所有的元素都存放在散列表中,同时使用线性探查(linear probing)来解决探查序列问题,见以下ThreadLocalMap代码及2.3小节图:

	// 开放寻址中的线性探测法(linear probing),循环数组向后寻址,走到table.length - 1的位置再从0继续
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }
  
 	// 循环数组向前寻址,走到0的位置,再从table.length - 1的位置继续 */
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }

2.3 弱引用的作用

​ ThreadLocalMap还有一个非常值得关注的点,即Entry中的弱引用。我们都知道,java内存管理中对象能否被回收与引用有很大关系,引用由强到弱分别为:强引用,软引用,弱引用,虚引用。对于弱引用的对象,只要GC运行就会被回收。如果这里使用普通的形式来定义存储结构,就会造成Entry节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收。而使用弱引用,当某个ThreadLocal已经没有强引用可达,就会被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,即null,便于ThreadLocalMap使用自带带垃圾清理机制进行清理,至于如何清理我们后面在看源码时就清楚了。

至此,我们已经可以大致勾勒出ThreadLocal的设计。下面是我绘制的示意图:

ThreadLocal开放寻址与弱引用

2.4 魔数0x61c88647

​ 散列通常分两个步骤:通过哈希函数计算哈希值,之后根据哈希值计算所在的槽位(slot),不同于HashMap中用户自定义哈希函数,每个ThreadLocal实例在初始化时被赋值threadLocalHashCode,可以看作是该ThreadLocal实例的id,这个值是根据上一个ThreadLocal实例的threadLocalHashCode累加0x61c88647得到的,即:

// 原子操作,从0开始
private static AtomicInteger nextHashCode =
  new AtomicInteger();

// 生成threadLocalHashCode的间隙为0x61c88647,使依次生成的ThreadLocal的id(threadLocalHashCode)较为均匀地分布在2的幂次方大小的数组中
private static final int HASH_INCREMENT = 0x61c88647;

private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
  return nextHashCode.getAndAdd(HASH_INCREMENT);

那么为什么累加0x61c88647呢?给threadLocalHashCode设置一个随机数,貌似也可以啊?

Fibonacci Hashing

​ 关于Fibonacci Hashing的资料不多,按照自己的理解在此整理。首先我们来看下Fibonacci序列,逐步了解下这种思想在哈希中的应用:

\[F(n) = F(n-1) + F(n-2) \]

得到的序列为:

\[1 \quad 1\quad 2\quad 3\quad 5\quad 8\quad 13\quad 21\quad 34\quad 55 \quad89 \quad ..... \]

观察这个序列,\(5/8=0.625\)\(21/34=0.6176...\)\(55/89=0.61797...\),当\(n\)趋近于无穷时,这个序列前一项与后一项的比值越来越接近\(1 / \phi = \frac{\sqrt{5} - 1}{2}\approx 0.618\)(为什么是\(\frac{\sqrt{5} - 1}{2}\),这里就暂不证明了,有兴趣可以去查阅资料),或者说这个序列后一项与前一项的比值越来越接近黄金比例\(\phi \approx 1.618\),我们都知道,黄金比例的应用层面相当广阔,数学、物理、建筑、美术甚至是音乐,不过这和我们今天讨论的Fibonacci Hashing有什么关系呢?不着急,我们再来看看自然界中一种神奇的现象:

自然界中,植物的叶子或者花瓣都希望能得到更多的阳光,那怎么办呢?

没错,让一根茎上的叶子或者一朵花的花瓣尽量不重叠。哈哈,植物也是这么想的。

那么怎么做到不重叠呢?叶子该怎么一片一片长出来?

左一片,右一片,左一片,右一片。。。不好,左右两边都有重叠了。

如果植物知道自己一根茎上只长8片叶子,那么按照\(360^o / 8\)这个角度均匀长一圈就好了,可是植物恐怕算数不好,又或者算数太累,而且也不保证一根茎上能长多少片叶子,但还希望所有叶子尽量不重叠,该怎么办呢?

我们上面提到的\(\phi = 1.6180...\)又派上用场了,如果每次生成一片叶子或者一个花瓣就旋转\(360^o/\phi \approx 222.5^o\)呢?见下图:

image-20200708182913234

有没有惊叹大自然的鬼斧神工?

​ 好了,我们回到正题,既然我们想把一些ThreadLocal对象分散到\(2^N\)个槽(slot)上,整数范围\(N\)最大是32,java里我们用long型表示32位无符号整数,范围是\([0,2^{32}-1]\),再转成有符号整数,范围是\([-2^{31},2^{31}-1]\),如果将ThreadLocal的id设置在整数范围,我们来算下\(2^{32} / \phi\),以及由符号整数又是什么?

image-20200719190915036

输出如下:

image-20200708220040666

而有符号的32位整数的黄金分割是-1640531527,如下图所示:

image-20200719101709571

ThreadLocal中的魔数0x61c88647对应的十进制为1640531527,1640531527和-1640531527,正负号有关系么?

没关系,ThreadLocal的id一直在累加,递增的方向相反而已,就像我们上面的花瓣生长图,顺时针或逆时针旋转\(222.5^o\)都可以,我们也可以看看其他数字的效果,来对比一下:

//    // 可以尝试其他的数字,观察一下
//    private static final int HASH_INCREMENT = 0x61c88641;
//    private static final int HASH_INCREMENT = 0x61c88643;
//    private static final int HASH_INCREMENT = 0x61c88645;
//    private static final int HASH_INCREMENT = 0x61c88648;
//    private static final int HASH_INCREMENT = 0x61c88649;

    private static final int HASH_INCREMENT = 0x61c88647;
//    private static final int HASH_INCREMENT = -0x61c88647;

    public static void main(String[] args) {
        magicHash(16); //初始大小16
        magicHash(128); //扩容3次后
    }

    private static void magicHash(int size){
        int hashCode = 0;
        for(int i = 0; i < size; i++){
            hashCode = hashCode + HASH_INCREMENT;
            // 根据size的大小选取部分低位二进制,作为槽
            int slot = hashCode & (size-1);
            System.out.print(slot + " ");
        }
        System.out.println();
    }

HASH_INCREMENT = 0x61c88647运行结果部分截图如下:

image-20200719211304273

HASH_INCREMENT = -0x61c88647运行结果部分截图如下:

image-20200719211153679

可以看出魔数累加得到的id截取低位之后也仍能保持均匀,如果使用随机数来设置ThreadLocal的id不能保证这样的均匀结果。同时能看到正负号的影响确实是递增方向而已。1640531527 mod 16为7,槽位以7为基数自增向后排列,1640531527 mod 128为71,槽位以71为基数自增向后排列。我们也可以尝试其他数字作为魔数,来观察一下槽位分布情况,比如HASH_INCREMENT = 0x61c88648,会有重复:

image-20200719220724267

而HASH_INCREMENT = 0x61c88641,又不够分散:

image-20200719221142790

因此与其他数字相比较,1640531527作为魔数得到的槽位较更为分散,分散且均匀,不正是我们想要的么。

​ 在网上看到一位不知名的学数学的女神从另一个角度证明了这个问题,即累加1640531527,不会有两个值同时映射到同一个槽,为我们看问题提供了新的角度,女神亲笔,我就直接拿来了,感谢博主和这位不知名的女神:

image-20200708220259381

根据上图假设存在\(i,j\)均是由\(HASH\_INCREMENT=1640531527\)递增得到的数字,且能经过mod \(2^N\)得到相同的余数\(r_1\),则得到等式$ (j-i) * HASH_INCREMENT= 2^{N} * (m_2-m_1)$ ,由于\(j-i<2^N\),因此约不掉右侧整个\(2^N\),右侧肯定是偶数,左侧的\(HASH\_INCREMENT\)只要是奇数,\(i,j\)不相等,则必定等式不能成立,说明\(2^N\)范围内不存在满足条件的两个不相等的\(i,j\)。如果换成上面程序中的\(HASH\_INCREMENT = 0x61c88648\),即1640531528,取\(j=2,i=0\)时,可以使等式成立\(2 * 1640531528 = 16 * (m_2 - m_1)\),此时\(m_2 - m1 = 205066441\)。与我们程序运行结果一致。

\(j-i<2^N\),因此可以取到\([0, 2^N)\)之间的任意数,\((m_2 - m_1)\)也可以取任意数,因此只要魔数和\(2^N\)有公约数,就存在重复,由此可以扩展到\(a^N\)。也就是说只要\(HASH\_INCREMENT\)\(a^N\)没有公约数,就可以利用此哈希算法得到一组填充满整个表的散列值。

至此,就是我对这个魔数的一些个人理解了,至少能说服自己了,说服自己是说服别人的前提嘛~

猜你喜欢

转载自www.cnblogs.com/withwhom/p/13372100.html