一、業(yè)務(wù)開發(fā)缺陷
? ? ? ? ①?工期緊、邏輯復(fù)雜,開發(fā)人員會更多地考慮主流程邏輯的正確實現(xiàn),忽略非主流程邏輯,或保障、補償、一致性邏輯的實現(xiàn);
? ? ? ? ②??往往缺乏詳細的設(shè)計、監(jiān)控和容量規(guī)劃的閉環(huán),結(jié)果就是隨著業(yè)務(wù)發(fā)展出現(xiàn)各種各樣的事故。
二、學(xué)習(xí)方法
? ? ? ? ①?對于每一個坑點,實際運行調(diào)試一下源碼,使用文中提到的工具和方法重現(xiàn)問題,眼見為實。
? ? ? ? ②?對于每一個坑點,再思考下除了文內(nèi)的解決方案和思路外,是否還有其他修正方式。
????????③?對于坑點根因中涉及的 JDK 或框架源碼分析,你可以找到相關(guān)類再系統(tǒng)閱讀一下源碼。
? ? ? ? ④?實踐課后思考題。這些思考題,有的是對文章內(nèi)容的補充,有的是額外容易踩的坑。
三、如何盡量避免踩坑
? ? ? ? ①?遇到自己不熟悉的新類,在不了解之前不要隨意使用
? ? ? ? ? ? ? ? 例如:CopyOnWriteArrayList 是 ArrayList 的線程安全版本,在不知曉原理之前把它用于大量寫操作的場景,那么很可能會遇到性能問題。
? ? ? ? ②?盡量使用更高層次的框架
????????????????而高層次的框架,則會更多地考慮怎么方便開發(fā)者開箱即用
? ? ? ? ③?關(guān)注各種框架和組件的安全補丁和版本更新
????????????????我們使用的 Tomcat 服務(wù)器、序列化框架等,就是黑客關(guān)注的安全突破口
? ? ? ? ④?盡量少自己造輪子,使用流行的框架
????????????????因此使用 Netty 開發(fā) NIO 網(wǎng)絡(luò)程序,不但簡單而且可以少踩很多坑
? ? ? ? ⑤?開發(fā)的時候遇到錯誤,除了搜索解決方案外,更重要的是理解原理
? ? ? ? ⑥?網(wǎng)絡(luò)上的資料有很多,但不一定可靠,最可靠的還是官方文檔
? ? ? ? ⑦?做好單元測試和性能測試
? ? ? ? ⑧?做好設(shè)計評審和代碼審查工作
? ? ? ? ⑨?借助工具幫我們避坑
? ? ? ? ⑩?做好完善的監(jiān)控報警
????????如果一開始我們就可以對應(yīng)用程序的內(nèi)存使用、文件句柄使用、IO 使用量、網(wǎng)絡(luò)帶寬、TCP 連接、線程數(shù)等各種指標進行監(jiān)控,并且基于合理閾值設(shè)置報警,那么可能就能在事故的嬰兒階段及時發(fā)現(xiàn)問題、解決問題。????????????????
????????在遇到報警的時候,我們不能憑經(jīng)驗想當然地認為這些問題都是已知的,對報警置之不理。我們要牢記,所有報警都需要處理和記錄
四、并發(fā)工具類庫的線程安全問題
1、沒有意識到線程重用導(dǎo)致用戶信息錯亂的 Bug
錯誤示例:單線程存儲查看用戶信息會取到錯誤數(shù)據(jù)
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("wrong")
public Map wrong(@RequestParam("userId") Integer userId) {
//設(shè)置用戶信息之前先查詢一次ThreadLocal中的用戶信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
//設(shè)置用戶信息到ThreadLocal
currentUser.set(userId);
//設(shè)置用戶信息之后再查詢一次ThreadLocal中的用戶信息
String after = Thread.currentThread().getName() + ":" + currentUser.get();
//匯總輸出兩次查詢結(jié)果
Map result = new HashMap();
result.put("before", before);
result.put("after", after);
return result;
}
? ? ? ? ?原因:線程池會重用固定的幾個線程,一旦線程重用,那么很可能首次從 ThreadLocal 獲取的值是之前其他用戶的請求遺留的值。這時,ThreadLocal 中的用戶信息就是其他用戶的信息。
正確用法:使用類似 ThreadLocal 工具來存放一些數(shù)據(jù)時,在代碼運行完后清空設(shè)置的數(shù)據(jù)
private static final ThreadLocal currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(userId);
try {
String after = Thread.currentThread().getName() + ":" + currentUser.get();
Map result = new HashMap();
result.put("before", before);
result.put("after", after);
return result;
} finally {
//在finally代碼塊中刪除ThreadLocal中的數(shù)據(jù),確保數(shù)據(jù)不串
currentUser.remove();
}
}
2、使用了線程安全的并發(fā)工具,并不代表解決了所有線程安全問題
? ? ? ? 場景:有一個含 900 個元素的 Map,現(xiàn)在再補充 100 個元素進去,這個補充操作由 10 個線程并發(fā)進行
錯誤示例:在每一個線程的代碼邏輯中先通過 size 方法拿到當前元素數(shù)量,計算ConcurrentHashMap 目前還需要補充多少元素,并在日志中輸出了這個值,然后通過 putAll 方法把缺少的元素添加進去。
//線程個數(shù)
private static int THREAD_COUNT = 10;
//總元素數(shù)量
private static int ITEM_COUNT = 1000;
//幫助方法,用來獲得一個指定元素數(shù)量模擬數(shù)據(jù)的ConcurrentHashMap
private ConcurrentHashMap<String, Long> getData(int count) {
return LongStream.rangeClosed(1, count)
.boxed()
.collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
(o1, o2) -> o1, ConcurrentHashMap::new));
}
@GetMapping("wrong")
public String wrong() throws InterruptedException {
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
//初始900個元素
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
//使用線程池并發(fā)處理邏輯
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
//查詢還需要補充多少個元素
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("gap size:{}", gap);
//補充元素
concurrentHashMap.putAll(getData(gap));
}));
//等待所有任務(wù)完成
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//最后元素個數(shù)會是1000嗎?
log.info("finish size:{}", concurrentHashMap.size());
return "OK";
}
從日志中可以看到:
????????初始大小 900 符合預(yù)期,還需要填充 100 個元素。
????????worker1 線程查詢到當前需要填充的元素為 36,竟然還不是 100 的倍數(shù)。
????????worker13 線程查詢到需要填充的元素數(shù)是負的,顯然已經(jīng)過度填充了。
????????最后 HashMap 的總項目數(shù)是 1536,顯然不符合填充滿 1000 的預(yù)期。
原因:
? ? ? ? ①?ConcurrentHashMap 只能保證提供的原子性讀寫操作是線程安全的
? ? ? ? ② 使用了 ConcurrentHashMap,不代表對它的多個操作之間的狀態(tài)是一致的,是沒有其他線程在操作它的,如果需要確保需要手動加鎖。
? ? ? ? ③ 諸如 size、isEmpty 和 containsValue 等聚合方法,在并發(fā)情況下可能會反映 ConcurrentHashMap 的中間狀態(tài)。因此在并發(fā)情況下,這些方法的返回值只能用作參考,而不能用于流程控制。顯然,利用 size 方法計算差異值,是一個流程控制。
? ? ? ? ④?諸如 putAll 這樣的聚合方法也不能確保原子性,在 putAll 的過程中去獲取數(shù)據(jù)可能會獲取到部分數(shù)據(jù)。
解決方案:整段邏輯加鎖即可
@GetMapping("right")
public String right() throws InterruptedException {
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
//下面的這段復(fù)合邏輯需要鎖一下這個ConcurrentHashMap
synchronized (concurrentHashMap) {
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("gap size:{}", gap);
concurrentHashMap.putAll(getData(gap));
}
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info("finish size:{}", concurrentHashMap.size());
return "OK";
}
????????重新調(diào)用接口,程序的日志輸出結(jié)果符合預(yù)期:
????????ConcurrentHashMap 提供了一些原子性的簡單復(fù)合邏輯方法,用好這些方法就可以發(fā)揮其威力。
3、沒有充分了解并發(fā)工具的特性,從而無法發(fā)揮其威力
? ? ? ? 場景:使用 Map 來統(tǒng)計 Key 出現(xiàn)次數(shù)的場景
????????使用 ConcurrentHashMap 來統(tǒng)計,Key 的范圍是 10。
????????使用最多 10 個并發(fā),循環(huán)操作 1000 萬次,每次操作累加隨機的 Key。
????????如果 Key 不存在的話,首次設(shè)置值為 1。
//循環(huán)次數(shù)
private static int LOOP_COUNT = 10000000;
//線程數(shù)量
private static int THREAD_COUNT = 10;
//元素數(shù)量
private static int ITEM_COUNT = 10;
private Map<String, Long> normaluse() throws InterruptedException {
ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
//獲得一個隨機的Key
String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
synchronized (freqs) {
if (freqs.containsKey(key)) {
//Key存在則+1
freqs.put(key, freqs.get(key) + 1);
} else {
//Key不存在則初始化為1
freqs.put(key, 1L);
}
}
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return freqs;
}
????????直接通過鎖的方式鎖住 Map,然后做判斷、讀取現(xiàn)在的累計值、加 1、保存累加后值的邏輯。這段代碼在功能上沒有問題,但無法充分發(fā)揮 ConcurrentHashMap 的威力
? 優(yōu)化方案:
private Map<String, Long> gooduse() throws InterruptedException {
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
//利用computeIfAbsent()方法來實例化LongAdder,然后利用LongAdder來進行線程安全計數(shù)
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//因為我們的Value是LongAdder而不是Long,所以需要做一次轉(zhuǎn)換才能返回
return freqs.entrySet().stream()
.collect(Collectors.toMap(
e -> e.getKey(),
e -> e.getValue().longValue())
);
}
????????使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 來做復(fù)合邏輯操作,判斷 Key 是否存在 Value,如果不存在則把 Lambda 表達式運行后的結(jié)果放入 Map 作為 Value,也就是新創(chuàng)建一個 LongAdder 對象,最后返回 Value。
????????由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一個線程安全的累加器,因此可以直接調(diào)用其 increment 方法進行累加。
4、沒有認清并發(fā)工具的使用場景,因而導(dǎo)致性能問題
????????在 Java 中,CopyOnWriteArrayList 雖然是一個線程安全的 ArrayList,但因為其實現(xiàn)方式是,每次修改數(shù)據(jù)時都會復(fù)制一份數(shù)據(jù)出來,所以有明顯的適用場景,即讀多寫少或者說希望無鎖讀的場景。
?處理方案:
????????一定要認真閱讀官方文檔(比如 Oracle JDK 文檔)。充分閱讀官方文檔,理解工具的適用場景及其 API 的用法,并做一些小實驗。了解之后再去使用,就可以避免大部分坑。
????????如果你的代碼運行在多線程環(huán)境下,那么就會有并發(fā)問題,并發(fā)問題不那么容易重現(xiàn),可能需要使用壓力測試模擬并發(fā)場景,來發(fā)現(xiàn)其中的 Bug 或性能問題。
五、代碼加鎖問題
場景:在一個類里有兩個 int 類型的字段 a 和 b,有一個 add 方法循環(huán) 1 萬次對 a 和 b 進行 ++ 操作,有另一個 compare 方法,同樣循環(huán) 1 萬次判斷 a 是否小于 b,條件成立就打印 a 和 b 的值,并判斷 a>b 是否成立。
@Slf4j
public class Interesting {
volatile int a = 1;
volatile int b = 1;
public void add() {
log.info("add start");
for (int i = 0; i < 10000; i++) {
a++;
b++;
}
log.info("add done");
}
public void compare() {
log.info("compare start");
for (int i = 0; i < 10000; i++) {
//a始終等于b嗎?
if (a < b) {
log.info("a:{},b:{},{}", a, b, a > b);
//最后的a>b應(yīng)該始終是false嗎?
}
}
log.info("compare done");
}
}
起了兩個線程來分別執(zhí)行 add 和 compare 方法:
Interesting interesting = new Interesting();
new Thread(() -> interesting.add()).start();
new Thread(() -> interesting.compare()).start();
????????按道理,a 和 b 同樣進行累加操作,應(yīng)該始終相等,compare 中的第一次判斷應(yīng)該始終不會成立,不會輸出任何日志。但,執(zhí)行代碼后發(fā)現(xiàn)不但輸出了日志,而且更詭異的是,compare 方法在判斷 ab 也成立:
原因:之所以出現(xiàn)這種錯亂,是因為兩個線程是交錯執(zhí)行 add 和 compare 方法中的業(yè)務(wù)邏輯,而且這些業(yè)務(wù)邏輯不是原子性的:a++ 和 b++ 操作中可以穿插在 compare 方法的比較代碼中;更需要注意的是,a<b 這種比較操作在字節(jié)碼層面是加載 a、加載 b 和比較三步,代碼雖然是一行但也不是原子性的。
解決方案:為 add 和 compare 都加上方法鎖,確保 add 方法執(zhí)行時,compare 無法讀取 a 和 b:
public synchronized void add()
public synchronized void compare()
????????使用鎖解決問題之前一定要理清楚,我們要保護的是什么邏輯,多線程執(zhí)行的情況又是怎樣的。
1、加鎖前要清楚鎖和被保護的對象是不是一個層面的
????????靜態(tài)字段屬于類,類級別的鎖才能保護;而非靜態(tài)字段屬于類實例,實例級別的鎖就可以保護。
錯誤示例:
class Data {
@Getter
private static int counter = 0;
public static int reset() {
counter = 0;
return counter;
}
public synchronized void wrong() {
counter++;
}
}
寫一段代碼測試下:
@GetMapping("wrong")
public int wrong(@RequestParam(value = "count", defaultValue = "1000000") int count) {
Data.reset();
//多線程循環(huán)一定次數(shù)調(diào)用Data類不同實例的wrong方法
IntStream.rangeClosed(1, count).parallel().forEach(i -> new Data().wrong());
return Data.getCounter();
}
????????因為默認運行 100 萬次,所以執(zhí)行后應(yīng)該輸出 100 萬,但頁面輸出的是 639242
原因:在非靜態(tài)的 wrong 方法上加鎖,只能確保多個線程無法執(zhí)行同一個實例的 wrong 方法,卻不能保證不會執(zhí)行不同實例的 wrong 方法。而靜態(tài)的 counter 在多個實例中共享,所以必然會出現(xiàn)線程安全問題。
解決方案:同樣在類中定義一個 Object 類型的靜態(tài)字段,在操作 counter 之前對這個字段加鎖。
class Data {
@Getter
private static int counter = 0;
private static Object locker = new Object();
public void right() {
synchronized (locker) {
counter++;
}
}
}
2、加鎖要考慮鎖的粒度和場景問題
濫用 synchronized 的問題:
? ? ?①? 一是,沒必要。通常情況下 60% 的業(yè)務(wù)代碼是三層架構(gòu),數(shù)據(jù)經(jīng)過無狀態(tài)的 Controller、Service、Repository 流轉(zhuǎn)到數(shù)據(jù)庫,沒必要使用 synchronized 來保護什么數(shù)據(jù)。
? ? ? ② 二是,可能會極大地降低性能。使用 Spring 框架時,默認情況下 Controller、Service、Repository 是單例的,加上 synchronized 會導(dǎo)致整個程序幾乎就只能支持單線程,造成極大的性能問題。?????????????????
????????即使我們確實有一些共享資源需要保護,也要盡可能降低鎖的粒度,僅對必要的代碼塊甚至是需要保護的資源本身加鎖。
場景: 在業(yè)務(wù)代碼中,有一個 ArrayList 因為會被多個線程操作而需要保護,又有一段比較耗時的操作(代碼中的 slow 方法)不涉及線程安全問題
錯誤的做法是,給整段業(yè)務(wù)邏輯加鎖,把 slow 方法和操作 ArrayList 的代碼同時納入 synchronized 代碼塊
正確的做法,把加鎖的粒度降到最低,只在操作 ArrayList 的時候給這個 ArrayList 加鎖。
private List<Integer> data = new ArrayList<>();
//不涉及共享資源的慢方法
private void slow() {
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
}
}
//錯誤的加鎖方法
@GetMapping("wrong")
public int wrong() {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
//加鎖粒度太粗了
synchronized (this) {
slow();
data.add(i);
}
});
log.info("took:{}", System.currentTimeMillis() - begin);
return data.size();
}
//正確的加鎖方法
@GetMapping("right")
public int right() {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
slow();
//只對List加鎖
synchronized (data) {
data.add(i);
}
});
log.info("took:{}", System.currentTimeMillis() - begin);
return data.size();
}
????????如果精細化考慮了鎖應(yīng)用范圍后,性能還無法滿足需求的話,我們就要考慮另一個維度的粒度問題了,即:區(qū)分讀寫場景以及資源的訪問沖突,考慮使用悲觀方式的鎖還是樂觀方式的鎖。
? ? ? ? ①?對于讀寫比例差異明顯的場景,考慮使用 ReentrantReadWriteLock 細化區(qū)分讀寫鎖,來提高性能。
? ? ? ? ②?如果你的 JDK 版本高于 1.8、共享資源的沖突概率也沒那么大的話,考慮使用 StampedLock 的樂觀讀的特性,進一步提高性能。
? ? ? ? ③?JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平鎖的版本,在沒有明確需求的情況下不要輕易開啟公平鎖特性,在任務(wù)很輕的情況下開啟公平鎖可能會讓性能下降上百倍。
3、多把鎖要小心死鎖問題
場景:
????????之前我遇到過這樣一個案例:下單操作需要鎖定訂單中多個商品的庫存,拿到所有商品的鎖之后進行下單扣減庫存操作,全部操作完成之后釋放所有的鎖。代碼上線后發(fā)現(xiàn),下單失敗概率很高,失敗后需要用戶重新下單,極大影響了用戶體驗,還影響到了銷量。
????????經(jīng)排查發(fā)現(xiàn)是死鎖引起的問題,背后原因是扣減庫存的順序不同,導(dǎo)致并發(fā)的情況下多個線程可能相互持有部分商品的鎖,又等待其他線程釋放另一部分商品的鎖,于是出現(xiàn)了死鎖問題。
????????首先,定義一個商品類型,包含商品名、庫存剩余和商品的庫存鎖三個屬性,每一種商品默認庫存 1000 個;然后,初始化 10 個這樣的商品對象來模擬商品清單:
@Data
@RequiredArgsConstructor
static class Item {
final String name; //商品名
int remaining = 1000; //庫存剩余
@ToString.Exclude //ToString不包含這個字段
ReentrantLock lock = new ReentrantLock();
}
????????隨后,寫一個方法模擬在購物車進行商品選購,每次從商品清單(items 字段)中隨機選購三個商品(為了邏輯簡單,我們不考慮每次選購多個同類商品的邏輯,購物車中不體現(xiàn)商品數(shù)量):
private List<Item> createCart() {
return IntStream.rangeClosed(1, 3)
.mapToObj(i -> "item" + ThreadLocalRandom.current().nextInt(items.size()))
.map(name -> items.get(name)).collect(Collectors.toList());
}
????????下單代碼如下:先聲明一個 List 來保存所有獲得的鎖,然后遍歷購物車中的商品依次嘗試獲得商品的鎖,最長等待 10 秒,獲得全部鎖之后再扣減庫存;如果有無法獲得鎖的情況則解鎖之前獲得的所有鎖,返回 false 下單失敗。
private boolean createOrder(List<Item> order) {
//存放所有獲得的鎖
List<ReentrantLock> locks = new ArrayList<>();
for (Item item : order) {
try {
//獲得鎖10秒超時
if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
locks.add(item.lock);
} else {
locks.forEach(ReentrantLock::unlock);
return false;
}
} catch (InterruptedException e) {
}
}
//鎖全部拿到之后執(zhí)行扣減庫存業(yè)務(wù)邏輯
try {
order.forEach(item -> item.remaining--);
} finally {
locks.forEach(ReentrantLock::unlock);
}
return true;
}
? ? ? ? 模擬在多線程情況下進行 100 次創(chuàng)建購物車和下單操作,最后通過日志輸出成功的下單次數(shù)、總剩余的商品個數(shù)、100 次下單耗時,以及下單完成后的商品庫存明細:
@GetMapping("wrong")
public long wrong() {
long begin = System.currentTimeMillis();
//并發(fā)進行100次下單操作,統(tǒng)計成功次數(shù)
long success = IntStream.rangeClosed(1, 100).parallel()
.mapToObj(i -> {
List<Item> cart = createCart();
return createOrder(cart);
})
.filter(result -> result)
.count();
log.info("success:{} totalRemaining:{} took:{}ms items:{}",
success,
items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
System.currentTimeMillis() - begin, items);
return success;
}
????????100 次下單操作成功了 65 次,10 種商品總計 10000 件,庫存總計為 9805,消耗了 195 件符合預(yù)期(65 次下單成功,每次下單包含三件商品),總耗時 50 秒。
原因:
????????使用 JDK 自帶的 VisualVM 工具來跟蹤一下,重新執(zhí)行方法后不久就可以看到,線程 Tab 中提示了死鎖問題,根據(jù)提示點擊右側(cè)線程 Dump 按鈕進行線程抓取操作:
?查看抓取出的線程棧,在頁面中部可以看到如下日志:
?????????是出現(xiàn)了死鎖,線程 4 在等待的一個鎖被線程 3 持有,線程 3 在等待的另一把鎖被線程 4 持有。
死鎖問題原因:
????????個線程先獲取到了 item1 的鎖,同時另一個線程獲取到了 item2 的鎖,然后兩個線程接下來要分別獲取 item2 和 item1 的鎖,這個時候鎖已經(jīng)被對方獲取了,只能相互等待一直到 10 秒超時
解決方案:????????
????????為購物車中的商品排一下序,讓所有的線程一定是先獲取 item1 的鎖然后獲取 item2 的鎖,就不會有問題了
@GetMapping("right")
public long right() {
...
.
long success = IntStream.rangeClosed(1, 100).parallel()
.mapToObj(i -> {
List<Item> cart = createCart().stream()
.sorted(Comparator.comparing(Item::getName))
.collect(Collectors.toList());
return createOrder(cart);
})
.filter(result -> result)
.count();
...
return success;
}
4、線程池
①?線程池的聲明需要手動進行
????????newFixedThreadPool 方法的源碼不難發(fā)現(xiàn),線程池的工作隊列直接 new 了一個 LinkedBlockingQueue,而默認構(gòu)造方法的 LinkedBlockingQueue 是一個 Integer.MAX_VALUE 長度的隊列,可以認為是無界的:
????????newCachedThreadPool 的源碼可以看到,這種線程池的最大線程數(shù)是 Integer.MAX_VALUE,可以認為是沒有上限的,而其工作隊列 SynchronousQueue 是一個沒有存儲空間的阻塞隊列。
②?不建議使用 Executors 提供的兩種快捷的線程池,原因如下:
????????我們需要根據(jù)自己的場景、并發(fā)情況來評估線程池的幾個核心參數(shù),包括核心線程數(shù)、最大線程數(shù)、線程回收策略、工作隊列的類型,以及拒絕策略,確保線程池的工作行為符合需求,一般都需要設(shè)置有界的工作隊列和可控的線程數(shù)
????????任何時候,都應(yīng)該為自定義線程池指定有意義的名稱,以方便排查問題。當出現(xiàn)線程數(shù)量暴增、線程死鎖、線程占用大量 CPU、線程執(zhí)行出現(xiàn)異常等問題時,我們往往會抓取線程棧。此時,有意義的線程名稱,就可以方便我們定位問題。
③?線程池線程管理策略詳解
????????用一個 printStats 方法實現(xiàn)了最簡陋的監(jiān)控,每秒輸出一次線程池的基本內(nèi)部信息,包括線程數(shù)、活躍線程數(shù)、完成了多少任務(wù),以及隊列中還有多少積壓任務(wù)等信息:
private void printStats(ThreadPoolExecutor threadPool) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("=========================");
log.info("Pool Size: {}", threadPool.getPoolSize());
log.info("Active Threads: {}", threadPool.getActiveCount());
log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
log.info("=========================");
}, 0, 1, TimeUnit.SECONDS);
}
????????自定義一個線程池。這個線程池具有 2 個核心線程、5 個最大線程、使用容量為 10 的 ArrayBlockingQueue 阻塞隊列作為工作隊列,使用默認的 AbortPolicy 拒絕策略,也就是任務(wù)添加到線程池失敗會拋出 RejectedExecutionException。此外,我們借助了 Jodd 類庫的 ThreadFactoryBuilder 方法來構(gòu)造一個線程工廠,實現(xiàn)線程池線程的自定義命名。
?場景:每次間隔 1 秒向線程池提交任務(wù),循環(huán) 20 次,每個任務(wù)需要 10 秒才能執(zhí)行完成
? 錯誤場景:
@GetMapping("right")
public int right() throws InterruptedException {
//使用一個計數(shù)器跟蹤完成的任務(wù)數(shù)
AtomicInteger atomicInteger = new AtomicInteger();
//創(chuàng)建一個具有2個核心線程、5個最大線程,使用容量為10的ArrayBlockingQueue阻塞隊列作為工作隊列的線程池,使用默認的AbortPolicy拒絕策略
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, 5,
5, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(),
new ThreadPoolExecutor.AbortPolicy());
printStats(threadPool);
//每隔1秒提交一次,一共提交20次任務(wù)
IntStream.rangeClosed(1, 20).forEach(i -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
int id = atomicInteger.incrementAndGet();
try {
threadPool.submit(() -> {
log.info("{} started", id);
//每個任務(wù)耗時10秒
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
}
log.info("{} finished", id);
});
} catch (Exception ex) {
//提交出現(xiàn)異常的話,打印出錯信息并為計數(shù)器減一
log.error("error submitting task {}", id, ex);
atomicInteger.decrementAndGet();
}
});
TimeUnit.SECONDS.sleep(60);
return atomicInteger.intValue();
}
????????60 秒后頁面輸出了 17,有 3 次提交失敗了:
原因:
????????線程池默認的工作行為:
? ? ? ? a、不會初始化 corePoolSize 個線程,有任務(wù)來了才創(chuàng)建工作線程;
? ? ? ? b、當核心線程滿了之后不會立即擴容線程池,而是把任務(wù)堆積到工作隊列中;
? ? ? ? c、當工作隊列滿了后擴容線程池,一直到線程個數(shù)達到 maximumPoolSize 為止;
? ? ? ? d、如果隊列已滿且達到了最大線程后還有任務(wù)進來,按照拒絕策略處理;
? ? ? ? e、當線程數(shù)大于核心線程數(shù)時,線程等待 keepAliveTime 后還是沒有任務(wù)需要處理的話,收縮線程到核心線程數(shù)。? ? ? ??
?處理方案:
? ? ? ? a、聲明線程池后立即調(diào)用 prestartAllCoreThreads 方法,來啟動所有核心線程;
? ? ? ? b、傳入 true 給 allowCoreThreadTimeOut 方法,來讓線程池在空閑的時候同樣回收核心線程。
④ 務(wù)必確認清楚線程池本身是不是復(fù)用的
錯誤問題:
????????在線程數(shù)比較高的時候進行線程棧抓取,抓取后發(fā)現(xiàn)內(nèi)存中有 1000 多個自定義線程池。一般而言,線程池肯定是復(fù)用的,有 5 個以內(nèi)的線程池都可以認為正常,而 1000 多個線程池肯定不正常。
@GetMapping("wrong")
public String wrong() throws InterruptedException {
ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();
IntStream.rangeClosed(1, 10).forEach(i -> {
threadPool.execute(() -> {
...
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
});
});
return "OK";
}
? 原因:?
???????? ?ThreadPoolHelper 的實現(xiàn)讓人大跌眼鏡,getThreadPool 方法居然是每次都使用 Executors.newCachedThreadPool 來創(chuàng)建一個線程池。
class ThreadPoolHelper {
public static ThreadPoolExecutor getThreadPool() {
//線程池沒有復(fù)用
return (ThreadPoolExecutor) Executors.newCachedThreadPool();
}
}
????????回到 newCachedThreadPool 的定義就會發(fā)現(xiàn),它的核心線程數(shù)是 0,而 keepAliveTime 是 60 秒,也就是在 60 秒之后所有的線程都是可以回收的。
解決方案:
????????使用一個靜態(tài)字段來存放線程池的引用,返回線程池的代碼直接返回這個靜態(tài)字段即可。
class ThreadPoolHelper {
private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
10, 50,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get());
public static ThreadPoolExecutor getRightThreadPool() {
return threadPoolExecutor;
}
}
⑤?需要仔細斟酌線程池的混用策略
? ? ? ??要根據(jù)任務(wù)的“輕重緩急”來指定線程池的核心參數(shù),包括線程數(shù)、回收策略和任務(wù)隊列:
? ? ? ? ? ? ? ? a、對于執(zhí)行比較慢、數(shù)量不大的 IO 任務(wù),或許要考慮更多的線程數(shù),而不需要太大的隊列。
? ? ? ? ? ? ? ? b、而對于吞吐量較大的計算型任務(wù),線程數(shù)量不宜過多,可以是 CPU 核數(shù)或核數(shù) *2(理由是,線程一定調(diào)度到某個 CPU 進行執(zhí)行,如果任務(wù)本身是 CPU 綁定的任務(wù),那么過多的線程只會增加線程切換的開銷,并不能提升吞吐量),但可能需要較長的隊列來做緩沖。
場景:
????????業(yè)務(wù)代碼使用了線程池異步處理一些內(nèi)存中的數(shù)據(jù),但通過監(jiān)控發(fā)現(xiàn)處理得非常慢,整個處理過程都是內(nèi)存中的計算不涉及 IO 操作,也需要數(shù)秒的處理時間,應(yīng)用程序 CPU 占用也不是特別高,有點不可思議。
錯誤:
????????經(jīng)排查發(fā)現(xiàn),業(yè)務(wù)代碼使用的線程池,還被一個后臺的文件批處理任務(wù)用到了。
????????這個線程池只有 2 個核心線程,最大線程也是 2,使用了容量為 100 的 ArrayBlockingQueue 作為工作隊列,使用了 CallerRunsPolicy 拒絕策略:
private static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, 2,
1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("batchfileprocess-threadpool-%d").get(),
new ThreadPoolExecutor.CallerRunsPolicy());
原因:
? ? ? ? 線程池的 2 個線程始終處于活躍狀態(tài),隊列也基本處于打滿狀態(tài)。因為開啟了 CallerRunsPolicy 拒絕處理策略,所以當線程滿載隊列也滿的情況下,任務(wù)會在提交任務(wù)的線程,或者說調(diào)用 execute 方法的線程執(zhí)行,也就是說不能認為提交到線程池的任務(wù)就一定是異步處理的。如果使用了 CallerRunsPolicy 策略,那么有可能異步任務(wù)變?yōu)橥綀?zhí)行。從日志的第四行也可以看到這點。這也是這個拒絕策略比較特別的原因。
? ? ? ? 壓測結(jié)果:TPS 為 75,性能的確非常差。
????????因為原來執(zhí)行 IO 任務(wù)的線程池使用的是 CallerRunsPolicy 策略,所以直接使用這個線程池進行異步計算的話,當線程池飽和的時候,計算任務(wù)會在執(zhí)行 Web 請求的 Tomcat 線程執(zhí)行,這時就會進一步影響到其他同步處理的線程,甚至造成整個應(yīng)用程序崩潰。
解決方案:
????????使用獨立的線程池來做這樣的“計算任務(wù)”即可。計算任務(wù)打了雙引號,是因為我們的模擬代碼執(zhí)行的是休眠操作,并不屬于 CPU 綁定的操作,更類似 IO 綁定的操作,如果線程池線程數(shù)設(shè)置太小會限制吞吐能力:
private static ThreadPoolExecutor asyncCalcThreadPool = new ThreadPoolExecutor(
200, 200,
1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("asynccalc-threadpool-%d").get());
@GetMapping("right")
public int right() throws ExecutionException, InterruptedException {
return asyncCalcThreadPool.submit(calcTask()).get();
}
????????使用單獨的線程池改造代碼后再來測試一下性能,TPS 提高到了 1727
補充:
????????Java 8 的 parallel stream 功能,可以讓我們很方便地并行處理集合中的元素,其背后是共享同一個 ForkJoinPool,默認并行度是 CPU 核數(shù) -1。
????????對于 CPU 綁定的任務(wù)來說,使用這樣的配置比較合適,但如果集合操作涉及同步 IO 操作的話(比如數(shù)據(jù)庫操作、外部服務(wù)調(diào)用等),建議自定義一個 ForkJoinPool(或普通線程池)。
????????程池作為應(yīng)用程序內(nèi)部的核心組件往往缺乏監(jiān)控(如果你使用類似 RabbitMQ 這樣的 MQ 中間件,運維同學(xué)一般會幫我們做好中間件監(jiān)控),往往到程序崩潰后才發(fā)現(xiàn)線程池的問題,很被動。
5、連接池
①?連接池的結(jié)構(gòu)
②?注意鑒別客戶端 SDK 是否基于連接池
????????TCP 是面向連接的基于字節(jié)流的協(xié)議:
????????????????面向連接,意味著連接需要先創(chuàng)建再使用,創(chuàng)建連接的三次握手有一定開銷;
????????????????TCP 協(xié)議本身無法區(qū)分哪幾個字節(jié)是完整的消息體,也無法感知是否有多個客戶端在使用同一個 TCP 連接,TCP 只是一個讀寫數(shù)據(jù)的管道。
③?如果客戶端 SDK 沒有使用連接池,而直接是 TCP 連接,那么就需要考慮每次建立 TCP 連接的開銷,并且因為 TCP 基于字節(jié)流,在多線程的情況下對同一連接進行復(fù)用,可能會產(chǎn)生線程安全問題。
④?涉及 TCP 連接的客戶端 SDK,對外提供 API 的三種方式。
? ? ? ? a、連接池和連接分離的 API:有一個 XXXPool 類負責(zé)連接池實現(xiàn),先從其獲得連接 XXXConnection,然后用獲得的連接進行服務(wù)端請求,完成后使用者需要歸還連接。通常,XXXPool 是線程安全的,可以并發(fā)獲取和歸還連接,而 XXXConnection 是非線程安全的。對應(yīng)到連接池的結(jié)構(gòu)示意圖中,XXXPool 就是右邊連接池那個框,左邊的客戶端是我們自己的代碼。
? ? ? ? b、內(nèi)部帶有連接池的 API:對外提供一個 XXXClient 類,通過這個類可以直接進行服務(wù)端請求;這個類內(nèi)部維護了連接池,SDK 使用者無需考慮連接的獲取和歸還問題。一般而言,XXXClient 是線程安全的。對應(yīng)到連接池的結(jié)構(gòu)示意圖中,整個 API 就是藍色框包裹的部分。
? ? ? ? c、非連接池的 API:一般命名為 XXXConnection,以區(qū)分其是基于連接池還是單連接的,而不建議命名為 XXXClient 或直接是 XXX。直接連接方式的 API 基于單一連接,每次使用都需要創(chuàng)建和斷開連接,性能一般,且通常不是線程安全的。對應(yīng)到連接池的結(jié)構(gòu)示意圖中,這種形式相當于沒有右邊連接池那個框,客戶端直接連接服務(wù)端創(chuàng)建連接。
⑤?使用 SDK 的最佳實踐
? ? ? a、如果是分離方式,那么連接池本身一般是線程安全的,可以復(fù)用。每次使用需要從連接池獲取連接,使用后歸還,歸還的工作由使用者負責(zé)。
? ? ? ? b、??如果是內(nèi)置連接池,SDK 會負責(zé)連接的獲取和歸還,使用的時候直接復(fù)用客戶端。
? ? ? ? c、如果 SDK 沒有實現(xiàn)連接池(大多數(shù)中間件、數(shù)據(jù)庫的客戶端 SDK 都會支持連接池),那通常不是線程安全的,而且短連接的方式性能不會很高,使用的時候需要考慮是否自己封裝一個連接池。
場景:源碼角度分析下 Jedis 類到底屬于哪種類型的 API,直接在多線程環(huán)境下復(fù)用一個連接會產(chǎn)生什么問題
????????向 Redis 初始化 2 組數(shù)據(jù),Key=a、Value=1,Key=b、Value=2:
@PostConstruct
public void init() {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
Assert.isTrue("OK".equals(jedis.set("a", "1")), "set a = 1 return OK");
Assert.isTrue("OK".equals(jedis.set("b", "2")), "set b = 2 return OK");
}
}
????????然后,啟動兩個線程,共享操作同一個 Jedis 實例,每一個線程循環(huán) 1000 次,分別讀取 Key 為 a 和 b 的 Value,判斷是否分別為 1 和 2:
Jedis jedis = new Jedis("127.0.0.1", 6379);
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("a");
if (!result.equals("1")) {
log.warn("Expect a to be 1 but found {}", result);
return;
}
}
}).start();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("b");
if (!result.equals("2")) {
log.warn("Expect b to be 2 but found {}", result);
return;
}
}
}).start();
TimeUnit.SECONDS.sleep(5);
????????執(zhí)行程序多次,可以看到日志中出現(xiàn)了各種奇怪的異常信息,有的是讀取 Key 為 b 的 Value 讀取到了 1,有的是流非正常結(jié)束,還有的是連接關(guān)閉異常
原因:
????????Jedis 類
????????BinaryClient 封裝了各種 Redis 命令,其最終會調(diào)用基類 Connection 的方法,使用 Protocol 類發(fā)送命令??匆幌?Protocol 類的 sendCommand 方法的源碼,可以發(fā)現(xiàn)其發(fā)送命令時是直接操作 RedisOutputStream 寫入字節(jié)。
????????我們在多線程環(huán)境下復(fù)用 Jedis 對象,其實就是在復(fù)用 RedisOutputStream。如果多個線程在執(zhí)行操作,那么既無法確保整條命令以一個原子操作寫入 Socket,也無法確保寫入后、讀取前沒有其他數(shù)據(jù)寫到遠端
?????????比如,寫操作互相干擾,多條命令相互穿插的話,必然不是合法的 Redis 命令,那么 Redis 會關(guān)閉客戶端連接,導(dǎo)致連接斷開;又比如,線程 1 和 2 先后寫入了 get a 和 get b 操作的請求,Redis 也返回了值 1 和 2,但是線程 2 先讀取了數(shù)據(jù) 1 就會出現(xiàn)數(shù)據(jù)錯亂的問題。
解決方案:
????????使用 Jedis 提供的另一個線程安全的類 JedisPool 來獲得 Jedis 的實例。JedisPool 可以聲明為 static 在多個線程之間共享,扮演連接池的角色。使用時,按需使用 try-with-resources 模式從 JedisPool 獲得和歸還 Jedis 實例。
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
new Thread(() -> {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("a");
if (!result.equals("1")) {
log.warn("Expect a to be 1 but found {}", result);
return;
}
}
}
}).start();
new Thread(() -> {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i < 1000; i++) {
String result = jedis.get("b");
if (!result.equals("2")) {
log.warn("Expect b to be 2 but found {}", result);
return;
}
}
}
}).start();
????????通過 shutdownhook,在程序退出之前關(guān)閉 JedisPool:
@PostConstruct
public void init() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
jedisPool.close();
}));
}
????????Jedis 的 API 實現(xiàn)是我們說的三種類型中的第一種,也就是連接池和連接分離的 API,JedisPool 是線程安全的連接池,Jedis 是非線程安全的單一連接。
⑥?使用連接池務(wù)必確保復(fù)用
????????池一定是用來復(fù)用的,否則其使用代價會比每次創(chuàng)建單一對象更大。對連接池來說更是如此,原因如下:
? ? ? ? ? ? ? ? a、創(chuàng)建連接池的時候很可能一次性創(chuàng)建了多個連接,大多數(shù)連接池考慮到性能,會在初始化的時候維護一定數(shù)量的最小連接(畢竟初始化連接池的過程一般是一次性的),可以直接使用。如果每次使用連接池都按需創(chuàng)建連接池,那么很可能你只用到一個連接,但是創(chuàng)建了 N 個連接。
? ? ? ? ? ? ? ? b、連接池一般會有一些管理模塊,也就是連接池的結(jié)構(gòu)示意圖中的綠色部分。舉個例子,大多數(shù)的連接池都有閑置超時的概念。連接池會檢測連接的閑置時間,定期回收閑置的連接,把活躍連接數(shù)降到最低(閑置)連接的配置值,減輕服務(wù)端的壓力。一般情況下,閑置連接由獨立線程管理,啟動了空閑檢測的連接池相當于還會啟動一個線程。此外,有些連接池還需要獨立線程負責(zé)連接?;畹裙δ堋R虼耍瑔右粋€連接池相當于啟動了 N 個線程。
錯誤示例:
????????Apache HttpClient 連接池不復(fù)用的問題
????????創(chuàng)建一個 CloseableHttpClient,設(shè)置使用 PoolingHttpClientConnectionManager 連接池并啟用空閑連接驅(qū)逐策略,最大空閑時間為 60 秒,然后使用這個連接來請求一個會返回 OK 字符串的服務(wù)端接口:
@GetMapping("wrong1")
public String wrong1() {
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.evictIdleConnections(60, TimeUnit.SECONDS).build();
try (CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
?原因:? ? ? ?
????????CloseableHttpClient 屬于第二種模式,即內(nèi)部帶有連接池的 API,其背后是連接池,最佳實踐一定是復(fù)用。
解決方案:
????????CloseableHttpClient 聲明為 static,只創(chuàng)建一次,并且在 JVM 關(guān)閉之前通過 addShutdownHook 鉤子關(guān)閉連接池,在使用的時候直接使用 CloseableHttpClient 即可,無需每次都創(chuàng)建。
private static CloseableHttpClient httpClient = null;
static {
//當然,也可以把CloseableHttpClient定義為Bean,然后在@PreDestroy標記的方法內(nèi)close這個HttpClient
httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1).evictIdleConnections(60, TimeUnit.SECONDS).build();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
httpClient.close();
} catch (IOException ignored) {
}
}));
}
@GetMapping("right")
public String right() {
try (CloseableHttpResponse response = httpClient.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
????????修復(fù)之前按需創(chuàng)建 CloseableHttpClient 的代碼,每次用完之后確保連接池可以關(guān)閉
@GetMapping("wrong2")
public String wrong2() {
try (CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.evictIdleConnections(60, TimeUnit.SECONDS).build();
CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
⑦?連接池的配置不是一成不變的
????????最大連接數(shù)不是設(shè)置得越大越好
????????連接池最大連接數(shù)設(shè)置得太小,很可能會因為獲取連接的等待時間太長,導(dǎo)致吞吐量低下,甚至超時無法獲取連接。
????????對類似數(shù)據(jù)庫連接池的重要資源進行持續(xù)檢測,并設(shè)置一半的使用量作為報警閾值,出現(xiàn)預(yù)警后及時擴容。
? ? ? ? 強調(diào)的是,修改配置參數(shù)務(wù)必驗證是否生效,并且在監(jiān)控系統(tǒng)中確認參數(shù)是否生效、是否合理。之所以要“強調(diào)”,是因為這里有坑。
6、HTTP調(diào)用:你考慮到超時、重試、并發(fā)
① 網(wǎng)絡(luò)請求必然有超時的可能性
? ? ? ? a、?首先,框架設(shè)置的默認超時是否合理;
? ? ? ? b、?其次,考慮到網(wǎng)絡(luò)的不穩(wěn)定,超時后的請求重試是一個不錯的選擇,但需要考慮服務(wù)端接口的冪等性設(shè)計是否允許我們重試;
? ? ? ? c、?最后,需要考慮框架是否會像瀏覽器那樣限制并發(fā)連接數(shù),以免在服務(wù)并發(fā)很大的情況下,HTTP 調(diào)用的并發(fā)數(shù)限制成為瓶頸。
②?配置連接超時和讀取超時參數(shù)的學(xué)問
? ? ? ? a、連接超時參數(shù) ConnectTimeout,讓用戶配置建連階段的最長等待時間;
? ? ? ? b、讀取超時參數(shù) ReadTimeout,用來控制從 Socket 上讀取數(shù)據(jù)的最長等待時間。
③?連接超時參數(shù)和連接超時的誤區(qū)有這么兩個:
? ? ? ? a、連接超時配置得特別長,比如 60 秒。
????????????????如果是純內(nèi)網(wǎng)調(diào)用的話,這個參數(shù)可以設(shè)置得更短,在下游服務(wù)離線無法連接的時候,可以快速失敗。
? ? ? ? b、排查連接超時問題,卻沒理清連的是哪里。
????????????????而如果服務(wù)端通過類似 Nginx 的反向代理來負載均衡,客戶端連接的其實是 Nginx,而不是服務(wù)端,此時出現(xiàn)連接超時應(yīng)該排查 Nginx。
④?讀取超時參數(shù)和讀取超時則會有更多的誤區(qū),我將其歸納為如下三個
? ? ? ? a、第一個誤區(qū):認為出現(xiàn)了讀取超時,服務(wù)端的執(zhí)行就會中斷。
????????????????只要服務(wù)端收到了請求,網(wǎng)絡(luò)層面的超時和斷開便不會影響服務(wù)端的執(zhí)行。
? ? ? ? b、第二個誤區(qū):認為讀取超時只是 Socket 網(wǎng)絡(luò)層面的概念,是數(shù)據(jù)傳輸?shù)淖铋L耗時,故將其配置得非常短,比如 100 毫秒。
????????????????通??梢哉J為出現(xiàn)連接超時是網(wǎng)絡(luò)問題或服務(wù)不在線,而出現(xiàn)讀取超時是服務(wù)處理超時。
????????????????讀取超時指的是,向 Socket 寫入數(shù)據(jù)后,我們等到 Socket 返回數(shù)據(jù)的超時時間,其中包含的時間或者說絕大部分的時間,是服務(wù)端處理業(yè)務(wù)邏輯的時間。
? ? ? ? c、第三個誤區(qū):認為超時時間越長任務(wù)接口成功率就越高,將讀取超時參數(shù)配置得太長。
????????????????進行 HTTP 請求一般是需要獲得結(jié)果的,屬于同步調(diào)用。如果超時時間很長,在等待服務(wù)端返回數(shù)據(jù)的同時,客戶端線程(通常是 Tomcat 線程)也在等待,當下游服務(wù)出現(xiàn)大量超時的時候,程序可能也會受到拖累創(chuàng)建大量線程,最終崩潰。
????????????????但面向用戶響應(yīng)的請求或是微服務(wù)短平快的同步接口調(diào)用,并發(fā)量一般較大,我們應(yīng)該設(shè)置一個較短的讀取超時時間,以防止被下游服務(wù)拖慢,通常不會設(shè)置超過 30 秒的讀取超時。
?⑤?Feign 和 Ribbon 配合使用,你知道怎么配置超時嗎?
? ? ? ? a、結(jié)論一,默認情況下 Feign 的讀取超時是 1 秒,如此短的讀取超時算是坑點一。
? ? ? ? b、結(jié)論二,也是坑點二,如果要配置 Feign 的讀取超時,就必須同時配置連接超時,才能生效。
? ? ? ? c、結(jié)論三,單獨的超時可以覆蓋全局超時,這符合預(yù)期,不算坑:
? ? ? ? d、結(jié)論四,除了可以配置 Feign,也可以配置 Ribbon 組件的參數(shù)來修改兩個超時時間。這里的坑點三是,參數(shù)首字母要大寫,和 Feign 的配置不同。
? ? ? ? e、結(jié)論五,同時配置 Feign 和 Ribbon 的超時,以 Feign 為準
⑥?Ribbon 會自動重試請求
????????解決辦法有兩個:
? ? ? ? ? ? ? ? a、一是,把發(fā)短信接口從 Get 改為 Post。這里的一個誤區(qū)是,Get 請求的參數(shù)包含在 Url QueryString 中,會受瀏覽器長度限制,所以一些同學(xué)會選擇使用 JSON 以 Post 提交大參數(shù),使用 Get 提交小參數(shù)。
? ? ? ? ? ? ? ? b、二是,將 MaxAutoRetriesNextServer 參數(shù)配置為 0,禁用服務(wù)調(diào)用失敗后在下一個服務(wù)端節(jié)點的自動重試。在配置文件中添加一行即可:
⑦?并發(fā)限制了爬蟲的抓取能力
原因:
????????PoolingHttpClientConnectionManager 源碼,可以注意到有兩個重要參數(shù):????????
? ? ? ? a、defaultMaxPerRoute=2,也就是同一個主機 / 域名的最大并發(fā)請求數(shù)為 2。我們的爬蟲需要 10 個并發(fā),顯然是默認值太小限制了爬蟲的效率。
? ? ? ? b、maxTotal=20,也就是所有主機整體最大并發(fā)為 20,這也是 HttpClient 整體的并發(fā)度。目前,我們請求數(shù)是 10 最大并發(fā)是 10,20 不會成為瓶頸。舉一個例子,使用同一個 HttpClient 訪問 10 個域名,defaultMaxPerRoute 設(shè)置為 10,為確保每一個域名都能達到 10 并發(fā),需要把 maxTotal 設(shè)置為 100。
解決方案
????????聲明一個新的 HttpClient 放開相關(guān)限制,設(shè)置 maxPerRoute 為 50、maxTotal 為 100,然后修改一下剛才的 wrong 方法,
httpClient2 = HttpClients.custom().setMaxConnPerRoute(10).setMaxConnTotal(20).build();
7、小心 Spring 的事務(wù)可能沒有生效
@Transactional 生效原則
????????① 除非特殊配置(比如使用 AspectJ 靜態(tài)織入實現(xiàn) AOP),否則只有定義在 public 方法上的 @Transactional 才能生效。
? ? ? ? ②?必須通過代理過的類從外部調(diào)用目標方法才能生效。
????????????????Spring 通過 AOP 技術(shù)對方法進行增強,要調(diào)用增強過的方法必然是調(diào)用代理后的對象。我們嘗試修改下 UserService 的代碼,注入一個 self,然后再通過 self 實例調(diào)用標記有 @Transactional 注解的 createUserPublic 方法。設(shè)置斷點可以看到,self 是由 Spring 通過 CGLIB 方式增強過的類:
? ? ? ? ? ? ? ? a、CGLIB 通過繼承方式實現(xiàn)代理類,private 方法在子類不可見,自然也就無法進行事務(wù)增強;
? ? ? ? ? ? ? ? b、this 指針代表對象自己,Spring 不可能注入 this,所以通過 this 訪問方法必然不是代理。
????????this 自調(diào)用、通過 self 調(diào)用,以及在 Controller 中調(diào)用 UserService 三種實現(xiàn)的區(qū)別:
????????強烈建議你在開發(fā)時打開相關(guān)的 Debug 日志,以方便了解 Spring 事務(wù)實現(xiàn)的細節(jié),并及時判斷事務(wù)的執(zhí)行情況。
8、事務(wù)即便生效也不一定能回滾
????????當方法出現(xiàn)了異常并且滿足一定條件的時候,在 catch 里面我們可以設(shè)置事務(wù)回滾,沒有異常則直接提交事務(wù)。
? ? ? ? ①?只有異常傳播出了標記了 @Transactional 注解的方法,事務(wù)才能回滾。
? ? ? ? ②?默認情況下,出現(xiàn) RuntimeException(非受檢異常)或 Error 的時候,Spring 才會回滾事務(wù)。
錯誤示例:
? ? ? ? ①?在 createUserWrong1 方法中會拋出一個 RuntimeException,但由于方法內(nèi) catch 了所有異常,異常無法從方法傳播出去,事務(wù)自然無法回滾。
? ? ? ? ② 在 createUserWrong2 方法中,注冊用戶的同時會有一次 otherTask 文件讀取操作,如果文件讀取失敗,我們希望用戶注冊的數(shù)據(jù)庫操作回滾。雖然這里沒有捕獲異常,但因為 otherTask 方法拋出的是受檢異常,createUserWrong2 傳播出去的也是受檢異常,事務(wù)同樣不會回滾。
處理方案:
? ? ? ? ①?第一,如果你希望自己捕獲異常進行處理的話,也沒關(guān)系,可以手動設(shè)置讓當前事務(wù)處于回滾狀態(tài)
@Transactional
public void createUserRight1(String name) {
try {
userRepository.save(new UserEntity(name));
throw new RuntimeException("error");
} catch (Exception ex) {
log.error("create user failed", ex);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
? ? ? ? ②?第二,在注解中聲明,期望遇到所有的 Exception 都回滾事務(wù)(來突破默認不回滾受檢異常的限制)
@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}
9、請確認事務(wù)傳播配置是否符合自己的業(yè)務(wù)邏輯
? ? ? ? ①?出了異常事務(wù)不一定回滾,這里說的卻是不出異常,事務(wù)也不一定可以提交。原因是,主方法注冊主用戶的邏輯和子方法注冊子用戶的邏輯是同一個事務(wù),子邏輯標記了事務(wù)需要回滾,主邏輯自然也不能提交了。
? ? ? ? ②?想辦法讓子邏輯在獨立事務(wù)中運行,也就是改一下 SubUserService 注冊子用戶的方法,為注解加上 propagation = Propagation.REQUIRES_NEW 來設(shè)置 REQUIRES_NEW 方式的事務(wù)傳播策略,也就是執(zhí)行到這個方法時需要開啟新的事務(wù),并掛起當前事務(wù):
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUserWithExceptionRight(UserEntity entity) {
log.info("createSubUserWithExceptionRight start");
userRepository.save(entity);
throw new RuntimeException("invalid status");
}
10、InnoDB 是如何存儲數(shù)據(jù)的
? ? ? ? ①?雖然數(shù)據(jù)保存在磁盤中,但其處理是在內(nèi)存中進行的。為了減少磁盤隨機讀取次數(shù),InnoDB 采用頁而不是行的粒度來保存數(shù)據(jù),即數(shù)據(jù)被分成若干頁,以頁為單位保存在磁盤中。InnoDB 的頁大小,一般是 16KB。
????????各個數(shù)據(jù)頁組成一個雙向鏈表,每個數(shù)據(jù)頁中的記錄按照主鍵順序組成單向鏈表;每一個數(shù)據(jù)頁中有一個頁目錄,方便按照主鍵查詢記錄。數(shù)據(jù)頁的結(jié)構(gòu)如下:
11、聚簇索引和二級索引
① B+ 樹的特點包括:
????????最底層的節(jié)點叫作葉子節(jié)點,用來存放數(shù)據(jù);
????????其他上層節(jié)點叫作非葉子節(jié)點,僅用來存放目錄項,作為索引;
????????非葉子節(jié)點分為不同層次,通過分層來降低每一層的搜索量;
????????所有節(jié)點按照索引鍵大小排序,構(gòu)成一個雙向鏈表,加速范圍查找。
② 由于數(shù)據(jù)在物理上只會保存一份,所以包含實際數(shù)據(jù)的聚簇索引只能有一個。
????????為了實現(xiàn)非主鍵字段的快速搜索,就引出了二級索引,也叫作非聚簇索引、輔助索引。二級索引,也是利用的 B+ 樹的數(shù)據(jù)結(jié)構(gòu),如下圖所示:
????????這次二級索引的葉子節(jié)點中保存的不是實際數(shù)據(jù),而是主鍵,獲得主鍵值后去聚簇索引中獲得數(shù)據(jù)行。這個過程就叫作回表。
12、考慮額外創(chuàng)建二級索引的代價
? ? ? ? ①?首先是維護代價。
????????????????創(chuàng)建 N 個二級索引,就需要再創(chuàng)建 N 棵 B+ 樹,新增數(shù)據(jù)時不僅要修改聚簇索引,還需要修改這 N 個二級索引。
? ? ? ? ②?其次是空間代價。雖然二級索引不保存原始數(shù)據(jù),但要保存索引列的數(shù)據(jù),所以會占用更多的空間。
? ? ? ? ③?最后是回表的代價。二級索引不保存原始數(shù)據(jù),通過索引找到主鍵后需要再查詢聚簇索引,才能得到我們要的數(shù)據(jù)。
?13、索引開銷的最佳實踐
? ? ? ?① 第一,無需一開始就建立索引,可以等到業(yè)務(wù)場景明確后,或者是數(shù)據(jù)量超過 1 萬、查詢變慢后,再針對需要查詢、排序或分組的字段創(chuàng)建索引。創(chuàng)建索引后可以使用 EXPLAIN 命令,確認查詢是否可以使用索引。我會在下一小節(jié)展開說明。
? ? ? ?②? 第二,盡量索引輕量級的字段,比如能索引 int 字段就不要索引 varchar 字段。索引字段也可以是部分前綴,在創(chuàng)建的時候指定字段索引長度。針對長文本的搜索,可以考慮使用 Elasticsearch 等專門用于文本搜索的索引數(shù)據(jù)庫。
? ? ? ? ③ 第三,盡量不要在 SQL 語句中 SELECT *,而是 SELECT 必要的字段,甚至可以考慮使用聯(lián)合索引來包含我們要搜索的字段,既能實現(xiàn)索引加速,又可以避免回表的開銷。
?14、不是所有針對索引列的查詢都能用上索引
? ? ? ? ①?第一,索引只能匹配列前綴
? ? ? ? ②?第二,條件涉及函數(shù)操作無法走索引
? ? ? ? ③?第三,聯(lián)合索引只能匹配左邊的列
? 15、數(shù)據(jù)庫基于成本決定是否走索引
? ? ? ? ①?IO 成本,是從磁盤把數(shù)據(jù)加載到內(nèi)存的成本。默認情況下,讀取數(shù)據(jù)頁的 IO 成本常數(shù)是 1(也就是讀取 1 個頁成本是 1)
????????????????聚簇索引占用的頁面數(shù),用來計算讀取數(shù)據(jù)的 IO 成本;
? ? ? ? ②?CPU 成本,是檢測數(shù)據(jù)是否滿足條件和排序等 CPU 操作的成本。默認情況下,檢測記錄的成本是 0.2。
????????????????表中的記錄數(shù),用來計算搜索的 CPU 成本。
16、mysql選錯索引
????????在 MySQL 5.6 及之后的版本中,我們可以使用 optimizer trace 功能查看優(yōu)化器生成執(zhí)行計劃的整個過程。有了這個功能,我們不僅可以了解優(yōu)化器的選擇過程,更可以了解每一個執(zhí)行環(huán)節(jié)的成本,然后依靠這些信息進一步優(yōu)化查詢。
????????打開 optimizer_trace 后,再執(zhí)行 SQL 就可以查詢 information_schema.OPTIMIZER_TRACE 表查看執(zhí)行計劃了,最后可以關(guān)閉 optimizer_trace 功能:
SET optimizer_trace="enabled=on";
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";
17、注意 equals 和 == 的區(qū)別
? ? ? ? ①?對基本類型,比如 int、long,進行判等,只能使用 ==,比較的是直接值。因為基本類型的值就是其數(shù)值。
? ? ? ? ② 對引用類型,比如 Integer、Long 和 String,進行判等,需要使用 equals 進行內(nèi)容判等。因為引用類型的直接值是指針,使用 == 的話,比較的是指針,也就是兩個對象在內(nèi)存中的地址,即比較它們是不是同一個對象,而不是比較對象的內(nèi)容。
????????需要記得比較 Integer 的值請使用 equals,而不是 ==
18、實現(xiàn)一個更好的 equals 應(yīng)該注意的點
? ? ? ? ① 考慮到性能,可以先進行指針判等,如果對象是同一個那么直接返回 true;
? ? ? ? ② 需要對另一方進行判空,空對象和自身進行比較,結(jié)果一定是 fasle;
? ? ? ? ③?需要判斷兩個對象的類型,如果類型都不同,那么直接返回 false;
? ? ? ? ④?確保類型相同的情況下再進行類型強制轉(zhuǎn)換,然后逐一判斷所有字段。
19、注意 compareTo 和 equals 的邏輯一致性
????????① 我們使用了 Lombok 的 @Data 標記了 Student,@Data 注解(詳見這里)其實包含了 @EqualsAndHashCode 注解(詳見這里)的作用,也就是默認情況下使用類型所有的字段(不包括 static 和 transient 字段)參與到 equals 和 hashCode 方法的實現(xiàn)中。因為這兩個方法的實現(xiàn)不是我們自己實現(xiàn)的,所以容易忽略其邏輯。
? ? ? ?② compareTo 方法需要返回數(shù)值,作為排序的依據(jù),容易讓人使用數(shù)值類型的字段隨意實現(xiàn)。
20、小心 Lombok 生成代碼的“坑”
? ? ? ? ① 問題場景:
????????????????有繼承關(guān)系時,Lombok 自動生成的方法可能就不是我們期望的了。
? ? ? ? ② 解決方案
@Data
@EqualsAndHashCode(callSuper = true)
class Employee extends Person {
21、數(shù)值計算:注意精度、舍入和溢出問題
? ? ? ? ①?切記,要精確表示浮點數(shù)應(yīng)該使用 BigDecimal。并且,使用 BigDecimal 的 Double 入?yún)⒌臉?gòu)造方法同樣存在精度丟失問題,應(yīng)該使用 String 入?yún)⒌臉?gòu)造方法或者 BigDecimal.valueOf 方法來初始化。
? ? ? ? ②?對浮點數(shù)做精確計算,參與計算的各種數(shù)值應(yīng)該始終使用 BigDecimal,所有的計算都要通過 BigDecimal 的方法進行,切勿只是讓 BigDecimal 來走過場。任何一個環(huán)節(jié)出現(xiàn)精度損失,最后的計算結(jié)果可能都會出現(xiàn)誤差。
????????③ 對于浮點數(shù)的格式化,如果使用 String.format 的話,需要認識到它使用的是四舍五入,可以考慮使用 DecimalFormat 來明確指定舍入方式。但考慮到精度問題,我更建議使用 BigDecimal 來表示浮點數(shù),并使用其 setScale 方法指定舍入的位數(shù)和方式。
????????④ 進行數(shù)值運算時要小心溢出問題,雖然溢出后不會出現(xiàn)異常,但得到的計算結(jié)果是完全錯誤的。我們考慮使用 Math.xxxExact 方法來進行運算,在溢出時能拋出異常,更建議對于可能會出現(xiàn)溢出的大數(shù)運算使用 BigInteger 類。
22、List 列表相關(guān)的錯誤案例
①?想當然認為,Arrays.asList 和 List.subList 得到的 List 是普通的、獨立的 ArrayList,在使用時出現(xiàn)各種奇怪的問題。
? ? ? ? a、Arrays.asList 得到的是 Arrays 的內(nèi)部類 ArrayList,List.subList 得到的是 ArrayList 的內(nèi)部類 SubList,不能把這兩個內(nèi)部類轉(zhuǎn)換為 ArrayList 使用。
? ? ? ? b、Arrays.asList 直接使用了原始數(shù)組,可以認為是共享“存儲”,而且不支持增刪元素;List.subList 直接引用了原始的 List,也可以認為是共享“存儲”,而且對原始 List 直接進行結(jié)構(gòu)性修改會導(dǎo)致 SubList 出現(xiàn)異常。
? ? ? ? c、對 Arrays.asList 和 List.subList 容易忽略的是,新的 List 持有了原始數(shù)據(jù)的引用,可能會導(dǎo)致原始數(shù)據(jù)也無法 GC 的問題,最終導(dǎo)致 OOM。
②?想當然認為,Arrays.asList 一定可以把所有數(shù)組轉(zhuǎn)換為正確的 List。當傳入基本類型數(shù)組的時候,List 的元素是數(shù)組本身,而不是數(shù)組中的元素。
③?想當然認為,內(nèi)存中任何集合的搜索都是很快的,結(jié)果在搜索超大 ArrayList 的時候遇到性能問題。我們考慮利用 HashMap 哈希表隨機查找的時間復(fù)雜度為 O(1) 這個特性來優(yōu)化性能,不過也要考慮 HashMap 存儲空間上的代價,要平衡時間和空間。
④?想當然認為,鏈表適合元素增刪的場景,選用 LinkedList 作為數(shù)據(jù)結(jié)構(gòu)。在真實場景中讀寫增刪一般是平衡的,而且增刪不可能只是對頭尾對象進行操作,可能在 90% 的情況下都得不到性能增益,建議使用之前通過性能測試評估一下。
? ? ? ?解決方案:
????????????????重新 new 一個 ArrayList 初始化 Arrays.asList 返回的 List 即可:
????????
String[] arr = {"1", "2", "3"};
List list = new ArrayList(Arrays.asList(arr));
?23、空值處理
????????① 對于 Integer 的判空,可以使用 Optional.ofNullable 來構(gòu)造一個 Optional,然后使用 orElse(0) 把 null 替換為默認值再進行 +1 操作。
????????②?對于 String 和字面量的比較,可以把字面量放在前面,比如"OK".equals(s),這樣即使 s 是 null 也不會出現(xiàn)空指針異常;而對于兩個可能為 null 的字符串變量的 equals 比較,可以使用 Objects.equals,它會做判空處理。
????????③??對于 ConcurrentHashMap,既然其 Key 和 Value 都不支持 null,修復(fù)方式就是不要把 null 存進去。HashMap 的 Key 和 Value 可以存入 null,而 ConcurrentHashMap 看似是 HashMap 的線程安全版本,卻不支持 null 值的 Key 和 Value,這是容易產(chǎn)生誤區(qū)的一個地方。
????????④?對于類似 fooService.getBarService().bar().equals(“OK”) 的級聯(lián)調(diào)用,需要判空的地方有很多,包括 fooService、getBarService() 方法的返回值,以及 bar 方法返回的字符串。如果使用 if-else 來判空的話可能需要好幾行代碼,但使用 Optional 的話一行代碼就夠了。
????????⑤ 對于 rightMethod 返回的 List,由于不能確認其是否為 null,所以在調(diào)用 size 方法獲得列表大小之前,同樣可以使用 Optional.ofNullable 包裝一下返回值,然后通過.orElse(Collections.emptyList()) 實現(xiàn)在 List 為 null 的時候獲得一個空的 List,最后再調(diào)用 size 方法。
24、姓名年齡昵稱校驗
? ? ? ? ① 對于姓名,我們認為客戶端傳 null 是希望把姓名重置為空,允許這樣的操作,使用 Optional 的 orElse 方法一鍵把空轉(zhuǎn)換為空字符串即可。
? ? ? ? ② 對于年齡,我們認為如果客戶端希望更新年齡就必須傳一個有效的年齡,年齡不存在重置操作,可以使用 Optional 的 orElseThrow 方法在值為空的時候拋出 IllegalArgumentException。
? ? ? ? ③ 對于昵稱,因為數(shù)據(jù)庫中姓名不可能為 null,所以可以放心地把昵稱設(shè)置為 guest 加上數(shù)據(jù)庫取出來的姓名。
25、mysql空值問題
? ? ? ? ①?MySQL 中 sum 函數(shù)沒統(tǒng)計到任何記錄時,會返回 null 而不是 0,可以使用 IFNULL 函數(shù)把 null 轉(zhuǎn)換為 0;
? ? ? ? ②?MySQL 中 count 字段不統(tǒng)計 null 值,COUNT(*) 才是統(tǒng)計所有記錄數(shù)量的正確方式。
? ? ? ? ③?MySQL 中使用諸如 =、<、> 這樣的算數(shù)比較操作符比較 NULL 的結(jié)果總是 NULL,這種比較就顯得沒有任何意義,需要使用 IS NULL、IS NOT NULL 或 ISNULL() 函數(shù)來比較。
26、異常處理:
? ? ? 第一,注意捕獲和處理異常的最佳實踐。首先,不應(yīng)該用 AOP 對所有方法進行統(tǒng)一異常處理,異常要么不捕獲不處理,要么根據(jù)不同的業(yè)務(wù)邏輯、不同的異常類型進行精細化、針對性處理;其次,處理異常應(yīng)該杜絕生吞,并確保異常棧信息得到保留;最后,如果需要重新拋出異常的話,請使用具有意義的異常類型和異常消息。
????????第二,務(wù)必小心 finally 代碼塊中資源回收邏輯,確保 finally 代碼塊不出現(xiàn)異常,內(nèi)部把異常處理完畢,避免 finally 中的異常覆蓋 try 中的異常;或者考慮使用 addSuppressed 方法把 finally 中的異常附加到 try 中的異常上,確保主異常信息不丟失。此外,使用實現(xiàn)了 AutoCloseable 接口的資源,務(wù)必使用 try-with-resources 模式來使用資源,確保資源可以正確釋放,也同時確保異??梢哉_處理。
????????第三,雖然在統(tǒng)一的地方定義收口所有的業(yè)務(wù)異常是一個不錯的實踐,但務(wù)必確保異常是每次 new 出來的,而不能使用一個預(yù)先定義的 static 字段存放異常,否則可能會引起棧信息的錯亂。
????????第四,確保正確處理了線程池中任務(wù)的異常,如果任務(wù)通過 execute 提交,那么出現(xiàn)異常會導(dǎo)致線程退出,大量的異常會導(dǎo)致線程重復(fù)創(chuàng)建引起性能問題,我們應(yīng)該盡可能確保任務(wù)不出異常,同時設(shè)置默認的未捕獲異常處理程序來兜底;如果任務(wù)通過 submit 提交意味著我們關(guān)心任務(wù)的執(zhí)行結(jié)果,應(yīng)該通過拿到的 Future 調(diào)用其 get 方法來獲得任務(wù)運行結(jié)果和可能出現(xiàn)的異常,否則異??赡芫捅簧塘恕??
26、字符集亂碼處理問題修復(fù)
private static void right1() throws IOException {
char[] chars = new char[10];
String content = "";
try (FileInputStream fileInputStream = new FileInputStream("hello.txt");
InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName("GBK"))) {
int count;
while ((count = inputStreamReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
log.info("result: {}", content);
}
27、流讀取關(guān)閉
????????注意使用 try-with-resources 方式來配合,確保流的 close 方法可以調(diào)用釋放資源。
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> {
try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) {
lines.forEach(line -> longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
});
log.info("total : {}", longAdder.longValue());
28、緩沖區(qū)讀取數(shù)據(jù)
private static void bufferOperationWith100Buffer() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
byte[] buffer = new byte[100];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}
29、java 8時間處理
30、時間計算下一個月
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
其實是因為 int 發(fā)生了溢出。修復(fù)方式就是把 30 改為 30L,讓其成為一個 long:
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
31、Java 8 的日期時間類型,可以直接進行各種計算,更加簡潔和方便:
①?可以使用各種 minus 和 plus 方法直接對日期進行加減操作,比如如下代碼實現(xiàn)了減一天和加一天,以及減一個月和加一個月:
LocalDateTime localDateTime = LocalDateTime.now();
System.out.println(localDateTime.plusDays(30));
System.out.println("http://測試操作日期");
System.out.println(LocalDate.now()
.minus(Period.ofDays(1))
.plus(1, ChronoUnit.DAYS)
.minusMonths(1)
.plus(Period.ofMonths(1)));
②?還可以通過 with 方法進行快捷時間調(diào)節(jié),比如:
????????使用 TemporalAdjusters.firstDayOfMonth 得到當前月的第一天;使用 ????????TemporalAdjusters.firstDayOfYear() 得到當前年的第一天;使用 ????????TemporalAdjusters.previous(DayOfWeek.SATURDAY) 得到上一個周六;使用 ????????TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY) 得到本月最后一個周五。
System.out.println("http://本月的第一天");
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()));
System.out.println("http://今年的程序員日");
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).plusDays(255));
System.out.println("http://今天之前的一個周六");
System.out.println(LocalDate.now().with(TemporalAdjusters.previous(DayOfWeek.SATURDAY)));
System.out.println("http://本月最后一個工作日");
System.out.println(LocalDate.now().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));
③?可以直接使用 lambda 表達式進行自定義的時間調(diào)整。比如,為當前時間增加 100 天以內(nèi)的隨機天數(shù):
System.out.println(LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
④?除了計算外,還可以判斷日期是否符合某個條件。比如,自定義函數(shù),判斷指定日期是否是家庭成員的生日:
public static Boolean isFamilyBirthday(TemporalAccessor date) {
int month = date.get(MONTH_OF_YEAR);
int day = date.get(DAY_OF_MONTH);
if (month == Month.FEBRUARY.getValue() && day == 17)
return Boolean.TRUE;
if (month == Month.SEPTEMBER.getValue() && day == 21)
return Boolean.TRUE;
if (month == Month.MAY.getValue() && day == 22)
return Boolean.TRUE;
return Boolean.FALSE;
}
????????然后,使用 query 方法查詢是否匹配條件:
System.out.println("http://查詢是否是今天要舉辦生日");
System.out.println(LocalDate.now().query(CommonMistakesApplication::isFamilyBirthday));
⑤?Java 8 中有一個專門的類 Period 定義了日期間隔,通過 Period.between 得到了兩個 LocalDate 的差,返回的是兩個日期差幾年零幾月零幾天。如果希望得知兩個日期之間差幾天,直接調(diào)用 Period 的 getDays() 方法得到的只是最后的“零幾天”,而不是算總的間隔天數(shù)。
????????比如,計算 2019 年 12 月 12 日和 2019 年 10 月 1 日的日期間隔,很明顯日期差是 2 個月零 11 天,但獲取 getDays 方法得到的結(jié)果只是 11 天,而不是 72 天:
System.out.println("http://計算日期差");
LocalDate today = LocalDate.of(2019, 12, 12);
LocalDate specifyDate = LocalDate.of(2019, 10, 1);
System.out.println(Period.between(specifyDate, today).getDays());
System.out.println(Period.between(specifyDate, today));
System.out.println(ChronoUnit.DAYS.between(specifyDate, today));
32、緩存重復(fù)信息導(dǎo)致OOM
? ? ? ? 解決方案?:HashSet?來緩存用戶信息,防止多份重復(fù)的用戶信息
?33、spring注意事項
????????第一,讓 Spring 容器管理對象,要考慮對象默認的 Scope 單例是否適合,對于有狀態(tài)的類型,單例可能產(chǎn)生內(nèi)存泄露問題。
????????第二,如果要為單例的 Bean 注入 Prototype 的 Bean,絕不是僅僅修改 Scope 屬性這么簡單。由于單例的 Bean 在容器啟動時就會完成一次性初始化。最簡單的解決方案是,把 Prototype 的 Bean 設(shè)置為通過代理注入,也就是設(shè)置 proxyMode 屬性為 TARGET_CLASS。文章來源:http://www.zghlxwxcb.cn/news/detail-680625.html
????????第三,如果一組相同類型的 Bean 是有順序的,需要明確使用 @Order 注解來設(shè)置順序。你可以再回顧下,兩個不同優(yōu)先級切面中 @Before、@After 和 @Around 三種增強的執(zhí)行順序,是什么樣的。文章來源地址http://www.zghlxwxcb.cn/news/detail-680625.html
到了這里,關(guān)于java錯誤處理百科的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!