分享|[八股取士]Java多线程--ThreadLocal深入理解
1590
2021.10.17
2021.10.17
发布于 未知归属地

在前面的「ThreadLocal初步」「Threadlocal原理」中, 我们介绍了ThreadLocal的基本使用方法和基本原理. 下面我们稍微深入地了解一下ThreadLocal的原理, 这也是ThreadLocal原理的最后一篇了~

面试题及解答均来自网络上的面经以及个人理解

一. 面试题

  1. ThreadLocal为什么要使用弱引用?

答: 为了避免内存泄漏, 如果ThreadLocal使用强引用, 那么这样做会间接导致ThreadLocalMap对我们存储的对象实例的强引用, 从而导致内存泄漏. 如果使用弱引用, 在ThreadLocal对象被回收以后, 在哈希表中它对应的Entry就会被标记(key置为null), 这样就可能会被ThreadLocal自动回收.

  1. ThreadLocal为什么会造成内存泄漏? 为了防止内存泄漏我们应该怎么做呢?

答: ThreadLocal内部会自动解除掉被回收的Entry引用, 帮助垃圾回收. 但是这只是局部的回收, 在ThreadLocalMap中可能仍然存在未被回收的垃圾. 为了防止内存泄漏, 我们应该在使用完ThtreadLocal对象后及时调用remove方法进行删除.

  1. ThreadLocal为什么使用static进行修饰?

答: 使用static可以防止ThreadLocal对象被重复地创建, 节省了内存资源.缺点请查看参考资料中的第3篇文章.

  1. ThreadLocal的哈希表是使用什么方法处理哈希碰撞的? 这样做有什么好处?

ThreadLocal使用开放地址法和线性探测进行哈希碰撞的处理, 这样做的好处是操作简单, 且节省内存空间.

这几道题1, 2, 3都是围绕弱引用; 第四题则涉及哈希表的具体操作.

二. 弱引用

在Java中有四种引用, 分别是强,软,弱,虚, 我们这里只介绍弱引用:

弱引用对象的存在不会阻止它所指向的对象变被垃圾回收器回收。弱引用最常见的用途是实现规范映射(canonicalizing mappings,比如哈希表)。

假设垃圾收集器在某个时间点决定一个对象是弱可达的(weakly reachable)(也就是说当前指向它的全都是弱引用),这时垃圾收集器会清除所有指向该对象的弱引用,然后垃圾收集器会把这个弱可达对象标记为可终结(finalizable)的,这样它们随后就会被回收。与此同时或稍后,垃圾收集器会把那些刚清除的弱引用放入创建弱引用对象时所登记到的**引用队列(Reference Queue)**中。

下面我们看一下代码演示:

public class WearReferenceTest {
    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<>();
        // 创建弱引用, 指向一个String对象
        WeakReference<String> wr = new WeakReference(new String("weakReference"), queue);
		// 获取弱引用中的对象
        System.out.println(wr.get()); // weakReference
		// 手动FullGC
        System.gc();
        // 再次查看弱引用中的对象
        System.out.println(wr.get()); // null
        System.out.println(queue.poll() == wr); // true
    }
}

我们可以看到, 被弱引用指向的对象在一次垃圾收集以后会被回收并且弱引用本身会被放到创建它的时候传入的队列当中.

三. ThreadLocal中的弱引用

在上期「Threadlocal原理」我们讲到了ThreadLocal实际上会在每个线程中存储一个哈希表, 这个哈希表是一个数组, 里面的每一个元素是一个Entry, 这里面包含了ThreadLocal对象和我们存储的对象的引用. 下面看一下这个Entry的源码:

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

    Entry(ThreadLocal<?> k, Object v) {
        // k是ThreadLocal对象
        super(k);
        value = v; // 在ThreadLocal中存储的值.
    }
}

我们可以看到, 这个Entry继承了WeakReference, 并且在里面添加了一个属性value . 还可以看到, ThreadLocal对象被传递给了super, 也就是WeakReference的构造方法.

所以在一个Entry当中, 会有一个弱引用key指向ThreadLocal对象, 然后还会有一个强引用value指向我们要使用的对象.

image-20211016192330276

然后我们再来看一下ThreadLocal在线程中存在的全貌, 首先会有一个引用是指向ThreadLocal的, 就如同我们第一期里面在类中创建的成员变量:

