线程安全、锁、线程通信、线程池
1. 什么是线程安全问题
1.1 为什么要有线程安全问题?
当多个线程同时共享同一个全局变量或静态变量,做写的操作(修改变量值)时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作时不会发生数据冲突问题。
案例:需求现在有100张火车票,有两个窗口同时抢火车票,请使用多线程模拟抢票效果。
package com.lanou.day12.lessonEx.ticket;
public class Station implements Runnable{
private int ticketNum = 100;
public Station() {
}
public Station(int ticketNum) {
this.ticketNum = ticketNum;
}
@Override
public void run() {
while (true){
synchronized ("aa"){
if (ticketNum > 0 ){
System.out.println(Thread.currentThread().getName() + "正在卖第" + (100-ticketNum+1) + "张票");
ticketNum--;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}else {
System.out.println("票已卖光!");
break;
}
}
}
}
}
有两位电工,长期驻扎在一个小区作为物业,一个电工 A,上早班,上班时间是 6:00 ~ 18:00,另一位电工 B,上班时间为 18:00 ~ 6:00。这两位电工轮流在小区值班。某一天,在下午 17:55 的时候,电工 A 接到小区居民投诉:小区中的电压不稳,希望电工能够修复一下。虽然马上要到下班时间了,但是电工 A 还是决定去查看一下。为了爬上电线杆进行高空带电作业,于是电工 A 暂时把小区的电闸给关了,然后再到高空进行电压不稳的检查。
在 A 在工作过程中,不知不觉到了 18:01,电工 B 上班了。这个时候,电工 B 接到小区居民投诉:小区停电了。电工 B 也决定去查看一下。当他查看到电闸时,一下就明白了:怪不得小区停电呢,谁把闸关了啊。于是,电工 B 就把电闸打开了。于是,听到一声惨叫,A 从天上掉了下来„„
半个月以后,等 A 把伤养得差不多了以后,变电所决定解决吸取教训,争取再也不要发生这样的问题。那这个问题是怎么发生的呢?
我们可以把 A 和 B 当做是两个线程,而电闸就当做是临界资源。因此,这样就形成了两个线程共同访问临界资源,由于 A 检修电路时,“关掉电闸、爬上电线杆检修、打开电闸、恢复供电”是不可分割的原子操作,而一旦其中的某一步被打断,就有可能产生问题,这就是两个线程数据不一致的情况。
那怎么办呢?变电所决定,要解决这个问题,这就借助于一样东西:锁。在电闸上挂一个挂锁。平时没有问题时,锁是打开的;但是一旦有一个电工需要操作电闸的话,为了防止别人动电闸,他可以把电闸给锁上,并把唯一的钥匙随身携带。这样,当他进行原子操作时,由于临界资源被他锁上了,其他线程就访问不了这个临界资源,因此就能保证他的原子操作不被破坏。
2. 线程同步
2.1 什么是线程同步
并行指的是线程同时执行。
同步不是线程同时执行,而是线程不同时执行。同步本质指的是数据的同步。一般情况下,线程之间是相互独立,如果都去访问同一个变量,极有可能让这个数据变乱。如果不想让数据变乱,应在不让他们同时访问同一个变量。这个控制过程称为线程同步。
2.2 synchronized与同步代码块
Java 中采取了类似的机制,也采用锁来保护临界资源,防止数据不一致的情况产生。下面我们就来介绍Java 中的同步机制以及 synchronized 关键字。
在 Java 中,每个对象都拥有一个“互斥锁标记”,这就好比是我们说的挂锁。这个锁标记,可以用来分给不同的线程。之所以说这个锁标记是“互斥的”,因为这个锁标记同时只能分配给一个线程。
光有锁标记还不行,还要利用 synchronized 关键字进行加锁的操作。synchronized 关键字有两种用法,我们首先介绍第一种:synchronized + 代码块。
这种用法的语法如下:
synchronized(obj){
代码块…
}
synchronized 关键字后面跟一个圆括号,括号中的是某一个引用,这个引用应当指向某一个对象。后面紧跟一个代码块,这个代码块被称为“同步代码块”。
这种语法的含义是,如果某一个线程想要执行代码块中的代码,必须要先获得 obj 所指向对象的互斥锁标记。也就是说,如果有一个线程 t1 要想进入同步代码块,必须要获得 obj对象的锁标记;而如果 t1线程正在同步代码块中运行,这意味着 t1 有着 obj 对象的互斥锁标记;而这个时候如果有一个 t2 线程想要访问同步代码块,会因为拿不到 obj 对象的锁标记而无法继续运行下去。
需要注意的是,synchronized 与同步代码块是与对象紧密结合在一起的,加锁是对对象加锁。例如下面的例子,假设有两个同步代码块:
synchronized(obj1){
代码块 1;
}
synchronized(obj1){
代码块 2;
}
synchronized(obj2){
代码块 3;
}
假设有一个线程 t1 正在代码块 1 中运行,那假设另有一个线程 t2,这个 t2 线程能否进入代码块 2 呢?能否进入代码块 3 呢?
由于 t1 正在代码块 1 中运行,这也就意味着 obj1 对象的锁标记被 t1 线程获得,而此时t2 线程如果要进入代码块 2,也必须要获得 obj1 对象的锁标记。但是由于这个标记正在 t1手中,因此 t2 线程无法获得锁标记,因此 t2 线程无法进入代码块 2。但是 t2 线程能够进入代码块 3,原因在于:如果要进入代码块 3 中,要获得的是 obj2对象的锁标记,这个对象与 obj1 不是同一个对象,此时 t2 线程能够顺利的获得 obj2 对象的锁标记,因此能够成功的进入代码块 3。
从上面这个例子中,我们可以看出,在分析、编写同步代码块时,一定要搞清楚,同步代码块锁的是哪个对象。只有把这个问题搞清楚了之后,才能正确的分析多线程以及同步的相关问题。
下面我们结合线程的状态转换,来考察一下 synchronized 关键字在程序运行中的作用。
首先,如果一个线程获得不了某个对象的互斥锁标记,这个线程就会进入一个状态:锁池状态。
如下图:
当运行中的线程,运行到某个同步代码块,但是获得不了对象的锁标记时,会进入锁池状态。在锁池状态的线程,会一直等待某个对象的互斥锁标记。如果有多个线程都需要获得同一个对象的互斥锁标记,则可以有多个线程进入锁池,而某个线程获得锁标记,执行同步代码块中的代码。
当对象的锁标记被某一个线程释放之后,其他在锁池状态中的线程就可以获得这个对象的锁标记。假设有多个线程在锁池状态中,那么会由操作系统决定,把释放出来的锁标记分配给哪一个线程。当在锁池状态中的线程获得锁标记之后,就会进入可运行状态,等待获得CPU 时间片,从而运行代码。
2.3 如何实现线程的同步?
同步代码块
synchronized(对象){
共享资源//我们所谓的那个变量。
}
示例代码:
public class SellWindow implements Runnable {
private int tickets = 100;
private Object lock = new Object();
@Override
public void run() {
while(tickets > 0) {
String threadName = Thread.currentThread().getName();
synchronized (lock) {
tickets--;
if(tickets >= 0) {
System.out.println(threadName + "卖掉1张票,剩余" + tickets);
}
}
}
}
}
同步代码块synchronized (对象),多个线程要公用同一个对象,才能真正意义上加上锁。对象没有特殊要求,可以是任何继承于Object类的对象。包括this
同步方法
被synchronized修饰的方法称为同步方法。
public class SellWindow3 implements Runnable {
private int tickets = 100;
@Override
public void run() {
while(tickets > 0) {
//method();
method2();
}
}
public void method() {
synchronized (this) {
tickets--;
String threadName = Thread.currentThread().getName();
if(tickets >= 0) {
System.out.println(threadName + "卖掉1张票,剩余" + tickets);
}
}
}
public synchronized void method2() {
tickets--;
String = Thread.currentThread().getName();
if(tickets >= 0) {
System.out.println(threadName + "卖掉1张票,剩余" + tickets);
}
}
}
使用锁对象上锁和解锁
public class SellWindow4 implements Runnable {
private int tickets = 100;
Lock lock = new ReentrantLock();
@Override
public void run() {
while (tickets > 0) {
String threadName = Thread.currentThread().getName();
lock.lock();
tickets--;
if (tickets >= 0) {
System.out.println(threadName + "卖掉1张票,剩余" + tickets);
}
lock.unlock();
}
}
}
线程同步小节
同步不是线程同时执行,而是线程不同时执行。同步本质指的是数据的同步。一般情况下,线程之间是相互独立,如果都去访问同一个变量,极有可能让这个数据变乱。如果不想让数据变乱,应不让他们同时访问同一个变量。这个控制过程称为线程同步。
在开发中,如果多个线程访问一个资源(某变量),为了保证数据的正确性,可以使用3种方式来实现线程同步:使用synchronized(){}代码块,使用synchronized方法,或者给共享资源加锁和解锁。
3. 线程通信
3.1 wait与notify
在 synchronized 关键字的作用下,还有可能产生新的问题:死锁。
考虑下面的代码,假设 a 和 b 是两个不同的对象。
synchronized(a){
...//1
synchronized(b){
}
}
synchronized(b){
... //2
synchronized(a){
}
}
假设现在有两个线程,t1 线程运行到了//1 的位置,而 t2 线程运行到了//2 的位置,接下来会发生什么情况呢?
此时,a 对象的锁标记被 t1 线程获得,而 b 对象的锁标记被 t2 线程获得。对于 t1 线程而言,为了进入对 b 加锁的同步代码块,t1 线程必须获得 b 对象的锁标记。由于 b 对象的锁标记被 t2 线程获得,t1 线程无法获得这个对象的锁标记,因此它会进入 b 对象的锁池,等待 b 对象锁标记的释放。而对于 t2 线程而言,由于要进入对 a 加锁的同步代码块,由于a 对象的锁标记在 t1 线程手中,因此 t2 线程会进入a 对象的锁池。此时,t1 线程在等待 b 对象锁标记的释放,而 t2 线程在等待 a 对象锁标记的释放。由于两边都无法获得所需的锁标记,因此两个线程都无法运行。这就是“死锁”问题。
3.2 如何解决死锁问题?
在 Java 中,采用了 wait 和 notify 这两个方法,来解决死锁机制。
首先,在 Java 中,每一个对象都有两个方法:wait 和 notify 方法。这两个方法是定义在 Object 类中的方法。对某个对象调用 wait()方法,表明让线程暂时释放该对象的锁标记。
例如,上面的代码就可以改成:
synchronized(a){
...//1
a.wait();
synchronized(b){
}
}
synchronized(b){
//2
synchronized(a){
...
a.notify();
}
}
这样的代码改完之后,在//1 后面,t1 线程就会调用 a 对象的 wait 方法。此时,t1 线程会暂时释放自己拥有的 a 对象的锁标记,而进入另外一个状态:等待状态。
要注意的是,如果要调用一个对象的 wait 方法,前提是线程已经获得这个对象的锁标记。如果在没有获得对象锁标记的情况下调用 wait 方法,则会产生异常。
由于 a 对象的锁标记被释放,因此,t2 对象可以获得 a 对象的锁标记,从而进入对 a加锁的同步代码块。在同步代码块的最后,调用 a.notify()方法。这个方法与 wait 方法相对应,是让一个线程从等待状态被唤醒。
那么 t2 线程唤醒 t1 线程之后,t1 线程处于什么状态呢?由于 t1 线程唤醒之后还要在对 a 加锁的同步代码块中运行,而 t2 线程调用了 notify()方法之后,并没有立刻退出对 a 加锁的同步代码块,因此此时t1 线程并不能马上获得 a 对象的锁标记。因此,此时,t1 线程会在 a 对象的锁池中进行等待,以期待获得 a 对象的锁标记。也就是说,一个线程如果之前调用了 wait 方法,则必须要被另一个线程调用notify()方法唤醒。唤醒之后,会进入锁池状态。线程状态转换图如下:
由于可能有多个线程先后调用 a 对象 wait 方法,因此在 a 对象等待状态中的线程可能有多个。而调用a.notify()方法,会从 a 对象等待状态中的多个线程里挑选一个线程进行唤醒。与之对应的,有一个notifyAll()方法,调用 a.notifyAll() 会把 a 对象等待状态中的所有线程都唤醒。
3. 3 线程通信
不同线程之间可以相互的发信号。这就是线程通信。之所以需要进行线程通信,是因为有些时候,一个线程的执行需要依赖另外一个线程的执行结果。在结果到来之前,让线程等待(wait),有了结果只之后再进行后续的操作。对于另外一个线程而言,计算完结果,通知(notify)一下处于等待状态的线程.
线程通信借助的是Object类的wait,notify,notifyAll方法。
wait作用是让当前线程阻塞,阻塞多久,取决于有没有其他线程唤醒它。
notify作用是唤醒处于wait状态的线程。必须是同一个监视器下的线程。
notifyAll作用是唤醒所有处于wait状态的线程。必须是同一个监视器下的线程。
一般情况下,多线程里会出现线程同步的问题,我们不但要进行线程通信,还要解决线程同步的问题。
3.4 wait与notify 应用:生产者-消费者模式
这是一个比较经典的多线程场景。有商品的时候,消费者才可以消费,没有商品的时候,消费者等待。商品库存充足的时候,生产者等待,库存不满的时候,生产者生产商品。
public class Saler {//售货员类
private int productCount = 10; //商品数量
public synchronized void stockGoods() {
if(productCount < 2000) {
productCount++;
System.out.println(Thread.currentThread().getName() + "生产了1件商品, 库存是:" + productCount);
this.notifyAll();
}else {
System.out.println("库存满了");
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void sellGoods() {
if(productCount > 0) {
productCount--;
System.out.println(Thread.currentThread().getName() + "购买了1件商品, 库存剩余:" + productCount);
this.notifyAll();
}else {
System.out.println("库存不足");
try {this.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
public class Productor implements Runnable{//生产者类
private Saler s;
public Productor(Saler s) {
super();
this.s = s;
}
@Override
public void run() {
while(true) {
s.stockGoods();
}
}
}
public class Customer implements Runnable{//消费者类
private Saler s;
public Customer(Saler s) {
super();
this.s = s;
}
@Override
public void run() {
while(true) {
s.sellGoods();
}
}
}
public class TestTread {
public static void main(String[] args) { //生产者-消费者模式。模拟生产和消费过程
Saler s = new Saler();
Customer c = new Customer(s);
Productor p = new Productor(s);
Thread t1 = new Thread(c, "客户1");
t1.start();
Thread t2 = new Thread(p,"厂家");
t2.start();
Customer c2 = new Customer(s);
Thread t3 = new Thread(c2, "客户2");
t3.start();
}
}
4. 线程池
4.1 什么是线程池
水池:存放水的池子。
线程池:存放线程的池子。
Java中的线程池:是一个管理线程的池子。可以在需要的时候开辟线程,可以控制最大开辟的线程个数,可以在不需要的时候关闭线程,可以让任务排队执行。这些管理过程不需要我们干预,线程池能帮我们完成。我们所要做的就是往线程池中放任务。
4.2 为什么要有线程池?
多线程解决了任务并发问题,但是开辟和关闭线程很消耗系统的性能,开辟和关闭一个线程要处理很多细节,频繁的开辟和关闭线程会给系统增加很多开销。
线程池使用了重用的概念,可以控制线程开辟的数量,复用这些线程执行任务。这样就不用频繁的开辟和关闭线程了。
4.3 线程池使用场景及优势
线程池适合处理的任务:执行时间短、工作内容较为单一。
合理使用线程池带来的好处**:**
1)降低资源消耗:重复利用已创建的线程降低线程创建和销毁造成的开销
2)提高响应速度:当任务到达时,任务可以不用等待线程创建就能立即执行
3)提高线程的可管理性:可以统一对线程进行分配、调优和监控
4)提供更多强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池SchedulerThreadPoolExecutor,允许任务延期执行或定期执行
线程池使应用能更加充分利用CPU、内存、网络、IO等系统资源。线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间。在线程销毁时需要回收这些系统资源。因此频繁的创建和销毁线程会浪费大量的系统资源,增加并发编程风险。另外,在服务器负载过大的时候,如何让新的线程等待或者友好地拒绝服务?这些都是线程本身无法解决的。所以需要通过线程池协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。
线程池的作用包括:
1):利用线程池管理并复用线程、控制最大并发数等
2):实现任务线程队列缓存策略和拒绝机制
3):实现某些与时间相关的功能,如定时执行、周期执行
4):隔离线程环境。通过配置两个或多个线程池,将一台服务器上较慢的服务和其他服务隔离开,避免各服务线程相互影响。
4.4 ThreadPoolExecutor及线程池各参数含义
ThreadPoolExecutor
UML类图:
ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。
ExecutorService接口增加了一些能力:
(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;
(2)提供了管控线程池的方法,比如停止线程池的运行。
AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。
最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。
new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
handler)
主要参数:
1)corePoolSize 线程池核心线程的大小
2)maximumPoolSize 线程池最大线程的大小
3)keepAliveTime 空闲线程的存活时间
4)BlockingQueue 用来暂时保存任务的工作队列
5)RejectedExecutionHandler 线程池已经关闭或者饱和(达到了最大线程数且工作队列已满),executor()方法将调用Handler
参数详细说明:
- corePoolSize
表示常驻核心线程数,如果大于0,则即使执行完任务,线程也不会被销毁(allowCoreThreadTimeOut为false)。因此这个值的设置非常关键,设置过小会导致线程频繁地创建和销毁,设置过大会造成浪费资源
- maximumPoolSize
表示线程池能够容纳的最大线程数。必须大于或者等于1。
- keepAliveTime
表示线程池中的线程空闲时间,当空闲时间达到keepAliveTime值时,线程会被销毁,避免浪费内存和句柄资源。在默认情况下,当线程池中的线程数大于corePoolSize时,keepAliveTime才起作用,达到空闲时间的线程会被销毁,直到只剩下corePoolSize个线程为止。但是当ThreadPoolExecutor的allowCoreThreadTimeOut设置为true时(默认false),核心线程超时后也会被回收。(一般设置60s)
- TimeUnit
表示时间单位,keepAliveTime的时间单位通常是TimeUnit.SECONDS
- BlockingQueue
表示缓存队列。
- threadFactory
表示线程工厂。它用来生产一组相同任务的线程。线程池的命名是通过给threadFactory增加组名前缀来实现的。在用jstack分析时,就可以知道线程任务是由哪个线程工厂产生的。
- handler
表示执行拒绝策略的对象。当超过workQueue的缓存上限的时候,就可以通过该策略处理请求,这是一种简单的限流保护。
提供了四个预定义的处理程序策略:
-
在默认 ThreadPoolExecutor.AbortPolicy ,处理程序会引发运行RejectedExecutionException 后排斥反应。
-
在 ThreadPoolExecutor.CallerRunsPolicy 中,调用 execute 本身的线程运行任务。 这提供了一个简单的反馈控制机制,将降低新任务提交的速度。
-
在 ThreadPoolExecutor.DiscardPolicy 中 ,简单地删除无法执行的任务。
-
在 ThreadPoolExecutor.DiscardOldestPolicy 中 ,如果执行程序没有关闭,则工作队列头部的任务被删除,然后重试执行(可能会再次失败,导致重复)。
-
BlockingQueue
表示缓存队列。
-
threadFactory
表示线程工厂。它用来生产一组相同任务的线程。线程池的命名是通过给threadFactory增加组名前缀来实现的。在用jstack分析时,就可以知道线程任务是由哪个线程工厂产生的。
-
handler
表示执行拒绝策略的对象。当超过workQueue的缓存上限的时候,就可以通过该策略处理请求,这是一种简单的限流保护。
提供了四个预定义的处理程序策略:
- 在默认 ThreadPoolExecutor.AbortPolicy ,处理程序会引发运行
RejectedExecutionException 后排斥反应。
-
在 ThreadPoolExecutor.CallerRunsPolicy 中,调用 execute 本身的线程运行任务。 这提供了一个简单的反馈控制机制,将降低新任务提交的速度。
-
在 ThreadPoolExecutor.DiscardPolicy 中 ,简单地删除无法执行的任务。
-
在 ThreadPoolExecutor.DiscardOldestPolicy 中 ,如果执行程序没有关闭,则工作队列头部的任务被删除,然后重试执行(可能会再次失败,导致重复)。
首先,所有的任务都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来的执行流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:
-
首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
-
如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
-
如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
-
如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
-
如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
-
线程池中存活的线程执行完当前任务后,会在循环中反复从BlockingQueue队列中获取任务来执行
4.5 线程池工具类
Exectors 是线程池工具类,可以帮我们快速构建线程池。
三种常见的线程池:
-
固定线程个数的线程池
-
不限线程个数的线程池
-
单个线程的线程池(串行任务池)
public class TestExecutors {
public static void main(String[] args) {
//创建一个固定个数的线程池
ExecutorService es = Executors.newFixedThreadPool(3);
//创建一个不限容量的线程池
ExecutorService es = Executors.newCachedThreadPool();
//创建一个只有一条线程的线程池
ExecutorService es = Executors.newSingleThreadExecutor();
es.submit(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
}
}
5. 课堂总结
总结使用线程池需要注意以下几点:
-
合理设置各类参数,应根据实际业务场景来设置合理的工作线程数
-
线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
-
创建线程或线程池请指定有意义的线程名称,方便出错时回溯
-
自定义线程池不允许使用Executors,而是通过ThreadPoolExecutor的方式来创建,这样的处理方式能更加明确线程池的运行规则,规避资源耗尽的风险。
rService es = Executors.newCachedThreadPool();
//创建一个只有一条线程的线程池
ExecutorService es = Executors.newSingleThreadExecutor();
es.submit(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
}
}
## 5. 课堂总结
**总结使用线程池需要注意以下几点**:
1. 合理设置各类参数,应根据实际业务场景来设置合理的工作线程数
2. 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
3. 创建线程或线程池请指定有意义的线程名称,方便出错时回溯
4. 自定义线程池不允许使用Executors,而是通过ThreadPoolExecutor的方式来创建,这样的处理方式能更加明确线程池的运行规则,规避资源耗尽的风险。
另外,线程池中的线程数量不是越多越好,具体的数量需要评估每个任务的处理时间,以及当前计算机的处理器能力和数量。使用的线程过少,无法发挥处理器的性能;使用的线程过多,将会增加上下文切换的开销,反而起到相反的作用。