ConcurrentBag 聽過沒?好傢伙高併發知識點十分密集!


今天給大家剖析下一個叫 ConcurrentBag 的併發集合類,對 C# 熟悉的同學應該聽過這個名字,不過我今天介紹的是 HikariCP 中的 ConcurrentBag。

我們知道 SpringBoot 默認連接池就是 HikariCP,而 HikariCP 就是以快著稱的,而這個快離不開 ConcurrentBag。

如果你看過很多源碼你就會發現好多框架都會自定義集合類,因爲 JDK 通用的集合需要照顧到很多場景,而定製化肯定優於普適化

像 HikariCP 就沒有用 ArrayList 而是定義了一個  FastList,因爲 ArrayList 每次 get 都會有範圍檢查,並且 remove 是從前往後遍歷的。

而在 HikariCP 這個場景每次 get 範圍檢查沒有必要,並且 remove 的時候從後往前遍歷更好,所以就定製化了。

HikariCP 還有很多優化,這篇文章我們就談談其中之一,也就是今天的主角就是 ConcurrentBag 。

不過今天的目的不是爲了分析 HikariCP ,而只是介紹這個集合類。

從它身上找點優化的思路,到時候像面試官問你如何設計一個連接池的時候就可以搬出來:“哎呀,我有個優化思路。”

ConcurrentBag

一般而言我們設計一個連接池的初始想法是用鎖來保證線程安全,或者用一些線程安全的併發容器來存儲連接。

而 HikariCP 不滿足於此,它專門設計了 ConcurrentBag 用來存數據庫連接,當 HikariPool#getConnection 的時候就是去 ConcurrentBag  拿連接。

ConcurrentBag 整體就是無鎖設計,有三個重要的成員變量:

  • ThreadLocal 緩存,加快本地連接獲取速度
  • CopyOnWriteArrayList,寫時拷貝List
  • SynchronousQueue,無存儲的等待隊列

獲取數據庫連接基本流程如下:

  1. 當取連接的時候會先去 ThreadLocal 去找以前用過的連接,如果找到連接狀態是可以使用的話拿直接返回。(ThreadLocal 是本地資源,每個線程都優先去自己本地去找,所以競爭也更少,需要遍歷的連接也更少,所以速度就更快)
  2. 找不到再去 sharedList 這個共享的寫時複製列表中查找可用連接。
  3. 如果再找不到,則通過 handoffQueue 等待可用的連接,如果超過一定時間則返回 null。

其實這種思想很簡單。

每個線程一開始本地資源肯定是空的,然後每個線程把自己用過的連接存起來,之後優先用存着的鏈接。

久而久之每個線程都會有自己的本地存儲的連接,這樣大家都用自己的就少了競爭,那速度不就快了?

我們再來看下取連接的源碼,裏面還是有一些細節的。

其實應該叫借連接,因爲要還的,而且也不是把連接從 ConcurrentBag 移除,只是返回一個引用罷了。

細節已經在代碼上標註了,這裏強調一下借連接不是移除連接,別的線程還是能通過 sharedList 找到這個連接的,無非這個連接如果被佔用則狀態是 STATE_IN_USE,這樣別的線程就不會用這個連接了。

總體思路就是從本地找,沒有的話再去每個線程都能訪問的 sharedList 找,再沒有就等着。

這裏還有個竊取的概念,其實沒什麼花頭,就是充分利用連接。

無非就是本來屬於某個線程的本地連接,當它歸還連接的時,恰巧有另一個線程從 sharedList 遍歷找到這個連接,這時候連接的狀態是 STATE_NOT_IN_USE,那麼這個連接就會被另一個線程也保存到 ThreadLocal 中了。

這就是竊取,我們再來看下歸還連接的代碼,連接就是在這裏保存到 ThreadLocal 中的。

我在《HikariCP數據庫連接池實戰》這本書中看到,歸還連接的代碼在 HikariCP 2.6.0 是長下面這個樣子的

先停下來想想看有沒有啥問題?

當前歸還連接的線程需要等這個連接被其他線程取走時或者沒有等待線程時才能擺脫這個循環。

但是會出現一種情況:在設置連接爲可用時,這個連接已經被其他線程借走了,然後當前線程還傻傻的執行循環,而恰巧等待線程一直有,但是每次 handoffQueue.offer 就是沒線程取,然後 yield ,如此往復。

這就造成明明連接已經歸還了,而歸還的線程還做無用功的自旋操作,所以就做優化成上面的代碼,如果bagEntry.getState() != STATE_NOT_IN_USE 說明已經被別的線程借去用了,所以直接 return。

再提一提 CopyOnWriteArrayList 吧。

連接池是一個典型的讀多寫少的場景,所以寫時複製用在此處再合適不過了。

簡單的說:寫操作的時候會複製當前的 list 來做修改,等修改完了再替換老的 list。

在替換之前讀的線程讀取的是老的 list 的數據,這樣就能做到讀的時候是無鎖的。

寫時複製的缺點就是內存的佔用,因爲需要拷貝一份數據,如果數據很大的話那就需要考慮內容的佔用量了。

比如操作系統進程的 fork 操作也會用到寫時複製,子進程和父進程一開始共享數據,當有修改的時候就會拷貝一份。

在 Redis 的 BGSAVE 命令或者 BGREWRITEAOF 命令的過程中就會 fork 子進程來進行後臺操作,而此時 Redis 的哈希表擴容的負載因子就會變大,來避免 fork 期間不必要的內存寫入操作 (擴容)。

最後

所以 ConcurrentBag 的優化思路就是本地緩存有的去本地緩存找連接,找不到就去公共的 sharedList 去找,還找不到就等着。

通過將連接本地存儲化來減少競爭,又根據連接池讀多寫少的特性用 CopyOnWriteArrayList 來實現 sharedList 。

當然還有像上面 borrow 和 requite 的一些細節也值得品味,追求極致速度就需要扣細節。

巨人的肩膀

《HikariCP數據庫連接池實戰》

本文分享自微信公衆號 - 武培軒(wupeixuan404)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章