并发编程
进程和线程
进程是程序的一次执行过程,是系统运行程序的基本单位
线程是比进程更小的执行单位。一个进程执行过程中可以产生多个线程。多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈
- 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
- 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
现在的 Java 线程的本质其实就是操作系统的线程。
- 程序计数器:线程切换后能恢复到正确的执行位置:
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
堆和方法区是所有线程共享的资源
- 堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),
- 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
线程创建方式
- 继承 Thread 类,重写run方法
- 实现 Runnable 接口
- 实现 Callable 接口。
- 继承 Thread 类,重写 run()方法,调用 start()方法启动线程,没有返回值
public class ThreadTest {
/**
* 继承Thread类
*/
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("This is child thread");
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
- 实现 Runnable 接口,重写 run()方法,没有返回值
public class RunnableTask implements Runnable {
public void run() {
System.out.println("Runnable!");
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
new Thread(task).start();
}
}
- 实现 Callable 接口,重写 call()方法,可以通过 FutureTask 获取任务执行的返回值
public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}
public static void main(String[] args) {
//创建异步任务
FutureTask<String> task=new FutureTask<String>(new CallerTask());
//启动线程
new Thread(task).start();
try {
//等待执行完成,并获取返回结果
String result=task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
Start(), run()
JVM 执行 start 方法,会先创建一条线程,由创建出来的新线程去执行 thread 的 run 方法,这才起到多线程的效果。
直接调用 Thread 的 run()方法, run 方法还是运行在主线程中,相当于顺序执行,没有多线程效果。
守护线程
Java线程分为两类
- daemon 线程(守护线程)
- user 线程(用户线程)。
在 JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程。
当最后一个非守护线程束时, JVM 会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM 退出。只要有一个用户线程还没结束,正常情况下 JVM 就不会退出。
线程间通信方式
ThreadLocal
ThreadLocal,是线程本地变量。创建一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有变量的一个本地拷贝,多个线程操作这个变量实际是操作自己本地内存里的变量,从而起到线程隔离的作用,避免了线程安全问题。
ThreadLocal 的 set(T)方法中,先获取到当前线程,再获取ThreadLocalMap,然后把元素存到这个 map 中。Thread 类中定义了一个类型为ThreadLocal.ThreadLocalMap的成员变量threadLocals。
- Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,每个线程都有一个自己的 ThreadLocalMap。
- ThreadLocalMap 内部维护着 Entry 数组,每个 Entry 代表一个完整的对象,key 是 ThreadLocal 的弱引用,value 是 ThreadLocal 的泛型值。
- 每个线程在往 ThreadLocal 里设置值的时候,都是往自己的 ThreadLocalMap 里存,读也是以某个 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。
- ThreadLocal 本身不存储值,它只是作为一个 key 来让线程往 ThreadLocalMap 里存取值。
内存泄漏
在 JVM 中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。栈中存储了 ThreadLocal、Thread 的引用,堆中存储了它们的具体实例。
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用。
“弱引用:只要垃圾回收机制一运行,不管 JVM 的内存空间是否充足,都会回收该对象占用的内存。”
弱引用很容易被回收,如果 ThreadLocal(ThreadLocalMap 的 Key)被垃圾回收器回收了,但是 ThreadLocalMap 生命周期和 Thread 是一样的,它这时候如果不被回收,就会出现:ThreadLocalMap 的 key 没了,value 还在,这就会造成了内存泄漏问题。
那怎么解决内存泄漏问题呢?
使用完 ThreadLocal 后,及时调用 remove()方法释放内存空间。
那为什么 key 还要设计成弱引用?
key 设计成弱引用同样是为了防止内存泄漏。
假如 key 被设计成强引用,如果 ThreadLocal Reference 被销毁,此时它指向 ThreadLocal 的强引用就没有了,但是此时 key 还强引用指向 ThreadLocal,就会导致 ThreadLocal 不能被回收,也会发生内存泄漏
扩容机制
在 ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:
rehash()具体实现:会先去清理过期的 Entry,然后还要根据条件判断size >= threshold - threshold / 4 也就是size >= threshold* 3/4来决定是否需要扩容。
具体的resize()方法,扩容后的newTab的大小为老数组的两倍,然后遍历老的 table 数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab,遍历完成后,oldTab中所有的entry数据都已经放入到newTab中了,然后 table 引用指向newTab
引用方式
- 强引用:new出来的对象,强引用存在,垃圾回收器永远不会回收被引用的对象,哪怕内存不足
- 软引用:SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出时被回收
- 弱引用:WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若对象只被弱引用指向,就会被回收
- 虚引用:最弱的引用,在 Java 中使用 PhantomReference 进行定义。唯一的作用就是用队列接收对象即将死亡的通知
父子线程共享数据
JAVA内存模型JMM
Java Memory Model,是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。
JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
本地内存是 JMM 的抽象概念,实际不存在。涵盖缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
原子性、可见性、有序性
- 原子性:原子性指的是一个操作不可分割、不可中断,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。
- 可见性:一个线程修改了某一共享变量的值后,其它线程能立即知道这个修改。
- 有序性:对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,并发时有可能会指令重排。
原子性、可见性、有序性都应该怎么保证呢?
- 原子性:JMM 只能保证基本的原子性,保证代码块的原子性需要使用
synchronized。 - 可见性:Java 是利用
volatile关键字来保证可见性的,final和synchronized也能保证可见性。 - 有序性:
synchronized或者volatile都可以保证多线程之间操作的有序性。
指令重排
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型。
- 编译器优化。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行。现代处理器采用了指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
双重校验单例模式就是经典的指令重排的例子,Singleton instance=new Singleton();对应的 JVM 指令分为三步:分配内存空间-->初始化对象--->对象指向分配的内存空间,但是经过编译器指令重排序,第二步和第三步就可能会重排序。
Happens-before
指令重排也有一些限制,有两个规则happens-before和as-if-serial约束。
happens-before 的定义:
- 如果操作A happens-before操作B,那么操作A的执行结果将对操作B可见,且操作A的执行顺序排在操作B之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,重排序并不非法
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start()规则:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
- join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
as-if-serial
as-if-serial 语义是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
volatile
作用:保证可见性和有序性
可见性原理:volatile 可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
有序性原理:为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
- 在每个 volatile 写操作的前面插入一个
StoreStore屏障 - 在每个 volatile 写操作的后面插入一个
StoreLoad屏障 - 在每个 volatile 读操作的后面插入一个
LoadLoad屏障 - 在每个 volatile 读操作的后面插入一个
LoadStore屏障
Load:从主内存中加载
Store:写入主内存
synchronized
- 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
- 修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,只有⼀份)。
- 修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得 当前 class 的锁
原理
- 修改代码块:JVM 采用
monitorenter、monitorexit两个指令来实现同步,monitorenter指令指向同步代码块的开始位置,monitorexit指令则指向同步代码块的结束位置。 - 修饰同步方法时,JVM 采用
ACC_SYNCHRONIZED标记符来实现同步,这个标识指明了该方法是一个同步方法。
锁升级
synchronized,ReentrantLock
-
锁的实现: synchronized 是 Java 语言的关键字,基于 JVM 实现。而 ReentrantLock 是基于 JDK 的 API 层面实现的(一般是 lock()和 unlock()方法配合 try/finally 语句块来完成。)
-
性能: 在 JDK1.6 锁优化以前,synchronized 的性能比 ReenTrantLock 差很多。 JDK6 后增加了适应性自旋、锁消除等,两者性能差不多。
-
功能特点:ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。
- ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly()实现
- ReentrantLock 可以公平锁、非公平锁。synchronized 只能非公平锁。公平锁就是先等待的线程先获得锁。
- synchronized 与 wait()和 notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock 类借助 Condition 接口与 newCondition()方法实现。
- ReentrantLock 需要手工声明来加锁和释放锁,一般跟 finally 配合释放锁。而 synchronized 不用手动释放锁。
优化过程:
无锁
偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。
偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,无需再进入 Monitor 去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。
轻量级锁
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
重量级锁
轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源。
JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。
自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。
AQS
AbstractQueuedSynchronizer抽象同步队列简称 AQS ,是 Java 并发包的根基,并发包中的锁基于 AQS 实现
- AQS 是基于一个 FIFO 的双向队列,内部定义了一个节点类 Node,Node 节点内部的 SHARED 用来标记该线程是获取共享资源时被阻挂起后放入 AQS 队列的, EXCLUSIVE 用来标记线程是 取独占资源时被挂起后放入 AQS 队列
- AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态,修改同步状态成功即为获得锁,volatile 保证了变量在多线程之间的可见性,修改 State 值时通过 CAS 机制来保证修改的原子性
- 获取 state 的方式分为两种,独占方式和共享方式,一个线程使用独占方式获取了资源,其它线程就会在获取失败后被阻塞。一个线程使用共享方式获取了资源,另外一个线程还可以通过 CAS 的方式进行获取。
- 如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS 中会将竞争共享资源失败的线程添加到一个变体的 CLH 队列中。
ReentrantLock实现原理
ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。
默认创建的对象 lock()的时候:
- 如果锁当前没有被其它线程占用,并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置 AQS 的状态值为 1 ,然后直接返回。如果当前线程之前己经获取过该锁,则这次只是简单地把 AQS 的状态值加 1 后返回。
- 如果该锁己经被其他线程持有,非公平锁会尝试去获取锁,获取失败的话,则调用该方法线程会被放入 AQS 队列阻塞挂起。
非公平锁和公平锁的两处不同:
- 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
- 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。
保证原子性的方法
多线程下i++结果正确
- 使用循环原子类,例如 AtomicInteger,实现 i++原子操作
- 使用 juc 包下的锁,如 ReentrantLock ,对 i++操作加锁 lock.lock()来实现原子性
- 使用 synchronized,对 i++操作加锁
死锁排查
可以使用 jdk 自带的命令行工具排查:
- 使用 jps 查找运行的 Java 进程:jps -l
- 使用 jstack 查看线程堆栈信息:jstack -l 进程 id
CountDownLatch
- 场景 1:协调子线程结束动作:等待所有子线程运行结束
- 场景 2. 协调子线程开始动作:统一各线程动作开始的时机
CyclicBarrier同步屏障
CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
CountDownLatch 类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作
CyclicBarrier 和 CountDownLatch
两者最核心的区别[18]:
- CountDownLatch 是一次性的,而 CyclicBarrier 则可以多次设置屏障,实现重复利用;
- CountDownLatch 中的各个子线程不可以等待其他线程,只能完成自己的任务;而 CyclicBarrier 中的各个线程可以等待其他线程
| CyclicBarrier | CountDownLatch |
|---|---|
| CyclicBarrier 是可重用的,其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。 | CountDownLatch 是一次性的,不同的线程在同一个计数器上工作,直到计数器为 0. |
| CyclicBarrier 面向的是线程数 | CountDownLatch 面向的是任务数 |
| 在使用 CyclicBarrier 时,你必须在构造中指定参与协作的线程数,这些线程必须调用 await()方法 | 使用 CountDownLatch 时,则必须要指定任务数,至于这些任务由哪些线程完成无关紧要 |
| CyclicBarrier 可以在所有的线程释放后重新使用 | CountDownLatch 在计数器为 0 时不能再使用 |
| 在 CyclicBarrier 中,如果某个线程遇到了中断、超时等问题时,则处于 await 的线程都会出现问题 | 在 CountDownLatch 中,如果某个线程出现问题,其他线程不受影响 |
Semaphore信号量
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
Exchanger
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger 用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。
这两个线程通过 exchange 方法交换数据,如果第一个线程先执行 exchange()方法,它会一直等待第二个线程也执行 exchange 方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
线程池
工作流程
核心参数
常见线程池
| 线程池名称 | 线程数量 | 队列数量 | 说明 |
|---|---|---|---|
| fixedThreadPool SingleThreadExecutor | 固定 | 没有上限 | |
| cachedThreadPool | 没有上限 | 没有数据缓冲的阻塞队列,生产后必须消费 | |
| scheduledThreadPool | 没有上限的延迟阻塞队列 |
阻塞队列
- ArrayBlockingQueue:有界队列,是一个用数组实现的有界阻塞队列,按 FIFO 排序量。
- LinkedBlockingQueue:可设置容量队列,是基于链表结构的阻塞队列,按 FIFO 排序任务,容量可以选择设置,默认是一个无边界的阻塞队列,最大长度为 Integer.MAX_VALUE,吞吐量通常要高于 ArrayBlockingQuene;newFixedThreadPool 线程池使用了这个队列
- DelayQueue:延迟队列,是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool 线程池使用了这个队列。
- PriorityBlockingQueue:优先级队列,是具有优先级的无界阻塞队列
- SynchronousQueue:同步队列,一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQuene,newCachedThreadPool 线程池使用了这个队列。
execute和submit
execute 用于提交不需要返回值的任务
threadsPool.execute(new Runnable() {
@Override public void run() {
// TODO Auto-generated method stub }
});
submit()方法用于提交需要返回值的任务。线程池会返回一个 future 类型对象,通过 future 对象可以判断任务是否执行成功,可以通过 future.get()方法获取返回值
Future<Object> future = executor.submit(harReturnValuetask);
try { Object s = future.get(); } catch (InterruptedException e) {
// 处理中断异常
} catch (ExecutionException e) {
// 处理无法执行任务异常
} finally {
// 关闭线程池 executor.shutdown();
}
关闭方式
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。
shutdown() 将线程池状态置为 shutdown,并不会立即停止:
- 停止接收外部 submit 的任务
- 内部正在跑的任务和队列里等待的任务,会执行完
- 等到第二步完成后,才真正停止
shutdownNow() 将线程池状态置为 stop。一般会立即停止,事实上不一定:
- 和 shutdown()一样,先停止接收外部提交的任务
- 忽略队列里等待的任务
- 尝试将正在跑的任务 interrupt 中断
- 返回未执行的任务列表
区别:
- shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。立即生效,风险比较大。
- shutdown()只是关闭了提交通道,submit()是无效的;而内部的任务正常进行,跑完再彻底停止线程池。
拒绝策略
- AbortPolicy :直接抛出异常,默认使用此策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
- DiscardPolicy :当前任务直接丢弃