手边星辰

博客园 首页 新随笔 联系 订阅 管理

数据库事务、事务隔离级别以及锁机制详解

以下主要以MySQL(InnoDB引擎)数据库为讨论背景,纯属个人学习总结,不对的地方还请指出!

 

什么是事务?

事务是作为一个逻辑单元执行的一系列操作,要么一起成功,要么一起失败。一个逻辑工作单元必须有四个属性,称为 ACID(原子性、致性、隔离性和持久性)属性,只有这样才能成为一个事务。

 

数据库事物的四大特性(ACID):

1)原子性:(Atomicity)

务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。

2)一致性:(Consistency)

事务在完成时,必须使所有的数据都保持一致状态。在相关数据库中,所有规则都必须应用于事务的修改,保持所有数据的完整性。事务结束时,所有的内部数据结构(如 B 树索引或双向链表)都必须是正确的。

3)隔离线:(Isolation)

由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。这为可串行性,因为它能够重新装载起始数据,并且重播一系列事务,以使数据结束时的状态与原始事务执的状态相同。

4)持久性:(Durability)

事务完成之后,它对于系统的影响是永久性的。该修改即使出现系统故障也将一直保持。

 

事务并发时会发生什么问题?(在不考虑事务隔离情况下)

1)脏读:

脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。

例:

事务A修改num=123

------事务B读取num=123(A操作还未提交时)

事务A回滚

此时就会出现B事务读到的num并不是数据库中真正的num的值,这种情况就叫“脏读”。

2)不可重读:

不可重复读是指在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了。

例:

事务A读取num=123(事务A并未结束)

------事务B修改num=321,并提交了事务

事务A再次读取num=321

此时就会出现同一次事务A中两次读取num的值不一样,这种情况就叫“不可重读”。

3)虚读/幻读:

是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样。

例:

事务A查询或修改表中所有num=123的数据(事务A并未结束)

------事务B新增一条num=123的数据,并提交事务

事务A再次查询num=123的数据

此时就会出现两次查询到的数据条数不一致,或者存在还有数据没有被修改到,这种情况就叫“虚读/幻读”。

注:

不可重复读的重点是修改,同样的条件,你读取过的数据,再次读取出来发现值不一样;(主要在于update和delete)
幻读的重点在于新增或者删除,同样的条件,第 1 次和第 2 次读出来的记录数不一样。(主要在于insert)

 

为了解决以上事务并发时出现的一系列问题,就需要设置事务的隔离级别。

 

什么是数据库事务的隔离级别?

多个线程开启各自事务操作数据库中数据时,数据库系统要负责隔离操作,以保证各个线程在获取数据时的准确性。

MySQL官方解释,详见https://dev.mysql.com/doc/refman/5.6/en/innodb-transaction-isolation-levels.html

数据库共定义了四种隔离级别:

Read uncommitted:最低级别,以上情况均无法保证。(读未提交)

 

Read committed:可避免脏读情况发生(读已提交)。

实现机制:修改时加排他锁,直到事务提交后才释放,读取时加共享锁,读取完释放。事务1读取数据时加上共享锁后(这 样在事务1读取数据的过程中,其他事务就不会修改该数据),不允许任何事物操作该数据,只能读取,之后1如果有更新操作,那么会转换为排他锁,其他事务更 无权参与进来读写,这样就防止了脏读问题。

       但是当事务1读取数据过程中,有可能其他事务也读取了该数据,读取完毕后共享锁释放,此时事务1修改数据,修改 完毕提交事务,其他事务再次读取数据时候发现数据不一致,就会出现不可重复读问题,所以这样不能够避免不可重复读问题。

 

Repeatable read:可避免脏读、不可重复读情况的发生。(可重复读)

实现机制:读取数据时加共享锁,写数据时加排他锁,都是事务提交才释放锁。读取时候不允许其他事物修改该数据,不管数据在事务过程中读取多少次,数据都是一致的,避免了不可重复读问题。

 

Serializable:可避免脏读、不可重复读、虚读情况的发生。(串行化)

实现机制:所有的读操作均为当前读,读加读锁 (S锁),写加写锁 (X锁)。采用的是范围锁RangeS RangeS_S模式,锁定检索范围为只读,这样就避免了幻影读问题。