private static final ThreadLocal<Random> RAND = ThreadLocal.withInitial(()-> new Random());

然后当前线程会持有一个ThreadLocalMap, 也就是哈希表, 这里面存储的是很多Entry, 所以它大概是这个样子的:

image-20211016195648666

四. ThreadLocalMap中操作

在数据结构里我们知道哈希表处理哈希冲突的方式有很多种, 大致可以分成链接地址法和开放地址法, 然后每种又有不同的方法, 如开放地址法可以使用线性探测, 二次探测, rehash等等方式.

1. 线性探测

我们的ThreadLocalMap就是使用的开放定址法中的单向线性探测法, 大致说明一下, 就是在哈希冲突的时候我们尝试当前坐标的下一个坐标, 所谓下一个在线性探测中就是+1, 而且注意, 我们的哈希表是一个环形数组, 即末尾的下一个是开头, 开头的上一个是末尾. 在源码中是这样的:

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

2. 查找元素

继续看一看我们是如何在哈希表中查找值的我们之前介绍过, 拿到我们存储在ThreadLocal中存储的对象的过程就是使用当前的ThreadLocal对象作为, 在哈希表中找到它对应的.

// ThreadLocal中
// 这个在上一篇讲过
public T get() {
    // 拿到当前线程
    Thread t = Thread.currentThread();
    // 拿到线程对应的哈希表
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 拿到对应的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 懒加载, 哈希表没有初始化或者没有找到对应的值, 设置初始值
    return setInitialValue();
}
// ThreadLocal.ThreadLocalMap
// 拿到key对应的Entry
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);
}
// ThreadLocal.ThreadLocalMap
// 线性查找法
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;
	// 如果e为空, 说明不存在这个元素
    while (e != null) {
        ThreadLocal<?> k = e.get();
        // 如果找到了
        if (k == key)
            return e;
        // 如果某个key为null, 因为是弱引用, 说明当前key已经被垃圾回收. 对后面的元素进行rehash
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    // 没有找到
    return null;
}

3. 设置元素

set元素的过程和查找元素的过程相似, 就不展开讲了, 有兴趣的自己看看. 需要注意的地方是set函数会调用cleanSomeSlots函数, 在后面会介绍.

4. 删除元素

最后我们再看一下在哈希表中如何删除一个元素? 当我们使用开放地址法的时候, 我们是不可以直接删除这个元素的, 因为如果这样的话, 就可能造成在查找的时候本来应该能找到的元素找不到了.所以在删除元素的时候, 会把当前Entry的key设置为null, 标记一下.

// java.lang.ThreadLocal
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)]) {
        // 找到元素对应的Entry
        if (e.get() == key) {
            // 把key设置为null
            e.clear();
            // 对哈希表进行rehash
            expungeStaleEntry(i);
            return;
        }
    }
}

5. 清除已经回收的资源

  1. 线性搜索: expungeStaleEntry

在我们的线程调用了get, set和remove方法的时候, 在它遇到被回收的Entry以后, 它都会调用expungeStaleEntry这个函数, 这个函数的作用就是当前, 我已经找到了一个被回收的Entry, 他对应的下标是staleSlot, 从它开始, 把后面可能与它发生哈希冲突的Entry进行重新哈希(rehash).

// staleSlot是我们遇到的第一个key==null的Entry的索引
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
	// 把staleSlot对应的索引清除掉
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

	// rehash
    Entry e;
    int i;
    // 向后搜索, tab是一个循环队列, nextIndex就是(i + 1) % len
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        // 如果遇到被回收的Entry, 把这个Entry清空. 
        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;
}
  1. 启发式搜索: cleanSomeSlots
    如果你看到了set方法, 你就能看到一个我们没有介绍的函数cleanSomeSlots, 这就是ThreadLocal另一种清理被回收的Entry的方式, 是一种启发式搜索, 尝试进行一段扫描, 如果扫描到垃圾, 就重置搜索距离并且清理垃圾. 如果走着走着一直没发现垃圾, 就结束.
private boolean cleanSomeSlots(int i, int n) {
    // 标志有没有进行清理
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 如果找到了垃圾
        if (e != null && e.get() == null) {
            // 将搜索距离重置为len
            n = len;
            // 进行清理
            removed = true;
            i = expungeStaleEntry(i);
        }
        // 启发式搜索
        // 如果log(n)个格子没有找到垃圾, 就不继续搜索了
    } while ( (n >>>= 1) != 0);
    return removed;
}

