併發原子性技術之加鎖方式

1. 鎖的分類

樂觀鎖與悲觀鎖

  • 樂觀鎖:在併發環境下,一般情況認爲是屬於讀多寫少的情況,沒有數據衝突,當對共享資源發生寫操作的時候,會先檢測當前版本的數據與先前版本數據是否一致,如果不一致說明有其他線程已經發生寫操作,需要重複進行讀取然後檢測再嘗試修改,比如CAS機制,zk最優先通過最新主版本的策略來選舉master,數據庫根據版本更新數據等
  • 悲觀鎖:在併發環境下,認爲競爭非常激烈,於是在對共享資源發生寫操作的時候先加上鎖,然後執行臨界區代碼完成操作再釋放鎖,其他線程獲取相同的鎖需要進行等待,處於阻塞狀態
  • 樂觀鎖與悲觀鎖示例僞代碼:
// 使用java以及數據庫的操作方式演示樂觀鎖與悲觀鎖
// optimistic.java
AtomicInteger count = new AtomicInteger();
run(){
	// cas instance
	int expected = 10;
	int updated = 12;
	count.compareAndSet(expected, updated);
	// db instance
	Object row = selectById();
	// update bussiness data except row version
	row.setXX();
	// update data 
	updateRowByIdWithVersion(row);
	// db.execute("update tb_entity set x1=?,x2=?,version=#{version}+1 where id=#{id} and version=#{version}", row);
}

// pessimistic.java
run(){
	// mutex lock
	synchronized(this){
		// code ..
	}
	// db lock
	// start transaction
	db.execute("select * from tb_entity where id=#{id} for update");	// lock
	db.execute("update tb_entity set x1=?,... where id=#{id}");			// update
	// commit trasaction;
}

可重入鎖與遞歸鎖

  • 可重入鎖: 同一個線程獲取到鎖對象,同時能夠執行擁有該鎖的其他同步代碼,即遞歸鎖/外部方法調用內部方法,兩個方法持有同一把鎖
  • 遞歸鎖: 本質也是可重入鎖,也就是線程執行當前遞歸的方法時,由於是同一把鎖,因此不會再次獲取鎖,而是持有鎖進行執行方法的遞歸操作
  • java實現可重入鎖的技術
    • ReentrantLock
    • synchronized
// source.java
final static ReentrantLock reentrantLock = new ReentrantLock();
run(){
	reentrantLock.lock();
	// code ...
    {
        reentrantLock.lock();
        // code ...
        reentrantLock.unlock();
    }
    // code ...
    reentrantLock.unlock();
}
// order.java
synchronized void createOutBoundOrder(){
	//...
}

run(){
	//Order sharedOrder = new Order();
	synchronized(sharedOrder){
		// create order item
		// get logistics price and caculate the best optimisic algorithm
		createOutBoundOrder(orders);
		// dispatch order towards sys
	}
}

公平鎖與非公平鎖

  • 公平鎖: 在併發環境下多線程爭搶鎖的順序,如果是按照隊列的先來後到的順序,則視爲公平
  • 非公平鎖: 在併發多線程環境下不按照先來後到的順序,而是強行“插隊”的方式獲取鎖,則視爲不公平
  • 場景分析: 如果線程A已經持有鎖,這時候線程B獲取失敗並被掛起,處於阻塞狀態,與此同時線程C也來爭奪鎖也將被掛起阻塞,當線程A執行完同步方法之後釋放鎖的時候,如果鎖是公平的,那麼我們期望就是線程B獲取鎖而線程C仍然處於阻塞狀態,如果是非公平鎖,那麼將線程B/C獲取鎖的順序是隨機不確定的
  • java實現公平鎖的技術方案
 // 公平鎖,會消耗性能
 ReentrantLock pairLock = new ReentrantLock(true);

 // 非公平鎖, 默認爲不公平鎖,獲取鎖的方式是隨機,不按照線程爭搶鎖的順序
 ReentrantLock unpairLock = new ReentrantLock(false);