Serializable隔离级别下,读写冲突,因此并发度急剧下降,在MySQL/InnoDB下不建议使用。

 

那具体怎么避免脏读、不可重复读、幻读等这些情况的出现呢?

1)设置数据库的事务隔离级别:

四种隔离级别最高的是Serializable级别,最低的是Read uncommitted级别,当然级别越高,执行效率就越低。像Serializable这样的级别,就是以锁表的方式(类似于Java多线程中的锁)使得其他的线程只能在锁外等待,所以平时选用何种隔离级别应该根据实际情况。

在MySQL数据库中,支持上面四种隔离级别,默认的为Repeatable read (可重复读);而在Oracle数据库中,只支持Serializable (串行化)级别和Read committed (读已提交)这两种级别,其中默认的为Read committed级别。

 

您可以在全局、当前会话或仅下一个事务中设置事务特征:

使用GLOBAL关键字:

该声明适用于所有后续会话。

现有会话不受影响。

 

使用SESSION关键字:

该语句适用于当前会话中执行的所有后续事务。

该等陈述在交易中是允许的,但不会影响当前正在进行的交易。

如果在事务之间执行,则该语句将覆盖任何先前的语句,该语句设置命名特征的下一个事务值。

 

没有任何SESSION或 GLOBAL关键字:

该声明仅适用于会话中执行的下一个单个事务。

后续事务将恢复为使用指定特征的会话值。

 

在MySQL数据库中查看当前事务的隔离级别:

全局:

SELECT @@GLOBAL.tx_isolation, @@GLOBAL.tx_read_only; 

会话:

SELECT @@SESSION.tx_isolation, @@SESSION.tx_read_only;

select @@tx_isolation;

 

 

在MySQL数据库中设置事务的隔离 级别:

set  [glogal | session]  transaction isolation level 隔离级别名称;

 

或者

    set tx_isolation=’隔离级别名称;’

注:

设置数据库的隔离级别一定要是在开启事务之前!

  如果是使用JDBC对数据库的事务设置隔离级别的话,也应该是在调用Connection对象的setAutoCommit(false)方法之前。调用Connection对象的setTransactionIsolation(level)即可设置当前链接的隔离级别,至于参数level,可以使用Connection对象的字段:

  

在JDBC中设置隔离级别的部分代码:

 

隔离级别的设置只对当前链接有效。对于使用MySQL命令窗口而言,一个窗口就相当于一个链接,当前窗口设置的隔离级别只对当前窗口中的事务有效;对于JDBC操作数据库来说,一个Connection对象相当于一个链接,而对于Connection对象设置的隔离级别只对该Connection对象有效,与其他链接Connection对象无关。

 

2)只需要在添加事务额注解上加上这样的代码即可提升事务的隔离级别:

 @Transactional(rollbackFor = OrderProcException.class, isolation = Isolation.SERIALIZABLE)

 

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

 

乐观锁:(不能解决脏读的问题)

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制或CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

 

1)使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。或者在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

2)CAS算法即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

需要读写的内存值 V

进行比较的值 A

拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

注:

乐观锁适用于写比较少的情况下(并发量大/多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

 

Mysql InnoDB引擎的锁机制(属于悲观锁)

(之所以以InnoDB为主介绍锁,是因为InnoDB支持事务,支持行锁和表锁用的比较多,Myisam不支持事务,只支持表锁)

1)按照锁的使用方式可分为共享锁、排它锁意向共享锁意向排他锁

共享锁/读锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。(其他事务可以读但不能写该数据集) 

排他锁/写锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。 (其他事务不能读和写该数据集)

意向共享锁(IS):通知数据库接下来需要施加什么锁并对表加锁。如果需要对记录A加共享锁,那么此时innodb会先找到这张表,对该表加意向共享锁之后,再对记录A添加共享锁。

意向排他锁(IX):通知数据库接下来需要施加什么锁并对表加锁。如果需要对记录A加排他锁,那么此时innodb会先找到这张表,对该表加意向排他锁之后,再对记录A添加排他锁。

注:

A、意向共享锁和意向排它锁是数据库主动加的,不需要我们手动处理;

B、对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁(X);对于普通SELECT语句,InnoDB不会加任何锁,事务可以通过以下语句显示给记录集加共享锁或排他锁。