在这里总结一下, 在使用ThreadLocal的方法(get, set, remove)的时候, 它会自动进行垃圾清理, 识别垃圾的方式就是因为ThreadLocal使用了弱引用, 如果某个Entry中对应的key(也就是TL对象)被回收, 那么Entry.key就会变为null,这样ThreadLocal在访问到被标记的Entry的时候, 就会进行回收.

五. ThreadLocal内部如何防止内存泄漏

在了解了全貌以后, 我们就要问, 为什么要使用一个弱引用指向我们的ThreadLocal对象呢, 这是为了防止内存泄漏, 下面我们假设这样一个场景, 如果在ThreadLocal中使用的是强引用, 那么在持有ThreadLocal引用的对象被垃圾回收以后会发生什么呢?

image-20211016195829622

这样即使当前的ThreadLocal对象已经不可达了, 我们每个Entry中又持有对它的强引用, 所以只要我们当前的线程存在, 那么这个对象就一直不会被垃圾回收了. 也就是说它的寿命是和线程一样的, 这样就造成了内存泄漏.

那么如果我们使用的是弱引用的话, 当遇到同样的情况, 因为垃圾回收的时候收集器不会考虑弱引用, 所以这个ThreadLocal对象就会被垃圾回收了, 从而避免了内存泄漏.

image-20211016200356812

在上面, 我们解决了key(ThreadLocal对象)的内存泄漏问题, 但是Entry中还持有我们存储的对象的引用, 所以还是存在内存泄漏问题的. 那么这个问题怎么解决呢? 其实我们已经在上一节段介绍过了, 当我们使用ThreadLocal中的get, set和remove方法的时候, 我们会对哈希表进行扫描, 那么在扫描的过程中, 当我们扫描的过程中如果发现某个Entry被标记了, 就会触发expungeStaleEntry或cleanSomeSlots, 对已经回收的Entry进行处理. 但是这两种方式都只是对表的局部进行搜索, 所以哈希表中可能仍然存在没有被回收的垃圾.

六. static引用的优点和缺点

在第一期我们介绍过ThreadLocal的使用方式:

private static final ThreadLocal<Random> RAND

当我们使用static描述符修饰对象的时候, 这个对象是属于类的, 这个类的所有对象都会持有一个这个对象的引用, 而且它们是相同的.

所以当我们这样使用一个ThreadLocal对象的时候, 它只会创建一个对象, 由这个类持有, 在多个线程中如果用到了这个对象, 他们的Entry中的key也都是引用的相同的类对象, 这样做就避免了对象重复创建的问题.

image-20211017013733819

反之呢, 如果不声明成static, 那么一个线程每创建一个该类的实例, 就会创建一个ThreadLocal对象, 同样的, 多个线程创建了多个该类的实例, 也会创建多个TL对象. 在下图中, 线程1如果创建了两个实例, 就会有两个ThreadLocal对象, 同样的, 每个线程也会创建不同的ThreadLocal对象.

image-20211017014355917

所以说, 声明成static可以避免ThreadLocal对象多余的实例化. 在同一个线程中, 即使拥有多个对象, 它们的ThreadLocal中存储的线程私有化对象也不会出现线程安全问题.

以上是优点, 缺点请大家参考将ThreadLocal变量设置为private static的好处是啥?里面Viscent大千大佬的回答. 至于解决办法, 那就是每次使用完以后都调用remove进行删除咯.

参考资料

  1. 理解Java中的弱引用(Weak Reference)
  2. JUC ThreadLocal源码行级解析 JDK8
  3. 将ThreadLocal变量设置为private static的好处是啥?

八股取士, 时也, 命也

因为我在学习八股文的过程中遇到了很多问题, 但是网络上有很多面经, 但是很少有人一起交流. 我也会尽量更新一些面试常问的问题. 所以我建了一个群, 我的初衷是聚集一些小伙伴, 组织一个有活力的小团体, 能帮助我, 帮助大家一起学习.
欢迎你『关注我们』

以上的内容均来自网络上的一些文章以及我个人的一点点理解, 希望能抛砖引玉, 也欢迎各位指正.

评论 (0)