1. 程式人生 > >執行緒安全策略

執行緒安全策略

四個執行緒安全策略

執行緒限制:

  • 一個被執行緒限制的物件,由執行緒獨佔,並且只能被佔有它的執行緒修改

共享只讀:

  • 一個共享只讀的物件,在沒有額外同步的情況下,可以被多個執行緒併發訪問,但是任何執行緒都不能修改它

執行緒安全物件:

  • 一個執行緒安全的物件或者容器,在內部通過同步機制來保證執行緒安全,所以其他執行緒無需額外的同步就可以通過公共介面隨意訪問它

被守護物件:

  • 被守護物件只能通過獲取特定的鎖來訪問

不可變物件

有一種物件釋出了就是安全的,這就是不可變物件,本小節簡單介紹一下不可變物件。不可變物件可以在多執行緒在保證執行緒安全,不可變物件需要滿足的條件:

  • 物件建立以後其狀態就不能修改
  • 物件所有域都是final型別
  • 物件是正確建立的(在物件建立期間,this引用沒有逸出)

建立不可變物件的方式(參考String):

  • 將類宣告成final型別,使其不可以被繼承
  • 將所有的成員設定成私有的,使其他的類和物件不能直接訪問這些成員
  • 對變數不提供set方法
  • 將所有可變的成員宣告為final,這樣只能對他們賦值一次
  • 通過構造器初始化所有成員,進行深度拷貝
  • 在get方法中,不直接返回物件本身,而是克隆物件,返回物件的拷貝

提到不可變的物件就不得不說一下final關鍵字,該關鍵字可以修飾類、方法、變數:

  • 修飾類:不能被繼承(final類中的所有方法都會被隱式的宣告為final方法)
  • 修飾方法:
    • 1、鎖定方法不被繼承類修改;
    • 2、可提升效率(private方法被隱式修飾為final方法)
  • 修飾變數:基本資料型別變數(初始化之後不能修改)、引用型別變數(初始化之後不能再修改其引用)
  • 修飾方法引數:同修飾變數

通常我們會使用一些工具類來完成不可變物件的建立:

  • Collections.unmodifiableXXX:Collection、List、Set、Map...
  • Guava:ImmutableXXX:Collection、List、Set、Map...

由於這些工具類的存在,所以我們建立不可變物件並不是很費勁,而且其實現原始碼也不會很難懂。所以如果需要自定義不可變物件,也可以參考這些工具類的實現原始碼去進行實現。接下來我們看一下如何使用Collections.unmodifiableXXX方法將map轉換為一個不可變的物件,程式碼如下:

@Slf4j
public class ImmutableExample2 {

    private static Map<Integer, Integer> map = Maps.newHashMap();

    static {
        map.put(1, 2);
        // 轉換成不可變物件
        map = Collections.unmodifiableMap(map);
    }

    public static void main(String[] args) {
        // 此時map就是不可變物件了,修改會報錯
        map.put(1, 3);
        log.info("{}", map.get(1));
    }
}

