詳解 final 關鍵字的不可變性
關于final關鍵字也是筆者早期整理的一篇文章,內容比較基礎,所以借著假期將文章迭代一些,聊一些final關鍵字中的一些比較有意思的技術點,希望對你有幫助。

一、final關鍵字的不可變性
1. 為什么String要用final關鍵字修飾
final可以保證構造時的安全初始化,從而實現不受限制的并發訪問,查看String源碼可以看到無論是在類聲明還是存儲字符串的value成員變量,都通過final符加以修飾結合構造時安全初始化:
public finalclass String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
privatefinalchar value[];
//......
//安全構造,從而保證當前string類不受限制的被安全訪問
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
}這也就是利用到final關鍵字的第一個關鍵特性——不可變性,在Java中一切皆為對象,字符串類型也是一樣,所有的字符串對象也都存放在堆內存中,為了更好的做到解決堆內存空間和字符串的復用,一旦字符串類型做好了聲明并完成字符串創建之后,這個字符串對象的對應值就不可再修改了。
例如,我們通過下面這段代碼:
String str = new String("hello world");在完成該字符串對象的創建時,因為final對于value的修飾,其底層本質上就是創建了一個hello world的original不可變字符串,然后再創建一個String對象,并將其value和hash值設置hello world以及hello world的hash值:

這一點我們查看String的源碼定義也可以知曉這一點,可以看到上述的動作本質上就是將堆區的常量和我的字符串類進行關聯,后續對于代碼的各種修改操作,本質上也都是在字符串常量池中創建新的字面量與字符串類進行關聯:
public String(String original) {
//將字符串常量復制給當前字符串類
this.value = original.value;
//將字符串常量hash賦值給當前字符串
this.hash = original.hash;
}通過上述的原因保證了字符串的不可變性,使得堆內存中有了字符串常量池的概念,保證同一字符串可以復用,節約堆內存的同時還提升了程序的性能。同時為了保證這些操作不可被開發者修改與破壞,對于字符串類,設計者也將該類通過final修飾,保證字符串類的不可變性不被使用者通過繼承等方式遭到破壞,避免了一些字符串操作的安全漏洞和線程安全問題。
2. 利用final關鍵字實現常量折疊
我們再來看一個例子,如下所示,可以看到不同變量聲明的字符串test都和數字1進行拼接,最終與test1字符串進行==判斷引用地址是否一致:
//字符串常量池
String str1 = "test1";
//字符串變量
final String constStr = "test";
String str2 = "test";
//字符串拼接
String concatenatedWithConst = constStr + 1;
String concatenatedWithVar = str2 + 1;
//判斷是否是同一個對象
System.out.println(str1 == concatenatedWithConst);
System.out.println(str1 == concatenatedWithVar);最終輸出結果如下,可以看到采用final修飾的test字符串和數字1進行拼接之后,和str1的引用一致是一致的:
true
false對應的我們也給出上述代碼的字節碼,可以看到在JIT階段,對應19行(將constStr和數字1拼接),因為constStr的不可變性,JIT階段就會直接將其視為編譯時常量和1進行拼接運算,由此直接得出test1。由此concatenatedWithConst就和str1同時指向字符串常量test1所以輸出結果比對一致:
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
//......
//
L3
LINENUMBER 19 L3
//將字符串常量test1放到操作數棧
LDC "test1"
//將操作數棧上的test1存儲到局部變量concatenatedWithConst 中
ASTORE 4對應我們也給出concatenatedWithVar生成的字節碼,可以看到其底層本質上就是通過StringBuilder拿到字符串常量中的test和數值1進行拼接從而得到常量池中的test1,然后將當前concatenatedWithVar的引用指向這個常量,因為concatenatedWithVar間接的指向test1字符串,所以和str1的比對結果就不一致了:
L4
//初始化StringBuilder
LINENUMBER 20 L4
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
//將第三個變量也就是我們的str2 壓入操作數棧
ALOAD 3
//str2追加一個數值1
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ICONST_1
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
//基于toString 生成字符串
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 53. final語義中的常量折疊
上文我們提到JIT會針對運行時常量進行常量折疊避免非必要的字符串運算,這是否意味著編碼時針對局部變量采用final修飾字符串就一定可以做到常量折疊呢?
來看看下面這個例子,我們通過final修飾兩個從靜態方法得出字符串的變量,然后進行拼接返回:
public static String function() {
final String str = getStr();
final String str2 = getStr();
return str + str2;
}
public static String getStr() {
return "hello";
}下面這段代碼就是function的字節碼,可以看到final語義在字節碼并沒有很實際的體現(和普通局部變量的JIT編譯后的代碼無異),所有的字符串操作本質上都是從靜態函數中獲取字符串然后通過StringBuilder完成拼接返回:
public static function()Ljava/lang/String;
//final String str = getStr();
L0
LINENUMBER 17 L0
INVOKESTATIC com/sharkchili/Main.getStr ()Ljava/lang/String;
ASTORE 0
// final String str2 = getStr();
L1
LINENUMBER 18 L1
INVOKESTATIC com/sharkchili/Main.getStr ()Ljava/lang/String;
ASTORE 1
//return str + str2;
L2
LINENUMBER 19 L2
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
ALOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 1
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ARETURN對此我們不將代碼改一下,所有final語義的字符串直接顯示賦值字面量字符串:
public static String function() {
final String str = "hello";
final String str2 = "hello";
return str + str2;
}此時查看JIT編譯后的字節碼即可看到因為字符串return str + str2;分配的字符串直接就是上述兩個變量的拼接結果,由此可知,JIT對于final變量的優化更多是針對編譯時常量即final修飾的字面量才能進行一定的優化:
public static function()Ljava/lang/String;
//final String str = "hello";
L0
LINENUMBER 17 L0
LDC "hello"
ASTORE 0
// final String str2 = "hello";
L1
LINENUMBER 18 L1
LDC "hello"
ASTORE 1
//return str + str2;
L2
LINENUMBER 19 L2
LDC "hellohello"
ARETURN
L3
LOCALVARIABLE str Ljava/lang/String; L1 L3 0
LOCALVARIABLE str2 Ljava/lang/String; L2 L3 1
MAXSTACK = 1
MAXLOCALS = 2二、詳解final關鍵字在內存模型中的語義
1. final域關于寫的重排序規則
實際上final關鍵字也能針對一些特殊場景插入內存屏障從而避免一些指令重排序,它要求編譯器和處理器遵守下面這條規則:
對于一個構造函數的初始化,涉及的final域的寫,與隨后把這個構造對象引用到外部引用,這兩個操作之間不能重排序。
例如,我們用final修飾User類的成員變量name,執行new操作時,按照final的語義它會在name初始化后面插入一個storestore屏障,保證name初始化完成之后,user對象才能發布:

