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);
}
}
直接看 ThreadLocalMap
的 set
方法,直接看注释
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
之后,决定是否进行resize
是size
和阈值的四分之三进行比较的。扩容就是将数组扩容到原来的两倍。
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大家可以自己研究一下,它的核心方式就是包装Runnable
成TtlRunnable
,然后将父线程的线程变量放进去。
ThreadLocal总结
在上面我说了ThreadLocal
的数据结构,还有它为什么要设计成软引用的方式,还有造成内存泄露的原因。其实推荐大家使用ThreadLoca
l的时候还是要注意一些的,比如你的线程结束之后,在finally
里面remove
这个key
,不要过度依赖GC
,同时要缩短ThreadLocal
的使用的生命周期,不要放在一个非常长生命周期里面,一旦线程使用,那么就造成一个强引用关系,并发量上来之后,可想而知ThreadLocalMap
的大小。
同时我还写了一些源码的简单解释,说了一下它的清理方式和扩容机制,最后还是推荐大家使用一下TransmittableThreadLocal。
2、AQS
AQS
也是Java
多线程中 非常重要的一个部分,全称就是抽象队列同步器AbstractQueuedSynchronizer
从命名上面就能看到一些门路,抽象类,使用队列的同步器。具体的功能类比如 ReentrantLock
,Semaphore
,CountDownLatch
都是基于 AQS
实现的。
2.1 我们怎么理解 AQS
AQS
我认为它就是一种规范,我们可以通过它自己实现公平锁/非公平锁,共享锁/独占锁。比如ReentrantLock
就可以自己声明公平还是非公平,而且ReentrantLock
是可重入的独占锁。Semaphore
和CountDownLatch
都是共享锁。(公平锁就是多个线程访问共享资源的时候,可以根据线程的访问顺序来决定给锁的顺序,非公平锁就是大家一起竞争比如 Sychronized
)。它是自旋锁的一种改进,它将没有获取锁的线程封装成一个结点,每个结点都包含了当前线程,等待状态,前驱结点和后继结点,然后以此构成一个双向的等待队列。
它里面使用一个属性 state
并且声明是 volatile
的类型,保证多线程的可见性,来表示锁的状态。以ReentrantLock
为例,它是一个可重入的锁,所以每次同一个线程获取该锁都会将 state+1
,然后释放的时候-1,当 state
为 0 就代表锁全部被释放。
所以需要记住 AQS
核心的数据结构就是双向链表,前驱节点持有锁释放的时候就可以通知后继节点,以此保证可以实现公平锁,同时可以实现共享锁和独占锁,这个概念在它的核心组件中体现。
2.2 ReentrantLock
ReentrantLock
并没有实现 AQS
,但是它内部有一个内部类Sync
,Sync
实现了 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、实现方式上:Sychronized
是 Java
内置特性,基于Monitor
实现的,而ReentrantLock
是通过 Java
代码实现的一个组件。 2、锁的显示:Sychronized
是自动加锁和释放的,ReentrantLock
需要手动的加锁和释放锁 3、ReentrantLock
还支持超时等待,响应中断等高级特性。 4、ReentrantLock
可以支持公平/非公平锁,而 Sychronized
只支持非公平锁。