Java性能 -- 线程上下文切换

线程数量

  1. 在并发程序中,并非启动更多的线程就能让程序最大限度地并发执行
  2. 线程数量设置过小,会致使程序不能充分地利用系统资源
  3. 线程数量设置太大,可能带来资源的过分竞争,致使上下文切换,带来的额外的系统开销

上下文切换

1.在单处理器时期,操做系统就能处理多线程并发任务,处理器给每一个线程分配CPU时间片,线程在CPU时间片内执行任务linux

  • CPU时间片是CPU分配给每一个线程执行的时间段,通常为几十毫秒

2.时间片决定了一个线程能够连续占用处理器运行的时长算法

  • 当一个线程的时间片用完,或者因自身缘由被迫暂停运行,此时另外一个线程会被操做系统选中来占用处理器
  • 上下文切换(Context Switch):一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程
  • 切出:一个线程被剥夺处理器的使用权而被暂停运行
  • 切入:一个线程被选中占用处理器开始运行或者继续运行
  • 切出切入的过程当中,操做系统须要保存和恢复相应的进度信息,这个进度信息就是上下文

3.上下文的内容编程

  • 寄存器的存储内容:CPU寄存器负责存储已经、正在和将要执行的任务
  • 程序计数器存储的指令内容:程序计数器负责存储CPU正在执行的指令位置、即将执行的下一条指令的位置

4.当CPU数量远远不止1个的状况下,操做系统将CPU轮流分配给线程任务,此时的上下文切换会变得更加频繁缓存

  • 而且存在跨CPU的上下文切换,更加昂贵

切换诱因

1.在操做系统中,上下文切换的类型能够分为进程间的上下文切换和线程间的上下文切换bash

2.线程状态:NEW、RUNNABLE、RUNNING、BLOCKED、DEAD多线程

  • Java线程状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED

3.线程上下文切换:RUNNING -> BLOCKED -> RUNNABLE -> 被调度器选中执行并发

  • 一个线程从RUNNING状态转为BLOCKED状态,称为一个线程的暂停
  • 线程暂停被切出后,操做系统会保存相应的上下文
  • 以便该线程再次进入RUNNABLE状态时可以在以前执行进度的基础上继续执行
  • 一个线程从BLOCKED状态进入RUNNABLE状态,称为一个线程的唤醒
  • 此时线程将获取上次保存的上下文继续执行

4.诱因:程序自己触发的自发性上下文切换、系统或虚拟机触发的非自发性上下文切换ide

  • 自发性上下文切换
  • sleep、wait、yield、join、park、synchronized、lock
  • 非自发性上下文切换
  • 线程被分配的时间片用完、JVM垃圾回收(STW、线程暂停)、线程执行优先级
Java性能之线程上下文切换究极解析

 

监控切换

样例代码高并发

public static void main(String[] args) {
new MultiThreadTesterAbstract().start();
new SerialThreadTesterAbstract().start();
// multi thread take 5401ms
// serial take 692ms
}
static abstract class AbstractTheadContextSwitchTester {
static final int COUNT = 100_000_000;
volatile int counter = 0;
void increaseCounter() {
counter++;
}
public abstract void start();
}
static class MultiThreadTesterAbstract extends AbstractTheadContextSwitchTester {
@Override
public void start() {
Stopwatch stopwatch = Stopwatch.createStarted();
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
while (counter < COUNT) {
synchronized (this) {
if (counter < COUNT) {
increaseCounter();
}
}
}
}
});
threads[i].start();
}
for (int i = 0; i < 4; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("multi thread take {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS));
}
}
static class SerialThreadTesterAbstract extends AbstractTheadContextSwitchTester {
@Override
public void start() {
Stopwatch stopwatch = Stopwatch.createStarted();
for (int i = 0; i < COUNT; i++) {
increaseCounter();
}
log.info("serial take {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS));
}
}

1.串行的执行速度比并发执行的速度要快,由于线程的上下文切换致使了额外的开销工具

  • 使用synchronized关键字,致使了资源竞争,从而引发了上下文切换
  • 即便不使用synchronized关键字,并发的执行速度也没法超越串行的执行速度,由于多线程一样存在上下文切换

2.Redis的设计很好地体现了单线程串行的优点

  • 从内存中快速读取值,不用考虑IO瓶颈带来的阻塞问题

 

监控工具

vmstat

cs:系统的上下文切换频率

root@5d15480e8112:/# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 693416 33588 951508 0 0 77 154 116 253 1 1 98 0 0

pidstat

-w Report task switching activity (kernels 2.6.23 and later only). The following values may be displayed:
UID
The real user identification number of the task being monitored.
USER
The name of the real user owning the task being monitored.
PID
The identification number of the task being monitored.
cswch/s
Total number of voluntary context switches the task made per second. A voluntary context switch occurs when a task blocks because it requires a
resource that is unavailable.
nvcswch/s
Total number of non voluntary context switches the task made per second. A involuntary context switch takes place when a task executes for the
duration of its time slice and then is forced to relinquish the processor.
Command
The command name of the task.
root@5d15480e8112:/# pidstat -w -l -p 1 2 5
Linux 4.9.184-linuxkit (5d15480e8112) 09/16/2019 _x86_64_ (2 CPU)
07:28:03 UID PID cswch/s nvcswch/s Command
07:28:05 0 1 0.00 0.00 /bin/bash
07:28:07 0 1 0.00 0.00 /bin/bash
07:28:09 0 1 0.00 0.00 /bin/bash
07:28:11 0 1 0.00 0.00 /bin/bash
07:28:13 0 1 0.00 0.00 /bin/bash
Average: 0 1 0.00 0.00 /bin/bash

切换的系统开销

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

竞争锁优化

