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. 當需要重用數量有限的同一類對象時
  • 簡單來說就是我們會創建一系列該類的對象,但是當實際調用時,對於相同對象我們可以引用相同的類對象地址

我們給出享元的意義:

  • 希望藉此簡化記憶體的大小,用來壓縮記憶體

體現

享元的概念實際上已經在很多類中進行了體現:

  1. 包裝類
/*解釋*/

在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
  1. String 串池(不可變、執行緒安全)
  2. 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指的是引用不能被更改,但是你可以向其中增加,刪除或者改變內容。

參考鏈接:Java中final實現原理的深入分析(附示例)-java教程-PHP中文網

無狀態

我們這一小節來簡單介紹一下無狀態

無狀態概述

首先我們來簡述一下無狀態:

  • 無狀態指的是對於請求方的每個請求,接收方都當這次請求是第一次請求。
  • 成員變數保存的數據也可以稱為狀態資訊,因此沒有成員變數就稱之為”無狀態”
  • 無狀態並不代表接收方不會保存請求方的任何數據,它只是不保存與接收方可能的下次請求相關的數據。

那麼無狀態有什麼優勢:

  • 在 web 階段學習時,設計 Servlet 時為了保證其執行緒安全,都會有這樣的建議,不要為 Servlet 設置成員變數
  • 因為沒有任何成員變數的類是執行緒安全的

結束語

到這裡我們JUC的共享模型之不可變就結束了,希望能為你帶來幫助~

附錄

該文章屬於學習內容,具體參考B站黑馬程式設計師滿老師的JUC完整教程

這裡附上影片鏈接:07.001-本章內容_嗶哩嗶哩_bilibili