Java中的鎖事

Java的鎖

Java根據不同的特性來對鎖進行分類,大概有以下分類方式。
lock.png
這裏主要討論樂觀鎖和悲觀鎖以及在Java中對應的實現。

對於同一個數據的併發操作,悲觀鎖認爲自己在使用數據時,一定會有其它線程來修改數據,所以在每次操作數據前都會加上一個鎖,以確保沒有其它線程來修改數據。Java中的synchronized鎖和lock鎖都是悲觀鎖。
而樂觀鎖每次都認爲不會有其它線程來修改數據,所以在操作數據時不會上鎖,而是在修改數據時去判斷有沒有其它線程修改了這個數據,如果沒有被修改,則更新成功,如果已經被其它線程修改,則重新嘗試或失敗。Java中最常用的就是通過CAS算法來實現無鎖併發編程。
twoLock.png
根據悲觀鎖和樂觀鎖的概念可以發現:

  • 悲觀鎖適合寫多讀少的場景,因爲先加鎖能保證寫操作的正確性。
  • 樂觀鎖適合讀多寫少的場景,因爲讀操作一般並不需要加鎖(沒有修改數據),所以樂觀鎖的無鎖特性能使讀性能有很大的提升(減少了加鎖等待的時間)。

悲觀鎖

Synchroniezd鎖

synchronized是一種互斥鎖,也就是悲觀鎖,每次只允許一個線程進入synchronized修飾的方法或代碼塊中。synchronized鎖是可重入的,即一個線程可以多次獲取同一個對象或類的鎖。
synchronized通過使用內置鎖,對變量進行同步,來保證線程操作的原子性、有序性、可見性,可以確保多線程下的操作安全。
synchronized鎖有三種使用方式,分別是對對象加鎖(修飾普通方法,鎖的是當前類的對象)、對代碼塊加鎖(鎖的是非當前類對象)、對類加鎖(修飾靜態方法,鎖的是當前類)。更多:synchronized鎖
下面是用synchronized鎖,用N個線程循環打印0~M個數字。

public class SynchronizedTest implements Runnable {

	// 定義一個對象用來保持鎖
	private static final Object LOCK = new Object();

	// 當前線程
	private int threadNum;

	// 線程總數
	private int threadSum;

	// 當前數字,從0開始打印
	private static int current = 0;

	// 要打印的最大值
	private int max;

	public SynchronizedTest(int threadNum, int threadSum, int max) {
		this.threadNum = threadNum;
		this.threadSum = threadSum;
		this.max = max;
	}

