注册

Java并发-ThreadLocal

Java并发-ThreadLocal


ThreadLocal简介:


多线程访问同一个共享变量的时候容易出现并发问题,特别是多个线程对一个变量进行写入的时候,为了保证线程安全,一般使用者在访问共享变量的时候需要进行额外的同步措施才能保证线程安全性。


而ThreadLocal是除了加锁这种同步方式之外的另一种保证一种规避多线程访问出现线程不安全的方法,线程并发的安全问题在于多个线程同时操作同一个变量的时候,不加以额外限制会存在线程争着写入导致数据写入不符合预期,如果我们在创建一个变量后,每个线程对其进行访问的时候访问的都是线程自己的变量这样就不会存在线程不安全问题。


ThreadLocal的典型适用场景:


典型场景1:


每一个线程需要有一个独享的对象(通常是工具类,典型比如SimpleDateFormat,Random)。


以代码为例,通过SimpleDateFormat实现时间戳转换为格式化时间串的功能:


public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.execute(new Runnable() {
@Override
public void run() {
String result = toDate(1000L + finalI);
}
});
}
threadPool.shutdown();
}

public static String toDate(long seconds){
Date currentDate = new Date(seconds *1000);
SimpleDateFormat formator = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
return formator.format(currentDate);
}

上面的代码其实是没有线程安全问题的,但是存在的不足是我们调用了1000次,创建了1000次SimpleDateFormat对象,为了结局这个问题,我们可以把SimpleDateFormat对象从toDate中抽离出来,成为一个全局的变量:


static SimpleDateFormat formator = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

public static String toDate(long seconds){
Date currentDate = new Date(seconds *1000);
return formator.format(currentDate);
}
// output:
1970-01-01 08:33:06
1970-01-01 08:33:07
1970-01-01 08:32:47
1970-01-01 08:32:47
1970-01-01 08:33:10
1970-01-01 08:33:11

很容易发现,全局唯一的formator对象,因为没有加锁,是有线程安全问题的,那么我们可以通过加锁修复:


public static String toDate(long seconds){
Date currentDate = new Date(seconds *1000);
synchronized (formator){
return formator.format(currentDate);
}
}

虽然修复了线程安全问题,但是随之而来的,synchronized关键字导致各个线程需要频繁的申请锁资源,等待锁资源释放,释放锁资源,这并不划算,而利用ThreadLocal这个工具类,可以很方便的解决问题:


ThreadLocal改造如下:


class FormatorThreadLocalGetter {
public static ThreadLocal<SimpleDateFormat> formator = new ThreadLocal<>() {
@Override
protected SimpleDateFormat initialValue() {
return new SSimpleDateFormat("yyyy-MM-dd hh:mm:ss");
}
};

// or use Lambda
public static ThreadLocal<SimpleDateFormat> formator2 = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
}

public class ThreadLocalTest {
// 这里我们使用COW容器记录下每一个SimpleDateFormator的hashcode
static CopyOnWriteArraySet<String> hashSet = new CopyOnWriteArraySet<String>();

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.execute(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
System.out.println(toDate(1000L + finalI));
}
});
}
threadPool.shutdown();
Thread.sleep(5000);
// 延迟5s,确保所有的输出都执行完毕,然后看看我们创建了多少个formator对象。
System.out.println(hashSet.size());
hashSet.forEach(new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
});
}

public static String toDate(long seconds) {
Date currentDate = new Date(seconds * 1000);
SimpleDateFormat formator = FormatorThreadLocalGetter.formator.get();
// 将当前线程的formator的hashcode记录下来,看看最终有多少个hashCode
hashSet.add(String.valueOf(formator.hashCode()));
// 通过ThreadLocal去get一个formator。
return FormatorThreadLocalGetter.formator.get().format(currentDate);
}
}

// 这里我们需要override一下hashCode函数,因为默认的hashCode生成规则是
// 调用构造函数入参pattern这个String对象的hashCode,因为所有的formator
// 的pattern都一样,不重写一下会发现hashCode都一样。
class SSimpleDateFormat extends SimpleDateFormat {
private int hashCode = 0;
SSimpleDateFormat(String pattern) {
super(pattern);
}

@Override
public int hashCode() {
if (hashCode > 0) {
return hashCode;
}
hashCode = UUID.randomUUID().hashCode();
return hashCode;
}
}
// output:
1970-01-01 08:33:15
1970-01-01 08:33:10
1970-01-01 08:33:18
23 // 一千次任务总共创建了23个formator对象
-674481611
-424833271
-2124230669
411606156
-1600493931
900910308
540382160
-1054803206
...

