面霸的自我修养:ThreadLocal专题

发布时间 2023-09-09 11:29:00作者: 王有志

王有志,一个分享硬核Java技术的互金摸鱼侠
加入Java人的提桶跑路群:共同富裕的Java人

今天是《面霸的自我修养》第5篇文章,我们一起来看看面试中会问到哪些关于ThreadLocal的问题吧。
数据来源:

  • 大部分来自于各机构(Java之父,Java继父,某灵,某泡,某客)以及各博主整理文档;
  • 小部分来自于我以及身边朋友的实际经理,题目上会做出标识,并注明面试公司。

叠“BUFF”:

  • 八股文通常出现在面试的第一二轮,是“敲门砖”,但仅仅掌握八股文并不能帮助你拿下Offer;
  • 由于本人水平有限,文中难免出现错误,还请大家以批评指正为主,尽量不要喷~~
  • 本文及历史文章已经完成PDF文档的制作,提取关键字【面霸的自我修养】。

ThreadLocal是什么?它有什么作用?

难易程度:??

重要程度:?????

面试公司:腾讯

先来看Java源码中的ThreadLocal的注释:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.

ThreadLocal提供了线程的局部变量。线程局部变量与普通变量不同之处在于,访问线程局部变量的每个线程(通过get方法或set方法)都有它自己的独立初始化的变量副本
ThreadLocal“属于”线程,用于存储与线程相关的变量,以保证线程安全。需要注意的是,注释中的“copy of the variable”会让你认为是ThreadLocal内部实现了对存储变量的拷贝,实际上并不是这样的,
简单来说,ThreadLocal用于存储只与线程相关的变量。需要注意的是,ThreadLocal本身并不会拷贝变量,如果使用不当仍旧存在线程安全问题。这点可以在下面ThreadLocal的实现原理中看到,或者可以阅读我之前写的《ThreadLocal的那点小秘密》。


描述ThreadLocal的实现原理。

难易程度:????

重要程度:????

面试公司:百度,腾讯,海康威视

先来看ThreadLocal怎么使用,以最常见的ThreadLocal存储线程独立的SimpleDateFormat为例:

private static final ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<>(){
	@Override
	protected SimpleDateFormat initialValue() {
		return new SimpleDateFormat("yyyy-MM-dd");
	}
};

public static void main(String[] args) {
	SimpleDateFormat simpleDateFormat = threadLocal.get();
	System.out.println(simpleDateFormat.format(new Date()));
	threadLocal.set(new SimpleDateFormat(""));
}

众所周知,SimpleDateFormat存在并发安全问题,通常建议通过ThreadLocal来使用SimpleDateFormat,以避免并发安全问题,那么ThreadLocal能解决并发安全问题的原理是什么呢?
我们通过ThreadLocal的源码来一步一步的分析,先来看ThreadLocal的构造器和ThreadLocal#initialValue方法:

public ThreadLocal() {
}

protected T initialValue() {
	return null;
}

两个方法均没有做任何实现,但我们在创建ThreadLocal时重写了ThreadLocal#initialValue方法,不过目前还没有看到在哪里调用,我们接着往下看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();
}

整体看一下ThreadLocal#get方法的实现:

  • 第2行代码,获取到当前线程;
  • 第3行代码,通过当前线程获取到ThreadLocalMap;
  • 第4~11行代码,通过ThreadLocal实例(this对象)查询并返回ThreadLocalMap中的结果结果;
  • 第12行代码,通过名字可以猜测到是设置初始值的方法。

第2行代码并没有什么可以解释的,来看第3行代码中的ThreadLocal#getMap方法实现:

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

ThreadLocal#getMap方法返回的是Thread的变量threadLocals,再来看Thread中是如何定义threadLocals的:

ThreadLocal.ThreadLocalMap threadLocals = null;

可以看到Thread使用了ThreadLocal的内部类ThreadLocalMap,这里我们先通过源码中的注释来了解ThreadLocalMap是什么。

ThreadLocalMap is a customized hash map suitable only for maintaining thread local values. No operations are exported outside of the ThreadLocal class.

提取这段话的关键点:

  • “a customized hash map”,定制化的散列表,解释了ThreadLocalMap的本质;
  • “No operations are exported outside of the ThreadLocal class”,只有在ThreadLocal内部会调用ThreadLocalMap的操作,解释了ThreadLocalMap作为ThreadLocal的内部类的原因。

为了便于后面的理解,可以把ThreadLocalMap直接看做是HashMap,否则可能会陷入Thread,ThreadLocal和ThreadLocalMap的“混乱”关系中。
接着回到ThreadLocal#get方法的代码中,假设通过ThreadLocal#getMap方法获取到了ThreadLocalMap,则进入第4行的if语句中,通过ThreadLocal对象(ThreadLocalMap的key)获取结果(ThreadLocalMap的value)。
至此,我们已经能够建立Thread,ThreadLocal和ThreadLocalMap的关系:


这个关系中,ThreadLocal是如何帮助线程实现变量的线程隔离的呢?实际上就是Thread内部维护了一个Map用于存储线程间独立的变量,而ThreadLocal是作为Map的Key。要知道Thread的实例就是Java里的线程,Thread实例独有的,就是线程独有的,这点我在《关于线程你必须知道的8个问题(上)》中也提到过。
接着回到ThreadLocal#get方法中,来看第12行调用的ThreadLocal#setInitialValue方法做了什么:

