Day791.多线程上下文切换优化方案 -Java 性能调优实战
Posted 阿昌喜欢吃黄桃
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day791.多线程上下文切换优化方案 -Java 性能调优实战相关的知识,希望对你有一定的参考价值。
多线程上下文切换优化方案
Hi,我是阿昌
,今天学习记录的是关于多线程上下文切换优化方案
。
如果是单个线程,在 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();//通知生产者生产商品
1、wait/notify 的使用导致了较多的上下文切换
结合以下图片,可以看到,在消费者第一次申请到锁之前,发现没有商品消费,此时会执行 Object.wait() 方法,这里会导致线程挂起,进入阻塞状态,这里为一次上下文切换。
当生产者获取到锁并执行 notifyAll() 之后,会唤醒处于阻塞状态的消费者线程,此时这里又发生了一次上下文切换。
被唤醒的等待线程在继续运行时,需要再次申请相应对象的内部锁,此时等待线程可能需要和其它新来的活跃线程争用内部锁,这也可能会导致上下文切换。
如果有多个消费者线程同时被阻塞,用 notifyAll() 方法,将会唤醒所有阻塞的线程。而某些商品依然没有库存,过早地唤醒这些没有库存的商品的消费线程,可能会导致线程再次进入阻塞状态,从而引起不必要的上下文切换。
2、优化 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 的垃圾回收也会增加上下文切换。
总的来说,过于频繁的上下文切换会影响系统的性能,所以应该避免它。
另外,还可以将上下文切换
也作为系统的性能参考指标
,并将该指标纳入到服务性能监控,防患于未然。
除了总结中提到的线程间上下文切换的一些诱因,还知道其它诱因吗?对应的优化方法又是什么?
首先,所有的锁,无论synchronize还是lock,如果发生竞争条件,都可能造成上下文切换,优化锁的目的是为了尽量降低发生锁竞争的概率
。
-
synchronize做的优化都是把竞争的可能消灭在前期的偏向锁,轻量级锁,把会造成上下文切换的“脏活”留在最后。
-
lock的乐观锁大体思路也是一样的,不到万不得已,不会轻易调用park方法。但是本质上java目前都是利用内核线程,所以都会有上下文切换。
volitile的读写不会导致上下文切换,操作系统层面怎么理解呢
volatile
主要是用来保证共享变量额可见性,以及防止指令重排序,保证执行的有序性。
通过生成.class文件之后,反编译文件可以看到通过volatile修饰的共享变量,在写入操作的时候会多一个Lock前缀这样的指令,当操作系统执行时会由于这个指令,将当前处理器缓存的数据写回系统内存中,并通知其他处理器中的缓存失效。
所以volatile不会带来线程的挂起操作,不会导致上下文切换。
既然用了vector,为什么还要用synchronize锁起来啊,vector本身不就是线程安全的?
这里的vector是一个对象锁,锁的是一个代码块,并不是保证vector的线程安全。
以上是关于Day791.多线程上下文切换优化方案 -Java 性能调优实战的主要内容,如果未能解决你的问题,请参考以下文章