共享鎖(讀)與獨佔鎖(寫)

  • 共享鎖: 也就是所謂的讀鎖,即給共享資源加上讀鎖,線程獲取該鎖的時候只能讀取不能進行寫操作,同理也可以被其他線程獲取,但不能寫,共享鎖可以被多個線程共同持有
  • 獨佔鎖: 也就是寫鎖,或者稱爲排它鎖,也就是隻能有一個線程擁有該鎖,當前給共享資源加上寫鎖時,當前線程可以進行寫操作,但是其他線程要獲取鎖只能處於等待
  • 簡言之,共享鎖能爲多個線程所持有並只能進行讀操作,獨佔鎖只能被單個線程所持有並只能進行單寫操作
  • java實現的讀寫鎖技術
// read_write_lock.java
private static void readWriteLock() {
    final Lock read = reentrantReadWriteLock.readLock();
    final Lock write = reentrantReadWriteLock.writeLock();

    for (int index = 0; index < 10; index++){
    	// 讀取操作交替執行,所有讀線程都能夠獲取讀鎖
        new Thread(){
            @Override
            public void run() {
                try{
                    read.lock();
                    read();
                }finally {
                    read.unlock();
                }
            }
        }.start();
		
		// 寫操作按照獲取鎖的方式順序執行,只能在單線程中實現寫操作
        new Thread(){
            @Override
            public void run() {
                try{
                    write.lock();
                    write();
                }finally {
                    write.unlock();
                }
            }
        }.start();
    }
}

自旋鎖 = CAS無鎖 + 循環

  • 自旋鎖: 在併發環境下,當一個線程獲取鎖的時候發現鎖已經被其他線程所佔有,這時候並沒有將該線程掛起而是在可用的CPU資源情況下,不斷嘗試獲取鎖的方式,如果成功則獲取鎖,如果失敗則進行嘗試,在進行嘗試一定次數之後會對當前的併發環境進行分析並採取其他的策略獲取鎖
  • 在Java中,默認嘗試此時爲10, 可以通過-XX:PreBlockSpinsh來設置對應的自旋失敗次數
  • 不足:消耗CPU資源,容易引起CPU佔用資源過高導致機器卡頓甚至處理效率變低
  • java技術實現的自旋鎖方式
