JUC學習筆記——共享模型之不可變
JUC學習筆記——共享模型之不可變
在本系列內容中我們會對JUC做一個系統的學習,本片將會介紹JUC的不可變內容
我們會分為以下幾部分進行介紹:
- 不可變案例
- 不可變設計
- 模式之享元
- 原理之final
- 無狀態
不可變案例
我們下面通過一個簡單的案例來講解不可變的共享
案例展示
首先我們給出一個簡單的不安全案例:
/*程式碼展示*/
// 首先我們都知道SimpleDateFormat屬於不安全類,如果我們在多執行緒下運行有可能導致錯誤
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}
/*結果展示*/
// 有很大幾率出現 java.lang.NumberFormatException 或者出現不正確的日期解析結果,例如:
19:10:40.859 [Thread-2] c.TestDateParse - {}
java.lang.NumberFormatException: For input string: ""
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:601)
at java.lang.Long.parseLong(Long.java:631)
at java.text.DigitList.getLong(DigitList.java:195)
at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at cn.itcast.n7.TestDateParse.lambda$test1$0(TestDateParse.java:18)
at java.lang.Thread.run(Thread.java:748)
19:10:40.859 [Thread-1] c.TestDateParse - {}
java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at cn.itcast.n7.TestDateParse.lambda$test1$0(TestDateParse.java:18)
at java.lang.Thread.run(Thread.java:748)
19:10:40.857 [Thread-8] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-9] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-6] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-4] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-5] c.TestDateParse - Mon Apr 21 00:00:00 CST 178960645
19:10:40.857 [Thread-0] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-7] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-3] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
同步鎖解決
我們可以按照我們之前學習的鎖的思路來解決並發問題;
/*程式碼展示*/
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 50; i++) {
new Thread(() -> {
synchronized (sdf) {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}
}).start();
}
不可變解決
但是我們可以選擇更換一種日期類型,我們選擇不可改變的日期類就可以完成並發下的數據修改問題:
/*程式碼展示*/
// DateTimeFormatter的所有賦值方法都是直接new一個新的對象然後進行賦值
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
log.debug("{}", date);
}).start();
}
/*內容分析*/
如果一個對象在不能夠修改其內部狀態(屬性),那麼它就是執行緒安全的,因為不存在並發修改
不可變對象,實際是另一種避免競爭的方式。
不可變設計
我們下面講解JDK中不可變的設計類
String類型設計
我們平時所使用的String類型就是無法修改的類:
/*String內部組成*/
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
// ...
}
我們進行簡單解析:
- 將類聲明為final,避免被帶外星方法的子類繼承,從而破壞了不可變性。
- 將字元數組聲明為final,避免被修改
- hash雖然不是final的,但是其只有在調用
hash()
方法的時候才被賦值,除此之外再無別的方法修改。
final 的使用
我們的不可變設計中final的使用實際上是非常重要的:
- 發現該類、類中所有屬性都是 final 的
- 屬性用 final 修飾保證了該屬性是只讀的,不能修改
- 類用 final 修飾保證了該類中的方法不能被覆蓋,防止子類無意間破壞不可變性
保護性拷貝
我們在JDK的一些不可變設計類中發現我們是可以對其進行修改的:
- 例如String,我們可以採用賦值方法進行賦值
- 但是其實底層卻不是直接採用賦值方法來實現的,底層是採用拷貝原String數組然後創建一個新String數據並進行賦值而產生的
我們給出一個簡單的例子:
/*String的substring方法源碼*/
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 我們這裡發現,實際上最後返回的String實際上是調用構造方法產生的
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
/*String構造方法*/
// 發現其內部是調用 String 的構造方法創建了一個新字元串,再進入這個構造看看,是否對 final char[] value 做出了修改:
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
我們最後會發現構造方法也沒有對value進行修改,構造新字元串對象時,會生成新的 char[] value,對內容進行複製 。
這種通過創建副本對象來避 免共享的手段稱之為【保護性拷貝(defensive copy)】
模式之享元
我們在這一小節會介紹一種新的模式享元
定義
我們首先給出享元的概念:
- 英文名稱:Flyweight pattern. 當需要重用數量有限的同一類對象時
- 簡單來說就是我們會創建一系列該類的對象,但是當實際調用時,對於相同對象我們可以引用相同的類對象地址
我們給出享元的意義:
- 希望藉此簡化記憶體的大小,用來壓縮記憶體
體現
享元的概念實際上已經在很多類中進行了體現:
- 包裝類
/*解釋*/
在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包裝類提供了 valueOf 方法
例如 Long 的 valueOf 會快取 -128~127 之間的 Long 對象,在這個範圍之間會重用對象,大於這個範圍,才會新建 Long 對象
/*程式碼展示*/
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}
/*內部設置展示*/
- Byte, Short, Long 快取的範圍都是 -128~127
- Character 快取的範圍是 0~127
- Integer的默認範圍是 -128~127
- 最小值不能變
- 但最大值可以通過調整虛擬機參數 -Djava.lang.Integer.IntegerCache.high 來改變
- Boolean 快取了 TRUE 和 FALSE
- String 串池(不可變、執行緒安全)
- BigDecimal BigInteger(不可變、執行緒安全)
案例
我們可以藉助享元的思想來完成一個簡單的連接池設計:
- 例如:一個線上商城應用,QPS 達到數千,如果每次都重新創建和關閉資料庫連接,性能會受到極大影響。
- 這時預先創建好一批連接,放入連接池。一次請求到達後,從連接池獲取連接,使用完畢後再還回連接池,這樣既節約了連接的創建和關閉時間,也實現了連接的重用,不至於讓龐大的連接數壓垮資料庫。
我們給出詳細程式碼:
/*測試程式碼*/
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
/*連接池程式碼展示*/
class Pool {
// 1. 連接池大小
private final int poolSize;
// 2. 連接對象數組
private Connection[] connections;
// 3. 連接狀態數組 0 表示空閑, 1 表示繁忙
private AtomicIntegerArray states;
// 4. 構造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("連接" + (i+1));
}
}
// 5. 借連接
public Connection borrow() {
while(true) {
for (int i = 0; i < poolSize; i++) {
// 獲取空閑連接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果沒有空閑連接,當前執行緒進入等待
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 6. 歸還連接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}
// 我們藉助MockConnection來模擬連接池
class MockConnection implements Connection {
// 實現略
}
原理之final
這一小節我們將介紹final的底層原理
設置原理
首先我們先來介紹一下final的設置原理:
/*程式碼*/
public class TestFinal {
final int a = 20;
}
/*底層源碼*/
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 20
7: putfield #2 // Field a:I
<-- 寫屏障
10: return
我們會發現 final 變數的賦值也會通過 putfield 指令來完成,同樣在這條指令之後也會加入寫屏障
這樣對final變數的寫入不會重排序到構造方法之外,保證在其它執行緒讀到它的值時不會出現為 0 的情況,普通變數不能保證這一點了。
獲得原理
我們下面通過一個案例進行展示:
public class TestFinal {
final static int A = 10;
final static int B = Short.MAX_VALUE+1;
final int a = 20;
final int b = Integer.MAX_VALUE;
final void test1() {
final int c = 30;
new Thread(()->{
System.out.println(c);
}).start();
final int d = 30;
class Task implements Runnable {
@Override
public void run() {
System.out.println(d);
}
}
new Thread(new Task()).start();
}
}
class UseFinal1 {
public void test() {
System.out.println(TestFinal.A);
System.out.println(TestFinal.B);
System.out.println(new TestFinal().a);
System.out.println(new TestFinal().b);
new TestFinal().test1();
}
}
class UseFinal2 {
public void test() {
System.out.println(TestFinal.A);
}
}
然後我們反編譯UseFinal1中的test方法:
public test()V
L0
LINENUMBER 31 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
BIPUSH 10
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L1
LINENUMBER 32 L1
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC 32768
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L2
LINENUMBER 33 L2
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
NEW cn/itcast/n5/TestFinal
DUP
INVOKESPECIAL cn/itcast/n5/TestFinal.<init> ()V
INVOKEVIRTUAL java/lang/Object.getClass ()Ljava/lang/Class;
POP
BIPUSH 20
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L3
LINENUMBER 34 L3
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
NEW cn/itcast/n5/TestFinal
DUP
INVOKESPECIAL cn/itcast/n5/TestFinal.<init> ()V
INVOKEVIRTUAL java/lang/Object.getClass ()Ljava/lang/Class;
POP
LDC 2147483647
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L4
LINENUMBER 35 L4
NEW cn/itcast/n5/TestFinal
DUP
INVOKESPECIAL cn/itcast/n5/TestFinal.<init> ()V
INVOKEVIRTUAL cn/itcast/n5/TestFinal.test1 ()V
L5
LINENUMBER 36 L5
RETURN
L6
LOCALVARIABLE this Lcn/itcast/n5/UseFinal1; L0 L6 0
MAXSTACK = 3
MAXLOCALS = 1
}
可以看見,jvm對final變數的訪問做出了優化:
- 另一個類中的方法調用final變數是,不是從final變數所在類中獲取(共享記憶體)
- 而是直接複製一份到方法棧棧幀中的操作數棧中(工作記憶體),這樣可以提升效率,是一種優化。
總結:
- 對於較小的static final變數:複製一份到操作數棧中
- 對於較大的static final變數:複製一份到當前類的常量池中
- 對於非靜態final變數,優化同上。
final總結
final關鍵字的好處:
(1)final關鍵字提高了性能。JVM和Java應用都會快取final變數。
(2)final變數可以安全的在多執行緒環境下進行共享,而不需要額外的同步開銷。
(3)使用final關鍵字,JVM會對方法、變數及類進行優化。
關於final的重要知識點
1、final關鍵字可以用於成員變數、本地變數、方法以及類。
2、final成員變數必須在聲明的時候初始化或者在構造器中初始化,否則就會報編譯錯誤。
3、你不能夠對final變數再次賦值。
4、本地變數必須在聲明時賦值。
5、在匿名類中所有變數都必須是final變數。
6、final方法不能被重寫。
7、final類不能被繼承。
8、final關鍵字不同於finally關鍵字,後者用於異常處理。
9、final關鍵字容易與finalize()方法搞混,後者是在Object類中定義的方法,是在垃圾回收之前被JVM調用的方法。
10、介面中聲明的所有變數本身是final的。
11、final和abstract這兩個關鍵字是反相關的,final類就不可能是abstract的。
12、final方法在編譯階段綁定,稱為靜態綁定(static binding)。
13、沒有在聲明時初始化final變數的稱為空白final變數(blank final variable),它們必須在構造器中初始化,或者調用this()初始化。不這麼做的話,編譯器會報錯「final變數(變數名)需要進行初始化」。
14、將類、方法、變數聲明為final能夠提高性能,這樣JVM就有機會進行估計,然後優化。
15、按照Java程式碼慣例,final變數就是常量,而且通常常量名要大寫。
16、對於集合對象聲明為final指的是引用不能被更改,但是你可以向其中增加,刪除或者改變內容。
無狀態
我們這一小節來簡單介紹一下無狀態
無狀態概述
首先我們來簡述一下無狀態:
- 無狀態指的是對於請求方的每個請求,接收方都當這次請求是第一次請求。
- 成員變數保存的數據也可以稱為狀態資訊,因此沒有成員變數就稱之為”無狀態”
- 無狀態並不代表接收方不會保存請求方的任何數據,它只是不保存與接收方可能的下次請求相關的數據。
那麼無狀態有什麼優勢:
- 在 web 階段學習時,設計 Servlet 時為了保證其執行緒安全,都會有這樣的建議,不要為 Servlet 設置成員變數
- 因為沒有任何成員變數的類是執行緒安全的
結束語
到這裡我們JUC的共享模型之不可變就結束了,希望能為你帶來幫助~
附錄
該文章屬於學習內容,具體參考B站黑馬程式設計師滿老師的JUC完整教程
這裡附上影片鏈接:07.001-本章內容_嗶哩嗶哩_bilibili