我們來看看是如何將map轉換為不可變物件的,原始碼如下:

   /**
     * Returns an <a href="Collection.html#unmodview">unmodifiable view</a> of the
     * specified map. Query operations on the returned map "read through"
     * to the specified map, and attempts to modify the returned
     * map, whether direct or via its collection views, result in an
     * {@code UnsupportedOperationException}.<p>
     *
     * The returned map will be serializable if the specified map
     * is serializable.
     *
     * @param <K> the class of the map keys
     * @param <V> the class of the map values
     * @param  m the map for which an unmodifiable view is to be returned.
     * @return an unmodifiable view of the specified map.
     */
    public static <K,V> Map<K,V> unmodifiableMap(Map<? extends K, ? extends V> m) {
        return new UnmodifiableMap<>(m);
    }

    /**
     * @serial include
     */
    private static class UnmodifiableMap<K,V> implements Map<K,V>, Serializable {
        private static final long serialVersionUID = -1034234728574286014L;

        private final Map<? extends K, ? extends V> m;

        UnmodifiableMap(Map<? extends K, ? extends V> m) {
            if (m==null)
                throw new NullPointerException();
            this.m = m;
        }    

        public int size()                        {return m.size();}
        public boolean isEmpty()                 {return m.isEmpty();}
        public boolean containsKey(Object key)   {return m.containsKey(key);}
        public boolean containsValue(Object val) {return m.containsValue(val);}
        public V get(Object key)                 {return m.get(key);}

        public V put(K key, V value) {
            throw new UnsupportedOperationException();
        }
        public V remove(Object key) {
            throw new UnsupportedOperationException();
        }        
    ...    

可以看到,實際上unmodifiableMap方法裡是返回了一個內部類UnmodifiableMap的例項。而這個UnmodifiableMap類實現了Map介面,並且在構造器中將我們傳入的map物件賦值到了final修飾的屬性m中。在該類中除了一些“查詢”方法,其他涉及到修改的方法都會丟擲UnsupportedOperationException異常,這樣外部就無法修改該物件內的資料。我們在呼叫涉及到修改資料的方法都會報錯,這樣就實現了將一個可變物件轉換成一個不可變的物件。

除了以上示例中所使用的unmodifiableMap方法外,還有許多轉換不可變物件的方法,如下:
執行緒安全策略


然後我們再來看看Guava中建立不可變物件的方法,示例程式碼如下:

@Slf4j
public class ImmutableExample3 {
    /**
     * 不可變的list
     */
    private final static List<Integer> list = ImmutableList.of(1, 2, 3);

    /**
     * 不可變的set
     */
    private final static Set<Integer> set = ImmutableSet.copyOf(list);

    /**
     * 不可變的map,需要以k/v的形式傳入資料,即奇數位引數為key,偶數位引數為value
     */
    private final static Map<Integer, Integer> map = ImmutableMap.of(1, 1, 2, 2, 3, 3);

    /**
     * 通過builder呼叫鏈的方式構造不可變的map
     */
    private final static Map<Integer, Integer> map2 = ImmutableMap.<Integer, Integer>builder()
            .put(1, 1).put(2, 2).put(3, 3).build();

    public static void main(String[] args) {
        // 修改物件內的資料就會丟擲UnsupportedOperationException異常
        list.add(4);
        set.add(5);
        map.put(1, 2);
        map2.put(1, 2);
    }
}

不可變物件的概念也比較簡單,又有那麼多的工具類可供使用,所以學習起來也不是很困難。由於Guava中實現不可變物件的方式和Collections差不多,所以這裡就不對其原始碼進行介紹了。


執行緒封閉

在上一小節中,我們介紹了不可變物件,不可變物件在多執行緒下是執行緒安全的,因為其避開了併發,而另一個更簡單避開併發的的方式就是本小節要介紹的執行緒封閉。

執行緒封閉最常見的應用就是用在資料庫連線物件上,資料庫連線物件本身並不是執行緒安全的,但由於執行緒封閉的作用,一個執行緒只會持有一個連線物件,並且持有的連線物件不會被其他執行緒所獲取,這樣就不會有執行緒安全的問題了。

執行緒封閉概念:

  • 把物件封裝到一個執行緒裡,只有這個執行緒能看到這個物件。那麼即便這個物件本身不是執行緒安全的,但由於執行緒封閉的關係讓其只能在一個執行緒裡訪問,所以也就不會出現執行緒安全的問題了

實現執行緒封閉的方式:

  • Ad-hoc 執行緒封閉:完全由程式控制實現,最糟糕的方式,忽略
  • 堆疊封閉:區域性變數,當多個執行緒訪問同一個方法的時候,方法內的區域性變數都會被拷貝一份副本到執行緒的棧中,所以區域性變數是不會被多個執行緒所共享的,因此無併發問題。所以我們在開發時應儘量使用區域性變數而不是全域性變數
  • ThreadLocal 執行緒封閉:每個Thread執行緒內部都有個map,這個map是以執行緒本地物件作為key,以執行緒的變數副本作為value。而這個map是由ThreadLocal來維護的,由ThreadLocal負責向map裡設定執行緒的變數值,以及獲取值。所以對於不同的執行緒,每次獲取副本值的時候,其他執行緒都不能獲取當前執行緒的副本值,於是就形成了副本的隔離,多個執行緒互不干擾。所以這是特別好的實現執行緒封閉的方式

ThreadLocal 應用的場景也比較多,例如在經典的web專案中,都會涉及到使用者的登入。而伺服器接收到每個請求都是開啟一個執行緒去處理的,所以我們通常會使用ThreadLocal儲存登入的使用者資訊物件,這樣我們就可以方便的在該請求生命週期內的任何位置獲取到使用者物件,並且不會有執行緒安全問題。示例程式碼如下:

@Slf4j
public class RequestHolder {

    private final static ThreadLocal<Long> REQUEST_HOLDER = new ThreadLocal<>();

    /**
     * 新增資料
     *
     * @param id id
     */
    public static void add(User user) {
        // ThreadLocal 內部維護一個map,key為當前執行緒id,value為當前set的變數
        REQUEST_HOLDER.set(user);
    }

    /**
     * 會通過當前執行緒id獲取資料
     *
     * @return id
     */
    public static Long getId() {
        return REQUEST_HOLDER.get();
    }

    /**
     * 移除變數資訊
     * 如果不移除,那麼變數不會釋放掉,會造成記憶體洩漏
     */
    public static void remove() {
        REQUEST_HOLDER.remove();
    }
}

常見的執行緒不安全的類與寫法

所謂執行緒不安全的類,是指該類的例項物件可以同時被多個執行緒共享訪問,如果不做同步或執行緒安全的處理,就會表現出執行緒不安全的行為。

1.字串拼接,在Java裡提供了兩個類可完成字串拼接,就是StringBuilder和StringBuffer,其中StringBuilder是執行緒不安全的,而StringBuffer是執行緒安全的

StringBuffer之所以是執行緒安全的原因是幾乎所有的方法都加了synchronized關鍵字,所以是執行緒安全的。但是由於StringBuffer 是以加 synchronized 這種暴力的方式保證的執行緒安全,所以效能會相對較差,在堆疊封閉等執行緒安全的環境下應該首先選用StringBuilder。

2.SimpleDateFormat

SimpleDateFormat 的例項物件在多執行緒共享使用的時候會丟擲轉換異常,正確的使用方法應該是採用堆疊封閉,將其作為方法內的區域性變數而不是全域性變數,在每次呼叫方法的時候才去建立一個SimpleDateFormat例項物件,這樣利於堆疊封閉就不會出現併發問題。另一種方式是使用第三方庫joda-time的DateTimeFormatter類(推薦使用)

錯誤寫法:

@Slf4j
public class DateFormatExample1 {
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");

    private static void parse() {
        try {
            // 多執行緒訪問下會報錯
            simpleDateFormat.parse("2018-02-08");
        } catch (ParseException e) {
            log.error("ParseException", e);
        }
    }
}

正確寫法:

@Slf4j
public class DateFormatExample1 {

    private static void parse() {
        try {
            // 在多執行緒下使用SimpleDateFormat的正確方式,利用堆疊封閉特性
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
            simpleDateFormat.parse("2018-02-08");
        } catch (ParseException e) {
            log.error("ParseException", e);
        }
    }
}

推薦使用DateTimeFormatter類:

@Slf4j
public class DateFormatExample3 {
    private static DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern("yyyy-MM-dd");

    private static void parse() {
        Date date = DateTime.parse("2018-02-08", dateTimeFormatter).toDate();
    }
}

3.ArrayList, HashMap, HashSet 等 Collections 都是執行緒不安全的

4.有一種寫法需要注意,即便是執行緒安全的物件,在這種寫法下也可能會出現執行緒不安全的行為,這種寫法就是先檢查後執行:

if(condition(a)){
    handle(a);
}

在這個操作裡,可能會有兩個執行緒同時通過if的判斷,然後去執行了處理方法,那麼就會出現兩個執行緒同時操作一個物件,從而出現執行緒不安全的行為。這種寫法導致執行緒不安全的主要原因是因為這裡分成了兩步操作,這個過程是非原子性的,所以就會出現執行緒不安全的問題。


同步容器簡介

在上一小節中,我們提到了一些常用的執行緒不安全的集合容器,當我們在使用這些容器時,需要自行處理執行緒安全問題。所以使用起來相對會有些不便,而Java在這方面提供了相應的同步容器,我們可以在多執行緒情況下可以結合實際場景考慮使用這些同步容器。

1.在Java中同步容器主要分為兩類,一類是集合介面下的同步容器實現類:

  • List -> Vector、Stack
  • Map -> HashTable(key、value不能為null)

注:vector的所有方法都是有synchronized關鍵字保護的,stack繼承了vector,並且提供了棧操作(先進後出),而hashtable也是由synchronized關鍵字保護

但是需要注意的是同步容器也並不一定是絕對執行緒安全的,例如有兩個執行緒,執行緒A根據size的值迴圈執行remove操作,而執行緒B根據size的值迴圈執行執行get操作。它們都需要呼叫size獲取容器大小,當迴圈到最後一個元素時,若執行緒A先remove了執行緒B需要get的元素,那麼就會報越界錯誤。錯誤示例如下:

@Slf4j
public class VectorExample2 {
    private static List<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }

            Runnable thread1 = () -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            };

            Runnable thread2 = () -> {
                for (int i = 0; i < vector.size(); i++) {
                    // 當thread2想獲取i=9的元素的時候,而thread1剛好將i=9的元素移除了,就會導致陣列越界
                    vector.get(i);
                }
            };

            new Thread(thread1).start();
            new Thread(thread2).start();
        }
    }
}