private T setInitialValue() {
	T value = initialValue();
	Thread t = Thread.currentThread();
	ThreadLocalMap map = getMap(t);
	if (map != null) {
		map.set(this, value);
	} else {
		createMap(t, value);
	}
	if (this instanceof TerminatingThreadLocal) {
		TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
	}
	return value;
}

可以看到ThreadLocal#setInitialValue方法中,第2行就是调用ThreadLocal#initialValue,实际上调用的是在使用ThreadLocal时重写的部分。后面的第49行代码是获取或创建ThreadLocalMap,第1012行是是ThreadLocal的子类TerminatingThreadLocal相关的内容,这里我们也不过多深入。
现在来思考下,如果在实现ThreadLocal#initialValue方法时提供了一个共享变量会发生什么?例如:

private static Boolean flag = false;

private static final ThreadLocal<Boolean> threadLocal = new ThreadLocal<>() {
	@Override
	protected Boolean initialValue() {
		return flag;
	}
};

public static void main(String[] args) throws InterruptedException {
	new Thread(() -> {
		flag = true;
		System.out.println("线程1:结束");
	}).start();

	TimeUnit.SECONDS.sleep(1);

	new Thread(() -> {
		while (threadLocal.get()) {
			System.out.println("线程2:死循环");
		}
		System.out.println("线程2:结束");
	}).start();
}

上面的代码不难理解:

  • 第1行创建了标记flag;
  • 第3~8行创建了ThreadLocal的实例,并存储了flag;
  • 第11~14行中,线程1修改了标记flag;
  • 第18~23行中,线程2根据标记决定是否进入循环。

执行上面代码会发现线程2进入了死循环,好像并没有起到线程隔离的作用,这是为什么?
答案也并不复杂,实际上就是通过ThreadLoca存入到ThreadLocalMap的变量并不是线程独立的,也就是说,只有变量本身是线程独立的,ThreadLocal才能起到变量线程隔离的作用。
说到这单的原因是,有些文章的描述可能会让大家认为是ThreadLocal内部实现了变量的拷贝,但实际情况是要先有变量的拷贝并存储ThreadLocal,而不是存入ThreadLocal的变量会“自动”拷贝出一份副本
按照以上分析的内容,可以看到ThreadLocal的本质并不能解决并发安全问题,它的主要功能应该是在线程内传递变量

ThreadLocal会造成内存泄漏吗?

难易程度:????

重要程度:???

面试公司:百度,美团

ThreadLocal是存在内存泄漏的风险的,或者确切的说ThreadLocalMap存在内存泄漏的风险,我们来看ThreadLocalMap的源码:

static class ThreadLocalMap {

	static class Entry extends WeakReference<ThreadLocal<?>> {
		
		Object value;

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

	private Entry[] table;

	ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
		table = new Entry[INITIAL_CAPACITY];
		int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
		table[i] = new Entry(firstKey, firstValue);
		size = 1;
		setThreshold(INITIAL_CAPACITY);
	}
}

ThreadLocalMap的内部类Entry是继承自WeakReference,在Entry的构造方法中,作为Key的ThreadLocal被设置为弱引用。
那么存就在一种情况,如果ThreadLocal自身失去外部的强引用且在发生GC后,Entry中作为key的ThreadLocal被回收,但value与Entry间存在强引用关系,且ThreadLocalMap是Thread的成员变量,那么Thread未被销毁时,ThreadLocalMap中会存在一个key为null,但value依旧存在Entry,而它会占用内存空间,却无法正常访问,此时会造成内存泄漏
来看一段代码:

public static void main(String[] args) throws InterruptedException {
	ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "Hello");
	System.out.println(threadLocal.get());
	Thread t = Thread.currentThread();
	threadLocal = null;
	System.gc();
	System.out.println(t.getName());
}

代码本身没有任何含义,只是为了展示ThreadLocal内存泄漏的场景。其中第4~5行代码,主动将threadLocal指向null,并且调用System#gc进行GC,这里我们通过Debug前后的代码来印证。
发生GC前,我们来观察Thread#threadLocals中的存储情况:


可以看到主线程的ThreadLocalMap中已经存入字符串“Hello”,并且Key是我们声明的ThreadLocal变量,此时一切正常。
接着来看发生GC后Thread#threadLocals中的存储情况:


可以看到ThreadLocalMap中,字符串“Hello”对应的引用已经为null,无法正常访问到,就造成了内存泄漏。
如果线程的生命周期较短,使用完成后就立即销毁,那么内存泄漏的危害并不严重,可如果是通过线程池创建的线程,使用完成后并不会立即销毁,那么内存泄漏的问题就会持续存在。

如何避免ThreadLocal的内存泄漏?

难易程度:???

重要程度:???

面试公司:无

通常我们不会写主动将threadLocal指向null,如果真的需要这么做的话,首先应该先调用ThreadLocal#remove方法,来删除ThreadLocal中存储的数据。该方法源码如下;

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

除了主动调用ThreadLocal#remove外,可以使用static修饰ThreadLocal的实例,保证ThreadLocal的强引用一直存在,这是也Java推荐的做法,源码中对此有说明:

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).

当然,如果存在过多的使用static修饰,但无用的ThreadLocal实例,可能会造成内存溢出的问题,这点也是我们在代码中需要考虑的部分。

参考资料


如果本文对你有帮助的话,还请多多点赞支持。如果文章中出现任何错误,还请批评指正。最后欢迎大家关注分享硬核Java技术的金融摸鱼侠王有志,我们下次再见!