ThreadLocal,AQS解析二合一

1、ThreadLocal

1.1 ThreadLocal 原理

ThreadLocal 我们在项目中是经常使用的,常见的场景比如通过切面的方式将请求中的认证信息进行解析,然后讲用户信息存到 ThreadLocal 中,这样在整个线程的生命周期都可以随时的获取到信息。所以这个特点我们就能发现 ThreadLocal 是线程隔离的,每一个线程都有一份自己的副本,多个线程之间互不影响。那么就需要先了解一下它的数据结构是什么样的。

1.1.1 ThreadLocal 的数据结构

Thread 类中有这样的一个属性ThreadLocal.ThreadLocalMap threadLocals = null;这个就是线程的 ThreadLocal,我们可以看到存储的是 ThreadLocal 类中的 ThreadLocalMap 这样的类型,我们再看一下ThreadLocalMap,存储数据的是这样的一个类型private Entry[] table; 一个 Entry 数组,然后 Entry 是什么样的呢?

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

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

从上面的代码我们可以看到 Entry 继承了弱引用,泛型指定了 ThreadLocal 也就是声明了一个键值对,key 就是 ThreadLocal 的弱引用,它通过继承弱引用来完成 key 的保存,value 就是我们存储的值,所以我们画出它的图

1.1.2 ThreadLocal 为什么要设置弱引用

上面我们说了在 ThreadLocalMap 中的 Entry 中存储key ThreadLocal的弱引用。为什么要这么设计,关于强软弱虚的概念大家可以看我的这篇文章Java多线程基础知识大杂烩。弱引用就是一旦GC,那么就会回收弱引用对象,为什么这么设计?

比如我们调用接口会创建一个 ThreadLocal 存储我们的用户信息,然后在线程执行的过程中使用了这个信息,那么在线程栈中就存在了一个引用,同时在 ThreadLocalMap 中也存在这样一种引用。假设在 ThreadLocalMap 中的引用是强/软,那么当调用线程结束的时候,线程栈中的引用就断掉了,那么当 GC 的时候就发现,我擦,你还有其他的一个引用,那就不能回收了,久而久之,可以想象,你创建的 ThreadLocal 将会有多少,这就是内存泄漏,然后就 OOM 了。所以设置成弱引用,当发生 GC 的时候就会回收掉。

那么就会引发另一种问题,就是 GC 之后,key 可能会是 null,而是不是 null 取决于你对这个 ThreadLocal 存不存在强引用,比如我们用户线程使用这个对象,存在强引用关系,那么 GC是不会回收的。如果 Key 被回收了,value其实是还在的,那么一样是内存泄漏。而最理想的情况就是要整个 Entry 直接干掉!怎么做呢?一会再说。

1.1.3 ThreadLocal 部分源码解读

set 方法解读