因为线程池执行1000次任务并不是只创建了10个线程,其中仍然包括线程的销毁和新建,因此通常而言是不止10个formator对象被创建,符合预期。


典型场景2:


每个线程内需要有保存自己线程内部全局变量的地方,可以让不同的方法直接使用,避免参数传递麻烦,同时规避线程不安全行为。


典型场景2其实对于客户端来说比较少见,但是可以作为ThreadLocal的另外用法的演示,在使用场景1中,我们用到的是在ThreadLocal对象构造的时候主动去初始化我们希望通过它去保存的线程独有对象。


下面的场景是用来演示主动给ThreadLocal赋值:


举个例子如下图所示,每一个请求都是在一个thread中被处理的,然后通过层层Handler去传递和处理user信息。


image-20220223005746413.png


这些信息在同一个线程内都是相同的,但是不同的线程使用的业务内容user是不同的,这个时候我们不能简单通过一个全局的变量去存储,因为这个全局变量是线程间都可见的,为此,我们可以声明一个map结构,去保存每一个线程所独有的user信息,key是这个线程,value是我们要保存的内容,为了线程安全,我们可以采取两种方式:



  1. 给map的操作加锁(synchronized等)。
  2. 借助线程安全的map数据结构来实现这个map,比如ConcurrentHashMap。

但是无论加锁还是CHM去实现,都免不得会面临线程同步互斥的压力,而这个场景下,ThreadLocal就是一个非常好的解决方式,无需同步互斥机制,在不影响性能的前提下,user参数也不需要通过函数入参的方式去层层传递,就可以达到保存当前线程(request)对应的用户信息的目的。


简单实现如下:


class UserContextHolder{
public static ThreadLocal<User> holder = new ThreadLocal<>();
}

class Handler1{
public void handle(){
User user = new User();
user.name = "UserInfo" + user.hashCode();
// handler1通过set方法给当前线程的ThreadLoca<User>赋值
UserContextHolder.holder.set(user);
}
}

class Handler2{
public void handle(){
// handler2通过get方法获取到当前线程对应的user信息。
System.out.println("UserInfo:" + UserContextHolder.holder.get());
}
}

通过上面的例子,我们可以总结出ThreadLocal几个好处:



  1. 线程安全的存储方式,因为每一个线程都会有自己独有的一份数据,不存在并发安全性。
  2. 不需要加锁,执行效率肯定是比加锁同步的方式更高的。
  3. 可以更高效利用内存,节省内存开销,见场景1,几遍有1000个任务,相比于原始的创建1000个SimpleDateFormator对象或者加锁,显然ThreadLocal是更好的方案。
  4. 从场景2,我们也能看出,在某些场景下,可以简化我们传参的繁琐流程,降低代码的耦合程度。

ThreadLocal原理分析:


理解ThreadLocal需要先知道Thread,ThreadLocal,ThreadLocalMap三者之间的关系,如下图所示:


image-20220223012629697.png


每一个Thread对象内部都有一个ThreadLocalMap变量,这个Map是一个散列表的数据结构,而Map的Entry的key是ThreadLocal对象,Value就是ThreadLocal对象要保存的Value对象。


ThreadLocalMap本身是一个数组结构的散列表,并非传统定义的Map结构,ThreadLocalMap在遇到hash冲突的时候,采用的是线性探测法去解决冲突 ,数组存放的Entry是ThreadLocal和Value对象。


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

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

**尤其需要注意,ThreadLocal工作机制的核心是线程持有的ThreadLocalMap这个数据结构,而不是ThreadLocal自身。**有点绕,可以看下文的分析。


核心API解析:


initialValue:


该方法会返回当前线程对应的数据的初始值,并且这是一个延迟初始化的方法,不会在ThreadLocal对象构造的时候调用,而是在线程调用ThreadLocal#get方法的时候调用到。


get:


得到这个线程对应的Value值,如果首次调用,会通过initialize这个方法初始化。


set:


为这个线程设置一个新的Value。


remove:


删除这个线程设置的Value,后续再get,就会再次initialValue。


