Java进阶(线程)
一. 线程复习
1.1 什么是线程?
进程时操作系统分配资源的最小单位
线程时cpu执行的最小单元
线程是进程中一个独立的任务,一个进程中可以有多个线程
1.2 java创建线程方式
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
1.3 线程中常用的方法
run() //线程要执行的任务写在run里面
start() //启动线程
sleep() //线程休眠指定的时间
join() //等待线程结束
yiled() //线程让步 主动让出cpu执行权
1.4 线程状态
new Thread() //新建状态
start() //就绪状态
或得到了cpu执行权 //运行状态
sleep wait //输入,等待获取锁 阻塞状态
任务执行完了,出现异常 //死亡/销毁
1.5 多线程
一个进程中,可以创建多个线程,执行多个任务,提高程序运行效率
多个线程,可以访问同一个共享数据(线程安全问题)
1.6 解决线程安全问题
1.6.1 synchronized加锁:
修饰代码块
synchronized(同步对象){ }
修饰方法
静态方法的同步对象: 是类的Class对象
非静态方法的同步对象: 是this
1.6.2 ReentrantLock类:
synchronized 是关键字,实现靠底层指令 既可以修饰代码块,也可以修饰方法 隐式枷锁和释放
ReentrantLock 是类 靠java代码实现 只能修饰代码块 手动加锁和释放锁
1.7 线程通信
多个线程,在同步的情况下,互相牵制执行
wait() 线程等待
notify() 唤醒等待的线程(优先级高的)
notifyAll() 唤醒所有等待的线程
wait()和slepp()区别:
wait是Object类中方法,需要等待别的线程对它唤醒 等待后可以释放锁
sleep()是Thread类中的方法,休眠时间到了以后,可以自动唤醒 不会释放锁
二. Java并发编程
2.1 什么是并发编程?
在多线程场景下
多线程优点:在一个进程中,可以有多个线程,同时执行不同的任务,提高程序响应速度,提高了cpu的利用率,同时压榨硬件的剩余价值
多线程问题:共享数据使得安全性不高(两个用户可能同时抢到一张票)
所以并发编程就是要让这么多同时到来的请求,并发的执行(在同一个时间段内,一个一个依次执行)
举个例子:大家排队在一个咖啡机上接咖啡,交替执行,是并发;两台咖啡机上面接咖啡, 是并行。
并发执行说的是在一个时间段内,多件事情在这个时间段内交替执行。
并行执行说的是多件事情在同一个时刻同事发生。
单核cpu本质是并发执行,但是多核cpu可以实现并行
2.2 线程的三个主要问题
2.2.1 不可见性
这一思想来源于cpu高速缓存
对于如今的多核处理器,每个CPU内核都有自己的缓存,而缓存仅仅对它所在 的处理器内核可见,CPU缓存与内存的数据不容易保证一致。由于java内存模型分为主内存和工作内存(缓冲区)。所有变量都存储在主内存中,当线程要对主内存中的数据操作时,首先要将主内存中的数据,加载到线程的工作内存(缓冲区)中才能进行操作。这样就会产生不可见性,两个线程同时操作一个数据时,其中一个线程在自己的工作内存中修改了数据,而另一个线程是不知道的。
2.2.2 乱序性
为了进一步优化,cpu执行指令时,有的指令需要等待数据的加载,此时会先执行后面的指令,这样会打乱指令的执行顺序。(但是有关系的语句顺序不可以被打乱)
2.2.3 非原子性
线程切换执行时,会带来非原子性问题。cpu的执行在指令层面是原子性的,但是高级语言一条语句可能需要多条指令。此时切换线程会导致指令执行非原子性
2.2.4 总结
- 缓存导致的不可见性
- 编译器优化造成了乱序性
- 线程切换带来的非原子性
2.3 解决三个问题
2.3.1 volatile关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后:
-
两个线程同时执行,一个线程修改了变量值,另一个线程立即可见
-
修饰的变量,编译器是不能对其重新排序的
但是不能解决非原子性问题。
只有加锁可以解决
2.3.2 原子类
java中对于++这种非原子性操作,除了加锁还有另外一种实现原子操作的方案。使用java中提供的一些原子类来实现。
AtomicInteger类:
AtomicInteger ai=new AtomicInteger(0);
ai.incrementAndGet() //不加锁实现++操作
采用了CAS思想(compare and swap)不加锁,自旋
当线程第一次执行++操作时,先将主内存数据加载到工作内存中,然后把这个值称为预期值,然后再进行加加操作。当返回给主内存时判断预期值和主内存中的值是否相等,相等说明没有其他线程更改过此值,不相等说明有其他线程更改过了此值。然后把返回的值作废,新值再进入线程中重复上述操作。
这种做法适合于线程数量较少情况,由于不加锁,线程都不会阻塞
所有线程一直尝试对变量进行修改操作,效率高于加锁。但如果线程比较多,一直在判断一直在自旋会导致cpu工作量特别大。
还有可能出现ABA问题,则可以使用带版本号的原子类:
AtomicStampedReference asrf=new AtomicStampedReference(100,0); //0是版本号
ABA问题:
1.就是当AB线程共同操作一个值x时,可能AB线程都拿走了x,但是B先把x改变后又变了回去,那么实际上x值还是变了的,但是A不知道。B完成后把x送回去,但是A看到x还是原来的值(说白了就是可能存在一个线程根本不知道数值发生了变化)
2.内存值V=100;
threadA 将100,改为50;
threadB 将100,改为50;
threadC 将50,改为100;
引入版本号是为了在比较值时还比较版本号
思想:
内存值V=100;
threadA 获取值100 版本号1,改为50 版本号2;
threadB 获取值100 版本号1
threadC 将50 版本号2 改为100 版本号3
// 期望值为100 和内存中的值进行比较,如果一样,且版本号stamp也和内存中一样,则改为50
asrf.compareAndSet(100,50,stamp,stamp+1);
场景:
取款,由于机器不太好使,多点了几次取款操作。后台threadA和threadB工作,此时threadA操作成功(100->50) 版本号1->版本号2,threadB取完值100,版本号是1,然后阻塞。正好有人打款50元过来(50->100) 版本号2->版本号3,threadC执行成功,之后threadB获取资源,开始执行,发现内存的值为100, 但是版本号不对应, 所以重新从内存中读取值,然后再进行判断。
三. 锁
3.1 锁的分类
3.1.1 乐观/悲观锁
悲观锁:认为多线程操作时,不加锁会出现问题,是一种加锁的实现,synchronized和ReentrantLock都属于锁,称为悲观锁
乐观锁:认为多线程操作时,不加锁也不会出现问题,如原子类就采用不加锁,通过自选方式避免非原子性问题,称为乐观锁
3.1.2 可重入锁(递归锁)
指同一个线程在外层方法获取锁时,再进入内层方法会自动获取锁。对于ReentrantLock而言,它就是可重入锁,意思是重新进入锁。对于Synchronized也是个可重入锁,好处是可以避免死锁
public class Demo{
synchronized void setA() throws Exception{
sout("方法A");
setB();
}
synchronized void setB() throws Exception{
sout("方法B");
}
}
如果不是可重入锁的话,setB不会被当前线程执行,会造成死锁
3.1.3 读写锁
ReentrantReanWriteLock实现读写锁,有读锁有写锁
读读不互斥,读写互斥,写写互斥
加读锁是防止在另外的线程在此时写入数据,防止读取脏数据
3.1.4 共享锁/独占锁
共享锁时指该锁可以被多个线程所持有,并发访问共享资源
独占锁也是互斥锁,一次只能被一个线程所持有
对于Java ReentrantLock,Synchronized 而言,都是独享锁。但是对于Lock 的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
3.1.5 公平/非公平锁
公平锁指按照请求锁的顺序分配,然后获得锁。当一个线程释放锁后,等待时间最长的线程会获得锁的访问权。
非公平锁是指不按照请求锁的顺序分配,不一定拥有锁的机会
对于synchronized而言是一种非公平锁,ReentrantLock默认是非公平锁,但是底层可以通过AQS来实现线程调度,所以可以变为公平锁。
//默认
public ReentranLock(){
sync=new NonfairSync();
}
//传入true or false
public ReentrantLock(boolean fair){
sync=fair ? new FairSync():new NonfairSync();
}
3.1.6 自旋锁
不断通过自旋重试的方式操作,抢到锁就继续,抢不到就阻塞线程。适合于低并发场景,效率高。高并发场景效率低。
3.1.7 分段锁
是一种思想,将锁单独加到每个更细致的数据分段上,这样把锁进一步细粒度化,以提高并发效率
例如ConcurrentHashMap的实现:
由于ConcurrentHashMap底层哈希表有16个空间,可以用每一个位置上的第一个节点当锁,这样可以同时由不同的线程操作不同的位置,只是同一个位置多个线程不能同时操作。
3.2 锁的状态
1.无锁 2.偏向锁状态 3.轻量级锁状态 4.重量级锁状态
锁状态是通过对象监视器在对象头中的字段来表明的。四种状态会随竞争情况逐渐升级
这四种状态都不是Java中的锁,只是JVM为了提高锁的获取与释放效率做的优化(用synchronized时)
- 偏向锁:只有一个线程一直来获取锁,此时会在对象头中记录线程id,id相同可以直接获取锁
- 轻量级锁:当锁状态为偏向锁时,继续有其他线程过来获取锁,锁状态升级为轻量级锁,线程不会阻塞,一直自旋获得锁
- 重量级锁:当锁为轻量级锁时,线程数量持续增多,且线程自旋次数到达一定数量时,锁就升级为重量级锁,线程进入阻塞,等待操作系统调度
不同的锁状态也是java对synchronized锁进行的优化
3.3 对象结构
在Hotspot虚拟机(JVM的具体实现)中,对象在内存中分局为三块区域:对象头,实例数据和对齐填充。Java对象头是实现synchronized的锁对象的基础,一般情况synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。
Java 代码打印对象头信息 添加依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
打印出相关的对象头信息
System.out.println(ClassLayout.parseInstance(myClass).toPrintable());
尝试加锁改变对象头信息
synchronized (myClass){
System.out.println(ClassLayout.parseInstance(myClass).toPrintable());
}
3.4 synchronized 锁实现
synchronized锁实现是隐式的,可以修饰方法,也可以修饰代码块。
底层实现是需要依赖字节码指令的。
修饰方法时,会在方法上添加一个ACC_SYNCHRONIZED标志,依赖底层的监视器实现锁的控制。
修饰代码块时,为同步代码块添加monitorenter指令,进行监视,执行结束,执行monitorexit指令。
有线程进入,计数器+1,线程执行完毕,计数器-1。
3.6 AQS
AQS 的 全 称 为 ( AbstractQueuedSynchronizer ) , 这 个 类 在 java.util.concurrent.locks 包下面。
AQS是一个用来构建锁和同步器的框架。
AQS实现原理:
AQS在内部有一个state变量表示锁的状态,初始化0也就是没有使用锁。在多线程条件下,线程要执行临界区的代码,首先获取state,一个线程获取成功后,state+1。
其他线程再获取就会去FIFO队列等待,当占有state的线程执行完临界区代码释放资源(state-1)后,会让FIFO的第一个线程去获取state。
由于state是多线程共享变量,所以必须定义成volatile来保证state的可见性,但是不能保证原子性,所以AQS还提供了对state的原子操作方法,保证了线程安全。
另外AQS中的FIFO队列是双向链表实现的,head节点代表当前占用的线程,其他节点由于暂时获取不到锁依次排队等待锁释放。队列由Node对象组成,Node是AQS中的内部类。
AQS成员:
private transient volatile Node head;
private transient volatile Node tail;
/*
使用变量state表示锁状态,0-锁未被使用,大于0锁已被使用
共享变量state,使用volatile 修饰保证线程可见性
*/
private volatile int state;
状态信息通过getState , setState , compareAndSetState 进行操作:
protected final int getState() {
//获得锁状态状态信息通过getState , setState , compareAndSetState 进行操作。
return state;
}
//使用CAS机制设置状态
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
双向链表内部类Node:
static final class Node{
volatile Node prev;
volatile Node next;
volatile Thread thread; //等待的线程
}
3.6 ReentrantLock
ReentrantLock是Java.util.concurrent.locks包下的类,实现Lock接口,Lock的意义在于提供区别于synchronized的另一种具有更多广泛操作的同步方式,能支持更多灵活的结构。
底层用的是AQS,它可以实现公平锁和非公平锁来对共享资源进行同步,同时支持可重入。
ReentrantLock总共有三个内部类。
ReentrantLock 类内部总共存在Sync、NonfairSync、FairSync 三个类, NonfairSync 与 FairSync 类 继 承 自 Sync 类 , Sync 类 继 承 自AbstractQueuedSynchronizer 抽象类。
ReentrantLock底层是通过Sync来实现的,Sync的两个子类分别实现公平和非公平锁,使得ReentranLock实现公平锁和非公平锁
ReentrantLock构造方法:
public ReentrantLock(){
sync=new NonfairSync(); //默认非公平
}
public ReentrantLock(boolean fair){
sync=fair?new FairSync():new NonfairSync();
}
NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现 了Sync类中抽象的lock方法.
staticfinalclassNonfairSyncextendsSync{
//加锁
finalvoidlock(){
//若通过CAS设置变量state成功,就是获取锁成功,则将当前线程设置为独占线程。
//若通过CAS设置变量state失败,就是获取锁失败,则进入acquire方法进行后续处理。
if(compareAndSetState(0,1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);//正常流程获取锁
}
}
FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中 的抽象lock方法.
staticfinalclassFairSyncextendsSync{
finalvoidlock(){
acquire(1);//正常流程获取锁
}
}
四.JUC常用类
Java5.0在java.utilconcurrent包中提供了多种并发容器类来改进同步容 器的性能。
4.1 ConcurrentHashMap
4.1.1 复习HashMap:
HashMap:
双列集合 实现Map接口 键值对 键不能重复,值可以重复
只能存储一个为null的键 键是无序的 是线程不安全的
HashMap不能有多个线程同时操作,如果有,则会抛出ConcurrentModificationException(并发修改异常)
4.1.2 并发实现
ConcurrentHashMap是线程安全的哈希表,对于多线程的操作介于HashMap和Hashtable之间。它不是对整个类添加锁而是对哈希表的每个位置上的第一个节点都加锁,将锁的粒度细化了,提高了并发效率。
当哈希表的某个位置还没有Node对象时,如果此时有多个线程操作,采用cas机制进行比较判断。
如果某个位置上已经有了Node对象,直接使用Node对象作为锁即可。
4.1.3 ConcurrentHashMap特点
ConcurrentHashMap和Hashtable都不能存储值或键为null
原因:map.get(“key”); 得到的是null会有两种情况,值为null还是没有"key"这个键,为了消除这种歧义,尤其是在多线程中,所以不能存null值
4.2 CopyOnWriteArrayList
4.2.1 复习ArrayList
ArrayList:数组列表 线程不安全
Vector 数组列表 线程安全 但是给每个方法都加了锁,效率降低
4.2.2 并发实现
将读写并发效率进一步提升了。
读操作(get())是完全不加锁的,只能给写操作(add,set,remove)加了锁,而且为了操作时能不影响读操作。操作前会将数组进行copy,在复制后的数组上修改,修改后的数组重新赋值到底层数组。这样子不影响读的时候读原数组,此时做到了读读,读写都不互斥,只有写写互斥。
适用于读操作多,写操作少的场景
4.3 CopyOnWriteArraySet
底层是CopyOnWriteArrayList,不能存储重复数据
4.4 CountDownLatch
辅助类 递减计数器
使一个线程等待其他线程执行结束后再执行
相当于一个线程计数器,先指定一个数量,当一个线程执行完计数器就–直到0,然后关闭计数器。
CountDownLatch dl=new CountDownLatch(6); //计数
for(int i=0;i<6;i++){
new Thread(
()->{
System.out.println(Thread.currentThread().getName());
dl.countDown(); //计数-1
}
).start();
}
//如果没有await主线程可能会提前执行,没有await相当于只在计数
//await会检查计数是否为0,如果是0就可以执行后面线程,不是0就阻塞
dl.await();//关闭计数
System.out.println("main线程");
五. 线程池
5.1 基本概念
5.1.1 池的概念
常见池:
字符串常量池:String a=“abc” String b=“abc" a==b;//true 是因为发现字符串常量池已经存在abc直接给b用了
IntegerCache.cache 存了-128—+127 的Integer对象
数据库连接池:为了避免重复创建连接对象和销毁链接对象,事先先创建若干个连接的对象
通过DruidUtil获取connection,可以定义初始化最大线程数
close也是使用了德鲁伊里的close实际上并没有关闭只是修改了一下状态(使用中/未使用)。
如果池中有空闲连接,连接池会将一个连接分配给应用程序。
如果没有空闲连接,根据配置,连接池可能会等待直到有连接可用,或者创建一个新的连接(如果配置了最大连接数允许)。
池就是一个缓冲,可以事先准备好一些数据,用的时候直接用,提高效率dsawsdw
5.1.2 为什么使用线程池
有时,需要执行许多任务,而且每个任务比较短,这种场景下需要大量创建线程。这样以来创建的开销就变大了。所以可以实现创建一部分线程,不销毁,有任务时提交给线程去执行,执行完后不结束线程,避免了频繁创建线程。
5.2 线程池类
5.2.1 Executors类
创建线程池
ExecutorService eS=Executors.newFixedThreadPool(5);
eS.submit(new Runnable(){
@Override
public void run(){
sout;
}
})
创建五个线程的线程池,要执行的代码提交给线程池分配线程执行
但是国内一般不这样使用,一般使用阿里巴巴开发规约推荐的ThreadPoolExecutor类
5.2.2 ThreadPoolExecutor类
构造方法的参数:
corePoolSize:核心池的大小,这个参数跟后面讲述的线程池的实现原理有非 常大的关系。在创建了线程池后,默认情况下,在创建了线程池后,线程池中的 线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线 程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;除非调用了 prestartAllCoreThreads()或者 prestartCoreThread()方法,从这 2 个方法的名 字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建 corePoolSize 个线程或者一个线程。
maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数, 它表示在线程池中最多能创建多少个线程;
keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情 况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起 作用,直到线程池中的线程数不大于corePoolSize,即当线程池中的线程数大 于corePoolSize 时,如果一个线程空闲的时间达到keepAliveTime,则会终止, 直到线程池中的线程数不超过corePoolSize。
unit:参数keepAliveTime 的时间单位,有7种取值,在TimeUnit类中有7 种静态属性:TimeUnit.DAYS,TimeUnit.HOURS,TimeUnit.MINUTES等总共七种
workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很 重要,会对线程池的运行过程产生重大影响
threadFactory:线程工厂,主要用来创建线程;
handler:表示当拒绝处理任务时的策略
5.2.3 线程池执行过程
当任务到达时,首先在核心线程池创建线程任务,如果核心线程池未满,则让核心线程来执行。
如果核心线程池的线程都有任务,那么就将任务存放到队列中等待执行。
当任务继续提交来,使得队列也满了后,判断线程数是否达到maximumPoolSize,如果没到达,就创建非核心线程执行任务。
如果到达最大线程数,还有任务过来,直接采用拒绝策略处理
5.2.4 线程池中的队列
ArrayBlockingQueue:是一个用数组实现的有界阻塞队列,创建时必须设置长 度,,按FIFO排序量。
LinkedBlockingQueue:基于链表结构的阻塞队列,按FIFO排序任务,容量 可以选择进行设置,不设置是一个最大长度为Integer.MAX_VALUE;
5.2.5 线程池的拒绝策略
构造方法的中最后的参数RejectedExecutionHandler用于指定线程池的拒绝 策略。当请求任务不断的过来,而系统此时又处理不过来的时候,我们就需要采 取对应的策略是拒绝服务。 默认有四种类型:
AbortPolicy 策略:该策略会直接抛出异常,阻止系统正常工作。
CallerRunsPolicy 策略:只要线程池未关闭,该策略在调用者线程中运行当前的 任务(如果任务被拒绝了,则由提交任务的线程(例如:main)直接执行此任务)。
DiscardOleddestPolicy 策略:该策略将丢弃最老的一个请求,也就是即将被执 行的任务,并尝试再次提交当前任务。
DiscardPolicy 策略:该策略丢弃无法处理的任务,不予任何处理。
5.2.6 execute和submit的区别
执行任务可以使用execute和submit方法。execute适用于不需要关注返回值的场景,submit适用于需要关注返回值的场景
for(int i=1;i<=8;i++){
MyTask myTask=new MyTask(i);
//提交
Future<?> submit=executor.submit(myTask);
}
5.2.7 关闭线程池
shutdownNow(); 立即关闭线程池,还有未执行的线程也会被中断
shutdown(); 关闭时,会把已经提交线程池中的线程执行完
六. ThreadLocal
6.1 ThreadLocal是什么
ThreadLocal叫做线程变量。意思是ThreadLocal中填充的变量只属于当前线程,与其他线程是隔离的。ThreadLocal为变量在每个线程中创建了一个副本,那么每个线程可以访问自己内部的副本变量
static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return 1;
}
};
6.2 ThreadLocal原理
ThreadLocal是一个泛型类,保证可以接受任何类型的对象。ThreadLocal内部维护了一个Map,ThreadLocal实现了一个叫ThreadLocalMap的静态内部类。get(),set()都是这个内部类中的。
set():
public void set(T value) {
//获取当前线程
Thread t = Thread.currentThread();
//获取当前线程的ThreadLocal
ThreadLocalMap map = getMap(t);
//说明有threadlocal
if (map != null)
map.set(this, value);
//说明没有threadlocal
else
createMap(t, value);
}
get():
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
实际上就是每一个线程都给了一个开始声明的那个ThreadLocal,让ThreadLocal在不同的这些线程里充当key值。每个线程有一个map,每个map有个ThreadLocal当key,和set的变量当value。
eg:
Thread1中有个ThreadLocalMap(key:ThreadLocal,value:value1)
Thread2中有个ThreadLocalMap(key:ThreadLocal,value:value2)
Thread3中有个ThreadLocalMap(key:ThreadLocal,value:value3)
6.3 ThreadLocal内存泄露
6.3.1 对象的四种引用
强引用:
有引用指向对象
String s="1";
String s1=s;
//等s=null,s1=null就被回收了
软引用:
被Soft Reference对象管理的对象,在内存不够时,先不回收这个对象。先进行一次垃圾回收,当垃圾回收后,如果内存够用了,就不回收。等这次垃圾回收后,内存还不够,就会回收这个被SoftReference管理的对象
SoftReference<byte[]> m=new SoftReference<>(new byte[1024*1024]);
弱引用:
只要遇到垃圾回收就会被回收
WeakReference<String> m=new WeakReference<>(new String("1"));
虚引用:
也是遇到垃圾回收就被回收,主要用来记录一个对象从出生到被销毁。不然对象啥时候被回收都不知道
6.3.2 ThreadLocal为什么会内存泄露
因为ThreadLocal与弱引用WeakReference有关系,那么在垃圾回收时,会把键回收了,但是值还存在强引用,不能回收。导致内存泄露
所以我们用完ThreadLocal后需要使用localNum.remove()来释放一下