锁
乐观锁:
乐观锁不是数据库自带的,需要我们去实现。总是假设最好的情况,每次去拿数据时都会认为数据没有被修改,所以不会上锁,但是在提交更新的时候会去判断一下在此期间别人有没有更改数据,可以使用版本号机制算法或者CAS算法实现。乐观锁适用于读多于写的情况,可以提高吞吐量。
乐观锁:假设不会出现并发冲突,只在提交操作时检查是否违反数据完整性。
Java JUC中的atomic包就是乐观锁的一种实现,AtomicInteger 通过CAS(Compare And Set)操作实现线程安全的自增。
实现方式:
- 使用数据版本记录机制实现,这是乐观锁最常用的一种实现方式。为当前数据增加一个版本标识,一般是为数据库表增加一个数据类型的version字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新时,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行对比,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
- 使用时间戳,乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
- CAS算法:即是compare and swap(比较与交换),是一种有名的无锁算法,无锁编程,在不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。CAS算法涉及到三个操作数:需要读写的内存值 V、进行比较的值 A、拟写入的新值 B。当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
问题:
ABA问题是一个乐观锁的常见问题:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 “ABA”问题。
JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
循环时间长开销大:
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作:
CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。但是从JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
悲观锁:
总是假设最坏的情况,每次去拿数据时都认为别人会修改,所以每次在拿数据时都会上锁,这样别人想拿数据就会阻塞直到他拿到锁。共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程。传统的关系型数据库里面就用到了很多这种锁机制,行锁,表锁,读锁,写锁等,都是在操作之前先上锁。
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
Java synchronized和ReentrantLock就属于悲观锁的一种实现,每次线程要修改数据时都先获得锁,保证同一时刻只有一个线程能操作数据,其他线程则会被block。
区别与场景:
区别:
乐观锁的思路一般是表中增加版本字段,更新时where语句中增加版本的判断,算是一种CAS(Compare And Swep)操作,商品库存场景中number起到了版本控制(相当于version)的作用( AND number=#{number})。
悲观锁之所以是悲观,在于他认为本次操作会发生并发冲突,所以一开始就对商品加上锁(SELECT … FOR UPDATE),然后就可以安心的做判断和更新,因为这时候不会有别人更新这条商品库存。
场景:
乐观锁适用于读多于写的情况,即冲突很少的情况,可以省去很大加锁的开销。多写的情况,冲突会比较多,不适合上面的场景,因为冲突的数据会导致应用不断的retry,一般多写的场景适合适用悲观锁。
CAS与synchronized:
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
- 补充:Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。
- synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
MySQL隐式和显示锁定:
MySQL InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行 COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。前面描述的锁定都是隐式锁定,InnoDB会根据事务隔离级别在需要的时候自动加锁。
另外,InnoDB也支持通过特定的语句进行显示锁定,这些语句不属于SQL规范:
1 | SELECT ... LOCK IN SHARE MODE |
共享锁:
共享锁(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。
解决死锁:
共享锁与排它锁会导致死锁问题。
预防死锁发生:
- 要求每一个事务必须一次封锁所要使用的全部数据(要么全成功,要么全不成功)。
- 规定封锁数据的顺序,所有事务必须按这个顺序实行封锁。
允许死锁发生,然后解除它,如果发现死锁,则将其中一个代价较小的事物撤消,回滚这个事务,并释放此事务持有的封锁,使其他事务继续运行。
更新锁:
更新锁用于防止常见形式的死锁,比如共享锁和排他锁产生的死锁问题。
更新锁(U锁)并不能保证不会产生死锁,只是针对共享锁和排他锁提出了一种较为简单的解决方式。共享锁解决死锁,实际上是阻碍了事务T1和T2的并发执行。
- S锁只能读取数据,不能升级成X锁。
- U锁给予事务T读取属性A的权限,没有写的权限,但是可以升级成X锁。
- 属性A上面有共享锁,可以添加U锁;但是有U锁,不能添加任何锁。
— | S | X | U |
---|---|---|---|
S | YES | NO | YES |
X | NO | NO | NO |
U | NO | NO | NO |
增量锁:
对于一部分数据库,对数据库的操作仅仅只涉及加与减操作。这样针对这种情况,我们引入增量锁。
只有在事务获取了增量锁的前提下,才能够进行增量操作。
— | S | X | I |
---|---|---|---|
S | YES | NO | NO |
X | NO | NO | NO |
I | NO | NO | YES |
行锁:
顾名思义,行锁就是一锁锁一行或者多行记录,mysql的行锁是基于索引加载的,所以行锁是要加在索引响应的行上,即命中索引。否则自动扫描全表,走表锁。
行锁的特征:锁冲突概率低,并发性高,但是会有死锁的情况出现。
- 行锁必须有索引才能实现,否则会自动锁全表,那么就不是行锁了。
- 两个事务不能锁同一个索引。
- insert ,delete , update在事务中都会自动默认加上排它锁。
当选中某一行时,如果是通过主键或者索引选中的,这个时候是行级锁;如果是通过其它条件选中的,这个时候行级锁会升级成表锁,其它事务无法对当前表进行更新或插入操作。
表锁:
顾名思义,表锁就是一锁锁一整张表,在表被锁定期间,其他事务不能对该表进行操作,必须等当前表的锁被释放后才能进行操作。表锁响应的是非索引字段,即全表扫描,全表扫描时锁定整张表。
区别与场景:
区别:
表锁:不会出现死锁,发生锁冲突几率高,并发低。
行锁:会出现死锁,发生锁冲突几率低,并发高。开销大,加锁慢,锁定粒度小。
锁冲突:例如说事务A将某几行上锁后,事务B又对其上锁,锁不能共存否则会出现锁冲突。(但是共享锁可以共存,共享锁和排它锁不能共存,排它锁和排他锁也不可以)。
死锁:例如说两个事务,事务A锁住了1-5行,同时事务B锁住了6-10行,此时事务A请求锁住6-10行,就会阻塞直到事务B施放6-10行的锁,而随后事务B又请求锁住1-5行,事务B也阻塞直到事务A释放1-5行的锁。死锁发生时,会产生Deadlock错误。
记录锁:
记录锁是在行锁上衍生的锁。
记录锁:记录锁锁的是表中的某一条记录,记录锁的出现条件必须是精准命中索引并且索引是唯一索引。
间隙锁:
间隙锁又称之为区间锁,每次锁定都是锁定一个区间,隶属行锁。既然间隙锁隶属行锁,那么,间隙锁的触发条件必然是命中索引的,当我们查询数据用范围查询而不是相等条件查询时,查询条件命中索引,并且没有查询到符合条件的记录,此时就会将查询条件中的范围数据进行锁定(即使是范围库中不存在的数据也会被锁定)。
间隙锁只会出现在可重复读的事务隔离级别中,mysql5.7默认就是可重复读。间隙锁锁的是一个区间范围,查询命中索引但是没有匹配到相关记录时,锁定的是查询的这个区间范围。
临间锁:
学习完间隙锁后我们再来看看什么是临间锁,mysql的行锁默认就是使用的临间锁,临间锁是由记录锁和间隙锁共同实现的,上面我们学习间隙锁时,间隙锁的触发条件是命中索引,范围查询没有匹配到相关记录。而临键锁恰好相反,临间锁的触发条件也是查询条件命中索引,不过,临间锁有匹配到数据库记录。
间隙锁所锁定的区间是一个左开右闭的集合,而临间锁锁定是当前记录的区间和下一个记录的区间。
临间锁锁定区间和查询范围后匹配值很重要,如果后匹配值存在,则只锁定查询区间,否则锁定查询区间和后匹配值与它的下一个值的区间。