CAS操作確保原子性+ 實現生產者消費者模式的四種方式(Synchronized、Lock、Semaphore、BlockingQueue)

CAS操作確保原子性

原子操作是不可分割的,在執行完畢之前不會被任何其它任務或事件中斷。 在單處理器系統(UniProcessor)中,能夠在單條指令中完成的操作都可以認爲是" 原子操作",因爲中斷只能發生於指令之間。

(一)CAS操作

在JDK 5之前Java語言是靠synchronized關鍵字保證同步的,這會導致有鎖
鎖機制存在以下問題:
(1)在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題。
(2)一個線程持有鎖會導致其它所有需要此鎖的線程掛起。
(3)如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險。
volatile是不錯的機制,但是volatile不能保證原子性。因此對於同步最終還是要回到鎖機制上來。
獨佔鎖是一種悲觀鎖,synchronized就是一種獨佔鎖,會導致其它所有需要鎖的線程掛起,等待持有鎖的線程釋放鎖。而另一個更加有效的鎖就是樂觀鎖。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因爲衝突失敗就重試,直到成功爲止。樂觀鎖用到的機制就是CAS,Compare and Swap。

一、什麼是CAS

CAS,compare and swap的縮寫,中文翻譯成比較並交換。

我們都知道,在java語言之前,併發就已經廣泛存在並在服務器領域得到了大量的應用。所以硬件廠商老早就在芯片中加入了大量直至併發操作的原語,從而在硬件層面提升效率。在intel的CPU中,使用cmpxchg指令。

在Java發展初期,java語言是不能夠利用硬件提供的這些便利來提升系統的性能的。而隨着java不斷的發展,Java本地方法(JNI)的出現,使得java程序越過JVM直接調用本地方法提供了一種便捷的方式,因而java在併發的手段上也多了起來。而在Doug Lea提供的cucurenct包中,CAS理論是它實現整個java包的基石。

CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。 如果內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值 。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該 位置的值。(在 CAS 的一些特殊情況下將僅返回 CAS 是否成功,而不提取當前 值。)CAS 有效地說明了“我認爲位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。”

通常將 CAS 用於同步的方式是從地址 V 讀取值 A,執行多步計算來獲得新 值 B,然後使用 CAS 將 V 的值從 A 改爲 B。如果 V 處的值尚未同時更改,則 CAS 操作成功。

類似於 CAS 的指令允許算法執行讀-修改-寫操作,而無需害怕其他線程同時 修改變量,因爲如果其他線程修改變量,那麼 CAS 會檢測它(並失敗),算法 可以對該操作重新計算。

二、CAS的目的

利用CPU的CAS指令,同時藉助JNI來完成Java的非阻塞算法。其它原子操作都是利用類似的特性完成的。而整個J.U.C都是建立在CAS之上的,因此對於synchronized阻塞算法,J.U.C在性能上有了很大的提升。

三、CAS存在的問題

CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大和只能保證一個共享變量的原子操作

  1. ABA問題。因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

關於ABA問題參考文檔: http://blog.hesey.net/2011/09/resolve-aba-by-atomicstampedreference.html

  1. 循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支持處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

  2. 只能保證一個共享變量的原子操作。當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行CAS操作。

四、concurrent包的實現

由於java的CAS同時具有 volatile 讀和volatile寫的內存語義,因此Java線程之間的通信現在有了下面四種方式:

A線程寫volatile變量,隨後B線程讀這個volatile變量。
A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。
Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支持原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機器,因此任何現代的多處理器都會去支持某種能對內存執行原子性讀-改-寫操作的原子指令)。同時,volatile變量的讀/寫和CAS可以實現線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:

首先,聲明共享變量爲volatile;
然後,使用CAS的原子條件更新來實現線程之間的同步;
同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信。
AQS,非阻塞數據結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent包的實現示意圖如下
在這裏插入圖片描述

(二)在AtomicInteger中應用

一 /CAS原理:

  通過查看AtomicInteger的源碼可知, 
private volatile int value;
public final boolean compareAndSet(int expect, int update) { 
                    return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 
               } 

通過申明一個volatile (內存鎖定,同一時刻只有一個線程可以修改內存值)類型的變量,再加上unsafe.compareAndSwapInt的方法,來保證實現線程同步的。

二、CAS(Compare and Swap)

CAS指令在Intel CPU上稱爲CMPXCHG指令,它的作用是將指定內存地址的內容與所給的某個值相比,如果相等,則將其內容替換爲指令中提供的新值,如果不相等,則更新失敗。這一比較並交換的操作是原子的,不可以被中斷。初一看,CAS也包含了讀取、比較 (這也是種操作)和寫入這三個操作,和之前的i++並沒有太大區別,是的,的確在操作上沒有區別,但CAS是通過硬件命令保證了原子性,而i++沒有,且硬件級別的原子性比i++這樣高級語言的軟件級別的運行速度要快地多。雖然CAS也包含了多個操作,但其的運算是固定的(就是個比較),這樣的鎖定性能開銷很小。

