前言
多线程环境中经常会发生因为资源竞争而出现的线程安全问题,通常情况下我们使用加锁的方式解决这类问题。但是除了控制资源的访问外,我们还可以增加资源来保证所有对象的线程安全。例如一个班级30个人填写个人信息表,如果只有一支笔大家都会哄抢,谁个填不完。但是从另一个角度出发,一支笔的成本也不大,人手一支笔的话,很快所有人都能填完表格。
线程安全案例
时间类在我们日常开发中是经常会用到的,虽然JDK为我们封装了很强大的SimpleDateFormat
类,但是如果稍不留心的话就有可能导致很大的问题,我们先来看一个简单的例子:
public class 没有ThreadLocal的场景 {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static class ParseDate implements Runnable {
private int i;
public ParseDate(int i) {
this.i = i;
}
@Override
public void run() {
try {
Date t = sdf.parse("2019-07-16 20:40:" + i % 60);
System.out.println(Thread.currentThread().getName() + " : " + t);
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
es.execute(new ParseDate(i));
}
}
}
这个例子很简单,就是创建了一个线程池,使用SimpleDateFormat
对象实例来解析字符串的日志。执行后却发现却抛异常了如下:
java.lang.NumberFormatException: For input string: ""
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
这个异常的本质其实就是SimpleDateFormat.parse()
方法并不是线程安全的,在多个线程中共享此变量必然会出现这个错误。
面对线程安全的问题,一种万金油的解决方式就是加锁,当然这里依然可以选择加锁,我们再sdf.parse()
前后加锁,当然可以达到我们的目的。或者说SimpleDateFormat
既然会有线程安全问题,我们干脆不设置成静态的而是每个线程运行的时候去new,这样也是可以的。但是这样的解决方式并不优雅,如果对每个方法调用的时候我们都要去重新的new SimpleDateFormat()
,当我们的日期格式不想要yyyy-MM-dd HH:mm:ss
想换成yyyy-MM-dd
,我们需要对所有的方法内部进行替换,没有静态变量来的方便。所以这时候就需要借助ThreadLocal
来帮我们实现。
ThreadLocal简单使用
ThreadLocal字面上翻译是线程局部变量,它用于存放当前线程的一些变量,既然只有当前线程可以访问,那么自然就是线程安全的了。上述代码我们使用ThreadLocal进行如下改造:
public class 使用ThreadLocal {
private static final ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();
public static class ParseDate implements Runnable {
int i = 0;
public ParseDate(int i) {
this.i = i;
}
@Override
public void run() {
try {
if (tl.get() == null) {
tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
}
Date t = tl.get().parse("2019-07-16 20:40:" + i % 60);
System.out.println(Thread.currentThread().getName() + " : " + t);
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
es.execute(new ParseDate(i));
}
}
}
执行程序后发现错误解决了,从这里就可以看出使用了ThreadLocal后每个线程独享一份SimpleDateFormat
对象,避免了在方法内频繁的创建对象,同时也避免了多线程的竞争。
ThreadLocal实现原理
ThreadLocal到底是如何做到每个线程独享对变量的独享的?首先ThreadLocal这个对象是存放于ThreadLocalMap中的,而ThreadLocalMap是定于在代表线程对象Thread内中的:
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap结构
ThreadLocalMap是一个自定义的Map,用于存放线程ThreadLocal变量。它的操作仅限于在ThreadLocal类中,不能对外暴露。我们来看一下ThreadLocalMap的结构。
public class Thread implements Runnable {
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
// 与当前ThreadLocal相关的对象
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初始容量
private static final int INITIAL_CAPACITY = 16;
// 存放信息的数组
private Entry[] table;
// 当前容器大小
private int size = 0;
// 当容量到达阈值就会进行扩容
private int threshold; // Default to 0
// 设置阈值threshold为数组长度的2/3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
// ThreadLocalMap的构造器,可以看出key是经过ThreadLocal内部一个变量threadLocalHashCode
// 计算而来的一个索引位置,稍后详解
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
}
从上述信息我们大概可以清楚了,当创建一个ThreadLocalMap时,实际上内部是构建了一个Entry类型的数组,Entry是类似Map的Key-Value结构的,Key是根据当前ThreadLocal计算来了一个hashCode,Value就是要保存的线程变量的副本(如上文中的SimpleDateFormat
)。key初始化大小为16,阈值threshold为数组长度的2/3,Entry类型为,有一个弱引用指向ThreadLocal对象。
所以每个Thread内部都维护这一个类似Map(虽然不是,但是可以简单的认为是HashMap),当我们创建一个ThreadLocal后,实际上是把当前的ThreadLocal信息存放到Thread内部所维护的ThreadLocalMap中。ThreadLocalMap是对当前线程中所有的方法都开放的,所以当就做到了每个线程共享,接下来进行详细分析。
ThreadLocal详解
- 首先看一下
set()
方法的源码:
public void set(T value) {
Thread t = Thread.currentThread();
// 获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
// 如果Map不为null,就把当前ThreadLocal和Value添加到Map中
map.set(this, value);
else
// 如果当前Map为null,就创建一个ThreadLocalMap保存到当前线程内部
createMap(t, value);
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
其实源码也非常简单,无非就是获取ThreadLocalMap对象,如果这个对象不存在就创建ThreadLocalMap,如果存在就将ThreadLocalMap写入Map。其中,Key为ThreadLocal当前对象,Value就是我们需要的值。
再上文中我们清楚了ThreadLocalMap其实就是一个Entry类型的数组,任何Map都要解决的的就是哈希冲突。其中int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
的i是ThreadLocal存放在ThreadLocalMap中的索引位置,然后threadLocalHashCode的具体细节:
private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
也就是说,每一个ThreadLocal都会根据nextHashCode生成一个int值,作为哈希值。然后根据这个哈希值和数组的长度len-1(因为len的长度总是2的倍数,减一的话就可以保证低N位都是1)进行求和,从而获取哈希值的低N位,从而获取再数组中的索引位置。
如何解决哈希冲突
我们熟悉的HashMap发生哈希冲突我们都很熟悉了,通过链表或者红黑树进行解决,而ThreadLocalMap它本身就是一个很简单Entry数组,并不像HashMap具有那么复杂的数据结构,那么ThreadLocalMap是如何解决的呢?
private void set(ThreadLocal<?> key, Object value) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 求索引位置
int i = key.threadLocalHashCode & (len-1);
// 如果要存放的i位置有数据,就说明发生了哈希冲突
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 如果是同一个ThreadLocal对象,就直接覆盖
if (k == key) {
e.value = value;
return;
}
// 如果key为null,则替换它的位置
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
// 否则就nextIndex(i, len),去找下一个位置
}
tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
如果发生哈希冲突主要就是判断当前位置是否可以替换,如果不可以替换就往后移动一位,继续判断。
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
- 接下来我们看
get()
方法
public T get() {
Thread t = Thread.currentThread();
// 获取当前线程的ThreadLocalMap
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null) {
// 从Map中获取当Entry
ThreadLocal.ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
// 如果不为null,则返回value
T result = (T)e.value;
return result;
}
}
// Map不存在或者找不到value值,则调用setInitialValue,进行初始化
return setInitialValue();
}
private T setInitialValue() {
// 获取初始化值,当然我们通过覆盖initialValue()方法可以设置自己想要的值
T value = initialValue();
Thread t = Thread.currentThread();
// 获取当前线程的ThreadLocalMap
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null)
// 如果不为null,则设置值
map.set(this, value);
else
// 如果当前Map为null,就创建一个ThreadLocalMap保存到当前线程内部
createMap(t, value);
return value;
}
protected T initialValue() {
return null;
}
总结
每个线程内部都有一个ThreadLocalMap的Map,当线程需要添加ThreadLocal对象时,都是保存到代表每个线程私有变量的ThreadLocalMap中,所以线程与线程间不会互相干扰。如下图:
ThreadLocal内存泄露问题
再了解了ThreadLocal的内部实现后,我们就会发现那些TheadLocal变量是维护在Thread类内部的,这也意味着只要线程不退出的话,对象的引用就一直存在。
ThreadLocal内也有一个常见的坑,如果使用不当的话就会发生内存泄漏,来看一个例子。
public class ThreadLocal内存溢出 {
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
TestClass t = new TestClass(i);
t.printId();
t = null;
// 如果换成这一句就不会发生问题了
//t.threadLocal.remove();
}
}).start();
}
static class TestClass{
private int id;
private int[] arr;
private ThreadLocal<TestClass> threadLocal;
TestClass(int id){
this.id = id;
arr = new int[1000000];
threadLocal = new ThreadLocal<>();
threadLocal.set(this);
}
public void printId(){
System.out.println(threadLocal.get().id);
}
}
}
调用t = null
后,虽然无法再通过t访问内存地址,但是当前线程依然存活,可以通过thread指向的内存地址访问到Thread对象从而访问ThreadLocalMap对象,访问到value指向的内存空间,访问arr指向的内存空间,从而导致java垃圾回收并不会回收int[1000000]
这一片空间,久而久之不被回收的空间越来越大,最后抛出java.lang.OutOfMemoryError: Java heap space。
如果我们稍加改进将t = null;
换成t.threadLocal.remove();
就可以完美的解决问题呢,首先来看看t.threadLocal.remove()
干了些什么:
public void remove() {
ThreadLocal.ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
private void remove(ThreadLocal<?> key) {
// 获取线程中的Entry数组
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 计算当前ThreadLocal变量存放索引位置
int i = key.threadLocalHashCode & (len-1);
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
// 如果找到就进行清楚
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
看完之后就恍然大悟,调用remove方法后就讲referent和value都被设置为null,这样就造成了内存不大可达,java垃圾回收就会回收这片内存,从而就不会导致内存泄漏。
Entry为什么要是WeakReference类型
ThreadLocalMap的实现使用了弱引用。弱引用是比强引用弱的多的引用。只要强引用存在,垃圾收集器就永远不会收集掉被引用的对象。当内存空间不足的时候,Java垃圾回收程序发现对象有一个强引用,宁愿抛出OutofMemory
错误,也不会去回收一个强引用的内存空间。软引用是万不得已的时候才抛弃,而弱引用是当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被软引用关联的对象。ThreadLocalMap的内部由一系列的Entry结构,没一个Entry都是WeakReference类型的
static class Entry extends WeakReference<ThreadLocal<?>> {
// 与当前ThreadLocal相关的对象
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
这里的Key是ThreadLocal实例,作为软引用来使用。所以,虽然ThreadLocal作为Map的Key,但是实际上,它并没有真正持有ThreadLocal的引用。而当ThreadLocal的外部强引用被回收时,ThreadLocalMap中的Key就会变成null。当系统进行ThreadlocalMap清理时,就会将这些垃圾数据进行回收。