Java併發學習筆記(六):不可變、final、保護性拷貝、享元模式、final原理、無狀態

不可變

一、 日期轉換的問題

1、引入

下面的代碼在運行時,由於 SimpleDateFormat 不是線程安全的

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        try {
            System.out.println(sdf.parse("1951-04-21"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
}

運行之後會出現下面的結果:

Mon Apr 21 00:00:00 CST 4
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Thu Apr 21 00:00:00 CST 214
Fri Apr 21 00:00:00 CST 4000
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:2056)
	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 concurrent.ch7_immutable.DataFormatTest.lambda$main$0(DataFormatTest.java:12)
	at java.lang.Thread.run(Thread.java:745)
Sat Apr 21 00:00:00 CST 1951
Wed Apr 21 00:00:00 CST 1
Fri Apr 21 00:00:00 CST 4000

不僅結果不對,而且還會出現NumberFormatException異常。

2、解決方法:鎖

要解決SimpleDateFormat的線程安全問題,一種方法時使用synchronized鎖,代碼如下:

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                //對sdf對象上鎖
                synchronized (sdf) {
                    try {
                        System.out.println(sdf.parse("1951-04-21"));
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

結果:

Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951
Sat Apr 21 00:00:00 CST 1951

可以看到結果沒有問題,但是我們之前也介紹過使用synchronized加鎖會帶來性能上的問題,所以又引入另外一種解決方法:不可變類

2、解決方法:不可變

如果一個對象在不能夠修改其內部狀態(屬性),那麼它就是線程安全的,因爲不存在併發修改。這樣的對象在 Java 中有很多,例如在 Java 8 後,提供了一個新的日期格式化類:

        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                System.out.println(dtf.parse("1951-04-21"));
            }).start();
        }

結果:

{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21
{},ISO resolved to 1951-04-21

雖然和上面的輸出格式不同,但是結果是沒問題的。DateTimeFormatter的註釋中也聲明它是一個不可變的線程安全類,不可變對象,實際是另一種避免競爭的方式。

/ *
 * @implSpec
 * This class is immutable and thread-safe.
 *
 * @since 1.8
 */
public final class DateTimeFormatter {

二、不可變設計

另一個大家更爲熟悉的 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
    
    //...
}

1、final的使用

我們發現String類和類中所有屬性都是 final 的

  • 屬性用 final 修飾保證了該屬性是隻讀的,不能修改
  • 類用 final 修飾保證了該類中的方法不能被覆蓋,防止子類無意間破壞不可變性

2、保護性拷貝

當需要修改屬性的值,如char value[]時,並沒有改變其中的值,而是直接新建(拷貝)一個對象賦值給該屬性。

例如:

    public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }

這裏沒有直接使用this.value = value,而是使用Arrays.copyOf進行拷貝,就是爲了防止外部引用對參數value的改變會影響到this.value,而Arrays.copyOf創建了一個值參數一樣但不是同一個對象,就避免了這種問題。

再以以 substring 爲例:

    public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        //上面都是參數合法性檢查,最後調用new String創建了新的對象
        return ((beginIndex == 0) && (endIndex == value.length)) ? this
                : new String(value, beginIndex, subLen);
    }

