面试官一步步逼问让我对ThreadLocal有了更深入的思考

1.前言

前几天面试面试官问到和ThreadLocal相关的一些问题,回来以后对相关问题进行了详细的思考并认真阅读了一下ThreadLocal源码,以前觉得ThreadLocal只是一个用来做线程变量隔离的类,看完源码发现这个类挺有意思的,里面涉及的原理还是比较复杂,特此做一下记录。

2.内存泄漏

面试官:Java语言会发生内存泄漏吗?谈谈你的理解

当时问到这样一个问题首先第一反应就是ThreadLocal类,毕竟早就听说过ThreadLocal的内存泄漏问题,要回答这个问题,首先是需要理解什么是内存泄漏。

什么是内存泄漏?

内存泄漏是指我们在写程序的时候开辟使用了一块内存区域,在这块内存区域使用完毕后,该区域的内容依然占用着内存,并且一直占着不会发生回收,这样就称为内存泄漏。这样将会导致资源浪费、频繁发生GC等问题。

内存泄露的本质

Java中造成内存泄漏的原因有很多,归结原因本质上都是由于资源已经使用完毕,但是该资源却被其他对象或变量引用着导致无法回收该资源。导致内存泄漏的原因有很多,ThreadLocal类只是Java其中一个例子。

3.ThreadLocal

ThreaLocal介绍

面试官:你刚才说到ThreadLocal,那你简单介绍一下ThreadLocal类以及用法吧

ThreadLocal从字面上看可以理解成线程的本地变量,线程中使用ThreadLocal填充的数据只属于当前线程,其他线程不能够使用,起到与其他线程的隔离作用。通过ThreadLocal对象的get和set方法可以设置和提取出改线程中设置的值。

ThreadLocal在类注释中给出的官方代码示例如下:

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

这段代码的作用是在调用get()的时候给当前线程生成唯一的id并且存在ThreadLocal对象threadId中,看完这段代码后可能还是很懵,下面用一个简单示例来演示ThreadLocal的使用:

public static void main(String[] args) {
    ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    for (int i = 0; i < 10; i++) {
        int finalI = i;
        new Thread(() -> {
            threadLocal.set(finalI * 100);
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                System.out.println("当前线程["+Thread.currentThread().getName()+"]的值:"+threadLocal.get());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        },String.valueOf(finalI)).start();
    }
}

这段代码启动了10个线程,线程名字为0到9,并且通过ThreadLocal的set将线程i编号乘100进行存放,最后打印出对应的线程和值,打印结果:

当前线程[7]的值:700
当前线程[6]的值:600
当前线程[1]的值:100
当前线程[8]的值:800
当前线程[5]的值:500
当前线程[2]的值:200
当前线程[3]的值:300
当前线程[9]的值:900
当前线程[4]的值:400
当前线程[0]的值:0

可以看到在代码中我们并没有使用和线程有关的变量,ThreadLocal对象仍然能够正确的获取到当前线程存放的值。

ThreaLocal原理

面试官:你解释一下ThreadLocal类是如何存放这些和线程有关的值呢?

ThreadLocal中的方法不多,常用的就set(),get(),remove(),接下来我们从这几个方法的源码层面来看ThreadLocal的原理。

1. ThreadLocal类结构

ThreadLocal类结构
从类结构可以看到在ThreadLocal类中有个内部类ThreadLocalMap,ThreadLocalMap中还有一个内部类Entry。

2. ThreadLocal.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方法源码中可以看到先从当前调用线程中获取到ThreadLocalMap,这个ThreadLocalMap在后面详细讲解,这里就先简单理解成一个key是ThreadLocal,value是待存入值的kv键值对(其实也差不多)。拿到ThreadLocalMap后,如果ThreadLocalMap不为空,就调用ThreadLocalMap.set()进行存储,如果为空则创建一个新的ThreadLocalMap对象。创建ThreadLocalMap对象通过new直接创建即可。

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