另外還有一點需要注意的是,當我們使用foreach迴圈或迭代器去遍歷元素的同時又執行刪除操作的話,即便在單執行緒下也會報併發修改異常。示例程式碼如下:

public class VectorExample3 {

    private static void test1(Vector<Integer> v1) {
        // 在遍歷的同時進行了刪除的操作,會丟擲java.util.ConcurrentModificationException併發修改異常
        for (Integer integer : v1) {
            if (integer.equals(5)) {
                v1.remove(integer);
            }
        }
    }

    private static void test2(Vector<Integer> v1) {
        // 在遍歷的同時進行了刪除的操作,會丟擲java.util.ConcurrentModificationException併發修改異常
        Iterator<Integer> iterator = v1.iterator();
        while (iterator.hasNext()) {
            Integer integer = iterator.next();
            if (integer.equals(5)) {
                v1.remove(integer);
            }
        }
    }

    private static void test3(Vector<Integer> v1) {
        // 可以正常刪除
        for (int i = 0; i < v1.size(); i++) {
            if (v1.get(i).equals(5)) {
                v1.remove(i);
            }
        }
    }

    public static void main(String[] args) {
        Vector<Integer> vector = new Vector<>();
        for (int i = 1; i <= 5; i++) {
            vector.add(i);
        }
        test1(vector);
//        test2(vector);
//        test3(vector);
    }
}

