乐趣区

关于后端:谈谈多线程的上线文切换

大家好,我是易安!

咱们晓得,在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。线程数量设置太小,会导致程序不能充沛地利用系统资源;线程数量设置太大,又可能带来资源的适度竞争,导致上下文切换带来额定的零碎开销,明天咱们就来谈下线程的上线文切换。

什么是上下文切换

在单个处理器的期间,操作系统就能解决多线程并发工作。处理器给每个线程调配 CPU 工夫片(Time Slice),线程在调配取得的工夫片内执行工作。

CPU 工夫片是 CPU 调配给每个线程执行的时间段,个别为几十毫秒。在这么短的工夫内线程相互切换,咱们基本感觉不到,所以看上去就如同是同时进行的一样。

工夫片决定了一个线程能够间断占用处理器运行的时长。当一个线程的工夫片用完了,或者因本身起因被迫暂停运行了,这个时候,另外一个线程(能够是同一个线程或者其它过程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者持续运行的过程就叫做上下文切换(Context Switch)。

具体来说,一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者持续运行,就是“切入”。在这种切出切入的过程中,操作系统须要保留和复原相应的进度信息,这个进度信息就是“上下文”了。

那上下文都包含哪些内容呢?具体来说,它包含了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储曾经、正在和将要执行的工作,程序计数器负责存储 CPU 正在执行的指令地位以及行将执行的下一条指令的地位。

在以后 CPU 数量远远不止一个的状况下,操作系统将 CPU 轮流调配给线程工作,此时的上下文切换就变得更加频繁了,并且存在跨 CPU 上下文切换,比起单核上下文切换,跨核切换更加低廉。

上下文切换的诱因

在操作系统中,上下文切换的类型还能够分为过程间的上下文切换和线程间的上下文切换。而在多线程编程中,咱们次要面对的就是线程间的上下文切换导致的性能问题,上面咱们就重点看看到底是什么起因导致了多线程的上下文切换。开始之前,先看下零碎线程的生命周期状态。

联合图示可知,线程次要有“新建”(NEW)、“就绪”(RUNNABLE)、“运行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五种状态。到了 Java 层面它们都被映射为了 NEW、RUNABLE、BLOCKED、WAITING、TIMED\_WAITING、TERMINADTED 等 6 种状态。

在这个运行过程中,线程由 RUNNABLE 转为非 RUNNABLE 的过程就是线程上下文切换。

一个线程的状态由 RUNNING 转为 BLOCKED,再由 BLOCKED 转为 RUNNABLE,而后再被调度器选中执行,这就是一个上下文切换的过程。

当一个线程从 RUNNING 状态转为 BLOCKED 状态时,咱们称为一个线程的暂停,线程暂停被切出之后,操作系统会保留相应的上下文,以便这个线程稍后再次进入 RUNNABLE 状态时可能在之前执行进度的根底上继续执行。

当一个线程从 BLOCKED 状态进入到 RUNNABLE 状态时,咱们称为一个线程的唤醒,此时线程将获取上次保留的上下文持续实现执行。

通过线程的运行状态以及状态间的互相切换,咱们能够理解到,多线程的上下文切换实际上就是由多线程两个运行状态的相互切换导致的。

那么在线程运行时,线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什么诱发的呢?

咱们能够分两种状况来剖析,一种是程序自身触发的切换,这种咱们称为自发性上下文切换,另一种是由零碎或者虚拟机诱发的非自发性上下文切换。

自发性上下文切换 指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下办法或关键字,经常就会引发自发性上下文切换。

  • sleep()
  • wait()
  • yield()
  • join()
  • park()
  • synchronized
  • lock

非自发性上下文切换 指线程因为调度器的起因被迫切出。常见的有:线程被调配的工夫片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。

这里重点说下“虚拟机垃圾回收为什么会导致上下文切换”。在 Java 虚拟机中,对象的内存都是由虚拟机中的堆调配的,在程序运行过程中,新的对象将一直被创立,如果旧的对象应用后不进行回收,堆内存将很快被耗尽。Java 虚拟机提供了一种回收机制,对创立后不再应用的对象进行回收,从而保障堆内存的可持续性调配。而这种垃圾回收机制的应用有可能会导致 stop-the-world 事件的产生,这其实就是一种线程暂停行为。

发现上下文切换

咱们总说上下文切换会带来零碎开销,那它带来的性能问题是不是真有这么蹩脚呢?咱们又该怎么去监测到上下文切换?上下文切换到底开销在哪些环节?接下来我将给出一段代码,来比照串联执行和并发执行的速度,而后一一解答这些问题。

public class DemoApplication {public static void main(String[] args) {
              // 运行多线程
              MultiThreadTester test1 = new MultiThreadTester();
              test1.Start();
              // 运行单线程
              SerialTester test2 = new SerialTester();
              test2.Start();}


       static class MultiThreadTester extends ThreadContextSwitchTester {
              @Override
              public void Start() {long start = System.currentTimeMillis();
                     MyRunnable myRunnable1 = new MyRunnable();
                     Thread[] threads = new Thread[4];
                     // 创立多个线程
                     for (int i = 0; i < 4; i++) {threads[i] = new Thread(myRunnable1);
                           threads[i].start();}
                     for (int i = 0; i < 4; i++) {
                           try {
                                  // 期待一起运行完
                                  threads[i].join();} catch (InterruptedException e) {
                                  // TODO Auto-generated catch block
                                  e.printStackTrace();}
                     }
                     long end = System.currentTimeMillis();
                     System.out.println("multi thread exce time:" + (end - start) + "s");
                     System.out.println("counter:" + counter);
              }
              // 创立一个实现 Runnable 的类
              class MyRunnable implements Runnable {public void run() {while (counter < 100000000) {synchronized (this) {if(counter < 100000000) {increaseCounter();
                                         }

                                  }
                           }
                     }
              }
       }

      // 创立一个单线程
       static class SerialTester extends ThreadContextSwitchTester{
              @Override
              public void Start() {long start = System.currentTimeMillis();
                     for (long i = 0; i < count; i++) {increaseCounter();
                     }
                     long end = System.currentTimeMillis();
                     System.out.println("serial exec time:" + (end - start) + "s");
                     System.out.println("counter:" + counter);
              }
       }

       // 父类
       static abstract class ThreadContextSwitchTester {
              public static final int count = 100000000;
              public volatile int counter = 0;
              public int getCount() {return this.counter;}
              public void increaseCounter() {this.counter += 1;}
              public abstract void Start();}
}

执行之后,看一下两者的工夫测试后果:

通过数据比照咱们能够看到: 串联的执行速度比并发的执行速度要快。这就是因为线程的上下文切换导致了额定的开销,应用 Synchronized 锁关键字,导致了资源竞争,从而引起了上下文切换,但即便不应用 Synchronized 锁关键字,并发的执行速度也无奈超过串联的执行速度,这是因为多线程同样存在着上下文切换。Redis、NodeJS 的设计就很好地体现了单线程串行的劣势。

在 Linux 零碎下,能够应用 Linux 内核提供的 vmstat 命令,来监督 Java 程序运行过程中零碎的上下文切换频率,cs 如下图所示:

如果是监督某个利用的上下文切换,就能够应用 pidstat 命令监控指定过程的 Context Switch 上下文切换。

因为 Windows 没有像 vmstat 这样的工具,在 Windows 下,咱们能够应用 Process Explorer,来查看程序执行时,线程间上下文切换的次数。

至于零碎开销具体产生在切换过程中的哪些具体环节,总结如下:

  • 操作系统保留和复原上下文;
  • 调度器进行线程调度;
  • 处理器高速缓存从新加载;
  • 上下文切换也可能导致整个高速缓存区被冲刷,从而带来工夫开销。

如果是单个线程,在 CPU 调用之后,那么它基本上是不会被调度进来的。如果可运行的线程数远大于 CPU 数量,那么操作系统最终会将某个正在运行的线程调度进去,从而使其它线程可能应用 CPU,这就会导致上下文切换。

还有,在多线程中如果应用了竞争锁,当线程因为期待竞争锁而被阻塞时,JVM 通常会将这个线程挂起,并容许它被替换进来。如果频繁地产生阻塞,CPU 密集型的程序就会产生更多的上下文切换。

那么问题来了,咱们晓得在某些场景下应用多线程是十分必要的,但多线程编程给零碎带来了上下文切换,从而减少的性能开销也是实打实存在的。那么咱们该如何优化多线程上下文切换呢?

竞争锁优化

大多数人在多线程编程中碰到性能问题,第一反馈多是想到了锁。

多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,零碎的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的本源,竞争锁才是。

上面咱们谈一下锁优化的一些思路:

1. 缩小锁的持有工夫

咱们晓得,锁的持有工夫越长,就意味着有越多的线程在期待该竞争资源开释。如果是 Synchronized 同步锁资源,就不仅是带来线程间的上下文切换,还有可能会减少过程间的上下文切换。

能够将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。

  • 优化前
public synchronized void mySyncMethod(){businesscode1();
        mutextMethod();
        businesscode2();}
  • 优化后
public void mySyncMethod(){businesscode1();
        synchronized(this)
        {mutextMethod();
        }
        businesscode2();}

2. 升高锁的粒度

同步锁能够保障对象的原子性,咱们能够思考将锁粒度拆分得更小一些,以此防止所有线程对一个锁资源的竞争过于强烈。具体形式有以下两种:

  • 锁拆散

与传统锁不同的是,读写锁实现了锁拆散,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规定是能够共享读,但只有一个写。

这样做的益处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。而传统的独占锁在没有辨别读写锁的时候,读写操作个别是:读读互斥、读写互斥、写写互斥。所以在读远大于写的多线程场景中,锁拆散防止了在高并发读状况下的资源竞争,从而防止了上下文切换。

  • 锁分段

咱们在应用锁来保障汇合或者大对象原子性时,能够思考将锁对象进一步合成。例如,Java1.8 之前版本的 ConcurrentHashMap 就应用了锁分段。

3. 非阻塞乐观锁代替竞争锁

volatile 关键字的作用是保障可见性及有序性,volatile 的读写操作不会导致上下文切换,因而开销比拟小。然而,volatile 不能保障操作变量的原子性,因为没有锁的排他性。

而 CAS 是一个原子的 if-then-act 操作,CAS 是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。CAS 操作中有 3 个操作数,内存值 V、旧的预期值 A 和要批改的新值 B,当且仅当 A 和 V 雷同时,将 V 批改为 B,否则什么都不做,CAS 算法将不会导致上下文切换。Java 的 Atomic 包就应用了 CAS 算法来更新数据,就不须要额定加锁。

下面咱们理解了如何从编码层面去优化竞争锁,那么除此之外,JVM 外部其实也对 Synchronized 同步锁做了优化。

在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏差锁、轻量级锁、自旋锁以及重量级锁,优化门路也是依照以上程序进行。JIT 编译器在动静编译同步块的时候,也会通过锁打消、锁粗化的形式来优化该同步锁。

wait/notify 优化

在 Java 中,咱们能够通过配合调用 Object 对象的 wait()办法和 notify()办法或 notifyAll() 办法来实现线程间的通信。

在线程中调用 wait()办法,将阻塞期待其它线程的告诉(其它线程调用 notify()办法或 notifyAll()办法),在线程中调用 notify()办法或 notifyAll()办法,将告诉其它线程从 wait()办法处返回。

上面咱们通过 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{synchronized (pool) {while(pool.isEmpty()) {System.out.println("消费者期待生产者生产商品, 以后商品数量为"+pool.size());
                    pool.wait();// 期待生产者生产商品}
            }
            synchronized (pool) {pool.remove(0);
                pool.notifyAll();// 告诉生产者生产商品}
        }

}

wait/notify 的应用导致了较多的上下文切换

联合以下图片,咱们能够看到,在消费者第一次申请到锁之前,发现没有商品生产,此时会执行 Object.wait() 办法,这里会导致线程挂起,进入阻塞状态,这里为一次上下文切换。

当生产者获取到锁并执行 notifyAll()之后,会唤醒处于阻塞状态的消费者线程,此时这里又产生了一次上下文切换。

被唤醒的期待线程在持续运行时,须要再次申请相应对象的外部锁,此时期待线程可能须要和其它新来的沉闷线程争用外部锁,这也可能会导致上下文切换。

如果有多个消费者线程同时被阻塞,用 notifyAll()办法,将会唤醒所有阻塞的线程。而某些商品仍然没有库存,过早地唤醒这些没有库存的商品的生产线程,可能会导致线程再次进入阻塞状态,从而引起不必要的上下文切换。

优化 wait/notify 的应用,缩小上下文切换

首先,咱们在多个不同生产场景中,能够应用 Object.notify() 代替 Object.notifyAll()。因为 Object.notify() 只会唤醒指定线程,不会过早地唤醒其它未满足需要的阻塞线程,所以能够缩小相应的上下文切换。

其次,在生产者执行完 Object.notify() / notifyAll()唤醒其它线程之后,应该尽快地开释外部锁,以防止其它线程在唤醒之后长时间地持有锁解决业务操作,这样能够防止被唤醒的线程再次申请相应外部锁的时候期待锁的开释。

最初,为了防止长时间期待,咱们常会应用 Object.wait (long)设置期待超时工夫,但线程无奈辨别其返回是因为期待超时还是被告诉线程唤醒,从而导致线程再次尝试获取锁操作,减少了上下文切换。

这里我倡议应用 Lock 锁联合 Condition 接口代替 Synchronized 外部锁中的 wait / notify,实现期待/告诉。这样做不仅能够解决上述的 Object.wait(long) 无奈辨别的问题,还能够解决线程被过早唤醒的问题。

Condition 接口定义的 await 办法、signal 办法和 signalAll 办法别离相当于 Object.wait()、Object.notify()和 Object.notifyAll()。

正当地设置线程池大小,防止创立过多线程

线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过零碎所领有的处理器数量,就会导致过多的上下文切换。

还有一种状况就是,在有些创立线程池的办法里,线程数量设置不会间接裸露给咱们。比方,用 Executors.newCachedThreadPool() 创立的线程池,该线程池会复用其外部闲暇的线程来解决新提交的工作,如果没有,再创立新的线程(不受 MAX\_VALUE 限度),这样的线程池如果碰到大量且耗时长的工作场景,就会创立十分多的工作线程,从而导致频繁的上下文切换。因而,这类线程池就只适宜解决大量且耗时短的非阻塞工作。

应用协程实现非阻塞期待

置信很多人一听到协程(Coroutines),马上想到的就是 Go 语言。协程对于大部分 Java 程序员来说可能还有点生疏,但其在 Go 中的应用相对来说曾经很成熟了。

协程是一种比线程更加轻量级的货色,相比于由操作系统内核来治理的过程和线程,协程则齐全由程序自身所管制,也就是在用户态执行。协程防止了像线程切换那样产生的上下文切换,在性能方面失去了很大的晋升。

缩小 Java 虚拟机的垃圾回收

很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片,从而须要进行内存整理,在这个过程中就须要挪动存活的对象。而挪动内存对象就意味着这些对象所在的内存地址会发生变化,因而在挪动对象前须要暂停线程,在挪动实现后须要再次唤醒该线程。因而缩小 JVM 垃圾回收的频率能够无效地缩小上下文切换。

总结

上下文切换是多线程编程性能耗费的起因之一,而竞争锁、线程间的通信以及过多地创立线程等多线程编程操作,都会给零碎带来上下文切换。除此之外,I/ O 阻塞以及 JVM 的垃圾回收也会减少上下文切换。零碎和 Java 程序自发性以及非自发性的调用操作,就会导致上下文切换,从而带来零碎开销。

线程越多,零碎的运行速度不肯定越快。那么咱们平时在并发量比拟大的状况下,什么时候用单线程,什么时候用多线程呢?

个别在单个逻辑比较简单,而且速度绝对来十分快的状况下,咱们能够应用单线程。例如 Redis,从内存中疾速读取值,不必思考 I/O 瓶颈带来的阻塞问题。而在逻辑相对来说很简单的场景,等待时间绝对较长又或者是须要大量计算的场景,我倡议应用多线程来进步零碎的整体性能。例如,NIO 期间的文件读写操作、图像处理以及大数据分析等。

本文由 mdnice 多平台公布

退出移动版