高并发笔记:StampedLock

回顾读写分离锁

假如使用synchronized关键字或者重入锁ReentrantLock来对读写操作进行加锁,在多个读写线程情景下效率是非常低的,因为独占锁的方式,使得读线程与读线程,读线程与写线程之间都必须等待。对于读-写操作之间的等待,倒是合理的,防止脏读,假如写操作对数据做完修改后,还没有提交,读操作就进行了读取数据,写操作提交数据后,读操作即读到了脏数据,假如读操作之后利用读到的数据进行其他操作,便会发生我们不希望的后果。对于读线程与读线程之间,如果它们之间也要互相等待,明显是不合理的,多个读线程之间并不会互相影响,读线程,例如查询,也不会对数据库里的数据做修改,只是读出数据copy一份再进行另外的操作,所以多个读线程之间是可以并行执行的,对于频繁查询的场景,也就是读操作较多的情况下,读-读操作的并行可以提高系统的效率。

为了让多个读线程同时进行,可以使用读写分离锁ReadWriteLock,一个简单的读写分离锁例子:

https://github.com/justinzengtm/Java-Multithreading/blob/master/Locks/ReadWriteLock.java

要注意的是,读写分离锁仅仅让读-读操作之间完成了并行,出于数据完整性考虑,读操作依然会阻塞写操作,写操作更不用说了,会阻塞读操作,写-写操作也一样。所以,ReadWriteLock在读操作较多的情况下才会带来明显的效率提升。

 

StampedLock:防止写操作“饥饿”

前面说到,即使用了读写分离锁,处于数据完整性的考虑,读操作依然会阻塞写操作,写操作也会阻塞读操作(这是必须的)。那么在读操作较多的场景下,ReadWriteLock是提高了效率,但会导致写操作一直无法执行,出现“饥饿”现象。为了改善这一情况,我们还可以使用StampedLock,它除了提供写锁和读锁外,还提供了一个乐观读锁。StampedLock是如何改善大量读操作阻塞写操作导致“饥饿”现象的呢?实现在于其乐观读锁,使用了一个变量stamp来作为获取锁的凭证,乐观读锁使用了类似于无锁CAS操作的方式,每次读操作进行乐观读时,先读取了数据值,然后可以使用数据值进行业务逻辑处理,最后通过validate()方法校验这个stamp变量是否被修改,如果没有被修改,则认为没有其他写操作对数据进行了修改,那么这次读数据成功。否则,如果validate()方法校验这个stamp被修改过,则表示期间有写操作执行,那么就重新读取数据。

Stamp标识锁状态

在StampedLock中,获取锁的方法都会返回一个long型的stamp值,来标识对锁的访问状态, 并且可以根据这个stamp值来进行锁的转换,例如在乐观读锁过程中发生了写操作,那么读线程可以将乐观读锁升级为悲观锁,此时stamp的值会被修改,所以stamp的值就是用来标识当前线程对锁的访问状态,如果stamp的值为0,即获取锁失败。

读/写/乐观读锁

来看看StampedLock提供的三种锁模式:写锁writeLock,读锁readLock和乐观读锁tryOptimisticRead。

  1. 写锁writeLock:获取锁成功后,返回一个stamp值,在调用unlockWrite()方法释放写锁时,需要传入参数stamp,来释放对应的写锁。如果writeLock()方法获取写锁失败,则线程会阻塞。当写锁获取成功后,读锁和乐观读锁都不能被获取,乐观读锁中的validate()校验也会返回不可用。
  2. 读锁readLock:与写锁一样,获取锁成功后,返回一个stamp值,调用unlockRead()方法时,也需要传入stamp来释放对应的读锁。
  3. 乐观读锁tryOptimisticRead:获取锁后也会返回一个stamp,之后通过validate()方法校验这个stamp,如果stamp可用,则表明没有其他线程修改过数据,本次读取操作成功。乐观读锁只有在写锁可用时,也就是没有写操作发生时,才能获取成功。

StampedLock示例

来看一个简单的StampedLock示例:

public class additionDemo {
	private int a, b;
	private final StampedLock sl = new StampedLock();
	
	void add(int numA, int numB) {
		long stamp = sl.writeLock();
		
		try {
			a += numA;
			b += numB;
		} finally {
			sl.unlockWrite(stamp);
		}
	}
	
	int addNum() {
		long stamp = sl.tryOptimisticRead();
		int numA = a, numB = b, addResult;
		
		addResult = numA + numB;
		if(!sl.validate(stamp)) {
			// 如果stamp不可用,即被其他线程修改了,则把乐观锁升级为悲观锁
			stamp = sl.readLock();
			try {
				// 重新读取数据
				numA = a;
				numB = b;
				addResult = numA + numB;
			} finally {
				sl.unlockRead(stamp);
			}
		}
		
		return addResult;
	}
}