對應的我們也給出User類的定義:
public class User {
private final String name;
public User() {
name = "sharkchili";
//隱含的storestore內存屏障
//隱式的 sharedRef = new User()和 return
}
}那么如果final關鍵字修飾會怎樣呢?我們都知道CPU為了提升指令執行效率是允許前后沒有依賴的指令亂序執行的,所以我們不妨帶入下面這段代碼說明一下,首先我們先介紹一下這段代碼的含義:
- 線程0先執行init,執行new User并將引用賦值給obj
- 線程1后執行,基于obj獲取name字段值
//線程0執行初始化
public static void init(){
obj=new User();
}
public static String getName(){
//線程1的引用接住obj中的user
Object o=obj;
//返回user的名稱
return ((User)o).name;
}試想這樣一個場景,并發場景下線程0初始化user,在此期間如果線程1訪問user的name字段,如果沒有內存屏障,很可能出現:
- return和name = "sharkchili";的指令重排序,未完全體的user提前發布
- 線程1訪問到的name的null值,導致一致性問題:

而通過final修飾name之后,對應的final成員變量初始化位置就會插入內存屏障,從而保證線程1訪問到的name是user對象完成初始化后的值:

2. final域關于讀的重排序規則
對于final域的讀,JMM內存模型規定了編譯器(注意只有編譯器)遵守下面這條規則:
對于包含final域的對象讀以及對應的final域字段的讀,兩者不能發生重排序。
舉個例子,假設我們的線程0還是執行user的初始化,將創建的user賦值給obj靜態引用:
private static final Object obj;
public static void init(){
obj=new User();
}線程2并發讀取obj和obj對應的name的值:
public static void getName() {
//線程2的引用接住obj中的user
User user = obj;
//獲取user的name
String userName = user.name;
}我們試想一下下面這個雙線程并發讀user的場景,線程0執行user創建,針對線程1的并發讀,我們不妨帶入obj有final修飾和沒有final修飾的場景:
- 假設user對象完成初始化過程中,因為obj和name都有final修飾,獲取obj和name操作沒有發生重排序,當線程1讀取到一個非空的user,就一定能夠得到一個非空的name。
- 假設user對象完成初始化過程中,沒有final修飾,獲取obj和name操作發生重排序,極端情況就可能得到一個空的name和非空的user

