面試官:ThreadLocal的應用場景和注意事項有哪些?


前言

ThreadLocal主要有如下2個作用

  1. 保證線程安全
  2. 在線程級別傳遞變量

保證線程安全

最近一個小伙伴把項目中封裝的日期工具類用在多線程環境下居然出了問題,來看看怎么回事吧

日期轉換的一個工具類

public class DateUtil {

    private static final SimpleDateFormat sdf = 
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static Date parse(String dateStr) {
        Date date = null;
        try {
            date = sdf.parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

然后將這個工具類用在多線程環境下

public static void main(String[] args) {

    ExecutorService service = Executors.newFixedThreadPool(20);

    for (int i = 0; i < 20; i++) {
        service.execute(()->{
            System.out.println(DateUtil.parse("2019-06-01 16:34:30"));
        });
    }
    service.shutdown();
}

結果報異常了,因為部分線程獲取的時間不對在這里插入圖片描述
這個異常就不從源碼的角度分析了,寫一個小Demo,理解了這個小Demo,就理解了原因

一個將數字加10的工具類

public class NumUtil {

    public static int addNum = 0;

    public static int add10(int num) {
        addNum = num;
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return addNum + 10;
    }
}
public static void main(String[] args) {

	ExecutorService service = Executors.newFixedThreadPool(20);

	for (int i = 0; i < 20; i++) {
		int num = i;
		service.execute(()->{
			System.out.println(num + " " +  NumUtil.add10(num));
		});
	}
	service.shutdown();
}

然后代碼的一部分輸出為

0 28
3 28
7 28
11 28
15 28

什么鬼,不是加10么,怎么都輸出了28?這主要是因為線程切換的原因,線程陸續將addNum值設置為0 ,3,7但是都沒有執行完(沒有執行到return addNum+10這一步)就被切換了,當其中一個線程將addNum值設置為18時,線程陸續開始執行addNum+10這一步,結果都輸出了28。SimpleDateFormat的原因和這個類似,那么我們如何解決這個問題呢?

解決方案

解決方案1:每次來都new新的,空間浪費比較大

public class DateUtil {

    public static Date parse(String dateStr) {
        SimpleDateFormat sdf =
                new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date date = null;
        try {
            date = sdf.parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

解決方案2:方法用synchronized修飾,並發上不來

public class DateUtil {

    private static final SimpleDateFormat sdf =
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static synchronized Date parse(String dateStr) {
        Date date = null;
        try {
            date = sdf.parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

解決方案3:用jdk1.8中的日期格式類DateFormatter,DateTimeFormatter

public class DateUtil {

    private static DateTimeFormatter formatter = 
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static LocalDateTime parse(String dateStr) {
        return LocalDateTime.parse(dateStr, formatter);
    }
}

解決方案4:用ThreadLocal,一個線程一個SimpleDateFormat對象

public class DateUtil {

    private static ThreadLocal<DateFormat> threadLocal = ThreadLocal.withInitial(
            ()-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    public static Date parse(String dateStr) {
        Date date = null;
        try {
            date = threadLocal.get().parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

上面的加10的工具類可以改成如下形式(主要為了演示ThreadLocal的使用)

public class NumUtil {

    private static ThreadLocal<Integer> addNumThreadLocal = new ThreadLocal<>();

    public static int add10(int num) {
        addNumThreadLocal.set(num);
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return addNumThreadLocal.get() + 10;
    }
}

現在2個工具類都能正常使用了,這是為啥呢?

原理分析

當多個線程同時讀寫同一共享變量時存在並發問題,如果不共享不就沒有並發問題了,一個線程存一個自己的變量,類比原來好幾個人玩同一個球,現在一個人一個球,就沒有問題了,如何把變量存在線程上呢?其實Thread類內部已經有一個Map容器用來存變量了。它的大概結構如下所示

在這里插入圖片描述
ThreadLocalMap是一個Map,key是ThreadLocal,value是Object

映射到源碼就是如下所示:
ThreadLocalMap是ThreadLocal的一個靜態內部類

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

往ThreadLocalMap里面放值

// ThreadLocal類里面的方法,將源碼整合了一下
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = t.threadLocals;
    if (map != null)
        map.set(this, value);
    else
		t.threadLocals = new ThreadLocalMap(this, firstValue);
}

從ThreadLocalMap里面取值

// ThreadLocal類里面的方法,將源碼整合了一下
public T get() {
	Thread t = Thread.currentThread();
	ThreadLocalMap map = t.threadLocals;
	if (map != null) {
		ThreadLocalMap.Entry e = map.getEntry(this);
		if (e != null) {
			@SuppressWarnings("unchecked")
			T result = (T)e.value;
			return result;
		}
	}
	return setInitialValue();
}

從ThreadLocalMap里面刪除值

// ThreadLocal類里面的方法,將源碼整合了一下
public void remove() {
	ThreadLocalMap m = Thread.currentThread().threadLocals;
	if (m != null)
		m.remove(this);
}

執行如下代碼

public class InfoUtil {

    private static ThreadLocal<String> nameInfo = new ThreadLocal<>();
    private static ThreadLocal<Integer> ageInfo = new ThreadLocal<>();

    public static void setInfo(String name, Integer age) {
        nameInfo.set(name);
        ageInfo.set(age);
    }

    public static String getName() {
        return nameInfo.get();
    }

    public static void main(String[] args) {
        new Thread(() -> {
            InfoUtil.setInfo("張三", 10);
            // 張三
            System.out.println(InfoUtil.getName());
        }, "thread1").start();
        new Thread(() -> {
            InfoUtil.setInfo("李四", 20);
            // 李四
            System.out.println(InfoUtil.getName());
        }, "thread2").start();
    }
}

變量的結構如下圖
在這里插入圖片描述

在線程級別傳遞變量

假設有如下一個場景,method1()調用method2(),method2()調用method3(),method3()調用method4(),method1()生成了一個變量想在method4()中使用,有如下2種解決辦法

  1. method 2 3 4的參數列表上都寫上method4想要的變量
  2. method 1 往ThreadLocal中put一個值,method4從ThreadLocal中get出來

哪種實現方式比較優雅呢?相信我不說你也能明白了

我在生產環境中一般是這樣用的,如果一個請求在系統中的處理流程比較長,可以對請求的日志打一個相同的前綴,這樣比較方便處理問題

這個前綴的生成和移除可以配置在攔截器中,切面中,當然也可以在一個方法的前后

public class Main {

    public static final ThreadLocal<String> SPANID =
            ThreadLocal.withInitial(() -> UUID.randomUUID().toString());

    public static void start() {
        SPANID.set(UUID.randomUUID().toString());
        // 方法調用過程中可以在日志中打印SPANID表明一個請求的執行鏈路
        SPANID.remove();
    }
}

當然Spring Cloud已經有現成的鏈路追蹤組件了。

ThreadLocal使用注意事項

ThreadLocal如果使用不當會造成如下問題

  1. 臟數據
  2. 內存泄露

臟數據

線程復用會造成臟數據。由於線程池會復用Thread對象,因此Thread類的成員變量threadLocals也會被復用。如果在線程的run()方法中不顯示調用remove()清理與線程相關的ThreadLocal信息,並且下一個線程不調用set()設置初始值,就可能get()到上個線程設置的值

內存泄露

static class ThreadLocalMap {

	static class Entry extends WeakReference<ThreadLocal<?>> {
		Object value;

		Entry(ThreadLocal<?> k, Object v) {
			super(k);
			value = v;
		}
	}
}

ThreadLocalMap使用ThreadLocal的弱引用作為key,如果一個ThreadLocal沒有外部強引用來引用它,那么系統 GC 的時候,這個ThreadLocal勢必會被回收,這樣一來,ThreadLocalMap中就會出現key為null的Entry,就沒有辦法訪問這些key為null的Entry的value,如果當前線程再遲遲不結束的話,這些key為null的Entry的value就會一直存在一條強引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永遠無法回收,造成內存泄漏

大白話一點,ThreadLocalMap的key是弱引用,GC時會被回收掉,那么就有可能存在ThreadLocalMap<null, Object>的情況,這個Object就是泄露的對象

其實,ThreadLocalMap的設計中已經考慮到這種情況,也加上了一些防護措施:在ThreadLocal的get(),set(),remove()的時候都會清除線程ThreadLocalMap里所有key為null的value

解決辦法

解決以上兩個問題的辦法很簡單,就是在每次用完ThreadLocal后,及時調用remove()方法清理即可

歡迎關注

在這里插入圖片描述

參考博客

神奇的ThreadLocal
[1]https://mp.weixin.qq.com/s/kuTspYfMkDK4AjlETifujg
還在使用SimpleDateFormat?你的項目崩沒?
ThreadLocal為什么會內存泄漏
[2]https://blog.xiaohansong.com/ThreadLocal-memory-leak.html
[3]https://zhangzw.com/posts/20190503.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM