多线程调优实战

摘要:在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。线程数量设置太小,会导致程序不能充分地利用系统资源;线程数量设置太大,又可能带来资源的过度竞争,导致上下文切换带来额外的系统开销。本文就多线程中的上下文切换中的性能问题进行了探讨。 参考资料:刘超的《Java性能调优实战》

1、上下文切换是什么?

概念: 当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,这个时候,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)

相关概念 详情
“切出” 一个线程被剥夺处理器的使用权而被暂停运行
“切入” 一个线程被选中占用处理器开始或者继续运行
“上下文” 在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了,(它包括了寄存器的存储内容以及程序计数器存储的指令内容

2、多线程上下文切换原因?

2.1 Java线程的生命周期状态

在这里插入图片描述
线程主要有“新建”(NEW)、“就绪”(RUNNABLE)、“运行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五种状态。线程由 RUNNABLE 转为非 RUNNABLE 的过程就是线程上下文切换

  • 线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什么诱发的呢?
原因 详情
一种是程序本身触发的切换,这种我们称为自发性上下文切换 自发性上下文切换指线程由 Java 程序调用导致切出。在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。sleep() 、wait()、yield()、join()、park()、synchronized、lock
另一种是由系统或者虚拟机诱发的非自发性上下文切换 非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致
  • 虚拟机垃圾回收为什么会导致上下文切换?

在Java虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运行过程中,新的对象将不断被创建,如果旧的对象使用后不进行回收,堆内存将很快被耗尽。Java 虚拟机提供了一种回收机制,对创建后不再使用的对象进行回收,从而保证堆内存的可持续性分配。而这种垃圾回收机制的使用有可能会导致 stop-the-world 事件的发生,这其实就是一种线程暂停行为。

2.2、系统开销发生在切换过程中的哪些具体环节

  1. 操作系统保存和恢复上下文;
  2. 调度器进行线程调度;
  3. 处理器高速缓存重新加载;
  4. 上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。

3、总结

上下文切换就是一个工作的线程被另外一个线程暂停,另外一个线程占用了处理器开始执行任务的过程。系统和 Java 程序自发性以及非自发性的调用操作,就会导致上下文切换,从而带来系统开销。

  • 线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?

    一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,我们前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。而在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,我建议使用多线程来提高系统的整体性能。例如,NIO 时期的文件读写操作、图像处理以及大数据分析等。

4、如何优化多线程上下文切换?

  • 在某些场景下使用多线程是非常必要的,但多线程编程给系统带来了上下文切换,从而增加的性能开销也是实打实存在的。那么该如何优化多线程上下文切换呢?

4.1 竞争锁优化

方法 详情
1. 减少锁的持有时间 可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作
2. 降低锁的粒度 可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:锁分离(读写锁实现了锁分离,读读不互斥)和锁分段( Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段)
3. 非阻塞乐观锁替代竞争锁 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阻塞的线程更消耗系统资源了

发布了26 篇原创文章 · 获赞 18 · 访问量 9736

猜你喜欢

转载自blog.csdn.net/qq_28959087/article/details/102169680
今日推荐