從內存領域來說這是樂觀鎖,因爲它在對共享變量更新之前會先比較當前值是否與更新前的值一致,如果是,則更新,如果不是,則無限循環執行(稱爲自旋),直到當前值與更新前的值一致爲止,才執行更新。
簡單的來說,CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。當且僅當預期值A和內存值V相同時,將內存值V修改爲B,否則返回V。這是一種樂觀鎖的思路,它相信在它修改之前,沒有其它線程去修改它;而Synchronized是一種悲觀鎖,它認爲在它修改之前,一定會有其它線程去修改它,悲觀鎖效率很低。下面來看一下AtomicInteger是如何利用CAS實現原子性操作的。

// volatile變量
private volatile int value;  
//首先聲明瞭一個volatile變量value,我們知道volatile保證了變量的內存可見性,
//也就是所有工作線程中同一時刻都可以得到一致的值。
public final int get() {    
    return value;    
}  
//Compare And Set
// setup to use Unsafe.compareAndSwapInt for updates    
private static final Unsafe unsafe = Unsafe.getUnsafe();    
private static final long valueOffset;// 注意是靜態的    
    
static {    
  try {    
    valueOffset = unsafe.objectFieldOffset    
        (AtomicInteger.class.getDeclaredField("value"));
        // 反射出value屬性,獲取其在內存中的位置    
  } catch (Exception ex) { throw new Error(ex); }    
}    
    
public final boolean compareAndSet(int expect, int update) {    
  return unsafe.compareAndSwapInt(this, valueOffset, expect, update);    
}    

比較並設置,這裏利用Unsafe類的JNI方法實現,使用CAS指令,可以保證讀-改-寫是一個原子操作。compareAndSwapInt有4個參數,this - 當前AtomicInteger對象,Offset - value屬性在內存中的位置(需要強調的是不是value值在內存中的位置),expect - 預期值,update - 新值,根據上面的CAS操作過程,當內存中的value值等於expect值時,則將內存中的value值更新爲update值,並返回true,否則返回false。在這裏我們有必要對Unsafe有一個簡單點的認識,從名字上來看,不安全,確實,這個類是用於執行低級別的、不安全操作的方法集合,這個類中的方法大部分是對內存的直接操作,所以不安全,但當我們使用反射、併發包時,都間接的用到了Unsafe。

實現生產者消費者模式的四種方式(Synchronized、Lock、Semaphore、BlockingQueue)

所謂生產者消費者模式,即N個線程進行生產,同時N個線程進行消費,兩種角色通過內存緩衝區進行通信
在這裏插入圖片描述
下面我們通過四種方式,來實現生產者消費者模式。

首先是最原始的synchronized方式

定義庫存類(即圖中緩存區)

class Stock {
    private String name;
    // 標記庫存是否有內容
    private boolean hasComputer = false;

    public synchronized void putOne(String name) {
        // 若庫存中已有內容,則生產線程阻塞等待
        while (hasComputer) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        this.name = name;
        System.out.println("生產者...生產了 " + name);
        // 更新標記
        this.hasComputer = true;
        // 這裏用notify的話,假設p0執行完畢,此時c0,c1都在wait, 同時喚醒另一個provider:p1,
        // p1判斷標記後休眠,造成所有線程都wait的局面,即死鎖;
        // 因此使用notifyAll解決死鎖問題
        this.notifyAll();
    }

    public synchronized void takeOne() {
        // 若庫存中沒有內容,則消費線程阻塞等待生產完畢後繼續
        while (!hasComputer) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("消費者...消費了 " + name);
        this.hasComputer = false;
        this.notifyAll();
    }
}

定義生產者和消費者(爲了節省空間和方便閱讀,這裏將生產者和消費者定義成了匿名內部類)

public static void main(String[] args) {
    // 用於通信的庫存類
    Stock computer = new Stock();
    // 定義兩個生產者和兩個消費者
    Thread p1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.putOne("Dell");
            }
        }
    });
    Thread p2 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.putOne("Mac");
            }
        }
    });
    
    Thread c1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.takeOne();
            }
        }
    });
    Thread c2 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.takeOne();
            }
        }
    });
    p1.start();
    p2.start();
    c1.start();
    c2.start();
}

第二種方式:Lock

Jdk1.5之後加入了Lock接口,一個lock對象可以有多個Condition類,Condition類負責對lock對象進行wait,notify,notifyall操作

class LockStock {
    final Lock lock = new ReentrantLock();
    final Condition notFull = lock.newCondition();
    final Condition notEmpty = lock.newCondition();

    // 加入庫存概念,可批量生產和消費
    // 定義最大庫存爲10
    final String[] stock = new String[10];
    // 寫入標記、讀取標記、已有商品數量
    int putptr, takeptr, count;