所以,這才有了final語義中對于讀操作的重排序規則,在對象讀和對象final域讀這兩個先后順序之間,編譯器會插入一個loadload內存屏障避免兩者重排序,從而保證user非空的情況下一定能夠讀到final域中的name。
3. final關鍵字中需要注意的逸出問題
final關鍵字通過內存屏障避免指令重排序保證變量讀寫的正確性,但我還是需要在使用上明確避免對象的逸出,例如下面這初始化user并賦值給obj靜態引用的代碼:
@Data
public class User {
private final String name;
public static Object obj;
public User() {
name = "sharkchili";
obj = this;
}
}試想一下,在處理器進行構造函數初始化時其內部操作就可能非順序將指令交由不同的電路單元執行,即:
- 先執行obj = this;。
- 再對name進行字符串分配。
面對上述的指令重排序,線程1執行如下代碼,判斷obj非空后獲取obj指向的user和name值:
public static void getName() {
if (obj!=null){
//線程2的引用接住obj中的user
User user = obj;
//獲取user的name
String userName = user.name;
}
}按照上述邏輯,即使構造函數發生重排序(即obj=this提前),它依然會得到一個非空的obj,步入邏輯,就可能因為指令重排序提前拿到obj進而獲取到一個空name值,所以盡管final語義帶有指令重排序的語義,我們在使用時也需要明確去避免對象的逸出:

三、詳解可見性下的哲學
1. 發布與逸出的把控
發布(publish)的定義即將內部一些對象對外發布使得外部代碼可操作,使得發布可以操作或者修改這個變量,例如下面這段代碼,這就是最簡單的發布模式,它將set采用public修飾使其對外部類和線程都可見:
/**
* public修飾使外部代碼可見
*/
public static Set<String> set;
public void init() {
set = new HashSet<String>();
}在并發編程中我們務必要把控要發布的粒度,例如下面這段,我們僅僅是要求map保管我們的元素,但是getMap方法卻將私有變量map發布,這種通過公有方法返回或者非私有字段引用私有變量的做法我們統稱為不安全的發布,存在各種并發操作的風險:
private ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>();
public void put(String key, Object obj) {
map.put(key, obj);
}
/**
* 通過方法將私有域對象發布
* @return
*/
public ConcurrentHashMap<String, Object> getMap() {
return map;
}另一個典型例子就是下面這段代碼,我們僅僅需要通過構造方法對外發布User實例,卻因為構造方法的public修飾符使得this被隱式發布導致逸出,這也是一種典型的錯誤:
public class ThisEscape {
private String name="thisEscape";
/**
* 將內部類存入list時,將this實例發布
* @param list
* @param name
*/
public ThisEscape(List<Object> list, String name) {
list.add(new ThisEscape.User(name));
}
class User {
private String name;
public User(String name) {
this.name = name;
}
}
}只有完全返回的的構造才處于可預測和一致的狀態,這種做法及時構造函數運行到代碼的最后一行,本質上構造的對象都不是完整且不爭取的被發布了:

而正確的做法是采用構造私有,通過對外暴露一個初始化工廠發布內部類:
/**
* 將構造函數私有
*/
private ThisEscape() {
}
/**
* 對外暴露一個創建工廠方法安全構造并添加user
* @param list
* @param name
*/
public static void addNewUser(List<Object> list, String name) {
list.add(new ThisEscape.User(name));
}最終調測結果如下,可以看到外部類的this實例構造不再逸出:

