前言
對于從事後端開發的同學來說,并發程式設計肯定再熟悉不過了。
說實話,在java中并發程式設計是一大難點,至少我是這麼認為的。不光了解起來比較費勁,使用起來更容易踩坑。
不信,讓繼續往下面看。
今天重點跟大家一起聊聊并發程式設計的10個坑,希望對你有幫助。
1. SimpleDateFormat線程不安全
在java8之前,我們對時間的格式化處理,一般都是用的SimpleDateFormat類實作的。例如:
@Service
public class SimpleDateFormatService {
public Date time(String time) throws ParseException {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return dateFormat.parse(time);
}
}
如果你真的這樣寫,是沒問題的。
就怕哪天抽風,你覺得dateFormat是一段固定的代碼,應該要把它抽取成常量。
于是把代碼改成下面的這樣:
@Service
public class SimpleDateFormatService {
private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public Date time(String time) throws ParseException {
return dateFormat.parse(time);
}
}
dateFormat對象被定義成了靜态常量,這樣就能被所有對象共用。
如果隻有一個線程調用time方法,也不會出現問題。
但Serivce類的方法,往往是被Controller類調用的,而Controller類的接口方法,則會被tomcat的線程池調用。換句話說,可能會出現多個線程調用同一個Controller類的同一個方法,也就是會出現多個線程會同時調用time方法的情況。
而time方法會調用SimpleDateFormat類的parse方法:
@Override
public Date parse(String text, ParsePosition pos) {
...
Date parsedDate;
try {
parsedDate = calb.establish(calendar).getTime();
...
} catch (IllegalArgumentException e) {
pos.errorIndex = start;
pos.index = oldStart;
return null;
}
return parsedDate;
}
該方法會調用establish方法:
Calendar establish(Calendar cal) {
...
//1.清空資料
cal.clear();
//2.設定時間
cal.set(...);
//3.傳回
return cal;
}
其中的步驟1、2、3是非原子操作。
但如果cal對象是局部變量還好,壞就壞在parse方法調用establish方法時,傳入的calendar是SimpleDateFormat類的父類DateFormat的成員變量:
public abstract class DateFormat extends Forma {
....
protected Calendar calendar;
...
}
這樣就可能會出現多個線程,同時修改同一個對象即:dateFormat,他的同一個成員變量即:Calendar值的情況。
這樣可能會出現,某個線程設定好了時間,又被其他的線程修改了,進而出現時間錯誤的情況。
那麼,如何解決這個問題呢?
- SimpleDateFormat類的對象不要定義成靜态的,可以改成方法的局部變量。
- 使用ThreadLocal儲存SimpleDateFormat類的資料。
- 使用java8的DateTimeFormatter類。
2. 雙重檢查鎖的漏洞
單例模式無論在實際工作,還是在面試中,都出現得比較多。
我們都知道,單例模式有:餓漢模式和懶漢模式兩種。
餓漢模式代碼如下:
public class SimpleSingleton {
//持有自己類的引用
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
//私有的構造方法
private SimpleSingleton() {
}
//對外提供擷取執行個體的靜态方法
public static SimpleSingleton getInstance() {
return INSTANCE;
}
}
使用餓漢模式的好處是:沒有線程安全的問題,但帶來的壞處也很明顯。
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
一開始就執行個體化對象了,如果執行個體化過程非常耗時,并且最後這個對象沒有被使用,不是白白造成資源浪費嗎?
還真是啊。
這個時候你也許會想到,不用提前執行個體化對象,在真正使用的時候再執行個體化不就可以了?
這就是我接下來要介紹的:懶漢模式。
具體代碼如下:
public class SimpleSingleton2 {
private static SimpleSingleton2 INSTANCE;
private SimpleSingleton2() {
}
public static SimpleSingleton2 getInstance() {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton2();
}
return INSTANCE;
}
}
示例中的INSTANCE對象一開始是空的,在調用getInstance方法才會真正執行個體化。
嗯,不錯不錯。但這段代碼還是有問題。
假如有多個線程中都調用了getInstance方法,那麼都走到 if (INSTANCE == null) 判斷時,可能同時成立,因為INSTANCE初始化時預設值是null。這樣會導緻多個線程中同時建立INSTANCE對象,即INSTANCE對象被建立了多次,違背了隻建立一個INSTANCE對象的初衷。
為了解決餓漢模式和懶漢模式各自的問題,于是出現了:雙重檢查鎖。
public class SimpleSingleton4 {
private static SimpleSingleton4 INSTANCE;
private SimpleSingleton4() {
}
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {
synchronized (SimpleSingleton4.class) {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton4();
}
}
}
return INSTANCE;
}
}
需要在synchronized前後兩次判空。
但我要告訴你的是:這段代碼有漏洞的。
有什麼問題?
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {//1
synchronized (SimpleSingleton4.class) {//2
if (INSTANCE == null) {//3
INSTANCE = new SimpleSingleton4();//4
}
}
}
return INSTANCE;//5
}
getInstance方法的這段代碼,我是按1、2、3、4、5這種順序寫的,希望也按這個順序執行。
但是java虛拟機實際上會做一些優化,對一些代碼指令進行重排。重排之後的順序可能就變成了:1、3、2、4、5,這樣在多線程的情況下同樣會建立多次執行個體。重排之後的代碼可能如下:
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {//1
if (INSTANCE == null) {//3
synchronized (SimpleSingleton4.class) {//2
INSTANCE = new SimpleSingleton4();//4
}
}
}
return INSTANCE;//5
}
原來如此,那有什麼辦法可以解決呢?
答:可以在定義INSTANCE是加上volatile關鍵字。具體代碼如下:
public class SimpleSingleton7 {
private volatile static SimpleSingleton7 INSTANCE;
private SimpleSingleton7() {
}
public static SimpleSingleton7 getInstance() {
if (INSTANCE == null) {
synchronized (SimpleSingleton7.class) {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton7();
}
}
}
return INSTANCE;
}
}
volatile關鍵字可以保證多個線程的可見性,但是不能保證原子性。同時它也能禁止指令重排。
雙重檢查鎖的機制既保證了線程安全,又比直接上鎖提高了執行效率,還節省了記憶體空間。
此外,如果你想了解更多單例模式的細節問題,可以看看我的另一篇文章《單例模式,真不簡單》
3. volatile的原子性
從前面我們已經知道volatile,是一個非常不錯的關鍵字,它能保證變量在多個線程中的可見性,它也能禁止指令重排,但是不能保證原子性。
使用volatile關鍵字禁止指令重排,前面已經說過了,這裡就不聊了。
可見性主要展現在:一個線程對某個變量修改了,另一個線程每次都能擷取到該變量的最新值。
先一起看看反例:
public class VolatileTest extends Thread {
private boolean stopFlag = false;
public boolean isStopFlag() {
return stopFlag;
}
@Override
public void run() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
stopFlag = true;
System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
}
public static void main(String[] args) {
VolatileTest vt = new VolatileTest();
vt.start();
while (true) {
if (vt.isStopFlag()) {
System.out.println("stop");
break;
}
}
}
}
上面這段代碼中,VolatileTest是一個Thread類的子類,它的成員變量stopFlag預設是false,在它的run方法中修改成了true。
然後在main方法的主線程中,用vt.isStopFlag()方法判斷,如果它的值是true時,則列印stop關鍵字。
那麼,如何才能讓stopFlag的值修改了,在主線程中通過vt.isStopFlag()方法,能夠擷取最新的值呢?
正例如下:
public class VolatileTest extends Thread {
private volatile boolean stopFlag = false;
public boolean isStopFlag() {
return stopFlag;
}
@Override
public void run() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
stopFlag = true;
System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
}
public static void main(String[] args) {
VolatileTest vt = new VolatileTest();
vt.start();
while (true) {
if (vt.isStopFlag()) {
System.out.println("stop");
break;
}
}
}
}
用volatile關鍵字修飾stopFlag即可。
下面重點說說volatile的原子性問題。
使用多線程給count加1,代碼如下:
public class VolatileTest {
public volatile int count = 0;
public void add() {
count++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for (int i = 0; i < 20; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
test.add();
}
}
;
}.start();
}
while (Thread.activeCount() > 2) {
//保證前面的線程都執行完
Thread.yield();
}
System.out.println(test.count);
}
}
執行結果每次都不一樣,但可以肯定的是count值每次都小于20000,比如:19999。
這個例子中count是成員變量,雖說被定義成了volatile的,但由于add方法中的count++是非原子操作。在多線程環境中,count++的資料可能會出現問題。
由此可見,volatile不能保證原子性。
答:使用synchronized關鍵字。
改造後的代碼如下:
public class VolatileTest {
public int count = 0;
public synchronized void add() {
count++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for (int i = 0; i < 20; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
test.add();
}
}
;
}.start();
}
while (Thread.activeCount() > 2) {
//保證前面的線程都執行完
Thread.yield();
}
System.out.println(test.count);
}
}
4. 死鎖
死鎖可能是大家都不希望遇到的問題,因為一旦程式出現了死鎖,如果沒有外力的作用,程式将會一直處于資源競争的假死狀态中。
死鎖代碼如下:
public class DeadLockTest {
public static String OBJECT_1 = "OBJECT_1";
public static String OBJECT_2 = "OBJECT_2";
public static void main(String[] args) {
LockA lockA = new LockA();
new Thread(lockA).start();
LockB lockB = new LockB();
new Thread(lockB).start();
}
}
class LockA implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_1) {
try {
Thread.sleep(500);
synchronized (DeadLockTest.OBJECT_2) {
System.out.println("LockA");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class LockB implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_2) {
try {
Thread.sleep(500);
synchronized (DeadLockTest.OBJECT_1) {
System.out.println("LockB");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
一個線程在擷取OBJECT_1鎖時,沒有釋放鎖,又去申請OBJECT_2鎖。而剛好此時,另一個線程擷取到了OBJECT_2鎖,也沒有釋放鎖,去申請OBJECT_1鎖。由于OBJECT_1和OBJECT_2鎖都沒有釋放,兩個線程将一起請求下去,陷入死循環,即出現死鎖的情況。
那麼如果避免死鎖問題呢?
4.1 縮小鎖的範圍
出現死鎖的情況,有可能是像上面那樣,鎖範圍太大了導緻的。
那麼解決辦法就是縮小鎖的範圍。
class LockA implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (DeadLockTest.OBJECT_2) {
System.out.println("LockA");
}
}
}
class LockB implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_2) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (DeadLockTest.OBJECT_1) {
System.out.println("LockB");
}
}
}
在擷取OBJECT_1鎖的代碼塊中,不包含擷取OBJECT_2鎖的代碼。同時在擷取OBJECT_2鎖的代碼塊中,也不包含擷取OBJECT_1鎖的代碼。
4.2 保證鎖的順序
出現死鎖的情況說白了是,一個線程擷取鎖的順序是:OBJECT_1和OBJECT_2。而另一個線程擷取鎖的順序剛好相反為:OBJECT_2和OBJECT_1。
那麼,如果我們能保證每次擷取鎖的順序都相同,就不會出現死鎖問題。
class LockA implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_1) {
try {
Thread.sleep(500);
synchronized (DeadLockTest.OBJECT_2) {
System.out.println("LockA");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class LockB implements Runnable {
@Override
public void run() {
synchronized (DeadLockTest.OBJECT_1) {
try {
Thread.sleep(500);
synchronized (DeadLockTest.OBJECT_2) {
System.out.println("LockB");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
兩個線程,每個線程都是先擷取OBJECT_1鎖,再擷取OBJECT_2鎖。
5. 沒釋放鎖
在java中除了使用synchronized關鍵字,給我們所需要的代碼塊加鎖之外,還能通過Lock關鍵字加鎖。
使用synchronized關鍵字加鎖後,如果程式執行完畢,或者程式出現異常時,會自動釋放鎖。
但如果使用Lock關鍵字加鎖後,需要開發人員在代碼中手動釋放鎖。
例如:
public class LockTest {
private final ReentrantLock rLock = new ReentrantLock();
public void fun() {
rLock.lock();
try {
System.out.println("fun");
} finally {
rLock.unlock();
}
}
}
代碼中先建立一個ReentrantLock類的執行個體對象rLock,調用它的lock方法加鎖。然後執行業務代碼,最後再finally代碼塊中調用unlock方法。
但如果你沒有在finally代碼塊中,調用unlock方法手動釋放鎖,線程持有的鎖将不會得到釋放。
6. HashMap導緻記憶體溢出
HashMap在實際的工作場景中,使用頻率還是挺高的,比如:接收參數,緩存資料,彙總資料等等。
但如果你在多線程的環境中使用HashMap,可能會導緻非常嚴重的後果。
@Service
public class HashMapService {
private Map<Long, Object> hashMap = new HashMap<>();
public void add(User user) {
hashMap.put(user.getId(), user.getName());
}
}
在HashMapService類中定義了一個HashMap的成員變量,在add方法中往HashMap中添加資料。在controller層的接口中調用add方法,會使用tomcat的線程池去處理請求,就相當于在多線程的場景下調用add方法。
在jdk1.7中,HashMap使用的資料結構是:數組+連結清單。如果在多線程的情況下,不斷往HashMap中添加資料,它會調用resize方法進行擴容。該方法在複制元素到新數組時,采用的頭插法,在某些情況下,會導緻連結清單會出現死循環。
死循環最終結果會導緻:記憶體溢出。
此外,如果HashMap中資料非常多,會導緻連結清單很長。當查找某個元素時,需要周遊某個連結清單,查詢效率不太高。
為此,jdk1.8之後,将HashMap的資料結構改成了:數組+連結清單+紅黑樹。
如果同一個數組元素中的資料項小于8個,則還是用連結清單儲存資料。如果大于8個,則自動轉換成紅黑樹。
為什麼要用紅黑樹?
答:連結清單的時間複雜度是O(n),而紅黑樹的時間複雜度是O(logn),紅黑樹的複雜度是優于連結清單的。
既然這樣,為什麼不直接使用紅黑樹?
答:樹節點所占存儲空間是連結清單節點的兩倍,節點少的時候,盡管在時間複雜度上,紅黑樹比連結清單稍微好一些。但是由于紅黑樹所占空間比較大,HashMap綜合考慮之後,認為節點數量少的時候用占存儲空間更多的紅黑樹不劃算。
jdk1.8中HashMap就不會出現死循環?
答:錯,它在多線程環境中依然會出現死循環。在擴容的過程中,在連結清單轉換為樹的時候,for循環一直無法跳出,進而導緻死循環。
那麼,如果想多線程環境中使用HashMap該怎麼辦呢?
答:使用ConcurrentHashMap。
7. 使用預設線程池
我們都知道jdk1.5之後,提供了ThreadPoolExecutor類,用它可以自定義線程池。
線程池的好處有很多,比如:
- 降低資源消耗:避免了頻繁的建立線程和銷毀線程,可以直接複用已有線程。而我們都知道,建立線程是非常耗時的操作。
- 提供速度:任務過來之後,因為線程已存在,可以拿來直接使用。
- 提高線程的可管理性:線程是非常寶貴的資源,如果建立過多的線程,不僅會消耗系統資源,甚至會影響系統的穩定。使用線程池,可以非常友善的建立、管理和監控線程。
當然jdk為了我們使用更便捷,專門提供了:Executors類,給我們快速建立線程池。
該類中包含了很多靜态方法:
- newCachedThreadPool:建立一個可緩沖的線程,如果線程池大小超過處理需要,可靈活回收空閑線程,若無可回收,則建立線程。
- newFixedThreadPool:建立一個固定大小的線程池,如果任務數量超過線程池大小,則将多餘的任務放到隊列中。
- newScheduledThreadPool:建立一個固定大小,并且能執行定時周期任務的線程池。
- newSingleThreadExecutor:建立隻有一個線程的線程池,保證所有的任務安裝順序執行。
在高并發的場景下,如果大家使用這些靜态方法建立線程池,會有一些問題。
那麼,我們一起看看有哪些問題?
- newFixedThreadPool:允許請求的隊列長度是Integer.MAX_VALUE,可能會堆積大量的請求,進而導緻OOM。
- newSingleThreadExecutor:允許請求的隊列長度是Integer.MAX_VALUE,可能會堆積大量的請求,進而導緻OOM。
- newCachedThreadPool:允許建立的線程數是Integer.MAX_VALUE,可能會建立大量的線程,進而導緻OOM。
那我們該怎辦呢?
優先推薦使用ThreadPoolExecutor類,我們自定義線程池。
ExecutorService threadPool = new ThreadPoolExecutor(
8, //corePoolSize線程池中核心線程數
10, //maximumPoolSize 線程池中最大線程數
60, //線程池中線程的最大空閑時間,超過這個時間空閑線程将被回收
TimeUnit.SECONDS,//時間機關
new ArrayBlockingQueue(500), //隊列
new ThreadPoolExecutor.CallerRunsPolicy()); //拒絕政策
順便說一下,如果是一些低并發場景,使用Executors類建立線程池也未嘗不可,也不能完全一棍子打死。在這些低并發場景下,很難出現OOM問題,是以我們需要根據實際業務場景選擇。
8. @Async注解的陷阱
之前在java并發程式設計中實作異步功能,一般是需要使用線程或者線程池。
線程池的底層也是用的線程。
而實作一個線程,要麼繼承Thread類,要麼實作Runnable接口,然後在run方法中寫具體的業務邏輯代碼。
開發spring的大神們,為了簡化這類異步操作,已經幫我們把異步功能封裝好了。spring中提供了@Async注解,我們可以通過它即可開啟異步功能,使用起來非常友善。
具體做法如下:
1.在springboot的啟動類上面加上@EnableAsync注解。
@EnableAsync
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
2.在需要執行異步調用的業務方法加上@Async注解。
@Service
public class CategoryService {
@Async
public void add(Category category) {
//添加分類
}
}
3.在controller方法中調用這個業務方法。
@RestController
@RequestMapping("/category")
public class CategoryController {
@Autowired
private CategoryService categoryService;
@PostMapping("/add")
public void add(@RequestBody category) {
categoryService.add(category);
}
}
這樣就能開啟異步功能了。
是不是很easy?
但有個壞消息是:用@Async注解開啟的異步功能,會調用AsyncExecutionAspectSupport類的doSubmit方法。
預設情況會走else邏輯。
而else的邏輯最終會調用doExecute方法:
protected void doExecute(Runnable task) {
Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task));
thread.start();
}
我去,這不是每次都會建立一個新線程嗎?
沒錯,使用@Async注解開啟的異步功能,預設情況下,每次都會建立一個新線程。
如果在高并發的場景下,可能會産生大量的線程,進而導緻OOM問題。
建議大家在@Async注解開啟的異步功能時,請别忘了定義一個線程池。
9. 自旋鎖浪費cpu資源
在并發程式設計中,自旋鎖想必大家都已經耳熟能詳了。
自旋鎖有個非常經典的使用場景就是:CAS(即比較和交換),它是一種無鎖化思想(說白了用了一個死循環),用來解決高并發場景下,更新資料的問題。
而atomic包下的很多類,比如:AtomicInteger、AtomicLong、AtomicBoolean等,都是用CAS實作的。
我們以AtomicInteger類為例,它的incrementAndGet沒有每次都給變量加1。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
它的底層就是用的自旋鎖實作的:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
在do...while死循環中,不停進行資料的比較和交換,如果一直失敗,則一直循環重試。
如果在高并發的情況下,compareAndSwapInt會很大機率失敗,是以導緻了此處cpu不斷的自旋,這樣會嚴重浪費cpu資源。
那麼,如果解決這個問題呢?
答:使用LockSupport類的parkNanos方法。
private boolean compareAndSwapInt2(Object var1, long var2, int var4, int var5) {
if(this.compareAndSwapInt(var1,var2,var4, var5)) {
return true;
} else {
LockSupport.parkNanos(10);
return false;
}
}
當cas失敗之後,調用LockSupport類的parkNanos方法休眠一下,相當于調用了Thread.Sleep方法。這樣能夠有效的減少頻繁自旋導緻cpu資源過度浪費的問題。
10. ThreadLocal用完沒清空
在java中保證線程安全的技術有很多,可以使用synchroized、Lock等關鍵字給代碼塊加鎖。
但是它們有個共同的特點,就是加鎖會對代碼的性能有一定的損耗。
其實,在jdk中還提供了另外一種思想即:用空間換時間。
沒錯,使用ThreadLocal類就是對這種思想的一種具體展現。
ThreadLocal為每個使用變量的線程提供了一個獨立的變量副本,這樣每一個線程都能獨立地改變自己的副本,而不會影響其它線程所對應的副本。
ThreadLocal的用法大緻是這樣的:
- 先建立一個CurrentUser類,其中包含了ThreadLocal的邏輯。
public class CurrentUser {
private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
public static void set(UserInfo userInfo) {
THREA_LOCAL.set(userInfo);
}
public static UserInfo get() {
THREA_LOCAL.get();
}
public static void remove() {
THREA_LOCAL.remove();
}
}
- 在業務代碼中調用CurrentUser類。
public void doSamething(UserDto userDto) {
UserInfo userInfo = convert(userDto);
CurrentUser.set(userInfo);
...
//業務代碼
UserInfo userInfo = CurrentUser.get();
...
}
在業務代碼的第一行,将userInfo對象設定到CurrentUser,這樣在業務代碼中,就能通過CurrentUser.get()擷取到剛剛設定的userInfo對象。特别是對業務代碼調用層級比較深的情況,這種用法非常有用,可以減少很多不必要傳參。
但在高并發的場景下,這段代碼有問題,隻往ThreadLocal存資料,資料用完之後并沒有及時清理。
ThreadLocal即使使用了WeakReference(弱引用)也可能會存在記憶體洩露問題,因為 entry對象中隻把key(即threadLocal對象)設定成了弱引用,但是value值沒有。
public void doSamething(UserDto userDto) {
UserInfo userInfo = convert(userDto);
try{
CurrentUser.set(userInfo);
...
//業務代碼
UserInfo userInfo = CurrentUser.get();
...
} finally {
CurrentUser.remove();
}
}
需要在finally代碼塊中,調用remove方法清理沒用的資料。
最近無意間獲得一份阿裡大佬寫的刷題筆記,一下子打通了我的任督二脈,進大廠原來沒那麼難。私信回複:「進大廠」,可以免費擷取阿裡大神寫的面試寶典,祝你早日進大廠。
連結:https://pan.baidu.com/s/1UECE5yuaoTTRpJfi5LU5TQ 密碼:bhbe
不會有人刷到這裡還想白嫖吧?點贊對我真的非常重要!線上求贊。加個關注我會非常感激!