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的頻率可以有效的減小上下文切換
相關文章
相關標籤/搜索