ThreadLocal
//ThreadLocal部分======//
ThreadLocal介绍
官方介绍
从Java官方文档中的描述:ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。
疑问??? 为什么是private static 类型的???
我们可以得知 ThreadLocal 的作用是:提供线程内的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。
总结:
- 线程并发: 在多线程并发的场景下
- 传递数据: 我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
- 线程隔离: 每个线程的变量都是独立的,不会互相影响
ThreadLocal类的特点
ThreadLocal 线程隔离的特点
public class MyDemo {
private String content;
private String getContent() {
return content;
}
private void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
MyDemo demo = new MyDemo();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println("-----------------------");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
});
thread.setName("线程" + i);
thread.start();
}
}
}
打印结果:从结果可以看出多个线程在访问同一个变量的时候出现的异常,线程间的数据没有隔离
下面我们来看下采用 ThreadLocal 的方式来解决这个问题的例子。
public class MyDemo1 {
private static ThreadLocal<String> tl = new ThreadLocal<>();
private String content;
private String getContent() {
return tl.get();
}
private void setContent(String content) {
tl.set(content);
}
public static void main(String[] args) {
MyDemo demo = new MyDemo();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println("-----------------------");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
});
thread.setName("线程" + i);
thread.start();
}
}
}
打印结果:从结果来看,这样很好的解决了多线程之间数据隔离的问题,十分方便。
这里可能有的朋友会觉得在上述例子中我们完全可以通过加锁来实现这个功能。我们首先来看一下用synchronized代码块实现的效果:
public class Demo02 {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
Demo02 demo02 = new Demo02();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(){
@Override
public void run() {
synchronized (Demo02.class){
demo02.setContent(Thread.currentThread().getName() + "的数据");
System.out.println("-------------------------------------");
String content = demo02.getContent();
System.out.println(Thread.currentThread().getName() + "--->" + content);
}
}
};
t.setName("线程" + i);
t.start();
}
}
}
打印结果:
ThreadLocal与synchronized的区别
虽然ThreadLocal模式与synchronized关键字都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同。
synchronized | ThreadLocal | |
---|---|---|
原理 | 同步机制采用’以时间换空间’的方式, 只提供了一份变量,让不同的线程排队访问 | ThreadLocal采用’以空间换时间’的方式, 为每一个线程都提供了一份变量的副本,从而实现同时访问而相不干扰 |
侧重点 | 多个线程之间访问资源的同步 | 多线程中让每个线程之间的数据相互隔离 |
总结:
在刚刚的案例中,虽然使用ThreadLocal和synchronized都能解决问题,但是使用ThreadLocal更为合适,因为这样可以使程序拥有更高的并发性。
ThreadLocal的内部结构
通过以上的学习,我们对ThreadLocal的作用有了一定的认识。现在我们一起来看一下ThreadLocal的内部结构,探究它能够实现线程数据隔离的原理。
常见的误解
如果我们不去看源代码的话,可能会猜测ThreadLocal是这样子设计的:每个ThreadLocal都创建一个Map,然后用线程作为Map的key,要存储的局部变量作为Map的value,这样就能达到各个线程的局部变量隔离的效果。这是最简单的设计方法,JDK最早期的ThreadLocal 确实是这样设计的,但现在早已不是了。
现在的设计
但是,JDK后面优化了设计方案,在JDK8中 ThreadLocal的设计是:每个Thread维护一个ThreadLocalMap,这个Map的key是ThreadLocal实例本身,value才是真正要存储的值Object。
具体的过程是这样的:
- 每个Thread线程内部都有一个Map (ThreadLocalMap)
- Map里面存储ThreadLocal对象(key)和线程的变量副本(value)
- Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。
- 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。
这样设计的好处
这个设计与我们一开始说的设计刚好相反,这样设计有如下两个优势:
- 这样设计之后每个Map存储的Entry数量就会变少。因为之前的存储数量由Thread的数量决定,现在是由ThreadLocal的数量决定。在实际运用当中,往往ThreadLocal的数量要少于Thread的数量。
- 当Thread销毁之后,对应的ThreadLocalMap也会随之销毁,能减少内存的使用。
ThreadLocal的核心方法源码
基于ThreadLocal的内部结构,我们继续分析它的核心方法源码,更深入的了解其操作原理。
除了构造方法之外, ThreadLocal对外暴露的方法有以下4个:
方法声明 | 描述 |
---|---|
protected T initialValue() | 返回当前线程局部变量的初始值 |
public void set( T value) | 设置当前线程绑定的局部变量 |
public T get() | 获取当前线程绑定的局部变量 |
public void remove() | 移除当前线程绑定的局部变量 |
以下是这4个方法的详细源码分析(为了保证思路清晰, )
set方法
(1 ) 源码和对应的中文注释
/**
* 设置当前线程对应的ThreadLocal的值
*
* @param value 将要保存在当前线程对应的ThreadLocal的值
*/
public void set(T value) {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 获取此线程对象中维护的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 判断map是否存在
if (map != null)
// 存在则调用map.set设置此实体entry
map.set(this, value);
else
// 1)当前线程Thread 不存在ThreadLocalMap对象
// 2)则调用createMap进行ThreadLocalMap对象的初始化
// 3)并将 t(当前线程)和value(t对应的值)作为第一个entry存放至ThreadLocalMap中
createMap(t, value);
}
/**
* 获取当前线程Thread对应维护的ThreadLocalMap
*
* @param t the current thread 当前线程
* @return the map 对应维护的ThreadLocalMap
*/
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
/**
*创建当前线程Thread对应维护的ThreadLocalMap
*
* @param t 当前线程
* @param firstValue 存放到map中第一个entry的值
*/
void createMap(Thread t, T firstValue) {
//这里的this是调用此方法的threadLocal
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
(2 ) 代码执行流程
A. 首先获取当前线程,并根据当前线程获取一个Map
B. 如果获取的Map不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key)
C. 如果Map为空,则给该线程创建 Map,并设置初始值
get方法
(1 ) 源码和对应的中文注释
/**
* 返回当前线程中保存ThreadLocal的值
* 如果当前线程没有此ThreadLocal变量,
* 则它会通过调用{@link #initialValue} 方法进行初始化值
*
* @return 返回当前线程对应此ThreadLocal的值
*/
public T get() {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 获取此线程对象中维护的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 如果此map存在
if (map != null) {
// 以当前的ThreadLocal 为 key,调用getEntry获取对应的存储实体e
ThreadLocalMap.Entry e = map.getEntry(this);
// 对e进行判空
if (e != null) {
@SuppressWarnings("unchecked")
// 获取存储实体 e 对应的 value值
// 即为我们想要的当前线程对应此ThreadLocal的值
T result = (T)e.value;
return result;
}
}
/*
初始化 : 有两种情况有执行当前代码
第一种情况: map不存在,表示此线程没有维护的ThreadLocalMap对象
第二种情况: map存在, 但是没有与当前ThreadLocal关联的entry
*/
return setInitialValue();
}
/**
* 初始化
*
* @return the initial value 初始化后的值
*/
private T setInitialValue() {
// 调用initialValue获取初始化的值
// 此方法可以被子类重写, 如果不重写默认返回null
T value = initialValue();
// 获取当前线程对象
Thread t = Thread.currentThread();
// 获取此线程对象中维护的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 判断map是否存在
if (map != null)
// 存在则调用map.set设置此实体entry
map.set(this, value);
else
// 1)当前线程Thread 不存在ThreadLocalMap对象
// 2)则调用createMap进行ThreadLocalMap对象的初始化
// 3)并将 t(当前线程)和value(t对应的值)作为第一个entry存放至ThreadLocalMap中
createMap(t, value);
// 返回设置的值value
return value;
}
(2 ) 代码执行流程
A. 首先获取当前线程, 根据当前线程获取一个Map
B. 如果获取的Map不为空,则在Map中以ThreadLocal的引用作为key来在Map中获取对应的Entry e,否则转到D
C. 如果e不为null,则返回e.value,否则转到D
D. Map为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的Map
总结: 先获取当前线程的 ThreadLocalMap 变量,如果存在则返回值,不存在则创建并返回初始值。
remove方法
(1 ) 源码和对应的中文注释
/**
* 删除当前线程中保存的ThreadLocal对应的实体entry
*/
public void remove() {
// 获取当前线程对象中维护的ThreadLocalMap对象
ThreadLocalMap m = getMap(Thread.currentThread());
// 如果此map存在
if (m != null)
// 存在则调用map.remove
// 以当前ThreadLocal为key删除对应的实体entry
m.remove(this);
}
(2 ) 代码执行流程
A. 首先获取当前线程,并根据当前线程获取一个Map
B. 如果获取的Map不为空,则移除当前ThreadLocal对象对应的entry
initialValue方法
/**
* 返回当前线程对应的ThreadLocal的初始值
* 此方法的第一次调用发生在,当线程通过get方法访问此线程的ThreadLocal值时
* 除非线程先调用了set方法,在这种情况下,initialValue 才不会被这个线程调用。
* 通常情况下,每个线程最多调用一次这个方法。
*
* <p>这个方法仅仅简单的返回null {@code null};
* 如果程序员想ThreadLocal线程局部变量有一个除null以外的初始值,
* 必须通过子类继承{@code ThreadLocal} 的方式去重写此方法
* 通常, 可以通过匿名内部类的方式实现
*
* @return 当前ThreadLocal的初始值
*/
protected T initialValue() {
return null;
}
此方法的作用是 返回该线程局部变量的初始值。
(1) 这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。
(2)这个方法缺省实现直接返回一个null。
(3)如果想要一个除null之外的初始值,可以重写此方法。(备注: 该方法是一个protected的方法,显然是为了让子类覆盖而设计的)
//ThreadLocalMap部分======//
ThreadLocalMap源码分析
在分析ThreadLocal方法的时候,我们了解到ThreadLocal的操作实际上是围绕ThreadLocalMap展开的。ThreadLocalMap的源码相对比较复杂, 我们从以下三个方面进行讨论。
基本结构
ThreadLocalMap是ThreadLocal的内部类,没有实现Map接口,用独立的方式实现了Map的功能,其内部的Entry也是独立实现。
(1) 成员变量
/**
* 初始容量 —— 必须是2的整次幂
*/
private static final int INITIAL_CAPACITY = 16;
/**
* 存放数据的table,Entry类的定义在下面分析
* 同样,数组长度必须是2的整次幂。
*/
private Entry[] table;
/**
* 数组里面entrys的个数,可以用于判断table当前使用量是否超过阈值。
*/
private int size = 0;
/**
* 进行扩容的阈值,表使用量大于它的时候进行扩容。
*/
private int threshold; // Default to 0
跟HashMap类似,INITIAL_CAPACITY代表这个Map的初始容量;table 是一个Entry 类型的数组,用于存储数据;size 代表表中的存储数目; threshold 代表需要扩容时对应 size 的阈值。
(2) 存储结构 - Entry
/* * Entry继承WeakReference,并且用ThreadLocal作为key. * 如果key为null(entry.get() == null),意味着key不再被引用, * 因此这时候entry也可以从table中清除。 */static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; }}
在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。不过Entry中的key只能是ThreadLocal对象,这点在构造方法中已经限定死了。
另外,Entry继承WeakReference,也就是key(ThreadLocal)是弱引用,其目的是将ThreadLocal对象的生命周期和线程生命周期解绑。
弱引用和内存泄漏
有些程序员在使用ThreadLocal的过程中会发现有内存泄漏的情况发生,就猜测这个内存泄漏跟Entry中使用了弱引用的key有关系。这个理解其实是不对的。
我们先来回顾这个问题中涉及的几个名词概念,再来分析问题。
(1) 内存泄漏相关概念
- Memory overflow:内存溢出,没有足够的内存提供申请者使用。
- Memory leak: 内存泄漏是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏的堆积终将导致内存溢出。
(2) 弱引用相关概念
Java中的引用有4种类型: 强、软、弱、虚。当前这个问题主要涉及到强引用和弱引用:
强引用(“Strong” Reference),就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾回收器就不会回收这种对象。
弱引用(WeakReference),垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
(3) 如果key使用强引用
假设ThreadLocalMap中的key使用了强引用,那么会出现内存泄漏吗?
此时ThreadLocal的内存图(实线表示强引用)如下:
假设在业务代码中使用完ThreadLocal ,threadLocal Ref被回收了。
但是因为threadLocalMap的Entry强引用了threadLocal,造成threadLocal无法被回收。
在没有手动删除threadLocalMap中的这个Entry以及CurrentThread依然运行的前提下,始终有强引用链 threadRef->currentThread->threadLocalMap->entry,Entry就不会被回收(Entry中包括了ThreadLocal实例和value),导致Entry内存泄漏。
也就是说,ThreadLocalMap中的key使用了强引用, 是无法完全避免内存泄漏的。
(5)如果key使用弱引用
那么ThreadLocalMap中的key使用了弱引用,会出现内存泄漏吗?
此时ThreadLocal的内存图(实线表示强引用,虚线表示弱引用)如下:
同样假设在业务代码中使用完ThreadLocal ,threadLocal Ref被回收了。
由于ThreadLocalMap只持有ThreadLocal的弱引用,没有任何强引用指向threadlocal实例, 所以threadlocal就可以顺利被gc回收,此时Entry中的key=null。
但是在没有手动删除这个Entry以及CurrentThread依然运行的前提下,也存在有强引用链 threadRef->currentThread->threadLocalMap->entry -> value ,value不会被回收, 而这块value永远不会被访问到了,导致value内存泄漏。
也就是说,ThreadLocalMap中的key使用了弱引用, 也有可能内存泄漏。
(6)出现内存泄漏的真实原因
比较以上两种情况,我们就会发现,内存泄漏的发生跟ThreadLocalMap中的key是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?
细心的同学会发现,在以上两种内存泄漏的情况中,都有两个前提:
- 没有手动删除这个Entry
- CurrentThread依然运行
第一点很好理解,只要在使用完ThreadLocal,调用其remove方法删除对应的Entry,就能避免内存泄漏。
第二点稍微复杂一点, 由于ThreadLocalMap是Thread的一个属性,被当前线程所引用,所以它的生命周期跟Thread一样长。那么在使用完ThreadLocal之后,如果当前Thread也随之执行结束,ThreadLocalMap自然也会被gc回收,从根源上避免了内存泄漏。
综上,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏。
(7) 为什么使用弱引用
根据刚才的分析, 我们知道了: 无论ThreadLocalMap中的key使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。
要避免内存泄漏有两种方式:
- 使用完ThreadLocal,调用其remove方法删除对应的Entry
- 使用完ThreadLocal,当前Thread也随之运行结束
相对第一种方式,第二种方式显然更不好控制,特别是使用线程池的时候,线程结束是不会销毁的。
也就是说,只要记得在使用完ThreadLocal及时的调用remove,无论key是强引用还是弱引用都不会有问题。那么为什么key要用弱引用呢?
事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。
这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。
即 threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。
从以上的分析可以看出,使用弱引用的话在threadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。
ThreadLocalMap 和HashMap
ThreadLocalMap 和HashMap的功能类似,但是实现上却有很大的不同:
区别 | 数据结构 | 解决hash 冲突 | Entry 内部类的引用 |
---|---|---|---|
HashMap | 数组+链表 | 链地址法 | Entry 内部类的引用都是强引用 |
ThreadLocalMap | 数组 | 开放地址法 | Entry 内部类中的key 是弱引用,value 是强引用 |
ThreadLocalMap hash冲突的解决
hash冲突的解决是Map中的一个重要内容。我们以hash冲突的解决为线索,来研究一下ThreadLocalMap的核心源码。
(1) 首先从ThreadLocal的set() 方法入手
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocal.ThreadLocalMap map = getMap(t); if (map != null) //调用了ThreadLocalMap的set方法 map.set(this, value); else createMap(t, value); } ThreadLocal.ThreadLocalMap getMap(Thread t) { return t.threadLocals; } void createMap(Thread t, T firstValue) { //调用了ThreadLocalMap的构造方法 t.threadLocals = new ThreadLocal.ThreadLocalMap(this, firstValue); }
这个方法我们刚才分析过, 其作用是设置当前线程绑定的局部变量 :
A. 首先获取当前线程,并根据当前线程获取一个Map
B. 如果获取的Map不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key)
(这里调用了ThreadLocalMap的set方法)
C. 如果Map为空,则给该线程创建 Map,并设置初始值
(这里调用了ThreadLocalMap的构造方法)
这段代码有两个地方分别涉及到ThreadLocalMap的两个方法, 我们接着分析这两个方法。
(2)构造方法ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)
/* * firstKey : 本ThreadLocal实例(this) * firstValue : 要保存的线程本地变量 */ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { //初始化table table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY]; //计算索引(重点代码) int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //设置值 table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue); size = 1; //设置阈值 setThreshold(INITIAL_CAPACITY); }
构造函数首先创建一个长度为16的Entry数组,然后计算出firstKey对应的索引,然后存储到table中,并设置size和threshold。
重点分析: int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)
。
a. 关于firstKey.threadLocalHashCode
:
private final int threadLocalHashCode = nextHashCode(); private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }//AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减,适合高并发情况下的使用 private static AtomicInteger nextHashCode = new AtomicInteger(); //特殊的hash值 private static final int HASH_INCREMENT = 0x61c88647;
这里定义了一个AtomicInteger类型,每次获取当前值并加上HASH_INCREMENT,HASH_INCREMENT = 0x61c88647,这个值跟斐波那契数列(黄金分割数)有关,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry[] table中,这样做可以尽量避免hash冲突。
b. 关于& (INITIAL_CAPACITY - 1)
计算hash的时候里面采用了hashCode & (size - 1)的算法,这相当于取模运算hashCode % size的一个更高效的实现。正是因为这种算法,我们要求size必须是2的整次幂,这也能保证在索引不越界的前提下,使得hash发生冲突的次数减小。
(3) ThreadLocalMap中的set方法
private void set(ThreadLocal<?> key, Object value) { ThreadLocal.ThreadLocalMap.Entry[] tab = table; int len = tab.length; //计算索引(重点代码,刚才分析过了) int i = key.threadLocalHashCode & (len-1); /** * 使用线性探测法查找元素(重点代码) */ for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); //ThreadLocal 对应的 key 存在,直接覆盖之前的值 if (k == key) { e.value = value; return; } // key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了, // 当前数组中的 Entry 是一个陈旧(stale)的元素 if (k == null) { //用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return; } } //ThreadLocal对应的key不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的Entry。 tab[i] = new Entry(key, value); int sz = ++size; /** * cleanSomeSlots用于清除那些e.get()==null的元素, * 这种数据key关联的对象已经被回收,所以这个Entry(table[index])可以被置null。 * 如果没有清除任何entry,并且当前使用量达到了负载因子所定义(长度的2/3),那么进行 * rehash(执行一次全表的扫描清理工作) */ if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();} /** * 获取环形数组的下一个索引 */ private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); }
代码执行流程:
A. 首先还是根据key计算出索引 i,然后查找i位置上的Entry,
B. 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值,
C. 若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry,
D. 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。
最后调用cleanSomeSlots,清理key为null的Entry,最后返回是否清理了Entry,接下来再判断sz 是否>= thresgold达到了rehash的条件,达到的话就会调用rehash函数执行一次全表的扫描清理。
重点分析 : ThreadLocalMap使用线性探测法来解决哈希冲突的。
该方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。
举个例子,假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。
按照上面的描述,可以把Entry[] table看成一个环形数组。
解决hash冲突的两种方法区别:链地址法 和 开放地址法
首先我们来看看这两种不同的方式
链地址法
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。列如对于关键字集合{12,67,56,16,25,37, 22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法:
开放地址法
这种方法的基本思想是一旦发生了冲突,就去寻找下一个空的散列地址(这非常重要,源码都是根据这个特性,必须理解这里才能往下走),只要散列表足够大,空的散列地址总能找到,并将记录存入。
比如说,我们的关键字集合为{12,33,4,5,15,25},表长为10。 我们用散列函数f(key) = key mod l0。
当计算前S个数{12,33,4,5}时,都是没有冲突的散列地址,直接存入(蓝色代表为空的,可以存放数据):
计算key = 15时,发现hash(15) = 5,此时就与5所在的位置冲突。
于是我们应用上面的公式h(15) = (h(15)+1) mod 10 =6。于是将15存入下标为6的位置。这其实就是房子被人买了于是买下一间的作法:
链地址法和开放地址法的比较
开放地址法:
-
容易产生堆积问题,不适于大规模的数据存储。
什么叫做堆积问题可以看一下这篇博客 哈希表之开地址法解决冲突
-
散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
-
删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
链地址法:
- 处理冲突简单,且无堆积现象,平均查找长度短。
- 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
- 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
- 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
ThreadLocalMap 采用开放地址法原因
hreadLocalMap 中的散列值分散的十分均匀,很少会出现冲突
- ThreadLocal 中看到一个属性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一个神奇的数字,让哈希码能均匀的分布在2的N次方的数组里, 即 Entry[] table,关于这个神奇的数字google 有很多解析,这里就不重复说了
ThreadLocalMap 经常需要清除无用的对象,使用纯数组更加方便
- ThreadLocal 往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也低
ThreadLocalMap =》Set方法
首先我们必须理解:我们要存储的ObjectValue真正是放在ThreadLocalMap 中存取的,ThreadLocalMap 内部类有一个Entry 类,key是ThreadLocal 对象,value 就是你要ObjectValue 。
private void set(ThreadLocal <?> key, Object value) { Entry[] tab = table; int len = tab.length; // 根据 ThreadLocal 的散列值,查找对应元素在数组中的位置 int i = key.threadLocalHashCode & (len - 1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal <?> k = e.get(); // ThreadLocal 对应的 key 存在,直接覆盖之前的值 if (k == key) { e.value = value; return; }// key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素 if (k == null) { // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return; } } // ThreadLocal 对应的 key 不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的 Entry。 tab[i] = new Entry(key, value); int sz = ++size; // cleanSomeSlot 清理陈旧的 Entry(key == null),具体的参考源码。如果没有清理陈旧的 Entry 并且数组中的元素大于了阈值,则进行 rehash。 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();}
在该方法中针对脏entry做了这样的处理:
- 如果当前table[i]!=null的话说明hash冲突就需要向后环形查找,若在查找过程中遇到脏entry就通过replaceStaleEntry进行处理;
- 如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry
cleanSomeSlots
ThreadLocalMap过期key的启发式清理流程,
启发式清理作用指导我们找到脏entry,并且通过 探索式清理流程 清理该脏entry
该方法的源码为:
private boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); return removed; }
而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.
入参:
-
i表示:插入entry的位置i,很显然在上述情况2(table[i]==null)中,entry刚插入后该位置i很显然不是脏entry;
-
参数n , n的用途
主要用于扫描控制(scan control),从while中是通过n来进行条件判断的说明n就是用来控制扫描趟数(循环次数)的。
Java中 >>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0
在扫描过程中,
如果没有遇到脏entry就整个扫描过程持续log2(n)次,log2(n)的得来是因为
n >>>= 1
,每次n右移一位相当于n除以2。如果在扫描过程中遇到脏entry的话就会令n为当前hash表的长度(
n=len
),再扫描log2(n)趟,注意此时n增加无非就是多增加了循环次数,让有更多的循环次数支持我们在后面的循环里面有机会能够找到脏entry ,并且调用expungeStaleEntry
进行清楚
真正的清除工作:
是通过i = nextIndex 执行i++ ,然后执行 expungeStaleEntry(i)方法进行清除
expungeStaleEntry
ThreadLocalMap过期key的探测式清理流程
// key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素if (k == null) { // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return;}
expungeStaleEntry()具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:
我们假设expungeStaleEntry(3) 来调用此方法,如上图所示,我们可以看到ThreadLocalMap中table的数据情况,接着执行清理操作:
第一步是清空当前staleSlot位置的数据,index=3位置的Entry变成了null。然后接着往后探测:
第二部分:代码实现在
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
·
执行完第二步后,index=4的元素挪到index=3的槽位中。
继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算slot位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置
在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体操作:
这里我们还是以staleSlot=3
来做示例说明:
①首先是将tab[staleSlot]
槽位的数据清空,然后设置size--
②接着以staleSlot
位置往后迭代,如果往后迭代找了的脏entry ,清理该slot 即可 。并且将size --
rehash
以staleSlot
位置往后迭代,如果往后迭代找的的entry不是脏数据,我们需要对判断该entry的当前位置 i 是否是因为解决hash冲突,而通过 通过开放地址法生成的 。
当tab[h]!= null 时候,我们需要找到离h[正确位置] 最近的空闲的 slot 。
代码的意思就是以 h为起点。向后通过 nextIndex(h,len)对h进行++ , 直到tab[h] = null,
则将当前没有脏的entry放到h槽中 ,tab[h] = e;
这里是处理正常的产生Hash
冲突的数据,经过迭代后,有过Hash
冲突数据的Entry
位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。
如果还没有此时你还没有明白expungeStaleEntry方法可以看看这个
expungeStaleEntry
如果对输入参数能够理解的话,那么cleanSomeSlots方法搜索基本上清除了,但是全部搞定还需要掌握expungeStaleEntry方法,当在搜索过程中遇到了脏entry的话就会调用该方法去清理掉脏entry。源码为:
/*** Expunge a stale entry by rehashing any possibly colliding entries* lying between staleSlot and the next null slot. This also expunges* any other stale entries encountered before the trailing null. See* Knuth, Section 6.4** @param staleSlot index of slot known to have null key* @return the index of the next null slot after staleSlot* (all between staleSlot and this slot will have been checked* for expunging).*/private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot tab[staleSlot].value = null;//1清除当前脏entry tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; //2.往后环形继续查找,直到遇到table[i]==null时结束 for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); //3. 如果在向后搜索过程中再次遇到脏entry,同样将其清理掉 if (k == null) { e.value = null; tab[i] = null; size--; } else { //处理rehash的情况 int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i;}
该方法逻辑请看注释(第1,2,3步),主要做了这么几件事情:
- 清理当前脏entry,即将其value引用置为null,并且将table[staleSlot]也置为null。value置为null后该value域变为不可达,在下一次gc的时候就会被回收掉,同时table[staleSlot]为null后以便于存放新的entry;
- 从当前staleSlot位置向后环形(nextIndex)继续搜索,直到遇到哈希桶(tab[i])为null的时候退出;
- 若在搜索过程再次遇到脏entry,继续将其清除。
也就是说该方法,清理掉当前脏entry后,并没有闲下来继续向后搜索,若再次遇到脏entry继续将其清理,直到哈希桶(table[i])为null时退出。因此方法执行完的结果为 从当前脏entry(staleSlot)位到返回的i位,这中间所有的entry不是脏entry。为什么是遇到null退出呢?原因是存在脏entry的前提条件是 当前哈希桶(table[i])不为null,只是该entry的key域为null。如果遇到哈希桶为null,很显然它连成为脏entry的前提条件都不具备。
现在对cleanSomeSlot方法做一下总结,其方法执行示意图如下:
cleanSomeSlots示意图.png
如图所示,cleanSomeSlot方法主要有这样几点:
- 从当前位置i处(位于i处的entry一定不是脏entry)为起点在初始小范围(log2(n),n为哈希表已插入entry的个数size)开始向后搜索脏entry,若在整个搜索过程没有脏entry,方法结束退出
- 如果在搜索过程中遇到脏entryt通过expungeStaleEntry方法清理掉当前脏entry,并且该expungeStaleEntry方法会返回下一个哈希桶(table[i])为null的索引位置为i。这时重新令搜索起点为索引位置i,n为哈希表的长度len,再次扩大搜索范围为log2(n')继续搜索。
下面,以一个例子更清晰的来说一下,假设当前table数组的情况如下图。
cleanSomeSlots执行情景图.png
- 如图当前n等于hash表的size即n=10,i=1,在第一趟搜索过程中通过nextIndex,i指向了索引为2的位置,此时table[2]为null,说明第一趟未发现脏entry,则第一趟结束进行第二趟的搜索。
- 第二趟所搜先通过nextIndex方法,索引由2的位置变成了i=3,当前table[3]!=null但是该entry的key为null,说明找到了一个脏entry,先将n置为哈希表的长度len,然后继续调用expungeStaleEntry方法,该方法会将当前索引为3的脏entry给清除掉(令value为null,并且table[3]也为null),但是该方法可不想偷懒,它会继续往后环形搜索,往后会发现索引为4,5的位置的entry同样为脏entry,索引为6的位置的entry不是脏entry保持不变,直至i=7的时候此处table[7]位null,该方法就以i=7返回。至此,第二趟搜索结束;
- 由于在第二趟搜索中发现脏entry,n增大为数组的长度len,因此扩大搜索范围(增大循环次数)继续向后环形搜索;
- 直到在整个搜索范围里都未发现脏entry,cleanSomeSlot方法执行结束退出。
hash冲突
那么接下来我们说一下到底有哪些hash冲突
前提:我们将int i = key.threadLocalHashCode & (len - 1); 用 int i = hash(key) = key mod len 替换
一:无hash冲突
如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry
当表长为10。 我们用散列函数hash(key) = key mod l0。
当存入{12,33,4,5}时,都是没有冲突的散列地址,直接存入(蓝色代表为空的,可以存放数据):
- key = 12 时候 :
- i = (12 mod 10 = 2) —> table[i]==null, 直接存入 table[2] = value
- key = 33 时候
- i = (33 mod 10 = 3) —> table[i]==null, 直接存入 table[3] = value
- key = 4 时候
- i = (4 mod 10 = 4) —> table[i]==null, 直接存入 table[4] = value
- key = 5 时候
- i = (5 mod 10 = 5) —> table[i]==null, 直接存入 table[5] = value
即跳过代码片段6-19 直接 执行代码片段21-25 :
tab[i] = new Entry(key, value); int sz = ++size; // cleanSomeSlot 清理陈旧的 Entry(key == null),具体的参考源码。如果没有清理陈旧的 Entry 并且数组中的元素大于了阈值,则进行 rehash。 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
二:table[i]!=null 说明hash冲突
hash冲突第一种:
table中已经存入{12,33,4,5}后,想要继续加入table中:
- 存入key = 15 时候 :
- i = (15mod 10 = 5) —> table[i]!=
则进入下面的代码段, 因为 key= 15 而 此时e = table[5] -> {5:value} ,k = e.get() = 5
key!= k ,k = 5 !=null 则 继续下一次循环得到i = 6 后,table[6] ==null跳出for循环 , 执行代码 16-20
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal <?> k = e.get(); // ThreadLocal 对应的 key 存在,直接覆盖之前的值 if (k == key) { e.value = value; return; }// key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素 if (k == null) { // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return; } } // ThreadLocal 对应的 key 不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的 Entry。 tab[i] = new Entry(key, value); int sz = ++size; // cleanSomeSlot 清理陈旧的 Entry(key == null),具体的参考源码。如果没有清理陈旧的 Entry 并且数组中的元素大于了阈值,则进行 rehash。 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
此时,table显示如下
hash冲突第二种:
table中已经存入{12,33,4,5}后,想要继续加入table中:
- 存入key = 5,value = new 时候 :
- i = (5mod 10 = 5) —> table[i]!=
key存在就很简单 ,当前key 存在table中,则直接覆盖之前的值 ,执行下面黄色框框的代码
hsah 冲突第三种:
table中已经存入{12,33,4,5}后,按照hash冲突第一种解决方法 :执行代码如下
加入 15,25 (看hash冲突第一种,加入后得到table表
此时ThreadLocalMap中的table表 为
[{12:value},{33:value},{4:value},{5:value},{15:value},{25:value}]
经过一段时间后:
key=33,k=5 已经过期了(蓝色代表为空的,可以存放数据,红色代表key 过期,过期的key为null):
此时ThreadLocalMap中的table表 为
[{12:value},{null:value},{4:value},{null:value},{15:value},{25:value}]
好了我们开始讲hash 冲突第三种,
现在我们想要将 {15: new}这个entry 插入到 ThreadLocalMap中的table表 中
当key = 15时候:
- int i = hash(key) = hash(15) = 5 ,此时 i =5 , 获取到的e=table[i] = table[5] = {null,value}, k = e.get() = null ,
key为空,value 有值 那么 entry不为空 , 即代表有脏entry
于是执行 :replaceStaleEntry 方法 用心元素替换陈旧的元素,这个方法还进行了不少的垃圾清理动作,防止内存泄漏!!
// key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素if (k == null) { // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return;}
replaceStaleEntry
// key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 是一个陈旧(stale)的元素if (k == null) { // 用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏 replaceStaleEntry(key, value, i); return;}
key为 null,但是entry(e)不为 null,说明之前的 ThreadLocal 对象已经被回收了,当前数组中的 Entry 也就是e 是一个陈旧(stale)的元素 ,e此时就叫做脏entry!!
此时我们的解决hash冲突的手段就是hash冲突就需要使用探测搜索查找 ,通过replaceStaleEntry清除脏entry
其中传入的staleSlot 就是当前脏entry 的下标,
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 往前寻找脏entry ,第一个for循环 int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) // e的key为null slotToExpunge = i; // 往后找到 可以覆盖的key 或者 没有可以覆盖的key 那么直到遇到null的slot 才终止循环 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // 如果存在,则开始清除前面过期的entry if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // 如果我们没有在向前扫描中找到脏entry,后面就以此这个位置作为起点执行cleanSomeSlots if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } // 如果在查找过程中没有找到可以覆盖的entry,则将新的entry插入在脏entry tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 如果还有其他过期的entries存在 run 中,则清除他们 if (slotToExpunge != staleSlot) //10 cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);}
PreIndex方法实现往前环形搜索脏entry的功能
我们截取replaceStaleEntry方法中的代码片段: 第8-13行,截取出来如下所示:
int slotToExpunge = staleSlot // 往前寻找过期的slot for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i;
其中prevIndex方法如下:
private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1);}
这部分代码通过PreIndex方法实现往前环形搜索脏entry的功能
初始时slotToExpunge和staleSlot【当前已知的脏entry下标】相同,
若在向前搜索过程中发现了脏entry,则更新slotToExpunge为当前索引i
(注意:这边slotToExpunge 是在for循循环里面进行更新的,这么做的目的是为了什么呢???这个我们后面解释)
replaceStaleEntry并不仅仅局限于处理当前已知的脏entry(前面我们已经说过了当前已知的脏entry下标为staleSolt),它认为在
出现脏entry的相邻位置也有很大概率出现脏entry,所以为了一次处理到位,就需要向前环形搜索,找到前面的脏entry。)
nextIndex方法实现向后环形搜索可覆盖entry的功能
我们截取replaceStaleEntry方法中的代码片段: 第16-43行,截取出来如下所示:
// 找到 key 或者 直到 遇到null 的slot 才终止循环 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // }
其中nextIndex的方法如下:
private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); }
该代码段实现的功能是 :在for循环后向环形查找中查找是否有可覆盖的entry,若查找到了可覆盖的entry,先覆盖当前位置的entry,然后再与staleSlot位置上的脏entry进行交换。交换之后脏entry就更换到了i处,
那么什么是可覆盖的entry??
我们想要将 {15: new}这个entry 插入到 ThreadLocalMap中的table表 中
int i = nextIndex(staleSlot, len) ,当staleSlot = 5时候,那么 向后 环形探索 此时 i = 6 ,(e = tab[i]) != null , e = {15,value} ;
k =e.get = 15 , 是的table[6]还存在entry{15: values}
而我们想要插入的entry{15:new} 中 key = 15
if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; }
这段代码主要是做了什么呢???
为什么要交换
这里解释下为什么交换,我们先来看看如果不交换的话,经过设置值和清理过期对象,会是以下这张图
这个时候如果我们再一次设置一个key=15,value=new2 的值,通过f(15)=5,这个时候由于上次index=5是过期对象,被清空了,所以可以存在数据,那么就直接存放在这里了
你看,这样整个数组就存在两个key=15 的数据了,这样是不允许的,所以一定要交换数据
上面描述的是这种场景 向前环形搜索到脏entry,向后环形查找到可覆盖的entry的情况
replaceStaleEntry 四种情况
根据 下面两个条件1、for循环 向前搜索中是否还有脏entry 2、for循环 后向环形查找中是否找到可覆盖的entry
我们分这四种情况来充分理解这两个条件
第一种:向前环形搜索到脏entry,向后环形查找到可覆盖的entry的情况
向前搜索中是否还有脏entry | 后向环形查找中是否找到可覆盖的entry |
---|---|
向前有脏entry | 后向环形查找找到可覆盖的entry |
该情形如下图所示。
向前环形搜索到脏entry,向后环形查找到可覆盖的entry的情况.png
如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索遇到脏entry时,在第一个打勾的红色框框中 slotToExpunge会更新为当前脏entry的索引i,直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束。
在接下来的for循环中进行后向环形查找,若查找到了可覆盖的entry,
则进行交换!! 具体看上面的例子 我描述的很清楚了 :
e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e;
最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程
第二种:前向环形搜索到脏entry,向后环形未搜索可覆盖entry.
向前搜索中是否还有脏entry | 后向环形查找中是否找到可覆盖的entry |
---|---|
向前有脏entry | 后向环形查找没有找到可覆盖的entry |
该情形如下图所示。
前向环形搜索到脏entry,向后环形未搜索可覆盖entry.png
如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索遇到脏entry时,在第一个打勾的红色框框中 slotToExpunge会更新为当前脏entry的索引i,直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束。
在接下来的for循环中进行后向环形查找,若没有查找到了可覆盖的entry,哈希桶(table[i])为null的时候,后向环形查找过程结束。
那么第二个打勾的红色框框中,将插入的新entry直接放在staleSlot处即可,
最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程
第三种:前向没有脏entry 后向环形查找 找到可覆盖的entry
向前搜索中是否还有脏entry | 后向环形查找中是否找到可覆盖的entry |
---|---|
向前没有有脏entry | 后向环形查找有找到可覆盖的entry |
该情形如下图所示。
前向未搜索到脏entry,后向环形搜索到可覆盖的entry.png
如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束,若在整个过程未遇到脏entry,slotToExpunge初始状态依旧和staleSlot相同。
在接下来的for循环中进行后向环形查找,
若遇到了脏entry,在第7行代码中更新slotToExpunge为位置i。
若查找到了可覆盖的entry,第2,3,4行代码先覆盖当前位置的entry,然后再与staleSlot位置上的脏entry进行交换,交换之后脏entry就更换到了i处。
如果在整个查找过程中都还没有遇到脏entry的话,会通过第5行代码,将slotToExpunge更新当前i处,
最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。
第四种:前向没有脏entry 后向环形查找 没有找到可覆盖的entry
向前搜索中是否还有脏entry | 后向环形查找中是否找到可覆盖的entry |
---|---|
向前没有脏entry | 后向环形查找 没有找到可覆盖的entry |
该情形如下图所示。
如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束,若在整个过程未遇到脏entry,slotToExpunge初始状态依旧和staleSlot相同。
在接下来的for循环中进行后向环形查找,若遇到了脏entry,在第7行代码中更新slotToExpunge为位置i。若没有查找到了可覆盖的entry,哈希桶(table[i])为null的时候,后向环形查找过程结束。
(注意第七行代码 ,找到staleSolt位置后的第一个脏数据对slotToExpunge进行赋值,后续的for循环在判断 表达式 k == null && slotToExpunge == staleSlot , k==null 成立但是 slotToExpunge == staleSlot 总是不成立, 所以不会重新对slotToExpunge进行赋值 )
那么接下来在8,9行代码中,将插入的新entry直接放在staleSlot处即可。另外,如果发现slotToExpunge被重置,则第10行代码if判断为true,
就使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。
下面用一个实例来有个直观的感受,示例代码就不给出了,代码debug时table状态如下图所示:
1.2情况示意图.png
如图所示,当前的staleSolt为i=4,首先先进行前向搜索脏entry,当i=3的时候遇到脏entry,slotToExpung更新为3,当i=2的时候tabel[2]为null,因此前向搜索脏entry的过程结束。然后进行后向环形查找,知道i=7的时候遇到table[7]为null,结束后向查找过程,并且在该过程并没有找到可以覆盖的entry。最后只能在staleSlot(4)处插入新entry,然后从slotToExpunge(3)为起点进行cleanSomeSlots进行脏entry的清理。
此时 就是第二种情况 前向环形搜索到脏entry,向后环形未搜索可覆盖entry.
这些核心方法,通过源码又给出示例图,应该最终都能掌握了,也还挺有意思的。若觉得不错,对我的辛劳付出能给出鼓励欢迎点赞,给小弟鼓励,在此谢过 😃。
当我们调用threadLocal的get方法时,当table[i]不是和所要找的key相同的话,会继续通过threadLocalMap的getEntryAfterMiss方法向后环形去找,该方法为:
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; }
当key==null的时候,即遇到脏entry也会调用expungeStleEntry对脏entry进行清理。
当我们调用threadLocal.remove方法时候,实际上会调用threadLocalMap的remove方法,该方法的源码为:
private void remove(ThreadLocal<?> key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } }
同样的可以看出,当遇到了key为null的脏entry的时候,也会调用expungeStaleEntry清理掉脏entry。
从以上set,getEntry,remove方法看出,在threadLocal的生命周期里,针对threadLocal存在的内存泄漏的问题,都会通过expungeStaleEntry,cleanSomeSlots,replaceStaleEntry这三个方法清理掉key为null的脏entry。
InheritableThreadLocal
我们使用ThreadLocal的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。
为了解决这个问题,JDK中还有一个InheritableThreadLocal类,我们来看一个例子:
public class InheritableThreadLocalDemo { public static void main(String[] args) { ThreadLocal<String> threadLocal = new ThreadLocal<>(); ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); threadLocal.set("父类数据:threadLocal"); inheritableThreadLocal.set("父类数据:inheritableThreadLocal"); new Thread(new Runnable() { @Override public void run() { System.out.println("子线程获取父类threadLocal数据:" + threadLocal.get()); System.out.println("子线程获取父类inheritableThreadLocal数据:" + inheritableThreadLocal.get()); } }).start(); }}
打印结果:
子线程获取父类threadLocal数据:null
子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal
实现原理是子线程是通过在父线程中通过调用new Thread()方法来创建子线程,Thread#init方法在Thread的构造方法中被调用。在init方法中拷贝父线程数据到子线程中:
private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) { throw new NullPointerException("name cannot be null"); } if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); this.stackSize = stackSize; tid = nextThreadID();}
但InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中的init()方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个TransmittableThreadLocal组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。
Thread.exit()
当线程退出时会执行exit方法:
private void exit() { if (group != null) { group.threadTerminated(this); group = null; } /* Aggressively null out all reference fields: see bug 4006245 */ target = null; /* Speed the release of some of these resources */ threadLocals = null; inheritableThreadLocals = null; inheritedAccessControlContext = null; blocker = null; uncaughtExceptionHandler = null;}
从源码可以看出当线程结束时,会令threadLocals=null,也就意味着GC的时候就可以将threadLocalMap进行垃圾回收,换句话说threadLocalMap生命周期实际上thread的生命周期相同。
threadLocal最佳实践
通过这篇文章对threadLocal的内存泄漏做了很详细的分析,我们可以完全理解threadLocal内存泄漏的前因后果,那么实践中我们应该怎么做?
- 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
- 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
参考资料
《java高并发程序设计》
http://blog.xiaohansong.com/2016/08/06/ThreadLocal-memory-leak/
ThreadLocal项目中使用实战
ThreadLocal使用场景
我们现在项目中日志记录用的是ELK+Logstash,最后在Kibana中进行展示和检索。
现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过traceId来关联,但是不同项目之间如何传递traceId呢?
这里我们使用org.slf4j.MDC来实现此功能,内部就是通过ThreadLocal来实现的,具体实现如下:
当前端发送请求到服务A时,服务A会生成一个类似UUID的traceId字符串,将此字符串放入当前线程的ThreadLocal中,在调用服务B的时候,将traceId写入到请求的Header中,服务B在接收请求时会先判断请求的Header中是否有traceId,如果存在则写入自己线程的ThreadLocal中。
图中的requestId
即为我们各个系统链路关联的traceId
,系统间互相调用,通过这个requestId
即可找到对应链路,这里还有会有一些其他场景:
针对于这些场景,我们都可以有相应的解决方案,如下所示
Feign远程调用解决方案
服务发送请求:
@Component@Slf4jpublic class FeignInvokeInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String requestId = MDC.get("requestId"); if (StringUtils.isNotBlank(requestId)) { template.header("requestId", requestId); } }}
服务接收请求:
@Slf4j@Componentpublic class LogInterceptor extends HandlerInterceptorAdapter { @Override public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3) { MDC.remove("requestId"); } @Override public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3) { } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestId = request.getHeader(BaseConstant.REQUEST_ID_KEY); if (StringUtils.isBlank(requestId)) { requestId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("requestId", requestId); return true; }}
线程池异步调用,requestId传递
因为MDC
是基于ThreadLocal
去实现的,异步过程中,子线程并没有办法获取到父线程ThreadLocal
存储的数据,所以这里可以自定义线程池执行器,修改其中的run()
方法:
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override public void execute(Runnable runnable) { Map<String, String> context = MDC.getCopyOfContextMap(); super.execute(() -> run(runnable, context)); } @Override private void run(Runnable runnable, Map<String, String> context) { if (context != null) { MDC.setContextMap(context); } try { runnable.run(); } finally { MDC.remove(); } }}
使用MQ发送消息给第三方系统
在MQ发送的消息体中自定义属性requestId,接收方消费消息后,自己解析requestId使用即可。
引用:
线程不安全的SimpleDateFormat https://www.jianshu.com/p/d9977a048dab
一篇文章,从源码深入详解ThreadLocal内存泄漏问题https://www.imooc.com/article/34571
万字长文带你深入理解ThreadLocal!ThreadLocal超详细解析!https://blog.csdn.net/weixin_43314519/article/details/108188298
面试官:小伙子,听说你看过ThreadLocal源码?(万字图文深度解析ThreadLocal)https://blog.csdn.net/u012881584/article/details/106013552/
threadlocal的过期数据_简直骚操作,ThreadLocal还能当缓存用 https://blog.csdn.net/weixin_42653691/article/details/112807181
ThreadLocal的replaceStaleEntry设计思考https://www.zhihu.com/question/412041096是什么呢?
被大厂面试官连环炮轰炸的ThreadLocal (吃透源码的每一个细节和设计原理)https://blog.csdn.net/wanghao112956/article/details/102678591
由浅入深,全面解析ThreadLocal https://blog.csdn.net/weixin_44050144/article/details/113061884
深入理解 ThreadLocal (这些细节不应忽略) https://www.jianshu.com/p/56f64e3c1b6c
证明:ThreadLocal的get,set方法无法防止内存泄漏
Java进阶(七)正确理解Thread Local的原理与适用场景http://www.jasongj.com/java/threadlocal/
一篇有深度的文章就是活在梦里的文章!!:ThreadLocal源码解读
为什么要弱引用
为什么要用弱引用,等于没读懂源码。
因为如果这里使用普通的key-value形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收,而程序本身也无法判断是否可以清理节点。弱引用是Java中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次GC。当某个ThreadLocal已经没有强引用可达,则随着它被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,这为ThreadLocalMap本身的垃圾清理提供了便利。