共享锁(S):SELECT * FROM table_name WHERE … LOCK IN SHARE MODE。

排他锁(X):SELECT * FROM table_name WHERE … FOR UPDATE。

 

2)按照锁的粒度分为行锁、页锁(间隙锁)、表锁

行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件来检索数据才会用到行锁,否则InnoDB将会使用表锁。

表锁:select * from table_nane where name = ‘小巷’  for update 。name字段不是唯一索引字段,所以是表锁。(表排他锁)

行锁:select * from table_name where  id = 1 for update 。id 字段为唯一索引字段,所以使用的就是行锁,且是排它锁。

页锁(又叫Gap锁/间隙锁:所谓表锁锁表,行锁锁行,那么页锁折中,锁相邻的一组数据。

 

通过加锁控制,可以保证数据的一致性,但是同样一条数据,不论用什么样的锁,只可以并发读,并不可以读写并发(因为写的时候加的是排他锁所以不可以读),这时就要引入数据多版本控制来实现读写并发。

 

MVCC数据多版本并发控制,属于乐观锁)

这项技术使得InnoDB的事务隔离级别下执行一致性读操作有了保证,换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值。这是一个可以用来增强并发性的强大的技术,因为这样的一来的话查询就不用等待另一个事务释放锁。

 

数据多版本实现的原理是:

1,写任务发生时,首先复制一份旧数据,以版本号区分

2,写任务操作新克隆的数据,直至提交

3,并发读的任务可以继续从旧数据(快照)读取数据,不至于堵塞

注:

快照读和当前读

快照读:读取的是快照版本,也就是历史版本

当前读:读取的是最新版本

普通的SELECT就是快照读,而UPDATE、DELETE、INSERT、SELECT ...  LOCK IN SHARE MODE、SELECT ... FOR UPDATE是当前读。

 

具体实现:

在InnoDB中,给每行增加两个隐藏字段来实现MVCC,一个用来记录数据行的创建时间,另一个用来记录行的过期时间(删除时间)。在实际操作中,存储的并不是时间,而是事务的版本号(即创建版本号和删除版本号),每开启一个新事务,事务的版本号就会递增。(严格的来讲,InnoDB会给数据库中的每一行增加三个字段,它们分别是DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID)

于是乎,默认的隔离级别(REPEATABLE READ)下,增删查改变成了这样:

SELECT

读取创建版本小于或等于当前事务版本号,并且删除版本为空或大于当前事务版本号的记录。这样可以保证在读取之前记录是存在的。

INSERT

将当前事务的版本号保存至行的创建版本号

UPDATE

新插入一行,并以当前事务的版本号作为新行的创建版本号,同时将原记录行的删除版本号设置为当前事务版本号

DELETE

将当前事务的版本号保存至行的删除版本号

例如:

此时books表中有5条数据,版本号为1

事务A,系统版本号2:select * from books;因为1<=2所以此时会读取5条数据。

事务B,系统版本号3:insert into books ...,插入一条数据,新插入的数据版本号为3,而其他的数据的版本号仍然是2,插入完成之后commit,事务结束。

事务A,系统版本号2:再次select * from books;只能读取<=2的数据,事务B新插入的那条数据版本号为3,因此读不出来,解决了幻读的问题。

 

注:

排它锁 是 串行执行

共享锁 是 读读并发

数据多版本 是 读写并发

 

乐观锁利用MVCC实现一致性非锁定读,这就有保证在同一个事务中多次读取相同的数据返回的结果是一样的,解决了不可重复读的问题,也可以解决幻读问题;悲观锁,serializable隔离级别,利用Gap Locks(页锁)、表锁可以阻止其它事务在锁定区间内插入数据,因此解决了幻读问题。

 

总结:

数据库并发问题,主要通过设置事务隔离级别来解决,而事务隔离级别一般则通过锁机制的实现;

MySQL默认隔离级别(RR)使用MVCC+锁混合的模式来解决脏读、不可重读、幻读等问题。

MySQL(Innodb引擎)下

默认的事务级别为:可重复读级别(RR);(可通过设置进行更改)

默认锁级别为:行锁;(可通过设置进行更改)