java 复制代码
public void set(T value) {
    //获取当前线程
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

直接看 ThreadLocalMapset 方法,直接看注释

java 复制代码
private void set(ThreadLocal<?> key, Object value) {

    //先拿一些初始值,比如 table,长度
    Entry[] tab = table;
    int len = tab.length;
    //计算索引位置,和 HashMap 大差不差,差别就是 HashMap 重新计算 hash 了
    //然后这里threadLocalHashCode是每次都增长的,我就不粘代码了,每次都增长0x61c88647
    //这个数是一个黄金分割数,和斐波那契数有关系,这就涉及到数学原理了,我不太行。
    //它的目的就是可以让 hash 分布的非常均匀,也就是更加三列
    int i = key.threadLocalHashCode & (len-1);
    //在循环体直接处理了 hash 冲突的情况,就是向后移动
    //这个循环很有意思啊,循环的范围是 e!=null;也就是寻找到的节点是空的情况下
    //就跳出循环,然后直接创建一个 Entry 赋值了
    //所以循环体里面处理的是槽位数据不为空的情况,然后循环增长条件我们看 nextIndex 方法
    //return ((i + 1 < len) ? i + 1 : 0);这代码就是往后找,到头了,就从头来
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
         //如果 key是相等的,那么就直接覆盖
        if (e.refersTo(key)) {
            e.value = value;
            return;
        }
        //如果槽位的key 为空,也就是咱们说的 GC,threadLocal 弱引用被回收了
        //然后就调用replaceStaleEntry这个方法。
        //这个方法我就不粘出来了,主要干的事就是从这个位置开始,我要清理东西了,然后在往下找
        //的过程中发现了 key 相等的我就覆盖掉,执行一些清理,清理的方法同样调用的是cleanSomeSlots
        if (e.refersTo(null)) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    //如果没有需要清理的,同时超出阈值了,那么就扩容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
    //扩容会调用expungeStaleEntries清理
        rehash();
}

所以总结起来就是在 set 的过程中找位置,或者找相通 key 的,然后插入进去,同时伴随着一些清理动作,也就和我上面说的清理 Entry 是相关的,注释中写了两种清理方法,一种在 rehash 的时候调用expungeStaleEntries方法,这个就是从头开始,过期的设置 null,否则重新计算槽位,然后放过去。另一种cleanSomeSlots顾名思义就是清理一些插槽,是跟着你 set 的时候部分清理。两个方法的源码大家自己可以看看,我一直都认为看源码主要看关键的设计思路和实现过程,而不是逐字逐句的分析。

1.2 ThreadLocal 的扩容机制

说到扩容机制,不得不谈的就是它的阈值,在HashMap中的阈值是长度的四分之三,而在ThreadLocal中的阈值是三分之二,如下的代码

java 复制代码
private void setThreshold(int len) {  
    threshold = len * 2 / 3;  
}

当然了,它里面还是有一点小设计的,在上面的set方法中最后先清理部分插槽,然后判断size是否大于了阈值,然后进行rehash操作,但是啊,这个是rehash,可不是扩容,我们进入里面看rehash方法

java 复制代码
private void rehash() {  
    expungeStaleEntries();  
  
    // Use lower threshold for doubling to avoid hysteresis  
    if (size >= threshold - threshold / 4)  
        resize();  
}

它里面先进行了一次探测清理,也就是从头开始的清理,然后,清理之后判断size是否大于等于阈值的四分之三。所以这里阈值就是一值两用。第一次用于是否进行rehash,这是时候阈值就是长度的的三分之二。而进入rehash之后,决定是否进行resizesize和阈值的四分之三进行比较的。扩容就是将数组扩容到原来的两倍。

1.3 ThreadLocal 主子线程的问题

在异步的场景下是无法给子线程共享父线程创建的线程变量副本的,我给大家写个例子

java 复制代码
public static void main(String[] args) {  
    ThreadLocal<String> parentThreadLocal = new ThreadLocal<>();  
    parentThreadLocal.set("我是你爹");  
    new Thread(new Runnable() {  
        @Override  
        public void run() {  
            System.out.println("父线程变量:"+parentThreadLocal.get());  
        }  
    }).start();  
}

得到的结果就是

因为不管你是主线程还是子线程,在ThreadLocal里面都是独立的。为了解决这个问题JDK提供了一个InheritableThreadLocal,这个类在使用构造方法创建子线程的时候调用了一个init方法,这个方法就复制了主线程的InheritableThreadLocal来达到目的。但是这个也存在问题。

实际上在我们的项目的开发中,大多数都是线程池的场景,线程池中的核心线程都是线程复用的,并不会每次都调用init方法。这个问题还是很常见的,在我们项目中在线程池处理逻辑里面使用的ThreadLocal读取线程变量的时候,很容易就发生数据错乱的情况,所以阿里阿巴巴提供一个开源组件TransmittableThreadLocal大家可以自己研究一下,它的核心方式就是包装RunnableTtlRunnable,然后将父线程的线程变量放进去。

ThreadLocal总结

在上面我说了ThreadLocal的数据结构,还有它为什么要设计成软引用的方式,还有造成内存泄露的原因。其实推荐大家使用ThreadLocal的时候还是要注意一些的,比如你的线程结束之后,在finally里面remove这个key,不要过度依赖GC,同时要缩短ThreadLocal的使用的生命周期,不要放在一个非常长生命周期里面,一旦线程使用,那么就造成一个强引用关系,并发量上来之后,可想而知ThreadLocalMap的大小。

同时我还写了一些源码的简单解释,说了一下它的清理方式和扩容机制,最后还是推荐大家使用一下TransmittableThreadLocal。

2、AQS

AQS 也是Java多线程中 非常重要的一个部分,全称就是抽象队列同步器AbstractQueuedSynchronizer从命名上面就能看到一些门路,抽象类,使用队列的同步器。具体的功能类比如 ReentrantLockSemaphoreCountDownLatch都是基于 AQS 实现的。

2.1 我们怎么理解 AQS

AQS 我认为它就是一种规范,我们可以通过它自己实现公平锁/非公平锁,共享锁/独占锁。比如ReentrantLock就可以自己声明公平还是非公平,而且ReentrantLock是可重入的独占锁。SemaphoreCountDownLatch都是共享锁。(公平锁就是多个线程访问共享资源的时候,可以根据线程的访问顺序来决定给锁的顺序,非公平锁就是大家一起竞争比如 Sychronized)。它是自旋锁的一种改进,它将没有获取锁的线程封装成一个结点,每个结点都包含了当前线程,等待状态,前驱结点和后继结点,然后以此构成一个双向的等待队列。

它里面使用一个属性 state 并且声明是 volatile 的类型,保证多线程的可见性,来表示锁的状态。以ReentrantLock为例,它是一个可重入的锁,所以每次同一个线程获取该锁都会将 state+1,然后释放的时候-1,当 state 为 0 就代表锁全部被释放。

所以需要记住 AQS 核心的数据结构就是双向链表,前驱节点持有锁释放的时候就可以通知后继节点,以此保证可以实现公平锁,同时可以实现共享锁和独占锁,这个概念在它的核心组件中体现。

2.2 ReentrantLock

ReentrantLock并没有实现 AQS,但是它内部有一个内部类SyncSync 实现了 AQS,同时ReentrantLock 的加锁释放锁的大部分操作实际上都是在 Sync 内部实现的。 同时它是一个可重入的独占锁,这个我们看一下它内部加锁的逻辑就可以了,如下

java 复制代码
final boolean tryLock() {
    //当前线程
    Thread current = Thread.currentThread();
    //锁状态
    int c = getState();
    //如果没有持有锁
    if (c == 0) {
        //CAS 设置为 1
        if (compareAndSetState(0, 1)) {
            //设置独占线程为当前线程
            setExclusiveOwnerThread(current);
            return true;
        }
        //如果当前线程就是独占线程,那么就 state++
    } else if (getExclusiveOwnerThread() == current) {
        if (++c < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(c);
        return true;
    }
    return false;
}

然后呢,Sync 有两个子类,一个是 NonFairSync 非公平,一个是 FairSync 公平。看一下公平锁里面的方法

java 复制代码
//这个方法就是要找有没有线程等待呢,也就是 next 是不是等着呢,没有的话你再去 CAS
protected final boolean tryAcquire(int acquires) {
    if (getState() == 0 && !hasQueuedPredecessors() &&
        compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    return false;
}

2.3 Sychronized 和ReentrantLock有啥区别

1、实现方式上:SychronizedJava 内置特性,基于Monitor 实现的,而ReentrantLock是通过 Java 代码实现的一个组件。 2、锁的显示:Sychronized 是自动加锁和释放的,ReentrantLock需要手动的加锁和释放锁 3、ReentrantLock还支持超时等待,响应中断等高级特性。 4、ReentrantLock可以支持公平/非公平锁,而 Sychronized 只支持非公平锁。

相关推荐
TT哇5 分钟前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
dessler21 分钟前
Docker-run命令详细讲解
linux·运维·后端·docker
火烧屁屁啦28 分钟前
【JavaEE进阶】初始Spring Web MVC
java·spring·java-ee
w_312345442 分钟前
自定义一个maven骨架 | 最佳实践
java·maven·intellij-idea
岁岁岁平安44 分钟前
spring学习(spring-DI(字符串或对象引用注入、集合注入)(XML配置))
java·学习·spring·依赖注入·集合注入·基本数据类型注入·引用数据类型注入
武昌库里写JAVA1 小时前
Java成长之路(一)--SpringBoot基础学习--SpringBoot代码测试
java·开发语言·spring boot·学习·课程设计
Q_19284999061 小时前
基于Spring Boot的九州美食城商户一体化系统
java·spring boot·后端
张国荣家的弟弟1 小时前
【Yonghong 企业日常问题 06】上传的文件不在白名单,修改allow.jar.digest属性添加允许上传的文件SH256值?
java·jar·bi
ZSYP-S1 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring