Java 并發(fā)編程對(duì)象組合與封閉性實(shí)踐指南
本文將介紹一種基于對(duì)象組合哲學(xué)的并發(fā)編程的封裝技術(shù),確保團(tuán)隊(duì)在開(kāi)發(fā)過(guò)程中,即使對(duì)整體項(xiàng)目不是非常了解的情況下,依然可以明確一個(gè)類的線程安全性。
一、對(duì)象組合與安全委托
1. 實(shí)例封閉技術(shù)
為了保證并發(fā)操作場(chǎng)景下實(shí)例訪問(wèn)的安全性,我們可利用組合的方式將實(shí)例委托給其它實(shí)例,即基于該委托類對(duì)外暴露實(shí)例的部分操作,封閉風(fēng)險(xiǎn)調(diào)用,確保對(duì)象訪問(wèn)時(shí)是安全且一致的。就像下圖這樣,將obj委托給delegate進(jìn)行管理,將set操作封閉不對(duì)外暴露,確保僅通過(guò)暴露只讀避免對(duì)象逸出:
對(duì)應(yīng)的,如果我們想實(shí)現(xiàn)一個(gè)線程安全的HashMap緩存的安全發(fā)布和訪問(wèn),對(duì)應(yīng)落地技巧為:
- HashMap實(shí)例私有封閉
- 基于final保證HashMap域的不可變
- 采用同一粒度的類鎖發(fā)布HashMap的讀寫操作一致和安全,同時(shí)保證外部不可直接操作cache
如下所示,我們隱藏了HashMap部分操作,同時(shí)基于監(jiān)視鎖synchronized 保證讀寫操作可見(jiàn)且安全:
public class Cache {
//實(shí)例私有并在內(nèi)部完成初始化
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);
}
}
需要注意的時(shí),筆者上文強(qiáng)調(diào)的是被委托的容器cache的安全,基于get方法訪問(wèn)到object還是會(huì)被發(fā)布出去,此時(shí)就可能在并發(fā)操的線程安全問(wèn)題:
所以如果開(kāi)發(fā)人員需要保證讀取對(duì)象的安全,建議用存儲(chǔ)的值也采用final修飾一下后存入容器中。
public static void main(String[] args) {
final User user = new User(4,"val-4");
put("k-1", user);
}
private static class User{
//使用final修飾保證對(duì)應(yīng)成員域不可修改
private final int id;
private final String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
}
2. 基于監(jiān)視器模式的對(duì)象訪問(wèn)
從線程封閉原則及邏輯推論可以得出java監(jiān)視器模式,對(duì)于并發(fā)操作下的對(duì)象讀訪問(wèn),我們可以采用監(jiān)視器模式將可變狀態(tài)加以封裝,我們以常用的java list為例,整體封裝思路為:
- 將需要管理的被委托的List以不可變的成員域的方式組合到SafeList 中
- 使用final保證列表安全初始化且不可變
- List選用不可變列表,做好安全兜底,避免順序等遭到破壞
- 屏蔽所有容器的刪改操作
- 訪問(wèn)對(duì)象在進(jìn)行必要性校驗(yàn)后,返回深拷貝的對(duì)象,不暴露容器內(nèi)部細(xì)節(jié)
對(duì)應(yīng)的代碼如下所示:
public class SafeList {
//final修飾保證list安全初始化
private final List<Person> list;
public SafeList(List<Person> list) {
//使用不可變方法為容器做好安全兜底,保證列表不可進(jìn)行增、閃、刪、改操作
this.list = Collections.unmodifiableList(list);
}
//通過(guò)拷貝將對(duì)象安全發(fā)布出去,因?yàn)橹蛔x所以無(wú)需上鎖
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());
}
}
對(duì)應(yīng)為了保證代碼的完整性我們也給出Person 類的實(shí)現(xiàn):
public class Person {
private int id;
private String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
//get set ......
}
3. 對(duì)象不可變性簡(jiǎn)化委托
基于監(jiān)視器模式我們可以很好的保證對(duì)象的安全訪問(wèn),實(shí)際上我們可以做好更好,上文通過(guò)實(shí)例封閉和僅只讀權(quán)限保證容器的并發(fā)操作安全,同時(shí)在只讀操作返回Person 時(shí)我們也用了深拷貝發(fā)布一個(gè)全新的實(shí)例出去,保證容器內(nèi)部的元素不可變,實(shí)際上如果我們能夠?qū)erson 屬性保證不可變的情況下將其委托給容器,訪問(wèn)操作也可以直接返回:
public class Person {
private final int id;
private final String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
}
由此我們的代碼就可以簡(jiǎn)化成下面這樣,因?yàn)楸苊獾膶?duì)象拷貝的過(guò)程,程序性能也得到提升:
public Person getPerson(int idx) {
if (idx >= list.size()) {
throw new RuntimeException("index out of bound");
}
//person字段不可變,可直接返回
return list.get(idx);
}
對(duì)應(yīng)的我們基于下屬代碼針對(duì)Person拷貝發(fā)布和只讀封裝兩種模式進(jìn)行壓測(cè),對(duì)應(yīng)結(jié)果為:
- 拷貝發(fā)布因?yàn)榭截惖拈_(kāi)銷耗時(shí)353ms
- 采用只讀發(fā)布的耗時(shí)為152ms
//生成測(cè)試樣本
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);
//進(jìn)行并發(fā)訪問(wèn)壓測(cè)
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();
//計(jì)算輸出耗時(shí)
Console.log("cost:{}ms", end - begin);
//關(guān)閉線程池
threadPool.shutdownNow();
4. 原子維度的訪問(wèn)
如果我們被委托的對(duì)象是要求可變的,那么我們就需要保證所有字段的操作是互斥且原子的。例如我們現(xiàn)在要委托給容器一個(gè)坐標(biāo)對(duì)象,因?yàn)樽鴺?biāo)的值會(huì)實(shí)時(shí)改變的,所以在進(jìn)行坐標(biāo)操作時(shí),我們必須保證讀寫的一致性,即set和get都必須一次性針對(duì)x、y,從而避免當(dāng)為非原子操作讀取操一些異常的做坐標(biāo)。
將兩者分開(kāi)處理則可能會(huì)因?yàn)榉窃硬僮髟诓l(fā)情況下看到一個(gè)非法的邏輯坐標(biāo),例如:
- 坐標(biāo)發(fā)生改變,線程0進(jìn)入修改,調(diào)用setX修改x坐標(biāo)。
- 線程2訪問(wèn),看到一個(gè)修改后的x和未修改的y,定位異常。
- 線程1修改y坐標(biāo)。
正確的坐標(biāo)設(shè)置方式如下代碼所示,即x、y保證同時(shí)進(jìn)行讀寫保證正確的坐標(biāo)更新與讀?。?/p>
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實(shí)時(shí)一致修改后的值
public synchronized int[] getXandY() {
return new int[]{x, y};
}
}
所以對(duì)于相關(guān)聯(lián)的字段,除了必要的同步鎖操作,我們還需要在將操作進(jìn)行原子化,保證讀取數(shù)據(jù)的實(shí)時(shí)正確一致。
二、現(xiàn)有容器的并發(fā)安全的封裝哲學(xué)
1. 使用繼承
Java類庫(kù)中內(nèi)置了許多見(jiàn)狀的基礎(chǔ)模塊類,日常使用時(shí)我們應(yīng)該優(yōu)先重要這些類,然后在此基礎(chǔ)上將類進(jìn)行拓展封裝,例如我們基于古典的線程安全列表vector實(shí)現(xiàn)一個(gè)若沒(méi)有對(duì)應(yīng)元素則添加的操作:
public class BetterVector extends Vector {
//通過(guò)繼承獲取vector的api完成如果沒(méi)有則添加的線程安全原子操作
public synchronized void addIfAbsent(Object o) {
if (!contains(o)) {
super.add(o);
}
}
}
當(dāng)然這種方法也是存在風(fēng)險(xiǎn)的:
- 它暴露了vector的其他方法
- 開(kāi)發(fā)者如果對(duì)于BetterVector沒(méi)有詳細(xì)的了解的話,可能還是會(huì)將contain和add操作錯(cuò)誤的組合使用,操作一致性問(wèn)題。
例如下圖所示步驟:
- 線程0先判斷1不存在釋放鎖
- 線程1判斷1不存在添加
- 線程0基于contain操作結(jié)果即1不存在將元素1添加
此時(shí)vector就出現(xiàn)兩個(gè)1:
2. 使用組合
所以我們推薦實(shí)用組合的方式,通過(guò)將需要拓展的容器以組合的方式屏蔽內(nèi)置容器的實(shí)現(xiàn)細(xì)節(jié):
private List<Person> list = new ArrayList<>();
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
但需要注意對(duì)于組合操作下操作粒度鎖的把控,例如下面這段代碼:
public class SafeList {
private final List<Person> list;
public SafeList(List<Person> list) {
this.list = Collections.synchronizedList(list);
}
//當(dāng)前方法鎖的粒度是被委托的實(shí)例
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
public void add(Person person) {
//add操作查看底層源碼用的鎖是 mutex = this;
list.add(person);
}
}
咋一看沒(méi)什么問(wèn)題,本質(zhì)上都是上了鎖,實(shí)際上add和addIfAbsent用的是兩把鎖:
- addIfAbsent用的是當(dāng)前SafeList實(shí)例作為鎖
- 而add因?yàn)橹苯訌?fù)用add方法所以用的是synchronizedList的對(duì)象鎖
這就使得addIfAbsent操作不是原子的,即在addIfAbsent操作期間,其他線程是可以直接調(diào)用list的api:
所以正確的做法是基于被組合安全容器的鎖,構(gòu)建相同維度的拓展方法:
private List<Person> list = Collections.synchronizedList(new ArrayList<>());
//當(dāng)前方法鎖的粒度是被委托的實(shí)例
public void addIfAbsent(Person person) {
synchronized (list) {
if (list.isEmpty()) {
list.add(person);
}
}
}
public void add(Person person) {
//add操作查看底層源碼用的鎖是 mutex = this;
list.add(person);
}