Where筛选条件中使用索引字段的,加的是行锁;不是使用索引字段筛选的,加的是表锁。

意向共享锁和意向排它锁是数据库主动加的,不需要我们手动处理;

对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁;

对于普通SELECT语句,InnoDB不会加任何锁;(可以自己手动上锁)

 

注:

READ UNCOMMITTED 读未提交 read-uncommitted

READ COMMITTED 读已提交 read-committed

REPEATABLE READ 可重复读 repeatable-read

SERIALIZABLE 串行化 serializable

 

查看数据库全局事务隔离级别:

SELECT @@GLOBAL.tx_isolation;

查看数据库当前会话事务隔离级别:

SELECT @@SESSION.tx_isolation;

SELECT @@tx_isolation;

设置全局事务隔离级别:

SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

设置当前会话事务隔离级别:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET tx_isolation = 'read-committed';

来自https://home.cnblogs.com/u/jieerma666/
 

共享锁(S锁)和排它锁(X锁)

 

共享锁【S锁】
又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

排他锁【X锁】
又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。

 

1、什么是共享锁和排它锁

 
     共享锁就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。
     排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。
 
2、排它锁和共享锁实例
 
     ReentrantLock就是一种排它锁。CountDownLatch是一种共享锁。这两类都是单纯的一类,即,要么是排它锁,要么是共享锁。
   ReentrantReadWriteLock是同时包含排它锁和共享锁特性的一种锁,这里主要以ReentrantReadWriteLock为例来进行分析学习。我们使用ReentrantReadWriteLock的写锁时,使用的便是排它锁的特性;使用ReentrantReadWriteLock的读锁时,使用的便是共享锁的特性。
 
3、锁的等待队列组成
 
 ReentrantReadWriteLock有一个读锁(ReadLock)和一个写锁(WriteLock)属性,分别代表可重入读写锁的读锁和写锁。有一个Sync属性来表示这个锁上的等待队列。ReadLock和WriteLock各自也分别有一个Sync属性表示在这个锁上的队列
 
通过构造函数来看,
    public ReentrantReadWriteLock(boolean fair) {
        sync = (fair)? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }
 
在创建读锁和写锁对象的时候,会把这个可重入的读写锁上的Sync属性传递过去。
protected ReadLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
protected WriteLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
 
所以,最终的效果是读锁和写锁使用的是同一个线程等待队列。这个队列就是通过我们在前面介绍过的AbstractQueuedSynchronizer实现的。
 
 
4、锁的状态
    
既然读锁和写锁使用的是同一个等待队列,那么这里要如何区分一个锁的读状态(有多少个线程正在读这个锁)和写状态(是否被加了写锁,哪个线程正在写这个锁)。
 
首先每个锁都有一个exclusiveOwnerThread属性,这是继承自AbstractQueuedSynchronizer,来表示当前拥有这个锁的线程。那么,剩下的主要问题就是确定,有多少个线程正在读这个锁,以及是否加了写锁。
 
这里可以通过线程获取锁时执行的逻辑来看,下面是线程获取读锁时会执行的一部分代码。
 
  final boolean tryReadLock() {
            Thread current = Thread.currentThread();
            for (;;) {
                int c = getState();
                if (exclusiveCount(c) != 0 &&
                    getExclusiveOwnerThread() != current)
                    return false ;
                if (sharedCount(c) == MAX_COUNT)
                    throw new Error("Maximum lock count exceeded" );
                if (compareAndSetState(c, c + SHARED_UNIT)) {
                    HoldCounter rh = cachedHoldCounter;
                    if (rh == null || rh.tid != current.getId())
                        cachedHoldCounter = rh = readHolds.get();
                    rh.count++;
                    return true ;
                }
            }
        }
 
注意这个函数的调用exclusiveCount(c) ,用来计算这个锁当前的写加锁次数(同一个进程多次进入会累加)。代码如下
 
/** Returns the number of shared holds represented in count  */
        static int sharedCount( int c)    { return c >>> SHARED_SHIFT; }
        /** Returns the number of exclusive holds represented in count  */
         static int exclusiveCount (int c) { return c & EXCLUSIVE_MASK; }
 
相关常量的定义如下
 
static final int SHARED_SHIFT   = 16;
 
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; 
 