第5-14行是一个写操作,写锁获取成功后,其他线程想要获取读锁或者乐观读锁都会失败,且乐观读锁中的validate()也会返回0,以此来告诉乐观读线程,有写操作执行了,那么进行乐观读的线程就会自旋等待。乐观读锁也可以在写操作执行后,将自己升级为悲观锁,第23行,如果validate()方法校验出stamp被修改了,那么可以获取悲观锁,然后重新读出数据。

 

StampedLock与自旋锁

自旋锁

StampedLock之所以能够使读操作不阻塞写操作,在于乐观读锁是可以随时被打破的,当一个读操作使用了乐观读锁,此时发生了写操作,那么该读操作便会进入自旋等待(也可以选择升级为悲观锁),通过重复不停获取stamp的值,直到stamp可用。这就是用到了自旋锁的思想。因为写操作可以随时破坏乐观读锁,当读线程暂时无法获得锁时,立刻阻塞该线程再等待调度切换,是一种消耗较大的方式,可能在几个时钟周期后,写操作就完成了,读线程就可以再次获得锁,所以采用自旋等待的方式,读线程做循环一直获取乐观读锁,而不是被挂起。

在StampedLock里面维护着一个线程等待队列,存放申请锁失败的线程,队列中每一个结点表示一个线程:

static final class WNode {
    volatile WNode prev;
    volatile WNode next;
    volatile WNode cowait; // 读线程链表
    volatile Thread thread;
    volatile int status; // 等待线程的状态(是否获得锁)
    final int mode;
    WNode(int m, WNode p) { mode = m; prev = p; }
}

//读锁单位
private static final long RUNIT = 1L;
// 写线程个数所在的位置
private static final long WBIT  = 1L << LG_READERS; // 128 = 1000 0000
// 读线程个数所在的位置
private static final long RBITS = WBIT - 1L;
// 最大读线程个数
private static final long RFULL = RBITS - 1L; //0000 0111 1110
// 读线程个数和写线程个数的掩码
private static final long ABITS = RBITS | WBIT; // 255 = 0000 1111 1111
// .......部分源码常量省略


private static final long ORIGIN = WBIT << 1; // state的初始值256 = 0001 0000 0000
private transient volatile long state;
private transient volatile WNode whead;
private transient volatile WNode wtail;
private transient volatile long state; // 标识当前的锁状态

long型的state标识锁的状态,初始值为256(二进制1 0000 0000),其中第八位标识写锁的状态,为1标识写锁有一个写锁,为0标识没有写锁。后七位标识读锁的数量,每增加一个读锁,则增加1。

writeLock()

StampedLock的写锁被获取后,怎么改变标志位?来看看writeLock()的实现:

public long writeLock() {
    // ABITS = 255 = 1111 1111
    // WBITS = 128 = 1000 0000
    long s, next;
    return ((((s = state) & ABITS) == 0L &&
             U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
            next : acquireWrite(false, 0L));
}

可以看到,((s = state)& ABTIS),使用当前状态state与上ABTIS(读线程和写线程的掩码,值为0000 1111 1111),state的值为初始值256(0001 0000 0000),如果当前的写锁是可用的,那么它和ABTIS相与的结果为:

0001 0000 0000

0000 1111 1111

结果 0000 0000 0000,表示当前写锁可用,可以申请,然后执行CAS操作来尝试获得写锁,通过s + WBIT方式:

0001 0000 0000 + 0000 1000 0000 = 0001 1000 0000

第八位被更新为1,表示写锁被占用。第7行的acquireWrite()方法表示如果获取写锁失败,就执行自旋等待。当写锁获取成功后,由于state状态值被改变,那么乐观读线程中的validate()方法就会发现这个改动,导致乐观读锁失败。

readLock()

StampedLock的读锁实现与写锁实现类似:

public long readLock() {
    long s = state, next;  
	//等待队列为空,读锁数量未达到最大值,尝试获取读锁
    return ((whead == wtail && (s & ABITS) < RFULL && 
                U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ? 
            next : acquireRead(false, 0L));
}

判断当whead == wtail,即线程等待链表为空时,且(s & ABITS) <

RFULL,当前读锁数量未达到最大值,就尝试通过CAS操作获取读锁,获取失败就进入acquireRead()方法自旋等待。

tryOptimisticLock()

乐观读锁的实现,就是每次申请锁时,获取state状态,与上WBIT写表示位,如果发现state中的写标识位为1,证明发生了写操作:

public long tryOptimisticRead() {
    long s;
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
发布了97 篇原创文章 · 获赞 71 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/justinzengTM/article/details/103114925