所以在foreach迴圈或迭代器遍歷的過程中不能做刪除操作,若需遍歷的同時進行刪除操作的話儘量使用for迴圈。實在要使用foreach迴圈或迭代器的話應該先標記要刪除元素的下標,然後最後再統一刪除。如下示例:

private static void test4(Vector<Integer> v1) {
    int delIndex = 0;
    for (Integer integer : v1) {
        if (integer.equals(5)) {
            delIndex = v1.indexOf(integer);
        }
    }
    v1.remove(delIndex);
}

最方便的方式就是使用jdk1.8提供的函數語言程式設計介面:

private static void test5(Vector<Integer> v1){
    v1.removeIf((i) -> i.equals(5));
}

2.第二類是Collections.synchronizedXXX (list,set,map)方法所建立的同步容器

示例程式碼:

public class CollectionsExample{
    private static List<Integer> list = Collections.synchronizedList(new ArrayList<>());

    private static Set<Integer> set = Collections.synchronizedSet(new HashSet<>());

    private static Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<>());
}

併發容器簡介

在上一小節中,我們簡單介紹了常見的同步容器,知道了同步容器是通過synchronized來實現同步的,所以效能較差。而且同步容器也並不是絕對執行緒安全的,在一些特殊情況下也會出現執行緒不安全的行為。那麼有沒有更好的方式代替同步容器呢?答案是有的,那就是併發容器,有了併發容器後同步容器的使用也越來越少的,大部分都會優先使用併發容器。本小節將簡單介紹一下併發容器,併發容器也稱為J.U.C,即是其包名:java.util.concurrent。

