从SimpleDateFormat开始
首先看一个例子,创建20个线程,线程里就干一件事,就是转换时间
public class ThreadLoaclExample {
//非线程安全的
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
}
运行一下,报错了
原因是什么,原因就是SimpleDateFormat是非线程安全的,点进去看一下SimpleDateFormat的源码,在类的上面就写着一段话,DateFormat不是同步的,它被推荐创建独立的format实例给每个线程,如果多线程要同时访问的话,必须在外部加一个同步的。
这段话是什么意思呢,就是解决这个问题有两个办法,一个是加synchronized,代码如下:
public static synchronized Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}
但是这样做肯定会降低性能。还有一种方法就是做线程隔离,就是他注释上写的,为每个线程单独创建一个SimpleDateFormat对象,独一份的,线程独有的,这样就不会产生线程安全问题。这个就需要用到今天的主角ThreadLocal,代码如下:
public class ThreadLoaclExample {
private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<>();
private static SimpleDateFormat getDateFormat() {
SimpleDateFormat dateFormat = dateFormatThreadLocal.get();//从当前线程的范围内获得一个DateFormat
if (dateFormat == null) {
dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//在当前线程的范围内设置一个simpleDateFormat对象
//Thread.currentThread();
dateFormatThreadLocal.set(dateFormat);
}
return dateFormat;
}
public static Date parse(String strDate) throws ParseException {
return getDateFormat().parse(strDate);
}
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
}
运行一下,不报错了
当然上面还有个优化点就是20个线程,当1000个线程的时候,每个线程都有自己独立的SimpleDateFormat副本,这样会创建1000个SimpleDateFormat对象,会很浪费空间,所以改写成线程池的方式:
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(16);
for (int i = 0; i < 1000; i++) {
executorService.execute(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
});
}
}
这样的话有个好处就是用16个SimpleDateFormat对象即可完成1000个任务。
以上就是第一种非常典型的适合使用 ThreadLocal 的场景。
第二种场景
第二个作用就是起到一个上下文的作用,有这样一个应用场景,当一个请求过来service-1把user的信息计算出来,后面的方法service-2,service-3,service-4都需要用到user信息,这时的做法就是把user作为参数,不停的往后传,这样的做法导致代码十分冗余。
有一个解决办法就是把user信息放在内存中,比如hashmap,这样service-1把user信息put进去,service-2,service-3,service-4直接get就能把user信息获取出来,这样可以避免把user作为参数不停的传。
那么随之而来就会产生另一个线程并发安全问题,当个线程同时请求访问的时候呢?那我们就是要使用 synchronized 或者 ConcurrentHashMap来保证hashmap的安全,它对性能都是有所影响的。
那么最终解决方案就是使用ThreadLocal,它使得每个线程独享自己的user信息,保证了线程安全,使用的时候也只要在service-1里面存进去,service-2,service-3,service-4里面取出来即可。
这个就是第二个作用,起到上下文的作用UserContextHolder,避免了传参。
ThreadLocal的存储位置
首先来看下Thread、 ThreadLocal 及 ThreadLocalMap 三者存储的位置。
在Thread类里面有个ThreadLocalMap变量,如下图,因为存在线程里面,这样才能做到线程独有。
在ThreadLocalMap里面有很多个Entry,这个Entry的key就是弱引用的threadlocal,value就是需要存储的值。
为什么在ThreadLocalMap里会有多个Entry呢,因为我们在使用的时候可以定义多个ThreadLocal,而这些值最终的存储就是一个一个的Entry。
有了上面宏观上的感受,我们再来看下源码分析,首先看set方法:
public void set(T value) {
//得到当前线程,保证隔离性
Thread t = Thread.currentThread();
//根据线程得到ThreadLocalMap,没有初始化则进行初始化
ThreadLocalMap map = getMap(t);
//如果map不为空,则将值set进去
if (map != null)
map.set(this, value);
else //否则的话创建map
createMap(t, value);
}
如果map为空的话先进行创建
初始化的过程也比较简单,新创建一个数组,根据hash值计算位置,然后把key和value放到该位置上
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//默认长度为16的数组
table = new Entry[INITIAL_CAPACITY];
//计算数组下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
//把key和value放到i的位置上
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
我们再看下map.set方法,set的时候也是先计算位置,如果位置上已经有值的,就是我之前这个key,则把value的值进行替换,如果是null则执行replaceStaleEntry方法,否则的话就移动到下一个位置。
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 (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//i位置已经有值了,直接替换
if (k == key) {
e.value = value;
return;
}
//如果key==null,则进行replaceStaleEntry(替换空余的数组)
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
我们知道Hashmap当发生冲突的时候,采用的是拉链法(也叫链地址法),而我们这的ThreadLocalMap采用的是线性探测法,如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。感兴趣的小伙伴可以看下《ConcurrentHashMap源码精讲》 。
我们再来看下get方法,这个方法也很简单,先从线程中拿到ThreadLocalMap,然后再从map中传入this自己作为key,来拿到Entry,再从Entry中拿到value。
public T get() {
//获取到当前线程
Thread t = Thread.currentThread();
//获取到当前线程内的 ThreadLocalMap 对象
ThreadLocalMap map = getMap(t);
if (map != null) {
//获取 ThreadLocalMap 中的 Entry 对象并拿到 Value
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果线程内之前没创建过 ThreadLocalMap,就创建
return setInitialValue();
}
如果map为空的话则进行初始化操作setInitialValue,这个跟上面的set方法里面的逻辑是一样的。
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
强引用和弱引用
标题中已经提到了强引用和弱引用,还有上面讲到的Entry里面的key是ThreadLocal的弱引用,那么具体什么是强引用,什么是弱引用,这里做下介绍。
先看下强引用的代码:
public class ReferenceExample {
static Object object = new Object();
public static void main(String[] args) {
Object strongRef = object;
object = null;
System.gc();
System.out.println(strongRef);
}
}
运行一下,没有被回收掉
我画了个示意图大家看下,一开始object和strongRef都指向了堆区的new Object()对象。
后来执行object = null,相当于栈和堆之间的连线断掉了,所以在System.gc()以后,由于strongRef还连接着new Object(),所以就没有被释放掉。
再看下弱引用的代码:
public class ReferenceExample {
static Object object = new Object();
public static void main(String[] args) {
WeakReference<Object> weakRef = new WeakReference<>(object);
object = null;
System.gc();
System.out.println(weakRef.get());
}
}
再执行一下,结果为null,已经被回收掉了
弱引用的连接就很弱,这根虚线等于没有,形同虚设,在回收的时候new Object()一看没人在引用了,那么就直接回收掉了,所以打印weakRef的时候就为null。
所以在上面看源码中会出现knull的判断,就是因为threadlocal是弱引用,当我们在业务代码中执行了 ThreadLocal instance = null 操作,我们想要清理掉这个 ThreadLocal 实例,由于是弱引用,就像上面的例子一样,经过垃圾回收以后key会变为null,那么这个Entry一直在数组里占着是不行的,所以会把keynull的给清理掉。
对于垃圾回收不是很懂的小伙伴可以看下《一篇文章搞懂GC垃圾回收》 。
内存泄露/remove()方法
首先说下用完ThreadLocal一定要调用remove()方法!一定要调用remove()方法!一定要调用remove()方法! 否则就是会造成内存泄露。
内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。
Key 的泄漏
在上面提到过key是弱引用,如果是强引用的话,当执行ThreadLocal instance = null的时候,key还在引用着threadlocal,这时候就不会释放内存,那么这个Entry就一直存在数组中,得不到清理,越堆越多。
但是如果采用弱引用,key会变为null,JDK帮我们考虑了这一点,在执行 ThreadLocal 的 get、set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,
这样,value 对象就可以被正常回收了,防止内存泄露。
value的泄露
虽然解决了key的泄露,但是我们知道value是强引用,我们看下下面的调用链:
Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。
这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,而ThreadLocal的get、set、remove、rehash 方法也没有被调用的话,那么这个value指向的内存也一直存在,一直占着。解决这种情况,就是使用remove方法。看下源码:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
还有一种危险,如果线程是线程池的话,在线程执行完代码的时候并没有结束,只是归还给线程池,那么这个线程中的value就一直被占着,得不到回收,造成内存泄露。所以我们在编码中要养成良好的习惯,不再使用ThreadLocal的时候就要调用remove()方法,及时释放内存。最后感谢大家的收看~