  1. 多线程对锁资源的竞争会引发上下文切换,锁竞争致使的线程阻塞越多,上下文切换就越频繁,系统的性能开销就越大
  • 在多线程编程中,锁自己不是性能开销的根源,锁竞争才是性能开销的根源
  1. 锁优化归根究竟是减小竞争

减小锁的持有时间

  1. 锁的持有时间越长,意味着越多的线程在等待该竞争锁释放
  2. 若是是synchronized同步锁资源,不只带来了线程间的上下文切换,还有可能会带来进程间的上下文切换
  3. 优化方法:将一些与锁无关的代码移出同步代码块,尤为是那些开销较大的操做以及可能被阻塞的操做

减小锁粒度

锁分离

  1. 读写锁实现了锁分离,由读锁和写锁两个锁实现,能够共享读,但只有一个写
  • 读写锁在多线程读写时,读读不互斥,读写互斥,写写互斥
  • 传统的独占锁在多线程读写时,读读互斥,读写互斥,写写互斥
  1. 在读远大于写的多线程场景中,锁分离避免了高并发读状况下的资源竞争,从而避免了上下文切换

锁分段

  1. 在使用锁来保证集合或者大对象的原子性时,能够将锁对象进一步分解
  2. Java 1.8以前的ConcurrentHashMap就是用了锁分段

非阻塞乐观锁代替竞争锁

  1. volatile
  • volatile关键字的做用是保证可见性和有序性,volatile的读写操做不会致使上下文切换,开销较小
  • 因为volatile关键字没有锁的排它性,所以不能保证操做变量的原子性
  1. CAS
  • CAS是一个原子的if-then-act操做
  • CAS是一个无锁算法实现,保障了对一个共享变量读写操做的一致性
  • CAS不会致使上下文切换,Java的Atomic包就使用了CAS算法来更新数据,而不须要额外加锁

synchronized锁优化

  1. 在JDK 1.6中,JVM将synchronized同步锁分为偏向锁、轻量级锁、自旋锁、重量级锁
  2. JIT编译器在动态编译同步代码块时,也会经过锁消除、锁粗化的方式来优化synchronized同步锁

wait/notify优化

能够经过Object对象的wait、notify、notifyAll来实现线程间的通讯,例如生产者-消费者模型

public class WaitNotifyTest {
public static void main(String[] args) {
Vector<Integer> pool = new Vector<>();
Producer producer = new Producer(pool, 10);
Consumer consumer = new Consumer(pool);
new Thread(producer).start();
new Thread(consumer).start();
}
}
@AllArgsConstructor
class Producer implements Runnable {
private final Vector<Integer> pool;
private Integer size;
@Override
public void run() {
for (; ; ) {
try {
produce((int) System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void produce(int i) throws InterruptedException {
while (pool.size() == size) {
synchronized (pool) {
pool.wait();
}
}
synchronized (pool) {
pool.add(i);
pool.notifyAll();
}
}
}
@AllArgsConstructor
class Consumer implements Runnable {
private final Vector<Integer> pool;
@Override
public void run() {
for (; ; ) {
try {
consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void consume() throws InterruptedException {
synchronized (pool) {
while (pool.isEmpty()) {
pool.wait();
}
}
synchronized (pool) {
pool.remove(0);
pool.notifyAll();
}
}
}

1.wait/notify的使用致使了较多的上下文切换

2.消费者第一次申请到锁,却发现没有内容可消费,执行wait,这会致使线程挂起,进入阻塞状态,这是一次上下文切换

3.当生产者得到锁并执行notifyAll以后,会唤醒处于阻塞状态的消费者线程,又会发生一次上下文切换

4.被唤醒的线程在继续运行时,须要再次申请相应对象的内部锁,此时可能须要与其余新来的活跃线程竞争,致使上下文切换

5.若是多个消费者线程同时被阻塞,用notifyAll将唤醒全部阻塞线程,但此时依然没有内容可消费

  • 所以过早地唤醒,也可能致使线程再次进入阻塞状态,从而引发没必要要的上下文切换

6.优化方法

  • 能够考虑使用notify代替notifyAll,减小上下文切换
  • 生产者执行完notify/notifyAll以后,尽快释放内部锁,避免被唤醒的线程再次等待该内部锁
  • 为了不长时间等待,使用wait(long),但线程没法区分其返回是因为等待超时仍是被通知线程唤醒,增长上下文切换
  • 建议使用Lock+Condition代替synchronized+wait/notify/notifyAll,来实现等待通知

合理的线程池大小

  1. 线程池的线程数量不宜过大
  2. 一旦线程池的工做线程总数超过系统所拥有的处理器数量,就会致使过多的上下文切换

协程:非阻塞等待

  1. 协程比线程更加轻量,相比于由操做系统内核管理的进程和线程,协程彻底由程序自己所控制,即在用户态执行
  2. 协程避免了像线程切换那样产生的上下文切换,在性能方面获得了很大的提高

减小GC频率

  1. GC会致使上下文切换
  2. 不少垃圾回收器在回收旧对象时会产生内存碎片,从而须要进行内存整理,该过程须要移动存活的对象
  • 而移动存活的对象意味着这些对象的内存地址会发生改变,所以在移动对象以前须要暂停线程,完成后再唤醒线程
  1. 所以减小GC的频率可以有效的减小上下文切换
相关文章
相关标签/搜索