1.ArrayList對應的CopyOnWriteArrayList:

CopyOnWrite容器即寫時複製的容器。通俗的理解是當我們往一個容器新增元素的時候,不直接往當前容器新增,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裡新增元素,新增完元素之後,再將原容器的引用指向新的容器。這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。而在CopyOnWriteArrayList寫的過程是會加鎖的,即呼叫add的時候,否則多執行緒寫的時候會Copy出N個副本出來。

CopyOnWriteArrayList.add()方法原始碼如下(jdk11版本):

/**
 * Appends the specified element to the end of this list.
 *
 * @param e element to be appended to this list
 * @return {@code true} (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    //1、先加鎖
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        //2、拷貝陣列
        es = Arrays.copyOf(es, len + 1);
        //3、將元素加入到新陣列中
        es[len] = e;
        //4、將array引用指向到新陣列
        setArray(es);
        return true;
    }
}

讀的時候不需要加鎖,但是如果讀的時候有多個執行緒正在向CopyOnWriteArrayList新增資料,讀還是會讀到舊的資料,因為寫的時候不會鎖住舊的CopyOnWriteArrayList。CopyOnWriteArrayList.get()方法原始碼如下(jdk11版本):

/**
 * {@inheritDoc}
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E get(int index) {
    return elementAt(getArray(), index);
}

CopyOnWriteArrayList容器有很多優點,但是同時也存在兩個問題,即記憶體佔用問題和資料一致性問題:

  • 記憶體佔用問題:

    因為CopyOnWriteArrayList的寫操作時的複製機制,所以在進行寫操作的時候,記憶體裡會同時駐紮兩個物件的記憶體,舊的物件和新寫入的物件(注意:在複製的時候只是複製容器裡的引用,只是在寫的時候會建立新物件新增到新容器裡,而舊容器的物件還在使用,所以有兩份物件記憶體)。如果這些物件佔用的記憶體比較大,比如說200M左右,那麼再寫入100M資料進去,記憶體就會佔用300M,那麼這個時候很有可能造成頻繁的Yong GC和Full GC。之前我們系統中使用了一個服務由於每晚使用CopyOnWrite機制更新大物件,造成了每晚15秒的Full GC,應用響應時間也隨之變長。

    針對記憶體佔用問題,可以通過壓縮容器中的元素的方法來減少大物件的記憶體消耗,比如,如果元素全是10進位制的數字,可以考慮把它壓縮成36進位制或64進位制。或者不使用CopyOnWrite容器,而使用其他的併發容器,如ConcurrentHashMap。

  • 資料一致性問題:

    CopyOnWriteArrayList容器只能保證資料的最終一致性,不能保證資料的實時一致性。所以如果你希望寫入的的資料,馬上能讀到,即實時讀取場景,那麼請不要使用CopyOnWriteArrayList容器。

CopyOnWrite的應用場景:

綜上,CopyOnWriteArrayList併發容器用於讀多寫少的併發場景。不過這類慎用因為誰也沒法保證CopyOnWriteArrayList 到底要放置多少資料,萬一資料稍微有點多,每次add/set都要重新複製陣列,這個代價實在太高昂了。在高效能的網際網路應用中,這種操作分分鐘引起故障。

參考文章:

http://ifeve.com/java-copy-on-write/#more-10403


2.HashSet對應的CopyOnWriteArraySet

CopyOnWriteArraySet是執行緒安全的,它底層的實現使用了CopyOnWriteArrayList,因此和CopyOnWriteArrayList概念是類似的。使用迭代器進行遍歷的速度很快,並且不會與其他執行緒發生衝突。在構造迭代器時,迭代器依賴於不可變的陣列快照,所以迭代器不支援可變的 remove 操作。

CopyOnWriteArraySet適合於具有以下特徵的場景:

  • set 大小通常保持很小,只讀操作遠多於可變操作,需要在遍歷期間防止執行緒間的衝突。

CopyOnWriteArraySet缺點:

  • 因為通常需要複製整個基礎陣列,所以可變操作(add、set 和 remove 等等)的開銷很大。

3.TreeSet對應的ConcurrentSkipListSet

ConcurrentSkipListSet是jdk6新增的類,它和TreeSet一樣是支援自然排序的,並且可以在構造的時候定義Comparator&lt;E&gt; 的比較器,該類的方法基本和TreeSet中方法一樣(方法簽名一樣)。和其他的Set集合一樣,ConcurrentSkipListSet是基於Map集合的,ConcurrentSkipListMap便是它的底層實現

在多執行緒的環境下,ConcurrentSkipListSet中的contains、add、remove操作是安全的,多個執行緒可以安全地併發執行插入、移除和訪問操作。但是對於批量操作 addAll、removeAll、retainAll 和 containsAll並不能保證以原子方式執行。理由很簡單,因為addAll、removeAll、retainAll底層呼叫的還是contains、add、remove的方法,在批量操作時,只能保證每一次的contains、add、remove的操作是原子性的(即在進行contains、add、remove三個操作時,不會被其他執行緒打斷),而不能保證每一次批量的操作都不會被其他執行緒打斷。所以在進行批量操作時,需自行額外手動做一些同步、加鎖措施,以此保證執行緒安全。另外,ConcurrentSkipListSet類不允許使用 null 元素,因為無法可靠地將 null 引數及返回值與不存在的元素區分開來。


4.HashMap對應的ConcurrentHashMap

HashMap的併發安全版本是ConcurrentHashMap,但ConcurrentHashMap不允許 null 值。在大多數情況下,我們使用map都是讀取操作,寫操作比較少。因此ConcurrentHashMap針對讀取操作做了大量的優化,所以ConcurrentHashMap具有很高的併發性,在高併發場景下表現良好。關於ConcurrentHashMap詳細的內容會在後續文章中進行介紹。


5.TreeMap對應的ConcurrentSkipListMap

ConcurrentSkipListMap的底層是通過跳錶來實現的。跳錶是一個連結串列,但是通過使用“跳躍式”查詢的方式使得插入、讀取資料時複雜度變成了O(logn)。

有人曾比較過ConcurrentHashMap和ConcurrentSkipListMap的效能,在4執行緒1.6萬資料的條件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。

但ConcurrentSkipListMap有幾個ConcurrentHashMap不能比擬的優點:

  • ConcurrentSkipListMap 的key是有序的
  • ConcurrentSkipListMap 支援更高的併發。ConcurrentSkipListMap的存取時間是O(logn),和執行緒數幾乎無關。也就是說在資料量一定的情況下,併發的執行緒越多,ConcurrentSkipListMap越能體現出其優勢

在非多執行緒的情況下,應當儘量使用TreeMap。此外對於併發性相對較低的並行程式可以使Collections.synchronizedSortedMap將TreeMap進行包裝,也可以提供較好的效率。對於高併發程式,應當使用ConcurrentSkipListMap,能夠提供更高的併發度。

所以在多執行緒程式中,如果需要對Map的鍵值進行排序時,請儘量使用ConcurrentSkipListMap,可能得到更好的併發度。
注意,呼叫ConcurrentSkipListMap的size時,由於多個執行緒可以同時對對映表進行操作,所以對映表需要遍歷整個連結串列才能返回元素個數,這個操作是個O(log(n))的操作。