3. ThreadLocal.get()方法

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();
}
  • get()首先从当前线程中取出ThreadLocalMap对象,ThreadLocalMap存在Thread对象的threadLocals变量中,每个线程有自己的threadLocals变量
  • 然后再从ThreadLocalMap中获取Entry对象
  • 如果Entry对象不为空,直接从Entry中取出value值并返回
  • 如果ThreadLocalMap为空或者Entry为空,调用setInitialValue()方法初始化ThreadLocalMap

4. ThreadLocal.remove()方法

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

remove()方法比较简单,先根据当前线程获取ThreadLocalMap对象,再判断ThreadLocalMap对象是否为空,不为空则移除该key对应kv键值对即可。

从上面的代码可以看出,对于ThreadLocal类的操作都是基于ThreadLocalMap进行的,所以真正存放变量的地方是在ThreadLocalMap类上。

4.ThreadLocalMap原理

面试官:你说ThreadLocal是基于ThreadLocalMap来存储的,那么ThreadLocalMap是怎么存储的呢?

ThreaLocalMap介绍

ThreadLocalMap是一个在ThreadLocal内定义的哈希映射,只适合维护线程本地值,ThreadLocalMap中所有方法都是私有的,其结构与HashMap比较类似ThreadLocalMap的默认初始化大小也是16。在ThreadLocalMap中以Entry对象数组进行数据存储,Entry类如下

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

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

Entry类中只有一个成员变量value用于存放实际值,并且该类继承了WeakReference对象,并在构造时调用WeakReference的构造方法,将Entry的key(ThreadLocal对象)设置为弱引用。而ThreadLocalMap又是由多个Entry对象构成的,其结构图如下:
在这里插入图片描述
ThreadLocalMap中存放的Entry数组其实可以是一个环型的,因为在ThreadLocalMap调用set()时会调用nextIndex()方法寻找槽位,当i+1大于数组长度时下一个索引就指向0。同样的prevIndex()方法寻找上一索引,当寻找的上一位小于零时,索引指向数组最后一位。

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

在这里插入图片描述

ThreaLocalMap设置值

ThreaLocalMap设置值主要是通过ThreadLocalMap.set()方法完成的,其源码如下:

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

1. hash的计算
索引通过 int i = key.threadLocalHashCode & (len-1);计算得到。首先计算当前ThreadLocal对象的hash值,通过一个AtomicInteger对象加上HASH_INCREMENT(0x61c88647),相加后的值与(len-1)进行与运算得到索引值,该值一定是小于len的。

在for循环中从tab[]数组中的i+1位拿Entry对象,例如计算得到的索引i为2,则在for循环中从数组第3位开始获取。

2. 设置的过程

拿到Entry对象后判断这个Entry对象,如果为空,跳出循环,并在当前位置新增一个Entry;
在这里插入图片描述

如果不为空则通过get()方法从WeakReference中取出存放的ThreadLocal对象k。

得到k之后进行判断,如果k不为空且k和传入的ThreadLocal不同则从下一个槽位寻找,若k和传入的ThreadLocal相同则直接重置value;
在这里插入图片描述
若k和key不相同,则寻找下一个槽位,继续判断,下一个槽位为空则跳出循环放入

在这里插入图片描述

若k为空,则表示这个位置的ThreadLocal对象被回收,调用replaceStaleEntry()方法顶替这个空的位置避免内存泄漏。

5.ThreadLocal内存泄漏

面试官:那你能解释一下为什么ThreadLocal会发生内存泄漏吗?

现在都知道ThreadLocal会发生内存泄漏问题,那么ThreadLocal为什么会发生内存泄漏呢?这就要从ThreadLocal的持有对象上来说了,下面示例代码