2. 不安全的發布
下面這段代碼就是典型的就存在構造發布不可見的情況,因為構造函數初始化存在指令重排序,實際上下面這個構造初始化可能存在:
- 初始化holder
- 發布holder
- 完成n的賦值
public class Holder {
privateint n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n) {
thrownew RuntimeException("Sanity check failed");
}
}
}所以如果線程并發初始化時,可能看到一個尚未初始化好的Holder,使得其他并發線程通過assertSanity訪問這個變量就既有可能存在不一致而報錯的情況,所以必要時我們建議需要保證并發安全性的成員字段需要在構造函數內部采用final關鍵字修飾:

引發異常的場景如下所示,因為構造函數的指令重排序等原因,顯示的訪問就可能存在線程2讀取不一致的情況。
這里也需要補充說明一下,這份代碼示例不知道是因為現代計算機性能優勢還是JIT某種機制的優化,即使筆者關閉了JIT優化也未能復現這個錯誤,也希望有讀者如果復現,務必指導一下筆者不對的地方:
// 線程1:初始化Holder
new Thread(() -> {
holder = new Holder(RandomUtil.randomInt()); // 不安全發布
}).start();
new Thread(() -> {
while (holder == null) {
// 等待holder被初始化
}
holder.assertSanity(); // 可能拋出異常
}).start();3. 利用final域下不可變性的安全訪問
利用final不可變語義,在構造時初始化需要被外部線程訪問的變量,在訪問時通過拷貝的方式安全發布對象到外部,保證所有線程對于共享變量的訪問是一致的。
就例如下面這段代碼,筆者在構造函數上顯示完成數組arr初始化,后續線程對于該內部變量的訪問一律以getArrayList為入口,其內部邏輯采用拷貝的方式將變量發布讓其進行自由操作,而原有對象內部元素對所有線程仍舊保持一致:
public class SafeAccessArr {
/**
* 使用final修飾在構造函數初始化保證可見性和一致性
*/
privatefinalint[] arr;
privatefinalint len;
public SafeAccessArr(int[] arr) {
this.arr = arr;
this.len = arr.length;
}
//訪問時通過拷貝的方式安全發布
publicint[] getArrayList(int len) {
if (this.len == len) {
return Arrays.copyOf(arr, len);
}
returnnull;
}
}對此,我們也給出安全發布的幾個要點:
- 使用final保證不可變
- 使用synchronized保證訪問的互斥
- 使用volatile修飾保證可見性
- 在靜態初始化函數中初始化一個對象引用(保證可見、一致、安全訪問)
4. 棧封閉技術在并發編程中的使用
實際上保證不可變性的手段還有一種名為棧封閉的技術,該技術利用線程棧私有的特性,將外部引用參數拷貝到內部進行邏輯計算,然后利用Java語言的語義天生保證了基本類型的引用無法獲得即基本類型的不可變性,將計算結果直接返回交由外部使用。由此保證操作變量只有一個線程的局部引用操作,保證了線程安全和不可變性:

就像下面這段代碼,可以看到筆者將外部全局操作的并發容器鍵值對拷貝的線程內部的map中,進行需要的非空過濾計數,同時筆者使用的計數器是采用基本類型的int而非包裝類,由此保證引用只有局部線程持有,且返回結果也是基本類型保證結果并發的不可變性,實現線程安全:
public int getValidCount(ConcurrentHashMap<String, Object> params) {
int count = 0;
//將外部參數拷貝到線程局部變量中
Map<String, Object> map = new HashMap<>();
map.putAll(params);
//進行有效計數
for (String key : map.keySet()) {
if (map.get(key) != null) {
count++;
}
}
return count;
}當然維持對象引用的棧封閉時,程序員還是需要額外注意一下引用對象的逸出問題,例如上文示例中map引用就局限在當前線程棧內,利用線程棧私有的特性,即使使用非線程安全的對象(例如本案例的HashMap)依然可以保證線程安全。
5. 事實不可變對象
最后一種,也算是并發哲學中大道至簡的方式,即事實不可變用筆者的話也就是業務不可變,例如下面這段代碼:
private ConcurrentHashMap<Integer, Date> map = new ConcurrentHashMap<>();事實上Date對象的操作是非線程安全的,但是我們業務上的場景它是只讀即不可變的,在協定的情況下這段代碼也變為事實不可變的并發安全。
四、小結
本文從final關鍵字底層工作機制以及常量折疊、JIT優化和幾個實踐案例全方位的演示了final關鍵字在并發編程中的優秀實踐,希望對你有幫助。
