// 使用CAS無鎖 + 循環的方式 -- 自旋鎖
	private static Unsafe unsafe;
    private static long valueOffsetValue = 0;
    private volatile int count = 0;

    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            unsafe = (Unsafe) theUnsafe.get(null);

            // CAS 硬件原語 ---java語言 無法直接改內存。 曲線通過對象及屬性的定位方式
            valueOffsetValue = unsafe.objectFieldOffset(LockCASDemo.class.getDeclaredField("count"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
	private void countAdder(){
     	// 自增加
     	int current;
     	int value;
     	do{
         	current = unsafe.getIntVolatile(this, valueOffsetValue); // 讀取當前值
         	value = current + 1; // 計算
     	}while (unsafe.compareAndSwapInt(this, valueOffsetValue, current , value));
 	}

分片加鎖

  • 分片加鎖其實將鎖的粒度縮小範圍,尤其是在併發情況下,需要對存儲容器不斷進行讀寫操作,如果每次都對容器進行加鎖,那麼鎖範圍十分大,會大大降低程序執行的效率,因此分片加鎖就是針對容器進行劃分爲若干等分(可以片段)進行加鎖,也就是說針對容器某一個等分進行讀寫操作的時候只針對該部分進行加鎖操作,其他部分仍保持無鎖的狀態,可以大大提升程序CPU利用率,加快程序的執行
  • 分片加鎖技術
    • java中使用ConcurrentHashMap針對Segment片段進行加鎖,每一份Segment存儲key-value,通過對Segment進行加鎖方式(進一步優化可以處理爲讀寫鎖方式)來保證線程安全
    • 在數據庫中,可以將一系列的update/delete操作進行行級別加鎖,相比表級別的加鎖方式,可以提升併發執行效率

jdk鎖的細節

  • 鎖響應中斷: 在併發環境下,當前線程想要獲取鎖卻發現鎖已經被其他線程所持有,這時候當前線程就處於阻塞狀態,如果當前線程在主線程中被中斷,那麼此時阻塞的線程收到中斷的通知將會結束線程工作,如果不做鎖的響應中斷處理,那麼當前線程仍然會獲取鎖並執行鎖中的同步代碼之後釋放鎖才中斷
// - java技術僞代碼
// business.java
private Lock lock = new ReentrantLock();
void doBusiness(){
	// code ...
	// 鎖響應中斷,如果主線程對當前線程進行中斷操作,那麼當前線程將會直接退出線程
	lock.lockInterruptibly();
	// 如果主線程對當前線程進行中斷操作,那麼當前線程會繼續獲取鎖之後再進行中斷操作
	// lock.lock();
	try{
		// execute core should spent 10s 
		TimeUnit.SECONDS.sleep(10L);
	}catch(Exception e){
		LOG.error(e);	
	}finally{
		lock.unlock();
	}
}
// main.java
// 僞代碼,爲了簡化代碼的編寫
Thread t1,t2 = new Thread(){
	public void run(){
		try{
			doBusiness();
			doOthers();
		}catch(InterruptedException e){
			LOG.error(e);
		}
	}
};

t1.start();
TimeUnit.SECONDS.sleep(1L);
t2.start();
TimeUnit.SECONDS.sleep(2L);
// 線程2中斷
t2.interrupt();	
  • 分類鎖Condition: 用於替代wait/notify方法,wait和notify方法是結合synchronized一起使用,可以令線程等待和喚醒等待線程的集合,而Condition是結合Lock一起使用,能夠提供多個等待集合,更精準地控制線程的等待和喚醒(底層使用LockSupport的park和unpark機制)
	// 使用線程通信方式
  final Condition full = reentrantLock.newCondition();
  final Condition empty = reentrantLock.newCondition();
  final int size = 10;
  final ArrayDeque<String> container = new ArrayDeque<>();

  Thread producer = new Thread(){
      @Override
      public void run() {
          reentrantLock.lock();
         try{
             // 不斷生產數據
             while (true){
                 // 數據已經滿了
                 while (size == container.size()){
                     // 不再進行生產數據
                     full.await();
                 }
                 // 生產數據
                 container.push(new String("xxx"));
                 System.out.println(Thread.currentThread().getName() + " produce ...");
                 empty.signalAll();// 通知消費進行消費
             }
         } catch (Exception e) {
             e.printStackTrace();
         } finally {
             reentrantLock.unlock();
         }
      }
  };

  Thread consumer = new Thread(){
      @Override
      public void run() {
          reentrantLock.lock();
          try{
              while (true){
                  // 判斷數據是否爲空
                  while(0 == container.size()){
                      empty.await();
                  }
                  String str = container.pop();
                  System.out.println(Thread.currentThread().getName() + " consumer data for :" + str);
                  full.signalAll();  // 通知生產者生產數據
              }
          } catch (Exception e) {
              e.printStackTrace();
          } finally {
              reentrantLock.unlock();
          }
      }
  };

  producer.start();

  TimeUnit.SECONDS.sleep(2L);
  consumer.start();
2. 加鎖原理

jdk中加鎖的工作原理

3. JVM鎖優化技術手段

synchronized加鎖方式的優化

  • 無鎖: 正常的java對象,屬於默認的初始化的鎖狀態
  • 偏向鎖: 對象頭markword中的hashcode通過CAS替換爲當前的ThreadId,同時對象的偏向鎖標誌爲1
  • 輕量級鎖: 達到一定的併發量,JVM撤銷偏向鎖,鎖升級爲輕量級鎖,即在對象頭markword中的bitfields通過CAS替換爲當前的執行線程棧幀的引用地址,同時棧幀中存儲對象頭的markword信息
  • 重量級鎖(mutex lock): 併發競爭激烈,升級爲重量級鎖,通過將對象頭markword中的bitfields通過CAS替換爲當前對象的引用地址,通過JVM優化會通過走“捷徑”方式來進行加鎖,鎖無法降級
  • 參考synchronized工作原理(三)

減少鎖持有時間

  • 僅在程序出現線程安全的情況下進行加鎖
  • 代碼演示減少鎖持有時間
run(){
	// query from user
	// query from order
	try{
		// 保證一致性, 思考: 這裏用事務也可以實現同樣的效果,事務與鎖有什麼關係? 後面有時間再寫
		lock.lock();
		// create order items
		// create orders
		// create outbound orders
	}finally{
		lock.unlock();
	}
}
  • 關於事務與鎖的聯繫與區別的參考文檔
參考mysql文檔: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-transaction-model.html
參考美團技術文檔: https://tech.meituan.com/2014/08/20/innodb-lock.html

鎖粗化

  • 在併發條件下,程序獲取鎖的成本十分昂貴,如果在一塊代碼中對同一個鎖不斷地獲取和釋放,容易導致CPU系統資源被消耗殆盡,嚴重影響程序在操作系統中的執行效率,也就是說爲了保證在併發多線程環境下,要求每個線程儘可能持有鎖的時間片段儘可能少,同時能夠在完成同步代碼之後釋放共享資源以便其他線程能夠獲取鎖進行相應的操作
// 鎖粗化僞代碼示例
increase(){
	synchronized(this){
		this.productNum -- ;
	}
	// send message to mq notice product num have been decreased
	synchronized(this){
		this.orderItem ++ ;
	}
	// shopping cart have add new one product one
}

take(){
	for(int index=0; index < LIMIT_SIZE; index++){
		synchronized(queue){
			queue.pop();
		}
	}
}
// 從上述看到,其實鎖粗化出現的情況很多時候是我們是在指定的業務順序邏輯進行編寫代碼造成的,一般情況下對代碼的優化我們也需要考慮到兩個同步代碼中間的其他代碼是否不影響執行程序代碼的執行結果來進行優化
increase(){
	// 減少鎖的獲取
	synchronized(this){
		this.productNum -- ;
		this.orderItem ++ ;
	}
	// send message to mq notice product num have been decreased
	// shopping cart have add new one product one
}
take(){
	synchronized(queue){
		for(...){
			// ...
		}
	}
}
  • JIT編譯器優化:如果發現調用的次數過多,JIT會在編譯的時候會進行鎖粗化的優化
int i = 0;
test1(){
    synchronized(this){
        i++;
    }
    synchronized(this){
        i++;
    }
}
// main函數
for (int i = 0; i < 1000000; i++) {
    new LockOptmistic().test1()
}
// 分別截圖JIT編譯 i < 10000 & i < 100000  & i < 1000000 對應的效果

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

鎖消除

  • 也就是在JIT在編譯階段發生對於非共享資源在程序代碼進行加鎖的處理,那麼這個時候JIT識別是非共享資源,這個時候將直接跳過加鎖的方式運行程序代碼
// 鎖消除的僞代碼
execute(){
	// 線程封閉,屬於線程私有的變量,此時非共享資源,加鎖沒有意義,JIT編譯器會自動將鎖進行消除
	Object object = new Object();
	int count = 0;	
	synchronized(object){
		count++;
	}
}
// 出現上述的原因很多時候是由於工程師本身在編譯不規範造成的,這種並非業務性邏輯錯誤,可以避免的
  • JIT Watch查看結果
    在這裏插入圖片描述

縮小鎖的粒度(分片鎖)

  • 將一個存儲同類數據的對象的容器拆分爲更小的“容器”或者是“片段”, 在併發情況下針對小片段進行加鎖,提升併發執行效率,降低鎖的競爭, 比如ConcurrentHashMap(分片)與HashTable(整塊)
  • 注意: 並非分片鎖執行效率一定比整塊鎖的方式執行效率快,只是分片能夠降低cpu併發爭搶鎖的壓力,要根據具體業務場景而定

鎖分離

  • 可以按照讀寫功能進行劃分爲讀寫鎖,即寫寫互斥,讀寫互斥,讀讀共享,也可以按照指定的業務場景來對相應的程序代碼設置對應的加鎖方式,有效地提升併發執行的處理能力,降低鎖之間的競爭,比如讀寫鎖ReadWriteLock
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章