并发编程的目的是为了让程序运行得更快,但并不是启动更多的线程就能让程序最大限度地并发执行,在进行并发编程时,会面临非常多的挑战,比如上下文切换的问题、死锁的问题,本文将介绍多线程中上下文切换带来的性能问题。
上下文切换简介
处理器给每个线程分配 CPU 时间片,线程在分配获得的时间片内执行任务,CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为几十毫秒。
当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
在上下文切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。
多线程并行一定比串行快吗?
我们常常听说要提升系统性能要使用多线程,那么多线程是不是万能钥匙呢? 下面我们给出一段代码,来进行累加求和操作,对比串联执行和并发执行的速度。
代码
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 锁关键字,导致了资源竞争,从而引起了上下文切换。
Redis 、 NodeJS的设计就很好地体现了单线程串行的优势。
在 Linux 系统下,可以使用 Linux 内核提供的 **vmstat **命令,来监视 Java 程序运行过程中系统的上下文切换频率,cs 如下图所示:
线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?
一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,我们前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。
在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,建议使用多线程来提高系统的整体性能。例如,NIO 时期的文件读写操作、图像处理以及大数据分析等。
如何优化多线程上下文切换?
多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,归根结底就是减少锁的竞争,可以采取以下几种方式:
减少锁的持有时间
锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。如果是 Synchronized 同步锁资源,就不仅是带来线程间的上下文切换,还有可能会增加进程间的上下文切换。
可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。
降低锁的粒度
同步锁可以保证对象的原子性,我们可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。
具体方式有以下两种:
-
一是锁分离,与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。而传统的独占锁在没有区分读写锁的时候,不管读写都互斥。所以在读远大于写的多线程场景中,锁分离避免了在高并发读情况下的资源竞争,从而避免了上下文切换,就比如ReentrantReadWriteLock
-
二是锁分段我们在使用锁来保证集合或者大对象原子性时,可以考虑将锁对象进一步分解。例如: Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段。
非阻塞乐观锁CAS替代竞争锁
Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
协程
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
PS: Java语言层面不支持,需要引入第三方框架实现
wait/notify 优化
在 Java 中,我们可以通过配合调用 Object 对象的 wait() 方法和 notify() 方法或 notifyAll() 方法来实现线程间的通信。
建议使用Lock 锁结合 Condition 接口替代 Synchronized 内部锁中的 wait / notify,实现等待/通知。这样做不仅可以解决上述的 Object.wait(long) 无法区分的问题,还可以解决线程被过早唤醒的问题。
合理地设置线程池大小,避免创建过多线程
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。
总结
上下文切换是多线程编程性能消耗的原因之一,而竞争锁、线程间的通信以及过多地创建线程等多线程编程操作,都会给系统带来上下文切换
除此之外,I/O 阻塞以及 JVM 的垃圾回收也会增加上下文切换。总的来说,过于频繁的上下文切换会影响系统的性能,所以我们应该避免它。
好了,以上就是今天分享的全部内容,enjoy ~ 欢迎吐槽、交流 微信公众号【AI黑板报】