    public void put(String computer) {
        // lock代替synchronized
        lock.lock();
        try {
            // 若庫存已滿則生產者線程阻塞
            while (count == stock.length)
                notFull.await();
            // 庫存中加入商品
            stock[putptr] = computer;
            // 庫存已滿,指針置零,方便下次重新寫入
            if (++putptr == stock.length) putptr = 0;
            ++count;
            System.out.println(computer + " 正在生產數據: -- 庫存剩餘:" + count);
            notEmpty.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public String take(String consumerName) {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            // 從庫存中獲取商品
            String computer = stock[takeptr];
            if (++takeptr == stock.length) takeptr = 0;
            --count;
            System.out.println(consumerName + " 正在消費數據:" + computer + " -- 庫存剩餘:" + count);
            notFull.signal();
            return computer;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

        // 無邏輯作用,放慢速度
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "";
    }
}

以上部分代碼摘自java7 API中Condition接口的官方示例

接着還是定義生產者和消費者

public static void main(String[] args) {
    LockStock computer = new LockStock();
    Thread p1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.put("Dell");
            }
        }
    });
    Thread p2 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.put("Mac");
            }
        }
    });

    Thread c1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.take("zhangsan");
            }
        }
    });
    Thread c2 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                computer.take("李四");
            }
        }
    });
    // 兩個生產者兩個消費者同時運行
    p1.start();
    p2.start();
    c1.start();
    c2.start();
}

在這裏插入圖片描述

第三種方式:Semaphore

首先依舊是庫存類:

class Stock {
    List<String> stock = new LinkedList();
    // 互斥量,控制共享數據的互斥訪問
    private Semaphore mutex = new Semaphore(1);

    // canProduceCount可以生產的總數量。 通過生產者調用acquire,減少permit數目
    private Semaphore canProduceCount = new Semaphore(10);

    // canConsumerCount可以消費的數量。通過生產者調用release,增加permit數目
    private Semaphore canConsumerCount = new Semaphore(0);

    public void put(String computer) {
        try {
            // 可生產數量 -1
            canProduceCount.acquire();
            mutex.acquire();
            // 生產一臺電腦
            stock.add(computer);
            System.out.println(computer + " 正在生產數據" + " -- 庫存剩餘:" + stock.size());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 釋放互斥鎖
            mutex.release();
            // 釋放canConsumerCount,增加可以消費的數量
            canConsumerCount.release();
        }
        // 無邏輯作用,放慢速度
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void get(String consumerName) {
        try {
            // 可消費數量 -1
            canConsumerCount.acquire();
            mutex.acquire();
            // 從庫存消費一臺電腦
            String removedVal = stock.remove(0);
            System.out.println(consumerName + " 正在消費數據:" + removedVal + " -- 庫存剩餘:" + stock.size());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            mutex.release();
            // 消費後釋放canProduceCount,增加可以生產的數量
            canProduceCount.release();
        }
    }
}

還是生產消費者:

public class SemaphoreTest {
    public static void main(String[] args) {
        // 用於多線程操作的庫存變量
        final Stock stock = new Stock();
        // 定義兩個生產者和兩個消費者
        Thread dellProducer = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    stock.put("Del");
                }
            }
        });
        Thread macProducer = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    stock.put("Mac");
                }
            }
        });
        Thread consumer1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    stock.get("zhangsan");
                }
            }
        });
        Thread consumer2 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    stock.get("李四");
                }
            }
        });
        dellProducer.start();
        macProducer.start();
        consumer1.start();
        consumer2.start();
    }
}

在這裏插入圖片描述

第四種方式:BlockingQueue

BlockingQueue的put和take底層實現其實也是使用了第二種方式中的ReentrantLock+Condition,並且幫我們實現了庫存隊列,方便簡潔
1、定義生產者

class Producer implements Runnable {
    // 庫存隊列
    private BlockingQueue<String> stock;
    // 生產/消費延遲
    private int timeOut;
    private String name;

    public Producer(BlockingQueue<String> stock, int timeout, String name) {
        this.stock = stock;
        this.timeOut = timeout;
        this.name = name;
    }
    @Override
    public void run() {
        while (true) {
            try {
                stock.put(name);
                System.out.println(name + " 正在生產數據" + " -- 庫存剩餘:" + stock.size());
                TimeUnit.MILLISECONDS.sleep(timeOut);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

2、定義消費者

class Consumer implements Runnable {
    // 庫存隊列
    private BlockingQueue<String> stock;
    private String consumerName;

    public Consumer(BlockingQueue<String> stock, String name) {
        this.stock = stock;
        this.consumerName = name;
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 從庫存消費一臺電腦
                String takeName = stock.take();
                System.out.println(consumerName + " 正在消費數據:" + takeName + " -- 庫存剩餘:" + stock.size());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

3、定義庫存並運行

public static void main(String[] args) {
        // 定義最大庫存爲10
        BlockingQueue<String> stock = new ArrayBlockingQueue<>(10);
        Thread p1 = new Thread(new Producer(stock, 500, "Mac"));
        Thread p2 = new Thread(new Producer(stock, 500, "Dell"));
        Thread c1 = new Thread(new Consumer(stock,"zhangsan"));
        Thread c2 = new Thread(new Consumer(stock, "李四"));

        p1.start();
        p2.start();
        c1.start();
        c2.start();

    }

在這裏插入圖片描述

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