	@Override
	public void run() {
		// 實現N個線程循環打印數字
		while (true) {
			// 對代碼塊加鎖,保證每次只有一個線程進入代碼塊
			synchronized (LOCK) {
				// 當前值 / 線程總數 = 當前線程
				// 這裏一定要用while,而不能用if。因爲當線程被喚醒時,監視條件可能還沒有滿足(線程喚醒後是從wait後面開始執行)。
				while (current % threadSum != threadNum) {
					// 打印完了,跳出循環
					if (current >= max) {
						break;
					}
					// 不滿足打印條件,則讓出鎖,進入等待隊列
					try {
						LOCK.wait();
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
				// 這裏還要做一次判斷
				if (current >= max) {
					break;
				}
				System.out.println(Thread.currentThread().getName() + " 打印 " + current);
				++current;
				// 當前線程打印完了,通知所有等待的線程進入阻塞隊列,然後一起去爭搶鎖
				LOCK.notifyAll();
			}
		}
	}

	public static void main(String[] args) {
		// 開啓N個線程
		int N = 3;
		// 打印M個數字
		int M = 15;
		for (int i = 0; i < N; ++i) {
			new Thread(new SynchronizedTest(i, N, M)).start();
		}
	}
}

Lock鎖

lock鎖也是一種互斥鎖,同時悲觀鎖。它是一種顯示鎖,加鎖與釋放鎖的操作都需要手動實現,而synchronized的釋放鎖是自動實現的。
ReentrantLock鎖內部定義了公平鎖和非公平鎖。對於公平鎖,內部維護了一個FIFO隊列用來保存進入的線程,保證先進入的線程能先執行。而對於非公平鎖,如果一個線程釋放了鎖,其它所有線程都可以去搶這個鎖,這樣就會導致有些人可能會餓死,可能永遠也得不到執行。但是公平鎖爲了實現時間上的絕對順序,需要頻繁的切換上下文,而非公平鎖會減少一定的上下文切換,降低了開銷。所以ReentrantLock默認採用的是非公平鎖,以提高性能。
reentrantLock實現可見性是通過AQS中用volatile修飾的state來實現的,下面來分析一下原理(以非公平鎖爲例)。
reentrantLock先顯示上鎖,調用lock方法。

final void lock() {
    // 先嚐試獲取鎖,也就是將state更新爲1(這裏用了CAS),如果獲取成功,則將當前線程設置爲獨佔模式同步的當前所有者
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    // 如果獲取失敗,則進入acquire()方法
    else
        acquire(1);
}

下面進入acquire()方法:

public final void acquire(int arg) {
    // 調用tryAcquire嘗試獲取鎖
    // 如果獲取鎖失敗,則用當前線程創建一個獨佔結點加到等待隊列的尾部,並繼續嘗試獲取鎖
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

這裏只進入tryAcquire看看:

protected final boolean tryAcquire(int acquires) {
    // 內部又調用了一個非公平的嘗試獲取鎖方法
    return nonfairTryAcquire(acquires);
}

進入往下看:

final boolean nonfairTryAcquire(int acquires) {
    // 獲取當前線程
    final Thread current = Thread.currentThread();
    // 重點!首先從主存中獲取state(state是個volatile修飾的變量)
    int c = getState();
    // 如果state爲0,說明沒有獲取過鎖
    if (c == 0) {
        // 嘗試獲取鎖
        if (compareAndSetState(0, acquires)) {
            // 將當前線程設置爲獨佔模式當前所有者
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 如果state不爲0,說明之前獲取過鎖
    else if (current == getExclusiveOwnerThread()) {
        // 將鎖的數量疊加
        int nextc = c + acquires;
        if (nextc < 0) // 溢出(超過最大鎖的數量)則拋出異常
            throw new Error("Maximum lock count exceeded");
        // 因爲當前線程已經獲取了鎖,在這一步不會有其它線程來干擾,所以不需要用CAS來設置state
        setState(nextc);
        return true;
    }
    return false;
}

上面就是獲取鎖的主要代碼,如果獲取失敗了,將會被加入到等到隊列中繼續嘗試獲取鎖。(這一步不再分析)
下面再來看看釋放鎖的過程:

public void unlock() {
    // 通過內部類調用父類AbstractQueuedSynchronizer的release方法
    sync.release(1);
}

下面進入release方法:

public final boolean release(int arg) {
    // 調用tryRelease方法來嘗試釋放鎖
    if (tryRelease(arg)) {
        Node h = head;
        // 如果頭節點不爲空且等待狀態非0
        if (h != null && h.waitStatus != 0)
            // 如果頭節點的後繼節點存在,則喚醒它
            unparkSuccessor(h);
        return true;
    }
    return false;
}

reentrantLock的內部類sync重寫了tryRelease方法:

protected final boolean tryRelease(int releases) {
    // 重點!也是首先獲取state,並減去要釋放的鎖的數量
    int c = getState() - releases;
    // 如果當前線程不等於當前獨佔模式擁有者線程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        // 拋出一個非法監視器狀態異常
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 如果持有鎖的數量爲0
    if (c == 0) {
        // 設置鎖爲可釋放
        free = true;
        // 把當前獨佔線程清空
        setExclusiveOwnerThread(null);
    }
    // 設置state
    setState(c);
    return free;
}

以上就是釋放鎖的關鍵代碼

通過以上分析可知,每次在加鎖和釋放鎖的時候,都會進入方法時先獲取state,最後以設置state結束。
由於state變量是通過volatile修飾的,所以state對於所有線程是可見的,又因爲volatile變量在每次強制刷新到主內存的時候,會將非volatile變量也刷新回主存。
在加鎖的代碼中,肯定是先調用lock(由於操作了volatile的state(先讀後寫),會強制刷新主存),最後調用unlock(也要操作state,會再次強制刷新主存),根據happens-before規則,volatile變量的寫對於下一次的讀是可見的。所以這保證了同步代碼中的共享變量是可見的。

下面是一個利用reentrantLock實現的循環交替打印ABC,其中還使用了locks的條件變量condition。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockTest {

	// 定義一個顯示鎖
	private static ReentrantLock lock = new ReentrantLock();
	// 監控a的條件變量
	private static Condition a = lock.newCondition();
	// 監控b的條件變量
	private static Condition b = lock.newCondition();
	// 監控c的條件變量
	private static Condition c = lock.newCondition();
	// 控制要打印的值
	private static int flag = 0;

	public static void printA() {
		for (int i = 0; i < 5; i++) {
			// 顯示加鎖
			lock.lock();
			try {
				try {
					while (flag != 0) {
						// 不滿足監視條件則等待
						a.await();
					}
					System.out.println(Thread.currentThread().getName() + "我是A");
					flag = 1;
					// 通知b線程去打印
					b.signal();
				} catch (Exception e) {
					e.printStackTrace();
				}
			} finally {
				// 釋放鎖
				lock.unlock();
			}
		}
	}

	public static void printB() {
		for (int i = 0; i < 5; i++) {
			lock.lock();
			try {
				try {
					while (flag != 1) {
						b.await();
					}
					System.out.println(Thread.currentThread().getName() + "我是B");
					flag = 2;
					c.signal();
				} catch (Exception e) {
					e.printStackTrace();
				}
			} finally {
				lock.unlock();
			}
		}
	}

	public static void printC() {
		for (int i = 0; i < 5; i++) {
			lock.lock();
			try {
				try {
					while (flag != 2) {
						c.await();
					}
					System.out.println(Thread.currentThread().getName() + "我是C");
					flag = 0;
					a.signal();
				} catch (Exception e) {
					e.printStackTrace();
				}
			} finally {
				lock.unlock();
			}
		}

	}

	public static void main(String[] args) {

		new Thread(() -> {
			printA();
		}).start();

		new Thread(() -> {
			printB();
		}).start();

		new Thread(() -> {
			printC();
		}).start();
	}
}

synchronized鎖和reentrantLock鎖

相同點

  • 都能對資源加鎖,保證線程間的同步訪問。
  • 都是可重入鎖,即一個線程能多資源反覆加鎖。
  • 都保證了多線程操作的原子性、有序性、可見性(這個只能保證共享變量在加鎖操作內的可見性,而在加鎖操作外的可見性不能得到絕對的保證,因爲鎖外不能保證一直從主存中獲取數據,工作內存可能會不同步)

不同點

  • 同步實現機制不同
    • synchronized通過java對象關聯的monitor監視器實現
    • reentrantLock通過AQS、CAS等實現
  • 可見性實現機制不同
    • synchronized通過java內存模型來保證可見性
    • reentrantLock通過AQS的state(volatile修飾的)來保證可見性
  • 監控條件不同
    • synchronized通過java對象作爲監控條件
    • reentrantLock通過Condition(提供 await、signal 等方法)作爲監控條件
  • 使用方式不同
    • synchronized可用來修飾實例方法(鎖住實例對象)、靜態方法(鎖住類對象)、同步代碼塊(鎖住指定的對象)
    • reentrantLock需要顯示的調用lock加鎖,並需要在finally中釋放鎖
  • 功能豐富程序不同
    • synchronized只是簡單的加鎖
    • ReentrantLock 提供定時獲取鎖、可中斷獲取鎖、Condition(提供 await、signal 等方法)等特性。
  • 鎖類型不同
    • synchronized只支持費公平鎖。
    • reentrantLock支持公平鎖和非公平鎖,但是非公平鎖效率更高

在 synchronized 優化以前,它比較重量級,其性能比 ReentrantLock 要差很多,但是自從 synchronized 引入了偏向鎖、輕量級鎖(自旋鎖)、鎖消除、鎖粗化等技術後,兩者的性能就相差不多了。

樂觀鎖

CAS算法

即compare and swap(比較與交換),是一種有名的無鎖算法。無鎖編程,即不使用鎖的情況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三個操作數:

  • 需要讀寫的內存值V(在內存中的值)
  • 進行比較的值A(輸入的值)
  • 要寫入的新值B(要更新的值)

當且僅當值V等於值A時,CAS通過原子方式將值V更新爲B(比較並替換是一個原子操作,unsafe底層通過操作系統來保證原子性),如果V不等於A,則失敗或重試。這裏沒有涉及到鎖操作,所以是很高效的。
但是它存在三個問題:

  • 循環開銷大。CAS如果長時間操作不成功(寫的併發量比較大),會導致長時間自旋,從而造成CPU資源的浪費。
  • 只能保證一個變量的原子操作。但是開始JDK1.5提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象裏來進行CAS操作。
  • ABA問題。如果CAS先把值改爲B,又改回A。在CAS看來這個值是沒有變化的,但實際上是變化了的。最典型的就是ATM取錢問題:餘額100,我取出50,此時ATM開了兩個線程,但是一個線程暫時掛了,一個線程成功把餘額更新爲50,然後我朋友又給我轉了50,此時餘額爲100,但是剛剛那個取錢的線程又活了,繼續剛剛的操作,嘗試將100更新爲50,emmm,在CAS看來它是可以成功的,但這是不符合邏輯的(我朋友轉給我的50塊去哪啦???)。ABA問題的一般解決思路就是在變量前加個版本號,這樣更新操作就變成了1A->2B->3A,這樣CAS就會認爲他們不一樣了。JDK1.5開始提供AtomicStampedReference中引入了標誌,這個類的compareAndSet()方法中需要當前標誌和預期標誌相同才能更新成功(每次更新時都會更新這個標誌)。

這裏結合AtomicStampedReference和CountDownLatch實現一個ABA的例子(通過版本號可以解決問題)

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicStampedReference;

public class CasTest {

    // 定義一個原子類型變量
	private static AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(1, 0);

    // 定義一個線程計數器
	private static CountDownLatch countDownLatch = new CountDownLatch(1);

	public static void main(String[] args) {
		new Thread(() -> {
			// 打印當前線程的值
				System.out.println("線程 " + Thread.currentThread().getName()
						+ " value" + asr.getReference());
				// 最開始的版本
				int stamp = asr.getStamp();
				try {
					// 等待其它線程全部執行完畢(這裏只需等待線程2運行結束)
					countDownLatch.await();
				} catch (Exception e) {
					e.printStackTrace();
				}
				// 將1改爲2,又改爲1後,再次嘗試將最開始的版本的1修改爲2
				// 操作結果應該是失敗的,因爲當前版本(0)與預期版本(2)不同
				// 如果將取版本號的操作放在當前,操作結果肯定是成功的(因爲這裏修改的1不是最開始版本的1)
				System.out.println(Thread.currentThread().getName()
						+ " CAS操作結果 "
						+ asr.compareAndSet(1, 2, stamp, stamp + 1));
			}, "線程1").start();

		new Thread(() -> {
			// 把值修改爲2
				asr.compareAndSet(1, 2, asr.getStamp(), asr.getStamp() + 1);
				System.out.println("線程 " + Thread.currentThread().getName()
						+ " value" + asr.getReference());
				// 把值修改爲1
				asr.compareAndSet(2, 1, asr.getStamp(), asr.getStamp() + 1);
				System.out.println("線程 " + Thread.currentThread().getName()
						+ " value" + asr.getReference());
				// 當前任務執行完畢,等待線程數減1
				countDownLatch.countDown();
			}, "線程2").start();
	}
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章