1、上下文切换是什么?
概念: 当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,这个时候,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
相关概念 | 详情 |
“切出” | 一个线程被剥夺处理器的使用权而被暂停运行 |
“切入” | 一个线程被选中占用处理器开始或者继续运行 |
“上下文” | 在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了,(它包括了寄存器的存储内容以及程序计数器存储的指令内容) |
2、多线程上下文切换原因?
2.1、Java线程的生命周期状态
线程的生命周期分为新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)这5种状态。在系统运行过程中不断有新的线程被创建,旧的线程在执行完毕后被清理,线程在排队获取共享资源或者锁时将被阻塞,因此运行中的线程会在就绪、阻塞、运行状态之间来回切换。
其流程如下所述。
(1)调用new方法新建一个线程,这时线程处于新建状态。
(2)调用start方法启动一个线程,这时线程处于就绪状态。
(3)处于就绪状态的线程等待线程获取CPU资源,在等待其获取CPU资源后线程会执行run方法进入运行状态。
(4)正在运行的线程在调用了yield方法或失去处理器资源时,会再次进入就绪状态。
(5)正在执行的线程在执行了sleep方法、I/O阻塞、等待同步锁、等待通知、调用suspend方法等操作后,会挂起并进入阻塞状态,进入Blocked池。
(6)阻塞状态的线程由于出现sleep时间已到、I/O方法返回、获得同步锁、收到通知、调用resume方法等情况,会再次进入就绪状态,等待CPU时间片的轮询。该线程在获取CPU资源后,会再次进入运行状态。
(7)处于运行状态的线程,在调用run方法或call方法正常执行完成、调用stop方法停止线程或者程序执行错误导致异常退出时,会进入死亡状态。
1、新建状态:
- New在Java中使用new关键字创建一个线程,新创建的线程将处于新建状态。在创建线程时主要是为线程分配内存并初始化其成员变量的值。
2、就绪状态:
- Runnable新建的线程对象在调用start方法之后将转为就绪状态。此时JVM完成了方法调用栈和程序计数器的创建,等待该线程的调度和运行
3、运行状态:
- Running就绪状态的线程在竞争到CPU的使用权并开始执行run方法的线程执行体时,会转为运行状态,处于运行状态的线程的主要任务就是执行run方法中的逻辑代码
4、阻塞状态:Blocked
- 运行中的线程会主动或被动地放弃CPU的使用权并暂停运行,此时该线程将转为阻塞状态,直到再次进入可运行状态,才有机会再次竞争到CPU使用权并转为运行状态。阻塞的状态分为以下三种。
- (1)等待阻塞:在运行状态的线程调用o.wait方法时,JVM会把该线程放入等待队列(WaittingQueue)中,线程转为阻塞状态。
- (2)同步阻塞:在运行状态的线程尝试获取正在被其他线程占用的对象同步锁时,JVM会把该线程放入锁池(Lock Pool)中,此时线程转为阻塞状态。
- (3)其他阻塞:运行状态的线程在执行Thread.sleep(long ms)、Thread.join()或者发出I/O请求时,JVM会把该线程转为阻塞状态。直到sleep()状态超时、Thread.join()等待线程终止或超时,或者I/O处理完毕,线程才重新转为可运行状态。
5、线程死亡:
- Dead线程在以下面三种方式结束后转为死亡状态。
- ◎ 线程正常结束:run方法或call方法执行完成。
- ◎ 线程异常退出:运行中的线程抛出一个Error或未捕获的Exception,线程异常退出。
- ◎ 手动结束:调用线程对象的stop方法手动结束运行中的线程(该方式会瞬间释放线程占用的同步对象锁,导致锁混乱和死锁,不推荐使用)
- 线程状态由 运行 转为 阻塞 或者由 阻塞 转为 就绪,这又是什么诱发的呢?
原因 | 详情 |
一种是程序本身触发的切换,这种我们称为自发性上下文切换 | 自发性上下文切换指线程由 Java 程序调用导致切出。在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。sleep() 、wait()、yield()、join()、park()、synchronized、lock |
另一种是由系统或者虚拟机诱发的非自发性上下文切换 | 非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致 |
- 虚拟机垃圾回收为什么会导致上下文切换?
在Java虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运行过程中,新的对象将不断被创建,如果旧的对象使用后不进行回收,堆内存将很快被耗尽。Java 虚拟机提供了一种回收机制,对创建后不再使用的对象进行回收,从而保证堆内存的可持续性分配。而这种垃圾回收机制的使用有可能会导致 stop-the-world 事件的发生,这其实就是一种线程暂停行为。
2.2、线程的基本方法
线程相关的基本方法有wait、notify、notifyAll、sleep、join、yield等,这些方法控制线程的运行,并影响线程的状态变化。
1 线程等待:
- wait方法调用wait方法的线程会进入WAITING状态,只有等到其他线程的通知或被中断后才会返回。需要注意的是,在调用wait方法后会释放对象的锁,因此wait方法一般被用于同步方法或同步代码块中。
2 线程睡眠:
- sleep方法调用sleep方法会导致当前线程休眠。与wait方法不同的是,sleep方法不会释放当前占有的锁,会导致线程进入TIMED-WATING状态,而wait方法会导致当前线程进入WATING状态。
3 线程让步:
- yield方法调用yield方法会使当前线程让出(释放)CPU执行时间片,与其他线程一起重新竞争CPU时间片。在一般情况下,优先级高的线程更有可能竞争到CPU时间片,但这不是绝对的,有的操作系统对线程的优先级并不敏感。
4 线程中断:interrupt方法
- interrupt方法用于向线程发行一个终止通知信号,会影响该线程内部的一个中断标识位,这个线程本身并不会因为调用了interrupt方法而改变状态(阻塞、终止等)。状态的具体变化需要等待接收到中断标识的程序的最终处理结果来判定。对interrupt方法的理解需要注意以下4个核心点
- ①调用interrupt方法并不会中断一个正在运行的线程,也就是说处于Running状态的线程并不会因为被中断而终止,仅仅改变了内部维护的中断标识位而已。
- 具体的JDK源码如下
- ②若因为调用sleep方法而使线程处于TIMED-WATING状态,则这时调用interrupt方法会抛出InterruptedException,使线程提前结束TIMED-WATING状态;
- ③许多声明抛出InterruptedException的方法如Thread.sleep(long mills),在抛出异常前都会清除中断标识位,所以在抛出异常后调用isInterrupted方法将会返回false;
- ④中断状态是线程固有的一个标识位,可以通过此标识位安全终止线程。比如,在想终止一个线程时,可以先调用该线程的interrupt方法,然后在线程的run方法中根据该线程isInterrupted方法的返回状态值安全终止线程。
5、线程加入:join方法
- join方法用于等待其他线程终止,如果在当前线程中调用一个线程的join方法,则当前线程转为阻塞状态,等到另一个线程结束,当前线程再由阻塞状态转为就绪状态,等待获取CPU的使用权。在很多情况下,主线程生成并启动了子线程,需要等到子线程返回结果并收集和处理再退出,这时就要用到join方法,具体的使用方法如下
6、线程唤醒:notify方法 - Object类有个notify方法,用于唤醒在此对象监视器上等待的一个线程,如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意的。
- 我们通常调用其中一个对象的wait方法在对象的监视器上等待,直到当前线程放弃此对象上的锁定,才能继续执行被唤醒的线程,被唤醒的线程将以常规方式与在该对象上主动同步的其他线程竞争。类似的方法还有notifyAll,用于唤醒在监视器上等待的所有线程。
7、后台守护线程:setDaemon方法
- setDaemon方法用于定义一个守护线程,也叫作“服务线程”,该线程是后台线程,有一个特性,即为用户线程提供公共服务,在没有用户线程可服务时会自动离开。
- 守护线程的优先级较低,用于为系统中的其他对象和线程提供服务。将一个用户线程设置为守护线程的方法是在线程对象创建之前用线程对象的
setDaemon(true)
来设置。
Demo如下所示:
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(kafkaConsumer.concurrency(), kafkaConsumer.concurrency(), 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), //线程设置为daemon new ThreadFactoryBuilder().setNameFormat("kafka-consume-" + topic + "-" + groupId + "-%d").setDaemon(true).build() , new ThreadPoolExecutor.AbortPolicy());
- 在后台守护线程中定义的线程也是后台守护线程。后台守护线程是JVM级别的,比如垃圾回收线程就是一个经典的守护线程,在我们的程序中不再有任何线程运行时,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以在回收JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态下运行,用于实时监控和管理系统中的可回收资源。
- 守护线程是运行在后台的一种特殊线程,独立于控制终端并且周期性地执行某种任务或等待处理某些已发生的事件。也就是说,守护线程不依赖于终端,但是依赖于JVM,与JVM“同生共死”。在JVM中的所有线程都是守护线程时,JVM就可以退出了,如果还有一个或一个以上的非守护线程,则JVM不会退出。
- 至此,对影响线程的核心方法基本介绍完毕,各方法对线程状态的影响如图所示
8、sleep方法与wait方法的区别 - sleep方法与wait方法的区别如下。
- ◎ sleep方法属于Thread类,wait方法则属于Object类。
- ◎ sleep方法暂停执行指定的时间,让出CPU给其他线程,但其监控状态依然保持,在指定的时间过后又会自动恢复运行状态。
- ◎ 在调用sleep方法的过程中,线程不会释放对象锁。
- ◎ 在调用wait方法时,线程会放弃对象锁,进入等待此对象的等待锁池,只有针对此对象调用notify方法后,该线程才能进入对象锁池准备获取对象锁,并进入运行状态
9、start方法与run方法的区别
- start方法与run方法的区别如下。
- ◎ start方法用于启动线程,真正实现了多线程运行。在调用了线程的start方法后,线程会在后台执行,无须等待run方法体的代码执行完毕,就可以继续执行下面的代码。
- ◎ 在通过调用Thread类的start方法启动一个线程时,此线程处于就绪状态,并没有运行。
- ◎ run方法也叫作线程体,包含了要执行的线程的逻辑代码,在调用run方法后,线程就进入运行状态,开始运行run方法中的代码。在run方法运行结束后,该线程终止,CPU再调度其他线程。
10 终止线程的4种方式
- 1.正常运行结束指线程体执行完成,线程自动结束。
- 2.使用退出标志退出线程在一般情况下,在run方法执行完毕时,线程会正常结束。然而,有些线程是后台线程,需要长时间运行,只有在系统满足某些特殊条件后,才能触发关闭这些线程。这时可以使用一个变量来控制循环,比如设置一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出,具体的实现代码如下
以上代码在线程中定义了一个退出标志exit, exit的默认值为false。在定义exit时使用了一个Java关键字volatile,这个关键字用于使exit线程同步安全,也就是说在同一时刻只能有一个线程修改exit的值,在exit为true时,while循环退出 - 3、使用Interrupt方法终止线程
- 使用interrupt方法终止线程有以下两种情况。
- (1)线程处于阻塞状态。例如,在使用了sleep、调用锁的wait或者调用socket的receiver、accept等方法时,会使线程处于阻塞状态。在调用线程的interrupt方法时,会抛出InterruptException异常。我们通过代码捕获该异常,然后通过break跳出状态检测循环,可以有机会结束这个线程的执行。通常很多人认为只要调用interrupt方法就会结束线程,这实际上理解有误,一定要先捕获InterruptedException异常再通过break跳出循环,才能正常结束run方法。具体的实现代码如下
- (2)线程未处于阻塞状态。此时,使用isInterrupted方法判断线程的中断标志来退出循环。在调用interrupt方法时,中断标志会被设置为true,并不能立刻退出线程,而是执行线程终止前的资源释放操作,等待资源释放完毕后退出该线程。
- 4.使用stop方法终止线程:不安全
- 在程序中可以直接调用Thread.stop方法强行终止线程,但这是很危险的,就像突然关闭计算机的电源,而不是正常关机一样,可能会产生不可预料的后果。在程序使用Thread.stop方法终止线程时,该线程的子线程会抛出ThreadDeatherror错误,并且释放子线程持有的所有锁。加锁的代码块一般被用于保护数据的一致性,如果在调用Thread.stop方法后导致该线程所持有的所有锁突然释放而使锁资源不可控制,被保护的数据就可能出现不一致的情况,其他线程在使用这些被破坏的数据时,有可能使程序运行错误。因此,并不推荐采用这种方法终止线程。
2.3、系统开销发生在切换过程中的哪些具体环节
- 操作系统保存和恢复上下文;
- 调度器进行线程调度;
- 处理器高速缓存重新加载;
- 上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。
3、总结
上下文切换就是一个工作的线程被另外一个线程暂停,另外一个线程占用了处理器开始执行任务的过程。系统和 Java 程序自发性以及非自发性的调用操作,就会导致上下文切换,从而带来系统开销。
- 线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?
一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,我们前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。而在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,我建议使用多线程来提高系统的整体性能。例如,NIO 时期的文件读写操作、图像处理以及大数据分析等。
4、如何优化多线程上下文切换?
- 在某些场景下使用多线程是非常必要的,但多线程编程给系统带来了上下文切换,从而增加的性能开销也是实打实存在的。那么该如何优化多线程上下文切换呢?
4.1 竞争锁优化
方法 | 详情 |
- 减少锁的持有时间|可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作
- 降低锁的粒度|可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:锁分离(读写锁实现了锁分离,读读不互斥)和锁分段( Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段)
- 非阻塞乐观锁替代竞争锁|CAS 是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏向锁、轻量级锁、偏向锁以及重量级锁,优化路径也是按照以上顺序进行。JIT 编译器在动态编译同步块的时候,也会通过锁消除、锁粗化的方式来优化该同步锁
4.2 wait/notify 优化
可以通过配合调用 Object 对象的 wait() 方法和 notify() 方法或 notifyAll() 方法来实现线程间的通信。
- 下面我们通过 wait() / notify() 来实现一个简单的生产者和消费者的案例
public class WaitNotifyTest { public static void main(String[] args) { Vector<Integer> pool=new Vector<Integer>(); Producer producer=new Producer(pool, 10); Consumer consumer=new Consumer(pool); new Thread(producer).start(); new Thread(consumer).start(); } } /** * 生产者 * @author admin */ class Producer implements Runnable{ private Vector<Integer> pool; private Integer size; public Producer(Vector<Integer> pool, Integer size) { this.pool = pool; this.size = size; } public void run() { for(;;){ try { System.out.println(" 生产一个商品 "); produce(1); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } private void produce(int i) throws InterruptedException{ while(pool.size()==size){ synchronized (pool) { System.out.println(" 生产者等待消费者消费商品, 当前商品数量为 "+pool.size()); pool.wait();// 等待消费者消费 } } synchronized (pool) { pool.add(i); pool.notifyAll();// 生产成功,通知消费者消费 } } } /** * 消费者 * @author admin */ class Consumer implements Runnable{ private Vector<Integer> pool; public Consumer(Vector<Integer> pool) { this.pool = pool; } public void run() { for(;;){ try { System.out.println(" 消费一个商品 "); consume(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } private void consume() throws InterruptedException{ while(pool.isEmpty()){ synchronized (pool) { System.out.println(" 消费者等待生产者生产商品, 当前商品数量为 "+pool.size()); pool.wait();// 等待生产者生产商品 } } synchronized (pool) { pool.remove(0); pool.notifyAll();// 通知生产者生产商品 } } }
wait/notify 的使用导致了较多的上下文切换
- 在多个不同消费场景中,可以使用 Object.notify() 替代 Object.notifyAll()。 因为 Object.notify() 只会唤醒指定线程,不会过早地唤醒其它未满足需求的阻塞线程,所以可以减少相应的上下文切换。
- 在生产者执行完 Object.notify() / notifyAll() 唤醒其它线程之后,应该尽快地释放内部锁,以避免其它线程在唤醒之后长时间地持有锁处理业务操作,这样可以避免被唤醒的线程再次申请相应内部锁的时候等待锁的释放。
- 最后,为了避免长时间等待,我们常会使用 Object.wait (long)设置等待超时时间,但线程无法区分其返回是由于等待超时还是被通知线程唤醒,从而导致线程再次尝试获取锁操作,增加了上下文切换。(建议使用 Lock 锁结合 Condition 接口替代 Synchronized 内部锁中的 wait / notify,实现等待/通知)
4.3 合理地设置线程池大小,避免创建过多线程
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换
在有些创建线程池的方法里,线程数量设置不会直接暴露给我们。比如,用 Executors.newCachedThreadPool() 创建的线程池,该线程池会复用其内部空闲的线程来处理新提交的任务,如果没有,再创建新的线程(不受 MAX_VALUE 限制),这样的线程池如果碰到大量且耗时长的任务场景,就会创建非常多的工作线程,从而导致频繁的上下文切换(只适合处理大量且耗时短的非阻塞任务)
4.4 使用协程实现非阻塞等待
- 协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升
4.5 减少 Java 虚拟机的垃圾回收
- 很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换
补充:思考题1:在JDK的Lock中,或者AQS中,线程“挂起”这个动作又是怎么实现的呢?为什么不会产生进程级别的上下文切换呢?
AQS挂起是通过LockSupport中的park进入阻塞状态,这个过程也是存在进程上下文切换的。但被阻塞的线程再次获取锁时,不会产生进程上下文切换,而synchronized阻塞的线程每次获取锁资源都要通过系统调用内核来完成,这样就比AQS阻塞的线程更消耗系统资源了
5、什么时候用多线程、为什么要设计多线程? 阿里
高并发
- 系统接受实现多用户多请求的高并发时, 通过多线程来实现。
线程后台处理大任务
- 一个程序是线性执行的。 如果程序执行到要花大量时间处理的任务时, 那主程序就得等待其执行完才能继续执行下面的。 那用户就不得不等待它执行完。这时候可以开线程把花大量时间处理的任务放在线程处理, 这样线程在后台处理时, 主程序也可以继续执行下去, 用户就不需要等待。 线程执行完后执行回调函数。
大任务
- 大任务处理起来比较耗时, 这时候可以起到多个线程并行加快处理( 例如: 分片上传) 。
好处: 可以提高 CPU 的利用率。 在多线程程序中, 一个线程必须等待的时候, CPU 可以运行其他的线程而不是等待, 这样就大大提高了程序的效率。 也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
6、多线程越多效率越高吗? 阿里
不是
- 当线程总数较少时, 线程越多, 效率越高。
- 当线程总数较多时, 由于线程本身调用耗时, 线程越多, 效率越低。
线程数越多会造成:
- 线程的生命周期开销非常高
- 消耗过多的 CPU 资源。
7、多线程会产生哪些并发问题 ?
安全性问题: 在单线程系统上正常运行的代码, 在多线程环境中可能会出现意料之外的结果。
活跃性问题: 不正确的加锁、 解锁方式可能会导致死锁 or 活锁问题。
性能问题: 多线程并发即多个线程切换运行, 线程切换会有一定的消耗并且不正确的加锁。