当前位置: 首页 > 科技观察

Java并发包读写锁及其实现浅析

时间:2023-03-18 22:15:05 科技观察

1.前言Java并发包中常用的锁(如:ReentrantLock)基本上都是独占锁,这种锁在同一时间只允许一个线程访问,而读写锁可以允许多个读线程同时访问,但是当写线程访问时,所有的读线程和其他写线程都被阻塞了。读写锁维护一对锁,读锁和写锁。通过分离读锁和写锁,相比一般的排它锁,并发性有了很大的提升。除了保证写操作对读操作的可见性,提高并发性,读写锁还可以简化读写交互场景的编程。假设在程序中定义了一个共享数据结构作为缓存,它大部分时间提供读服务(例如:查询和搜索),写操作占用的时间很少,但是写操作完成后的更新需要后续读取服务可见。当没有读写锁支持时(Java5之前),如果需要完成上述工作,就必须使用Java的等待通知机制,即写操作开始时,所有晚于写操作的读操作操作会进入等待状态,只有写操作完成并得到通知后,所有等待的读操作才能继续执行(使用synchronized关键字实现写操作之间的同步)。这样做的目的是为了让读操作能够读取到正确的数据,而不会出现脏读。改用读写锁来实现上述功能。只需要在读操作时获取读锁,在写操作时获取写锁。当获取到写锁后,后续(非当前写操作线程)的读写操作将被阻塞。阻塞,写锁释放后,所有操作继续执行,编程方式比使用等待通知机制的实现方式更加简单明了。一般来说,读写锁的性能优于排他锁,因为大多数场景读多于写。在读多于写的情况下,读写锁可以提供比独占锁更好的并发性和吞吐量。Java并发包提供的读写锁的实现是ReentrantReadWriteLock,它提供的特性如表1所示。表1.ReentrantReadWriteLock特性特性描述公平选择支持非公平(默认)和公平锁获取方式,吞吐量仍然是非公平大于公平。获取读锁后,可以再次获取读锁。写线程获取写锁后可以再次获取写锁,同时也可以获取读锁。降级按照获取写锁、获取读锁、释放写锁的顺序进行。写锁可以降级为读锁。2.读写锁ReadWriteLock的接口和例子只定义了两个获取读锁和写锁的方法,即readLock()和writeLock()方法,其实现——ReentrantReadWriteLock,除了接口方法外,还提供了一些外部监控其内部工作状态的方法,这些方法及其说明如表2所示。表2ReentrantReadWriteLock方法显示内部工作状态收购。次数不等于获取读锁的线程数。例如,如果只有一个线程连续n次获取(重新进入)读锁,那么占用读锁的线程数为1,但是这个方法返回的是nintgetReadHoldCount()返回的次数当前线程已获得读锁。该方法在Java6中加入了ReentrantReadWriteLock,ThreadLocal用于保存当前线程的获取次数,这也使得Java6的实现更加复杂。获取次数下面以一个缓存的例子来说明读写锁的使用。示例代码如代码清单1所示。代码清单1.Cache.javapublicclassCache{staticMapmap=newHashMap();staticReentrantReadWriteLockrwl=newReentrantReadWriteLock();staticLockr=rwl.readLock();staticLockw=rwl.writeLock();//获取一个key对应的valuepublicstaticfinalObjectget(Stringkey){r.lock();try{returnmap.get(key);}finally{r.unlock();}}//设置key对应的value,返回旧的valuepublicstaticfinalObjectput(Stringkey,Objectvalue){w.lock();try{returnmap.put(key,value);}finally{w.unlock();}}//清空所有内容publicstaticfinalvoidclear(){w.lock();try{map.clear();}finally{w.unlock();}}}在上面的例子中,Cache结合了一个非线程安全的HashMap作为缓存的实现,并使用了读锁和写锁读写锁保证Cache是??线程安全的。在读操作get(Stringkey)方法中,需要获取读锁,防止并发访问该方法被阻塞。写操作put(Stringkey,Objectvalue)和clear()方法在更新HashMap时必须提前获取写锁。当获取写锁时,其他线程获取读锁和写锁被阻塞,只有写锁被阻塞。释放锁后,其他的读写操作可以继续进行。Cache使用读写锁提高了读操作的并发性,同时也保证了每次写操作对所有读写操作都是可见的,同时简化了编程方式。3、读写锁的实现分析接下来分析ReentrantReadWriteLock的实现,主要包括:读写态的设计、写锁的获取与释放、读锁的获取与释放锁,以及锁的降级。可以认为是ReentrantReadWriteLock)。3.1读写状态的设计读写锁也是依赖自定义的同步器来实现同步功能,读写状态就是其同步器的同步状态。回顾一下ReentrantLock中自定义同步器的实现,同步状态表示一个线程重复获取锁的次数,而读写锁的自定义同步器需要在同步上维护多个读线程和一个写线程状态(整数变量)。线程的状态使得这个状态的设计成为实现读写锁的关键。如果在一个整数变量上维护多个状态,则该变量必须“按位切割并使用”。读写锁将变量分为两部分,高16位代表读,低16位代表写。方法如图1所示。图1.读写锁状态的划分方法如图1所示,当前同步状态表示一个线程已经获取了写锁,重新进入两次,获取了读锁连续锁定两次。读写锁如何快速判断读写各自的状态呢?答案是通过位运算。假设当前同步状态为S,写状态等于S&0x0000FFFF(高16位全部擦除),读状态等于S>>>16(无符号补0右移16位).写状态加1等于S+1,读状态加1等于S+(1<<16),即S+0×00010000。根据状态的划分,可以得出一个推论:当S不等于0时,当写状态(S&0x0000FFFF)等于0时,那么读状态(S>>>16)大于0,即已经获取到读锁。3.2写锁的获取和释放写锁是一种支持重入的独占锁。如果当前线程已经有写锁,增加写状态。如果当前线程获取写锁,读锁已经获取(读状态不为0)或者线程不是已经获取写锁的线程,则当前线程进入等待状态,代码获取写锁的方法如清单2所示。代码清单2.ReentrantReadWriteLock的tryAcquire方法protectedfinalbooleantryAcquire(intacquires){Threadcurrent=Thread.currentThread();intc=getState();intw=exclusiveCount(c);if(c!=0){//有读锁或者当前获取的线程不是获取了写锁的线程);setState(c+acquires);returntrue;}if(writerShouldBlock()||!compareAndSetState(c,c+acquires)){returnfalse;}setExclusiveOwnerThread(current);returntrue;}这个方法是除了重入条件(当前线程是有获取了写锁)另外增加了是否存在读锁的判断。如果有读锁,则无法获取写锁。原因是:读写锁必须保证写锁的操作对读锁是可见的。如果让读锁在已经获取到写锁的时候再去获取写锁,那么正在运行的其他读线程是无法感知到当前写线程的操作的。因此,写锁只有在其他读线程释放了读锁后才能被当前线程获取,而一旦获取到写锁,其他读写线程的后续访问就会被阻塞。写锁的释放和ReentrantLock的释放过程基本类似。每次发布都会减少写入状态。当写状态为0时,表示写锁已经释放,等待的读写线程可以继续访问读写锁。同时,一个线程之前写的修改对后续的读写线程是可见的。3.3读锁的获取和释放读锁是一种支持重入的共享锁。可以同时被多个线程获取。当没有其他写线程访问时(或者写状态为0),读锁总会被成功获取,它所做的只是(线程安全地)增加读取状态。如果当前线程已经获取到读锁,则增加读状态。如果当前线程获得了读锁,而写锁已经被其他线程获得,则进入等待状态。获取读锁的实现从Java5到Java6变得复杂了很多,主要原因是增加了一些新的函数,比如:getReadHoldCount()方法,返回当前线程获取读锁的次数.读状态是所有线程获得的读锁数量的总和,而每个线程获得的读锁数量只能保存在ThreadLocal中,由线程自己维护,这使得读锁获取的实现变得复杂。因此,这里删除获取读锁的代码,保留必要的部分。代码如清单3所示。代码清单3.ReentrantReadWriteLock的tryAcquireShared方法protectedfinalinttryAcquireShared(intunused){for(;;){intc=getState();intnextc=c+(1<<16);if(nextc