「 关注“石杉的架构笔记”,大厂架构经验倾囊相授 」
儒猿技术团队最新出品
《基于ShardingSphere的分库分表实战》
文章来源:【公众号:苏三说技术】
对于从事后端开发的同学来说,并发编程肯定再熟悉不过了。
说实话,在 Java 中并发编程是一大难点,至少我是这么认为的。不光理解起来比较费劲,使用起来更容易踩坑。
不信,让继续往下面看。今天重点跟大家一起聊聊并发编程的 10 个坑,希望对你有帮助。
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 类。
双重检查锁的漏洞
单例模式无论在实际工作,还是在面试中,都出现得比较多。我们都知道,单例模式有:饿汉模式和懒汉模式两种。
饿汉模式代码如下:
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 关键字可以保证多个线程的可见性,但是不能保证原子性。同时它也能禁止指令重排。
双重检查锁的机制既保证了线程安全,又比直接上锁提高了执行效率,还节省了内存空间。
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);
}
}
死锁
死锁可能是大家都不希望遇到的问题,因为一旦程序出现了死锁,如果没有外力的作用,程序将会一直处于资源竞争的假死状态中。
死锁代码如下:
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 锁都没有释放,两个线程将一起请求下去,陷入死循环,即出现死锁的情况。那么如果避免死锁问题呢?
出现死锁的情况,有可能是像上面那样,锁范围太大了导致的。那么解决办法就是缩小锁的范围。
具体代码如下:
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 锁的代码。
出现死锁的情况说白了是,一个线程获取锁的顺序是: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 锁。
没释放锁
在 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 方法手动释放锁,线程持有的锁将不会得到释放。
HashMap 导致内存溢出
HashMap 在实际的工作场景中,使用频率还是挺高的,比如:接收参数,缓存数据,汇总数据等等。
但如果你在多线程的环境中使用 HashMap,可能会导致非常严重的后果。
@Service
public class HashMapService {
private Map 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。
使用默认线程池
我们都知道 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 问题,所以我们需要根据实际业务场景选择。
@Async 注解的陷阱
之前在 Java 并发编程中实现异步功能,一般是需要使用线程或者线程池。线程池的底层也是用的线程。
而实现一个线程,要么继承 Thread 类,要么实现 Runnable 接口,然后在 run 方法中写具体的业务逻辑代码。
开发 Spring 的大神们,为了简化这类异步操作,已经帮我们把异步功能封装好了。Spring 中提供了 @Async 注解,我们可以通过它即可开启异步功能,使用起来非常方便。
具体做法如下:
在 SpringBoot 的启动类上面加上 @EnableAsync 注解:
@EnableAsync
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
在需要执行异步调用的业务方法加上 @Async 注解:
@Service
public class CategoryService {
@Async
public void add(Category category) {
//添加分类
}
}
在 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 注解开启的异步功能时,请别忘了定义一个线程池。
自旋锁浪费 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 资源过度浪费的问题。
ThreadLocal 用完没清空
在 Java 中保证线程安全的技术有很多,可以使用 synchroized、Lock 等关键字给代码块加锁。但是它们有个共同的特点,就是加锁会对代码的性能有一定的损耗。
其实,在 jdk 中还提供了另外一种思想即:用空间换时间。没错,使用 ThreadLocal 类就是对这种思想的一种具体体现。
ThreadLocal 为每个使用变量的线程提供了一个独立的变量副本,这样每一个线程都能独立地改变自己的副本,而不会影响其它线程所对应的副本。
ThreadLocal 的用法大致是这样的:
先创建一个 CurrentUser 类,其中包含了 ThreadLocal 的逻辑。
public class CurrentUser {
private static final ThreadLocal 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 方法清理没用的数据。
------------- END -------------
帮您解忧,儒猿特别打造 “跳槽训练专栏系列”
内容试看(点击) | 课程目录(点击)
<< 滑动查看更多专栏 >>
点个在看你最好看