源码如下:


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// JDK源码中上面两行代码其实等价于:
// ThreadLocalMap map = t.threadLocals
if (map != null) {
// 获取到当前线程的ThreadLocalMap中存放的Entry
// Entry的key其实就是this(ThreadLocal本身)
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
// 返回ThreadLocal存放的对象本身。
return result;
}
}
// 如果上面没有找到,那么就会初始化
return setInitialValue();
}

setInitialValue实现如下:


private T setInitialValue() {
// 调用initialValue初始化ThreadLocal要保存的对象。
T value = initialValue();
Thread t = Thread.currentThread();
// 拿到当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// Thread#ThreadLocalMap的初始值是null
if (map != null) {
// 如果map有了,set存
map.set(this, value);
} else {
// 否则就给当前Thread的threadLocals(即ThreadLocalMap)赋值并存入
// 上面创建的Value。
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
// 将Value返回,作为get的返回值。
return value;
}

再来看下set操作实现,其实就是做一件事,如果线程已经有了ThreadLocalMap,那么就直接存Value,如果线程没有ThreadLocalMap,就创建Map并且存Value。


    public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}

在这里我们也可以看到如果是通过initialValue将Value的初始值写入,那么就会调用setInitialValue,如果是通过set写入初始值,那么不会调用到setInitialValue。


同时,需要注意,initialValue通常是只会调用一次的,同一个线程重复get并不会触发多次init操作,但是如果通过remove这个API,主动移除Value,后续再get,还是会触发到initialValue这个方法的。


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

如果我们不主动重写initialValue这个方法,默认是返回null的,一般使用匿名内部类的方法来重写initialValue方法,这样方便在后续的使用中,可以直接使用,但是要注意,initialValue除非主动remove,否则是只会调用一次的,即仍然需要做空值确认。


ThreadLocal内存泄露:


ThreadLocal被讨论的最多的就是它可能导致内存泄露的问题。


我们看下ThreadLocalMap#Entry的定义:


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

ThreadLocalMap对Entry的引用是强引用,Entry对ThreadLocal的引用是弱引用,但是对Value的引用是强引用,这就可能会导致内存泄露。


正常情况下,当线程终止的时候,会将threadLocals置空,这看起来没有问题。


but:


如果线程不能终止,或者线程的存活时间比较久,那么Value对象将始终得不到回收,而如果Value对象再持有其它对象,比如Android当中的Activity,就会导致Activity的内存泄露,(Activity被销毁了,但是因为Value绑定的Thread还在运行状态,将导致Activity对象无法被GC回收)。


这个时候引用链就变成了如下:


Thread->ThreadLocalMap->Entry(key是null,Value不为空)->Value->Activity。


当然JDK其实已经考虑了这个问题,ThreadLocalMap在set,remove,rehash等方法中,都会主动扫描key为null的Entry,然后把对应的Value设置为null,这样原来Value对应的对象就可以被回收。


以resize为例:


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (Entry e : oldTab) {
if (e != null) {
ThreadLocal<?> k = e.get();
// 遍历到key为null的时候就将value也设置为null。
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

但是但是,还是有问题:


如果Thread一直在运行,但是其所持有的ThreadLocalMap又没被用到了,此事上面那些set,remove,rehash方法都不会被调用,那还是存在内存泄露的问题......


按照阿里Java规范,ThreadLocal的最佳实践需要在ThreadLocal用完之后,主动去remove,回到典型场景2的代码,我们需要在Handler2的末尾,执行ThreadLoca.remove操作,或者在Handler链路过程中,如果逻辑无法运行到Handler2末尾,相应的异常处也需要处理remove。


装箱拆箱的NPE问题:


如果使用ThreadLocal去保存基本数据类型,需要注意空指针异常,因为ThreadLocal保存的只能是封箱之后的Object类型,在做拆箱操作的时候需要兼容空指针,如下代码所示:


public class ThreadLocalNPE {
static ThreadLocal<Integer> intHolder = new ThreadLocal<>();
static int getV(){
return intHolder.get();
}
public static void main(String[] args) {
getV();// 抛异常
}
}

原因是我们在get之前没有主动set去赋值,getV中intHolder.get先拿到一个Integer的null值,null值转换为基本数据类型,当然报错,将getV的返回值修改为Integer即可。


作者:AaronXYZ
链接:https://juejin.cn/post/7067727693126565901
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册