可以看到substring並沒有直接修改成員變量char value[]的值,而是創建了一個新的對象。而該構造函數中也使用了Arrays.copyOfRange進行拷貝。構造新字符串對象時,會生成新的 char[] value,對內容進行復制 。這種通過創建副本對象來避 免共享的手段稱之爲保護性拷貝(defensive copy)

    public String(char value[], int offset, int count) {
        //...(參數合法性檢查)
        
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

三、設計模式:享元模式

1、 簡介

定義 英文名稱:Flyweight pattern. 當需要重用數量有限的同一類對象時,可以使用享元模式

wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects

2、體現

在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) {
            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 串池 (JVM筆記)、 BigDecimal、BigInteger 等。

3、實踐DIY

例如:一個線上商城應用,QPS 達到數千,如果每次都重新創建和關閉數據庫連接,性能會受到極大影響。 這時 預先創建好一批連接,放入連接池。一次請求到達後,從連接池獲取連接,使用完畢後再還回連接池,這樣既節約 了連接的創建和關閉時間,也實現了連接的重用,不至於讓龐大的連接數壓垮數據庫

//連接池實現類
class Pool {

    //定義連接池大小
    private final int poolSize;

    //連接對象數組
    private Connection[] connections;

    //定義連接狀態數組 0:表示空閒,1:表示繁忙
    private AtomicIntegerArray states;

    //構造方法初始化
    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("conn " + i);
        }
    }

    //借連接
    public Connection borrow() {
        while (true) {
            //遍歷連接狀態數組,找出空閒的連接並返回
            for (int i = 0; i < poolSize; i++) {
                if (states.get(i) == 0) {
                    //爲了先線程安全,需要使用CAS對連接狀態進行設置
                    if (states.compareAndSet(i, 0, 1)) {
                        System.out.println(Thread.currentThread().getName() + " borrow " + connections[i]);
                        return connections[i];
                    }
                }
            }
            //當前沒有連接池時,進入等待狀態
            synchronized (this) {
                try {
                    System.out.println(Thread.currentThread().getName() + " wait...");
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    //歸還連接
    public void free(Connection conn) {
        for (int i = 0; i < poolSize; i++) {
            if (connections[i] == conn) {
                //由於此時只有一個線程持有connections[i],所以不會有線程安全問題
                states.set(i, 0);
                //歸還之後,通知等待的線程
                synchronized (this) {
                    System.out.println(Thread.currentThread().getName() + " free " + conn);
                    this.notifyAll();
                }
                break;
            }
        }
    }
}

//連接實現類,無具體內容,省略
class MockConnection implements Connection {
    //...
}

測試代碼:

    public static void main(String[] args) {
        //創建2兩個連接
        Pool pool = new Pool(2);
        //創建5個線程使用鏈接
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                //創建連接
                Connection conn = pool.borrow();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //釋放連接
                    pool.free(conn);
                }
            }, "線程 " + i).start();
        }
    }

結果:

線程 1 borrow MockConnection{name='conn 0'}
線程 0 borrow MockConnection{name='conn 1'}
線程 2 wait...
線程 3 wait...
線程 4 wait...
線程 1 free MockConnection{name='conn 0'}
線程 4 borrow MockConnection{name='conn 0'}
線程 3 wait...
線程 2 wait...
線程 0 free MockConnection{name='conn 1'}
線程 2 borrow MockConnection{name='conn 1'}
線程 3 wait...
線程 4 free MockConnection{name='conn 0'}
線程 3 borrow MockConnection{name='conn 0'}
線程 2 free MockConnection{name='conn 1'}
線程 3 free MockConnection{name='conn 0'}

以上實現沒有考慮:

  • 連接的動態增長與收縮
  • 連接保活(可用性檢測)
  • 等待超時處理
  • 分佈式 hash

對於關係型數據庫,有比較成熟的連接池實現,例如c3p0, druid等

對於更通用的對象池,可以考慮使用apache commons pool,例如redis連接池可以參考jedis中關於連接池的實現

四、final 原理

1、設置 final 變量的原理

理解了 volatile 原理,再對比 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 指令來完成,同樣在這條指令之後也會加入寫屏障,保證在其它線程讀到 它的值時不會出現爲 0 的情況

2、獲取 final 變量的原理

讀取final變量時,如果值較小可以直接放入使用類的操作數棧中,如果值較大會放入使用類的常量池中。

五、無狀態

在 web 階段學習時,設計 Servlet 時爲了保證其線程安全,都會有這樣的建議,不要爲 Servlet 設置成員變量,這 種沒有任何成員變量的類是線程安全的

因爲成員變量保存的數據也可以稱爲狀態信息,因此沒有成員變量就稱之爲**【無狀態】**

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章