多個線程同時讀寫同一共享變量存在並發問題,其中的必要條件之一就是 讀寫 ,如果沒有寫,只存在讀,是不會存在並發問題的。
如果讓一個共享變量只有讀操作,沒有寫操作,如此則可以解決並發問題。該理論的具體實現就是 不變性(Immutability)模式。所謂不變性,簡單來講,就是對象一旦被創建之后,狀態就不再發生變化。換句話說,就是變量一旦被賦值,就不允許修改了(沒有寫操作);沒有修改操作,也就是保持了不變性。
實現具備不可變性的類
將一個類所有的屬性都設置成 final 的,並且只允許存在只讀方法,那么這個類基本上就具備不可變性了。更嚴格的做法是這個類本身也是 final 的,也就是不允許繼承。因為子類可以覆蓋父類的方法,有可能改變不可變性。
Java SDK 里很多類都具備不可變性,只是由於它們的使用太簡單,最后反而被忽略了。例如經常用到的 String 和 Long、Integer、Double 等基礎類型的包裝類都具備不可變性,這些對象的線程安全性都是靠不可變性來保證的。如果你仔細翻看這些類的聲明、屬性和方法,你會發現它們都嚴格遵守不可變類的三點要求:類和屬性都是 final 的,所有方法均是只讀的。
看到這里你可能會疑惑,Java 的 String 方法也有類似字符替換操作,怎么能說所有方法都是只讀的呢?下面通過String 的源代碼來看一哈。
下面的示例代碼源自 Java 1.8 SDK。String 這個類以及它的屬性 value[] 都是 final 的;而 replace() 方法的實現,就的確沒有修改 value[],而是將替換后的字符串作為返回值返回了。
public final class String {
private final char value[];
// 字符替換
String replace(char oldChar,
char newChar) {
// 無需替換,直接返回 this
if (oldChar == newChar){
return this;
}
int len = value.length;
int i = -1;
/* avoid getfield opcode */
char[] val = value;
// 定位到需要替換的字符位置
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
// 未找到 oldChar,無需替換
if (i >= len) {
return this;
}
// 創建一個 buf[],這是關鍵
// 用來保存替換后的字符串
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ?
newChar : c;
i++;
}
// 創建一個新的字符串返回
// 原字符串不會發生任何變化
return new String(buf, true);
}
}
由上面的代碼可以發現,String 是通過創建一個新的不可變對象 來實現 修改 的功能。如果 所有的修改操作都創建一個新的不可變對象,你可能會有這種擔心:是不是創建的對象太多了,有點太浪費內存呢?是的,這樣做的確有些浪費,那如何解決呢?
利用享元模式避免創建重復對象
利用享元模式可以減少創建對象的數量,從而減少內存占用。Java 語言里面 Long、Integer、Short、Byte 等這些基本數據類型的包裝類都用到了享元模式。
下面以 Long 這個類作為例子,看看它是如何利用享元模式來優化對象的創建的。
享元模式本質上其實就是一個對象池,利用享元模式創建對象的邏輯也很簡單:創建之前,首先去對象池里看看是不是存在;如果已經存在,就利用對象池里的對象;如果不存在,就會新創建一個對象,並且把這個新創建出來的對象放進對象池里。
Long 這個類並沒有照搬享元模式,Long 內部維護了一個靜態的對象池,僅緩存了 [-128,127] 之間的數字,這個對象池在 JVM 啟動的時候就創建好了,而且這個對象池一直都不會變化,也就是說它是靜態的。之所以采用這樣的設計,是因為 Long 這個對象的狀態共有 2的64次方 種,實在太多,並不適合全部緩存,而 [-128,127] 之間的數字利用率最高。下面的示例代碼出自 Java 1.8,valueOf() 方法就用到了 LongCache 這個緩存。
Long valueOf(long l) {
final int offset = 128;
// [-128,127] 直接的數字做了緩存
if (l >= -128 && l <= 127) {
return LongCache
.cache[(int)l + offset];
}
return new Long(l);
}
// 緩存,等價於對象池
// 僅緩存 [-128,127] 直接的數字
static class LongCache {
static final Long cache[]
= new Long[-(-128) + 127 + 1];
static {
for(int i=0; i<cache.length; i++)
cache[i] = new Long(i-128);
}
}
注意: “Integer 和 String 類型的對象不適合做鎖”,其實基本上所有的基礎類型的包裝類都不適合做鎖,因為它們內部用到了享元模式,這會導致看上去私有的鎖,其實是共有的。例如在下面代碼中,本意是 A 用鎖 al,B 用鎖 bl,各自管理各自的,互不影響。但實際上 al 和 bl 是一個對象,結果 A 和 B 共用的是一把鎖。
class A {
Long al=Long.valueOf(1);
public void setAX(){
synchronized (al) {
// 省略代碼無數
}
}
}
class B {
Long bl=Long.valueOf(1);
public void setBY(){
synchronized (bl) {
// 省略代碼無數
}
}
}
使用 Immutability 模式的注意事項
在使用 Immutability 模式的時候,需要注意以下兩點:
- 對象的所有屬性都是 final 的,並不能保證不可變性;
- 不可變對象也需要正確發布。
在 Java 語言中,final 修飾的屬性一旦被賦值,就不可以再修改,但是如果屬性的類型是普通對象,那么這個普通對象的屬性是可以被修改的。例如下面的代碼中,Bar 的屬性 foo 雖然是 final 的,依然可以通過 setAge() 方法來設置 foo 的屬性 age。所以,在使用 Immutability 模式的時候一定要確認保持不變性的邊界在哪里,是否要求屬性對象也具備不可變性。
class Foo{
int age=0;
int name="abc";
}
final class Bar {
final Foo foo;
void setAge(int a){
foo.age=a;
}
}
下面我們再看看如何正確地發布不可變對象。不可變對象雖然是線程安全的,但是並不意味着引用這些不可變對象的對象就是線程安全的。例如在下面的代碼中,Foo 具備不可變性,線程安全,但是類 Bar 並不是線程安全的,類 Bar 中持有對 Foo 的引用 foo,對 foo 這個引用的修改在多線程中並不能保證可見性和原子性。
//Foo 線程安全
final class Foo{
final int age=0;
final int name="abc";
}
//Bar 線程不安全
class Bar {
Foo foo;
void setFoo(Foo f){
this.foo=f;
}
}
如果你的程序僅僅需要 foo 保持可見性,無需保證原子性,那么可以將 foo 聲明為 volatile 變量,這樣就能保證可見性。如果你的程序需要保證原子性,那么可以通過原子類來實現。下面的示例代碼是合理庫存的原子化實現,你應該很熟悉了,其中就是用原子類解決了不可變對象引用的原子性問題。
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
// 省略構造函數實現
}
}
final AtomicReference<WMRange>
rf = new AtomicReference<>(
new WMRange(0,0)
);
// 設置庫存上限
void setUpper(int v){
while(true){
WMRange or = rf.get();
// 檢查參數合法性
if(v < or.lower){
throw new IllegalArgumentException();
}
WMRange nr = new
WMRange(v, or.lower);
if(rf.compareAndSet(or, nr)){
return;
}
}
}
}
總結
具備不變性的對象,只有一種狀態,這個狀態由對象內部所有的不變屬性共同決定。其實還有一種更簡單的不變性對象,那就是無狀態。無狀態對象內部沒有屬性,只有方法。除了無狀態的對象,你可能還聽說過無狀態的服務、無狀態的協議等等。無狀態有很多好處,最核心的一點就是性能。在多線程領域,無狀態對象沒有線程安全問題,無需同步處理,自然性能很好;在分布式領域,無狀態意味着可以無限地水平擴展,所以分布式領域里面性能的瓶頸一定不是出在無狀態的服務節點上。
