Java 并發編程對象組合與封閉性實踐指南
本文將介紹一種基于對象組合哲學的并發編程的封裝技術,確保團隊在開發過程中,即使對整體項目不是非常了解的情況下,依然可以明確一個類的線程安全性。
一、對象組合與安全委托
1. 實例封閉技術
為了保證并發操作場景下實例訪問的安全性,我們可利用組合的方式將實例委托給其它實例,即基于該委托類對外暴露實例的部分操作,封閉風險調用,確保對象訪問時是安全且一致的。就像下圖這樣,將obj委托給delegate進行管理,將set操作封閉不對外暴露,確保僅通過暴露只讀避免對象逸出:
對應的,如果我們想實現一個線程安全的HashMap緩存的安全發布和訪問,對應落地技巧為:
- HashMap實例私有封閉
- 基于final保證HashMap域的不可變
- 采用同一粒度的類鎖發布HashMap的讀寫操作一致和安全,同時保證外部不可直接操作cache
如下所示,我們隱藏了HashMap部分操作,同時基于監視鎖synchronized 保證讀寫操作可見且安全:
public class Cache {
//實例私有并在內部完成初始化
private static final Map<String, Object> cache = new HashMap<>();
public static synchronized void put(String key, Object object) {
cache.put(key, object);
}
public static synchronized Object get(String key) {
return cache.get(key);
}
}
需要注意的時,筆者上文強調的是被委托的容器cache的安全,基于get方法訪問到object還是會被發布出去,此時就可能在并發操的線程安全問題:
所以如果開發人員需要保證讀取對象的安全,建議用存儲的值也采用final修飾一下后存入容器中。
public static void main(String[] args) {
final User user = new User(4,"val-4");
put("k-1", user);
}
private static class User{
//使用final修飾保證對應成員域不可修改
private final int id;
private final String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
}
2. 基于監視器模式的對象訪問
從線程封閉原則及邏輯推論可以得出java監視器模式,對于并發操作下的對象讀訪問,我們可以采用監視器模式將可變狀態加以封裝,我們以常用的java list為例,整體封裝思路為:
- 將需要管理的被委托的List以不可變的成員域的方式組合到SafeList 中
- 使用final保證列表安全初始化且不可變
- List選用不可變列表,做好安全兜底,避免順序等遭到破壞
- 屏蔽所有容器的刪改操作
- 訪問對象在進行必要性校驗后,返回深拷貝的對象,不暴露容器內部細節
對應的代碼如下所示:
public class SafeList {
//final修飾保證list安全初始化
private final List<Person> list;
public SafeList(List<Person> list) {
//使用不可變方法為容器做好安全兜底,保證列表不可進行增、閃、刪、改操作
this.list = Collections.unmodifiableList(list);
}
//通過拷貝將對象安全發布出去,因為只讀所以無需上鎖
public Person getPerson(int idx) {
if (idx >= list.size()) {
throw new RuntimeException("index out of bound");
}
Person person = list.get(idx);
return new Person(person.getId(),person.getName());
}
}
對應為了保證代碼的完整性我們也給出Person 類的實現:
public class Person {
private int id;
private String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
//get set ......
}
3. 對象不可變性簡化委托
基于監視器模式我們可以很好的保證對象的安全訪問,實際上我們可以做好更好,上文通過實例封閉和僅只讀權限保證容器的并發操作安全,同時在只讀操作返回Person 時我們也用了深拷貝發布一個全新的實例出去,保證容器內部的元素不可變,實際上如果我們能夠將Person 屬性保證不可變的情況下將其委托給容器,訪問操作也可以直接返回:
public class Person {
private final int id;
private final String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
}
由此我們的代碼就可以簡化成下面這樣,因為避免的對象拷貝的過程,程序性能也得到提升:
public Person getPerson(int idx) {
if (idx >= list.size()) {
throw new RuntimeException("index out of bound");
}
//person字段不可變,可直接返回
return list.get(idx);
}
對應的我們基于下屬代碼針對Person拷貝發布和只讀封裝兩種模式進行壓測,對應結果為:
- 拷貝發布因為拷貝的開銷耗時353ms
- 采用只讀發布的耗時為152ms
//生成測試樣本
List<Person> personList = IntStream.rangeClosed(1, 500_0000).parallel()
.boxed()
.map(i -> new Person(i, RandomUtil.randomString(10)))
.collect(Collectors.toList());
//生成安全容器
SafeList safeList = new SafeList(personList);
//進行并發訪問壓測
int threadSize = 1000;
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService threadPool = Executors.newFixedThreadPool(threadSize);
long begin = System.currentTimeMillis();
for (int i = 0; i < threadSize; i++) {
threadPool.execute(() -> {
Person person = safeList.getPerson(RandomUtil.randomInt(500_0000));
boolean b = 1 != 1;
if (b) {
Console.log(JSONUtil.toJsonStr(person));
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
//計算輸出耗時
Console.log("cost:{}ms", end - begin);
//關閉線程池
threadPool.shutdownNow();
4. 原子維度的訪問
如果我們被委托的對象是要求可變的,那么我們就需要保證所有字段的操作是互斥且原子的。例如我們現在要委托給容器一個坐標對象,因為坐標的值會實時改變的,所以在進行坐標操作時,我們必須保證讀寫的一致性,即set和get都必須一次性針對x、y,從而避免當為非原子操作讀取操一些異常的做坐標。
將兩者分開處理則可能會因為非原子操作在并發情況下看到一個非法的邏輯坐標,例如:
- 坐標發生改變,線程0進入修改,調用setX修改x坐標。
- 線程2訪問,看到一個修改后的x和未修改的y,定位異常。
- 線程1修改y坐標。
正確的坐標設置方式如下代碼所示,即x、y保證同時進行讀寫保證正確的坐標更新與讀取:
public class SafePoint {
private int x;
private int y;
public SafePoint(int x, int y) {
this.x = x;
this.y = y;
}
//原子維度操作保證操作的一致性
public synchronized void setXandY(int x, int y) {
this.x = x;
this.y = y;
}
//原子返回保證x、y,保證看到x、y實時一致修改后的值
public synchronized int[] getXandY() {
return new int[]{x, y};
}
}
所以對于相關聯的字段,除了必要的同步鎖操作,我們還需要在將操作進行原子化,保證讀取數據的實時正確一致。
二、現有容器的并發安全的封裝哲學
1. 使用繼承
Java類庫中內置了許多見狀的基礎模塊類,日常使用時我們應該優先重要這些類,然后在此基礎上將類進行拓展封裝,例如我們基于古典的線程安全列表vector實現一個若沒有對應元素則添加的操作:
public class BetterVector extends Vector {
//通過繼承獲取vector的api完成如果沒有則添加的線程安全原子操作
public synchronized void addIfAbsent(Object o) {
if (!contains(o)) {
super.add(o);
}
}
}
當然這種方法也是存在風險的:
- 它暴露了vector的其他方法
- 開發者如果對于BetterVector沒有詳細的了解的話,可能還是會將contain和add操作錯誤的組合使用,操作一致性問題。
例如下圖所示步驟:
- 線程0先判斷1不存在釋放鎖
- 線程1判斷1不存在添加
- 線程0基于contain操作結果即1不存在將元素1添加
此時vector就出現兩個1:
2. 使用組合
所以我們推薦實用組合的方式,通過將需要拓展的容器以組合的方式屏蔽內置容器的實現細節:
private List<Person> list = new ArrayList<>();
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
但需要注意對于組合操作下操作粒度鎖的把控,例如下面這段代碼:
public class SafeList {
private final List<Person> list;
public SafeList(List<Person> list) {
this.list = Collections.synchronizedList(list);
}
//當前方法鎖的粒度是被委托的實例
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
public void add(Person person) {
//add操作查看底層源碼用的鎖是 mutex = this;
list.add(person);
}
}
咋一看沒什么問題,本質上都是上了鎖,實際上add和addIfAbsent用的是兩把鎖:
- addIfAbsent用的是當前SafeList實例作為鎖
- 而add因為直接復用add方法所以用的是synchronizedList的對象鎖
這就使得addIfAbsent操作不是原子的,即在addIfAbsent操作期間,其他線程是可以直接調用list的api:
所以正確的做法是基于被組合安全容器的鎖,構建相同維度的拓展方法:
private List<Person> list = Collections.synchronizedList(new ArrayList<>());
//當前方法鎖的粒度是被委托的實例
public void addIfAbsent(Person person) {
synchronized (list) {
if (list.isEmpty()) {
list.add(person);
}
}
}
public void add(Person person) {
//add操作查看底層源碼用的鎖是 mutex = this;
list.add(person);
}