簡單聊一聊公平鎖和非公平鎖,Parallel并行流
大家好,我是哪吒。
上一章提到了一個關于 i++ 和 ++i 的面試題打趴了所有人,最終方案是在兩個方法上添加synchronized關鍵字,從而避免i++的線程安全問題,不過,這樣真的好嗎?在所有有線程安全的方法都添加synchronized?
答案是顯而易見的,不行。
synchronized會極大的降低程序的性能,導致整個程序幾乎只能支持單線程操作,性能顯著降低。
那么,如何解決呢?
一、降低鎖的粒度,將synchronized關鍵字不放在方法上了,改為synchronized代碼塊。
鎖的粒度更小了,也解決了這個問題,確實可以的。
package com.guor.thread;
public class SynchronizedTest2 {
int a = 1;
int b = 1;
public void add() {
System.out.println("add start");
synchronized (this) {
for (int i = 0; i < 10000; i++) {
a++;
b++;
}
}
System.out.println("add end");
}
public synchronized void compare() {
System.out.println("compare start");
synchronized (this) {
for (int i = 0; i < 10000; i++) {
boolean flag = a < b;
if (flag) {
System.out.println("a=" + a + ",b=" + b + "flag=" + flag + ",a < b = " + (a < b));
}
}
}
System.out.println("compare end");
}
public static void main(String[] args) {
SynchronizedTest2 synchronizedTest = new SynchronizedTest2();
new Thread(() -> synchronizedTest.add()).start();
new Thread(() -> synchronizedTest.compare()).start();
}
}
為了更好的優化,有的時候可以將synchronized代碼塊變為區分讀寫場景的讀寫鎖,也可以考慮悲觀鎖和樂觀鎖的區分。
對于讀寫場景比較多的情況,可以使用ReentrantReadWriteLock區分讀寫,再次降低鎖的粒度,提高程序的性能。
ReentrantReadWriteLock 還可以選擇提供了公平鎖,在沒有明確必須使用公平鎖的情況下,盡量不要使用公平鎖,公平鎖會使程序性能降低很多很多。
二、先區分一下公平鎖和非公平鎖
- 公平鎖:多個線程按照申請鎖的順序去獲得鎖,線程會直接進入隊列去排隊,永遠都是隊列的第一個得到鎖。
- 非公平鎖:多個線程去獲取鎖的時候,會直接去嘗試獲取,獲取不到,進入等待隊列,如果能獲取到,就直接獲取到鎖。
簡單來說,公平鎖(誰先排隊,誰先執行),非公平鎖(不用排隊,每個人都有機會)。
1、公平鎖
有一天早上,云韻、美杜莎、小醫仙結伴去買醬香拿鐵,到了咖啡店,先排隊,一個一個來。不一會,哪吒來了,也買醬香拿鐵,只能在末尾排隊。這個就是公平鎖。
2、非公平鎖
但是呢?第二天早上,哪吒又去買醬香拿鐵,上一次去晚了沒買到(線程被餓死了),這次急了,要插隊買,不講武德。終于喝上了心心念念的醬香拿鐵,這個就是非公平鎖。
3、公平鎖的優缺點:
- 優點:所有線程都會獲取到鎖,只是一個時間的問題,不會出現有線程被餓死的情況;
- 缺點:吞吐量會下降很多,隊列里只有第一個線程能獲取到鎖,其他的線程都會阻塞,cpu喚醒阻塞線程的開銷會增大。
4、非公平鎖的優缺點:
- 優點:可以減少CPU喚醒線程的開銷,整體的吞吐效率會高點,CPU也不必取喚醒所有線程,會減少喚起線程的數量。
- 缺點:如果運氣不好,會出現一致獲取不到鎖的情況,會被活活的餓死。
三、是否對癥下藥
我們都知道,靜態字段屬于類,類級別的鎖才能保護;非靜態字段屬于類實例,實例級別的鎖才能保護。
先看一下下面的代碼:
import lombok.Data;
import java.util.stream.IntStream;
@Data
public class LockTest {
public static void main(String[] args) {
IntStream.rangeClosed(1, 100000).parallel().forEach(i -> new LockTest().increase());
System.out.println(time);
}
private static int time = 0;
public synchronized void increase() {
time++;
}
}
在LockTest類中定義一個靜態變量time,定義一個非靜態方法increase(),實現time++自增。先累加10萬次,測試一下。看看是否有線程安全的問題。
這...不對啊,上一節在介紹高并發下i++線程安全問題的時候,synchronized 是好使的啊。
今天這是怎么了?再運行一次,結果依然如此,不等于100000
先來分析一下。
在非靜態的方法上加synchronized,只能確保多個線程無法執行同一個實例的increase()方法,卻不能保證不同實例的increase()方法。靜態的變量time,在多個線程中共享,所以會出現線程安全的問題,synchronized失效了。
那么,將synchronized改為靜態方法是不是就可以了,試一下。
有兩種寫法,一種是直接將方法改為靜態方法,一種是使用synchronized代碼塊。
private static Object obj= new Object();
public void increase() {
synchronized (obj) {
time++;
}
}
四、IntStream.rangeClosed是干嘛的?
很多小伙伴,可能會好奇,這個是干什么的,干了5年后端代碼開發了,沒見過這玩意兒。
IntStream是一種特殊的stream,用來提供對int相關的stream操作。
IntStream.rangeClosed:生成某個數字范圍內的數字集合的stream。
比如上面代碼中的IntStream.rangeClosed(1, 100000).parallel().forEach(i -> new LockTest().increase());。
- range:不包含10000
- rangeClosed:包含10000
五、parallel是干嘛的?
1、parallel()是什么
Stream.parallel() 方法是 Java 8 中 Stream API 提供的一種并行處理方式。在處理大量數據或者耗時操作時,使用 Stream.parallel() 方法可以充分利用多核 CPU 的優勢,提高程序的性能。
Stream.parallel() 方法是將串行流轉化為并行流的方法。通過該方法可以將大量數據劃分為多個子任務交由多個線程并行處理,最終將各個子任務的計算結果合并得到最終結果。使用 Stream.parallel() 可以簡化多線程編程,減少開發難度。
需要注意的是,并行處理可能會引入線程安全等問題,需要根據具體情況進行選擇。
2、舉一個簡單的demo
定義一個list,然后通過parallel() 方法將集合轉化為并行流,對每個元素進行i++,最后通過 collect(Collectors.toList()) 方法將結果轉化為 List 集合。
使用并行處理可以充分利用多核 CPU 的優勢,加快處理速度。
public class StreamTest {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(i);
}
System.out.println(list);
List<Integer> result = list.stream().parallel().map(i -> i++).collect(Collectors.toList());
System.out.println(result);
}
}
我勒個去,什么情況?
這是大部分開發人員都會犯的小錯誤,在上篇中提到過,i++ 返回原來的值,++i 返回加1后的值。這誰都知道,可是,寫的時候,就不一定了,因為你習慣了i++,寫順手了,寫的時候也是心不在焉,一蹴而就了。
i++改了++i即可。
3、parallel()的優缺點
優點:
- 充分利用多核 CPU 的優勢,提高程序的性能;
- 可以簡化多線程編程,減少開發難度。
缺點:
- 并行處理可能會引入線程安全等問題,需要根據具體情況進行選擇;
- 并行處理需要付出額外的開銷,例如線程池的創建和銷毀、線程切換等,對于小數據量和簡單計算而言,串行處理可能更快。
4、何時使用parallel()?
在實際開發中,應該根據數據量、計算復雜度、硬件等因素綜合考慮。
比如:
- 數據量較大,有1萬個元素;
- 計算復雜度過大,需要對每個元素進行復雜的計算;
- 硬件夠硬,比如多核CPU。