public static void main(String[] args) {
    ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
    ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
    for (int i = 0; i < 5; i++) {
        int finalI = i;
        new Thread(() -> {
            threadLocal.set(finalI * 1);
            threadLocal2.set(finalI * 10);
            threadLocal3.set(finalI * 100);
            System.out.println("Thread " + Thread.currentThread().getName() + "\tset\t" + finalI);
            try {
                Thread.sleep(1000);
                System.out.println("Thread " + Thread.currentThread().getName() + "\tget:\t" + threadLocal.get());
                System.out.println("Thread " + Thread.currentThread().getName() + "\tget:\t" + threadLocal2.get());
                System.out.println("Thread " + Thread.currentThread().getName() + "\tget:\t" + threadLocal3.get());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, String.valueOf(i)).start();
    }
}

这里创建三个ThreadLocal对象,启动五个线程,在不同线程中设置ThreadLocal对象的值,
打印结果如下

Thread 0	set	0
Thread 4	set	4
Thread 3	set	3
Thread 1	set	1
Thread 2	set	2
Thread 3	get:	3
Thread 4	get:	4
Thread 4	get:	40
Thread 4	get:	400
Thread 2	get:	2
Thread 1	get:	1
Thread 0	get:	0
Thread 1	get:	10
Thread 2	get:	20
Thread 3	get:	30
Thread 2	get:	200
Thread 1	get:	100
Thread 0	get:	0
Thread 0	get:	0
Thread 3	get:	300

从打印结果中可以看出,在不同的线程线程中可以设置和读取多个ThreadLocal对象设置的值,在sleep()方法处打上断点查看执行线程属性时,当前线程中有一个threadLocals属性,该属性类型为ThreadLocalMap,此时这个ThreadLocalMap存放的Entry数为3个,分别在1、8、10当中。
在这里插入图片描述
我们知道内存泄漏是发生在ThreadLocalMap的内部类Entry上,因为继承了WeakReference弱引用,而用于存Entry的key也就是ThreadLocal对象,当发生GC时,弱引用的对象将会被回收即Entry的key被回收变为null。但是整个Entry对象却一直被持有没有被回收,所以该Entry一直占着空间却无法被获取(key为null),从而导致内存泄漏问题。

面试官:你确定内存泄漏是由于弱引用而造成的吗?

显然,弱引用只是造成内存的诱导因素,但不是根本原因。下图为一个Entry对象的引用链路图:
在这里插入图片描述
从图上可以看到一个Entry对象被Thread的中的threadLocals(ThreadLocalMap)持有以及ThreadLocal对象指向Entry的key,当ThreadLocal对象被回收,即图中虚线部分引用失效,此时如果线程结束,则Entry将不会有强引用指向,但是如果这个线程一直存在不释放,如线程池,那么Entry对象将一直被ThreadLocalMap强引用,但是此时Entry对象的key已被回收为null,无法根据key来获取,但是Entry对象未销毁始终会占用内存,因此发生内存泄漏的情况。

从很多地方看到ThreadLocal内存泄漏的原因是WeakReference弱引用引起的,其实根本原因并不完全是因为弱引用,相反使用WeakReference从某种程度上讲是为了解决内存占用(加快GC回收)。真正产生内存泄露的原因是线程生命周期没结束,线程未销毁导致Entry强引用始终存在。

面试官:那么,为什么要使用弱引用呢?

从上面分析可以看出,线程如果不结束将会导致ThreadLocal中的threadLocals一直持有Entry对象,如果Entry中的key使用强引用的话,那么ThreadLocal对象始终被Entry引用着,线程不结束ThreadLocal对象不会被回收。但是如果使用弱引用的话,ThreadLocal对象无论Thread是否结束依然会被回收。在不手动删除key的时候弱引用相比强引用多了一层保障,防止ThreadLocal对象占用内存。

6.总结

  • ThreadLocal在多线程开发中还是比较常用的一个类,能够使用一个ThreadLocal对象在不同线程中存放当前线程专属的变量,从而达到线程隔离的目的
  • ThreadLocal内部使用ThreadLocalMap进行存储
  • 每一个线程内部(Thread对象)通过threadLocals存储该线程中的不同ThreadLocal对象所需要保存的值,threadLocals变量是ThreadLocalMap类型的
  • ThreadLocalMap内部使用KV键值对的形式进行变量存储,key为ThreadLocal对象,value为值
  • ThreadLocal在调用get()、set()、remove()后都会清理key为null的部分
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值