如果从二进制来看EXCLUSIVE_MASK的表示,这个值的低16位全是1,而高16位则全是0,所以exclusiveCount是把state的低16位取出来,表示当前这个锁的写锁加锁次数。
再来看sharedCount,取出了state的高16位,用来表示这个锁的读锁加锁次数。所以,这里是用state的高16位和低16位来分别表示这个锁上的读锁和写锁的加锁次数。
 
现在再回头来看tryReadLock实现,首先检查这个锁上是否被加了写锁,同时检查加写锁的是不是当前线程。如果不是被当前线程加了写锁,那么试图加读锁就失败了。如果没有被加写锁,或者是被当前线程加了写锁,那么就把读锁加锁次数加1,通过compareAndSetState(c, c + SHARED_UNIT)来实现
SHARED_UNIT的定义如下,刚好实现了高16位的加1操作。
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
 
 
 
5、线程阻塞和唤醒的时机
 
线程的阻塞和访问其他锁的时机相似,在线程视图获取锁,但这个锁又被其它线程占领无法获取成功时,线程就会进入这个锁对象的等待队列中,并且线程被阻塞,等待前面线程释放锁时被唤醒。
 
但因为加读锁和加写锁进入等待队列时存在一定的区别,加读锁时,final Node node = addWaiter(Node.SHARED);节点的nextWaiter指向一个共享节点,表明当前这个线程是处于共享状态进入等待队列。
 
加写锁时如下,
public final void acquire (int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
线程是处于排它状态进入等待队列的。
 
 
在线程的阻塞上,读锁和写锁的时机相似,但在线程的唤醒上,读锁和写锁则存在较大的差别。
 
读锁通过AbstractQueuedSynchronizer的doAcquireShared来完成获取锁的动作。
  private void doAcquireShared( int arg) {
        final Node node = addWaiter(Node.SHARED);
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null ; // help GC
                        if (interrupted)
                            selfInterrupt();
                        return ;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true ;
            }
        } catch (RuntimeException ex) {
            cancelAcquire(node);
            throw ex;
        }
    }
在tryAcquireShared获取读锁成功后(返回正数表示获取成功),有一个setHeadAndPropagate的函数调用。
 
写锁通过AbstractQueuedSynchronizer的acquire来实现锁的获取动作。
  public final void acquire( int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
如果tryAcquire获取成功则直接返回,否则把线程加入到锁的等待队列中。和一般意义上的ReentrantLock的原理一样。
 
所以在加锁上,主要的差别在于这个setHeadAndPropagate方法,其代码如下
 
 
private void setHeadAndPropagate (Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        /*
         * Try to signal next queued node if:
         * Propagation was indicated by caller,
         * or was recorded (as h.waitStatus) by a previous operation
         * (note: this uses sign-check of waitStatus because
         * PROPAGATE status may transition to SIGNAL.)
         * and
         * The next node is waiting in shared mode,
         * or we don't know, because it appears null
         *
         * The conservatism in both of these checks may cause
         * unnecessary wake-ups, but only when there are multiple
         * racing acquires/releases, so most need signals now or soon
         * anyway.
         */
        if (propagate > 0 || h == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }
 
主要操作是把这个节点设为头节点(成为头节点,则表示不在等待队列中,因为获取锁成功了),同时释放锁(doReleaseShared)。
 
下面来看doReleaseShared的实现
 
  private void doReleaseShared() {
        /*
         * Ensure that a release propagates, even if there are other
         * in-progress acquires/releases. This proceeds in the usual
         * way of trying to unparkSuccessor of head if it needs
         * signal. But if it does not, status is set to PROPAGATE to
         * ensure that upon release, propagation continues.
         * Additionally, we must loop in case a new node is added
         * while we are doing this. Also, unlike other uses of
         * unparkSuccessor, we need to know if CAS to reset status
         * fails, if so rechecking.
         */
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue ; // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue ; // loop on failed CAS
            }
            if (h == head) // loop if head changed
                break ;
        }
    }
 
把头节点的waitStatus这只为0或者Node.PROPAGATE,并且唤醒下一个线程,然后就结束了。
 
