看文章前先了解java弱引用和多线程
简介
当我们想要多个线程使用同一个变量时,会出现并发安全问题,这时候我们必须采取措施,对变量的访问进行同步。
为了达到这个目的,我们一般是采用加锁的方式解决,但是加锁的方式太重了,会使系统的并发量大大降低,有什么办法能够解决这个问题呢?
当然有,不过是另辟蹊径: 不对访问进行同步,而是对每个线程,复制一份该变量,每个线程操作自己的那部分变量,来消除并发安全。Threadlocal就是用来干这个的
Threadlocal的使用
public class MyThreadLocal {
final static ExecutorService executor = Executors.newCachedThreadPool();
static ThreadLocal<Integer> num = new ThreadLocal<>();
public static void main(String[] args) {
try {
executor.execute(()->{
// 赋初始值
num.set(1);
print("thread-1");
num.remove();
});
executor.execute(()->{
// 赋初始值
num.set(2);
print("thread-2");
num.remove();
});
}finally {
executor.shutdown();
}
}
static void print(String str){
System.out.println(str + ":"+num.get());
}
}
可以看到两个线程正常运行,互不影响
ThreadLocal类结构解析
实际上,ThreadLocal并非是存储属性的实际变量,它只不过是个工具类,或者说门面,访问的入口
可以看到ThreadLocal下有个静态内部类ThreadLocalMap,ThreadLocalMap下又有一个静态内部类Entry,Entry继承了一个弱引用对象,这个Entry就是实际存放变量的地方,而ThreadLocalMap则是存放Entry的map对象,其实际实现是一个Entry数组,set时,变量通过hash算法,插入到特定的位置
源码解析
我们先从set方法入手
// ThreadLocal -> set(T value)
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// 如果map不为null,则插入
if (map != null) {
map.set(this, value);
} else {
// 在当前线程创建ThreadLocalMap
createMap(t, value);
}
}
// ThreadLocal -> createMap(Thread t, T firstValue)
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
// ThreadLocal -> ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)
protected ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 新建Entry数组,INITIAL_CAPACITY默认为16
table = new Entry[INITIAL_CAPACITY];
// hash出下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 第一次set,坑定不会出现hash冲突
table[i] = new Entry(firstKey, firstValue);
size = 1;
// 设置threshold,值为(INITIAL_CAPACITY * 2 / 3);
setThreshold(INITIAL_CAPACITY);
}
// ThreadLocal->ThreadLocalMap->Entry。注意它继承了一个弱引用对象
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
从上面我们可以看出,key就是ThreadLocal,value是set的值,最终这对kv会封装到Entry里,放入ThreadLocalMap中,ThreadLocalMap在第一次使用set时,初始化
现在我们再看看看非初始化的set方法
// ThreadLocal->ThreadLocalMap#set(ThreadLocal<?> key, Object value)
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循环快速找到插入位置
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 如果k已经有了,则直接覆盖
if (k == key) {
e.value = value;
return;
}
// 如果k为null,则说明被gc回收,需要先清除旧value再设置新value
// 除此之外,还得重新hash该下标左右范围内的Entry,否则该位置为修改后,可能会导致曾与该位置发生hash冲突的Entry找不到(因为置null之后,get()时碰到null就直接返回回了)
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
// ThreadLocal->ThreadLocalMap#nextIndex(int i, int len)
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
代码大致流程
- 使用开放地址法访问位置
- 位置上的key等于当前的ThreadLocal,则直接覆盖,并返回
- 若key为null,则说明被gc回收,我们可以把该位置清空并set,但在set之前我们还得做“清理”工作(稍后细琐,很关键)
- new一个Entry,赋给当前位置
- size加1
- 清理其余过期对象,若sz大于threshold,则需要扩容,重新hash
清理过期对象方法 void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot)
我们知道,map中k和v是1对1的关系,而ThreadLocalMap底层只是个Entry[],我们如何维护这种1对1关系呢?replaceStaleEntry()的意义就在于此。(replaceStaleEntry()的位置看上面代码)
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// 首先初始化slotToExpunge,它代表要被清除的table[i]
int slotToExpunge = staleSlot;
// 从table[stateSlot]开始,往回查询,直到遇见table[i] == null。此时slotToExpunge为从staleSlot左边起,到stateSlot为止,第一个弱引用被回收的table[i]。
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// Find either the key or trailing null slot of run, whichever
// occurs first
// 从stateSlot+1开始,到第一个table[i]==null位置,进行遍历
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 如果发现key有重复,则将本来要插入的位置的Entry和key的位置的Entry进行交换
//
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 如果slotToExpunge == staleSlot,则说明往前扫描并未发现过期的Entry,过期的只有tab[staleSlot]
// 将i赋给slotToExpunge,并清除。为什么会选择清除tab[i],而不是tab[staleSlot]?不是说过期的是tab[staleSlot]吗?因为就在前3行,我们把tab[staleSlot]和tab[i]的位置交换了....
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 清除
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 如果找到过期的Entry且slotToExpunge==staleSlot(slotToExpunge==staleSlot说明这次找到的就是从数组中一个过期的Entry,之前往前找没有找到)
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// If key not found, put new entry in stale slot
// 清除tab[staleSlot]上的引用,并赋值新的Entry
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
从之前的set方法我们知道,在找到应该插入的位置,且该位置(staleSlot)的Entry已经被gc清除后(强调一下,这里是指key变null了,整个Entry还是存在的)后,它不急着立刻插入进去,而是在[null->staleSlot->null]之间,先把[null,staleSlot]之间所有的过期Entry给清除,然后再在[statleSlot,null]之间寻找是否有重复key的Entry(设tab[i]),顺便把[statleSlot,null]中第一个过期的Entry下标赋值给slotToExpunge,并交换tab[staleSlot]和tab[i]的位置,清除tab[i](实际上清除的就是原来的tab[staleSlot])。遇到null,则结束循环
结束循环后,终于可以在tab[staleSlot]上放入新的Entry,最后在结束前执行cleanSomeSlots(expungeStaleEntry(slotToExpunge), len),目的是来重新hash右边第一个null之后的所有Entry,目的是为了防止因前面set后出现的新的null,导致其他get set获取不到本应该读取到的值的情况发生
可能有人要问了,为什么set执行后,若有新的位置上出现null,可能会导致get set失效呢?因为无论是哪种hash算法,一定会产生hash冲突,为了解决hash冲突,ThreadLocalMap采用的是开放地址法,冲突了就往后找。因此如果被清除的位置出现在冲突处,会导致产生冲突的Entry找不到,因为冲突处变null了,set在遇到null时会直接插入,map里就可能会出现两个key相同的Entry,而get时只能找到其中一个
举个例子
以下图为例,
先假设f(7)=4,f(4)=4
如果我们要新set的值是7,那么staleSlot就是4,而tab[4]已经有了Entry,但是key已经被回收了。
-
这时候7不会直接set到4的位置,而是先往左找,在碰到null之前,找到左边离它最远的key为null的Entry,并把它的下标赋值给为slotToExpunge
-
再往右找,寻找是否有key和当前相同Entry,若有则交换两者的位置
-
如果没有key重复,则遇到第一个null时,退出循环,并插入值
-
接着,也就是最重要的一步: 把slotToExpunge开始直到遇见第一个null之间的全部过期值都清除掉,并且重新hash,填满因清除旧数据而产生的null。
为什么ThreadLocal采用开放地址法
开放地址法
- 容易产生堆积问题,不适于大规模的数据存储。
- 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
- 删除的元素是多个冲突元素中的一一个,需要对后面的元素作处理,实现较复杂。
- 一般由数组实现,访问快
链地址法
- 处理冲突简单,且无堆积现象,平均查找长度短。
- 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
- 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
- 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
- 链表结构,访问慢
ThreadLocal一般不会存放大量的数据,而开发地址法结构相对简单,访问速度快,适合ThreadLocal。