总结一下,就是一个线程在获取读锁后,会唤醒锁的等待队列中的第一个线程。如果这个被唤醒的线程是在获取读锁时被阻塞的,那么被唤醒后,就会在for循环中,又执行到setHeadAndPropagate,这样就实现了读锁获取时的传递唤醒。这种传递在遇到一个因为获取写锁被阻塞的线程节点时被终止。
 
下面通过代码来理解这种等待和线程唤醒顺序。
 
 
package lynn.lock;
 
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
public class TestThread extends Thread {
    private ReentrantReadWriteLock lock;
    private String threadName;
    private boolean isWriter ;
 
    public TestThread(ReentrantReadWriteLock lock, String name, boolean isWriter) {
        this.lock = lock;
        this.threadName = name;
        this.isWriter = isWriter;
    }
 
    @Override
    public void run() {
        while (true ) {
            try {
                if (isWriter ) {
                    lock.writeLock().lock();
                } else {
                    lock.readLock().lock();
                }
                if (isWriter ) {
                    Thread. sleep(3000);
                    System. out.println("----------------------------" );
                }
                System. out.println(System.currentTimeMillis() + ":" + threadName );
                if (isWriter ) {
                    Thread. sleep(3000);
                    System. out.println("-----------------------------" );
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (isWriter ) {
                    lock.writeLock().unlock();
                } else {
                    lock.readLock().unlock();
                }
            }
            break;
        }
    }
 
}
 
 
TestThread是一个自定义的线程类,在生成线程的时候,需要传递一个可重入的读写锁对象进去,线程在执行时会先加锁,然后进行内容输出,然后释放锁。如果传递的是写锁,那线程在输出结果前后会先沉睡3秒,便于区分输出的结果时间。
 
package lynn.lock;
 
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 
public class Main {
 
    public static void blockByWriteLock() {
        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
        lock.writeLock().lock();
 
        TestThread[] threads = new TestThread[10];
        for (int i = 0; i < 10; i++) {
            boolean isWriter = (i + 1) % 4 == 0 ? true : false;
            TestThread thread = new TestThread(lock, "thread-" + (i + 1), isWriter);
            threads[i] = thread;
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i].start();
        }
        System. out.println(System.currentTimeMillis() + ": block by write lock");
        try {
            Thread. sleep(3000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        lock.writeLock().unlock();
    }
 
    public static void main(String[] args) {
        blockByWriteLock();
    }
}
 
在Main中构造了10个线程,由于这个锁一开始是被主线程拥有,并且是在排它状态下加锁的,所以我们构造的10个线程,在一开始执行便是按照其编号从小到大在等待队列中(1到10)。然后主线程打印结果,等待3秒后释放锁。由于前3个线程,编号1到3是处于共享状态阻塞的,而第4个线程是处于排它状态阻塞,所以,按照上面的唤醒顺序,唤醒传递到第4个线程时就结束。
 
依次类推,理论上的打印顺序是 :主线程 [1,2,3]  4  [5,6,7] 8 [9,10]
 
从下面的执行结果来看,也是符合我们的预期的。
     
6、读线程之间的唤醒
     
     如果一个线程在共享模式下获取了锁状态,这个时候,它是否要唤醒其它在共享模式下等待在该锁上的线程?
 
     由于多个线程可以同时获取共享锁而不相互影响,所以,当一个线程在共享状态下获取了锁之后,理论上是可以唤醒其它在共享状态下等待该锁的线程。但如果这个时候,在这个等待队列中,既有共享状态的线程,同时又有排它状态的线程,这个时候又该如何唤醒?
 
     实际上对于锁来说,在共享状态下,一个线程无论是获取还是释放锁的时候,都会试着去唤醒下一个等待在这个锁上的节点(通过上面的doAcquireShared代码能看出)。如果下一个线程也是处于共享状态等待在锁上,那么这个线程就会被唤醒,然后接着试着去唤醒下一个等待在这个锁上的线程,这种唤醒动作会一直持续下去,直到遇到一个在排它状态下阻塞在这个锁上的线程,或者等待队列全部被释放为止。
     因为线程是在一个FIFO的等待队列中,所以,这这样一个一个往后传递,就能保证唤醒被传递下去。
 
 
 
posted on 2020-03-22 16:52  手边星辰  阅读(275)  评论(0编辑  收藏  举报