本章威哥想分享一下乐观锁&悲观锁&AQS。在介绍之前,我们先普及下锁的相关知识,有利于我们稍后顺利的进入主题分享。
锁的模式主要分:
共享 (S) 用于不更改或不更新数据的操作(只读操作),如 SELECT 语句。
更新 (U) 用于可更新的资源中。防止当多个会话在读取、锁定以及随后可能进行的资源更新时发生常见形式的死锁。
排它 (X) 用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时同一资源进行多重更新。
意向锁 用于建立锁的层次结构。意向锁的类型为:意向共享 (IS)、意向排它 (IX) 以及与意向排它共享 (SIX)。
架构锁 在执行依赖于表架构的操作时使用。架构锁的类型为:架构修改 (Sch-M) 和架构稳定性 (Sch-S)。
大容量更新 (BU) 向表中大容量复制数据并指定了 TABLOCK 提示时使用。
执行表的数据定义语言 (DDL) 操作(例如添加列或除去表)时使用架构修改 (Sch-M) 锁。
共享锁:
共享 (S) 锁允许并发事务读取 (SELECT) 一个资源。资源上存在共享 (S) 锁时,任何其它事务都不能修改数据。一旦已经读取数据,便立即释放资源上的共享 (S) 锁,除非将事务隔离级别设置为可重复读或更高级别,或者在事务生存周期内用锁定提示保留共享 (S) 锁。
更新锁:
更新 (U) 锁可以防止通常形式的死锁。一般更新模式由一个事务组成,此事务读取记录,获取资源(页或行)的共享 (S) 锁,然后修改行,此操作要求锁转换为排它 (X) 锁。如果两个事务获得了资源上的共享模式锁,然后试图同时更新数据,则一个事务尝试将锁转换为排它 (X) 锁。共享模式到排它锁的转换必须等待一段时间,因为一个事务的排它锁与其它事务的共享模式锁不兼容;发生锁等待。第二个事务试图获取排它 (X) 锁以进行更新。由于两个事务都要转换为排它 (X) 锁,并且每个事务都等待另一个事务释放共享模式锁,因此发生死锁。
排它锁 :
排它 (X) 锁可以防止并发事务对资源进行访问。其它事务不能读取或修改排它 (X) 锁锁定的数据。
意向锁:
意向锁表示 SQL Server 需要在层次结构中的某些底层资源上获取共享 (S) 锁或排它 (X) 锁。例如,放置在表级的共享意向锁表示事务打算在表中的页或行上放置共享 (S) 锁。在表级设置意向锁可防止另一个事务随后在包含那一页的表上获取排它 (X) 锁。意向锁可以提高性能,因为 SQL Server 仅在表级检查意向锁来确定事务是否可以安全地获取该表上的锁。而无须检查表中的每行或每页上的锁以确定事务是否可以锁定整个表。
从程序员的角度看:分为乐观锁和悲观锁。
下面威哥针对乐观锁和悲观锁做一个例子。
假如12306上售卖上海到武汉的票还剩一张,而2个用户同时操作购票,导致并发,那就会产生一票多卖的情况。下面跟着威哥分别使用悲观锁和乐观锁解决该问题。
注意:售票时,需要先拿到余票,之后售卖,再操作余票减1。
先看会产生一票多卖的情况:
Declare @count as int;
Begin Tran
Select @count=余票数 From TrainTable
Where Delay ’00:00:05’ --模拟并发,故意延迟5秒
Update TrainTable Set余票数=@count-1
Commit Tran
Select * From TrainTable;
按照威哥上面说的那2个前世修得今世缘的童靴同时并发买票,就会导致一票多卖。
悲观锁解决方案:
Declare @count as int;
Begin Tran
Select @count=余票数 From TrainTable With(UpDLock)
Where Delay ’00:00:05’ --模拟并发,故意延迟5秒
Update TrainTable Set余票数=@count-1
Commit Tran
乐观锁解决方案:
首先需要给表添加一列时间挫字段。后面需要通过时间戳字段去判断并发。
Alter Table TrainTable Add timesFalg int Not Null;
更新时需要判断该时间戳字段是否被修改了。
Declare @count as int;
Declare @timesFalg as int;
Declare @rowCount as int;
Begin Tran
Select @count=余票数, @timesFalg= timesFalg From TrainTable With(UpDLock)
Where Delay ’00:00:05’ --模拟并发,故意延迟5秒
Update TrainTable Set余票数=@count-1, timesFalg=当前时间戳 Where timesFalg =@timesFalg ;
Set @rowCount=@@RowCount --获取被修改的行数
Commit Tran
下面需要对返回的修改的行数进行判断,即可判断操作成功与否,同时避免了一票多卖。
If @rowCount==1
恭喜喜提12306火车票一张!
Else
还是等明天再回武汉吧。
上面是威哥针对锁的一些概念的阐述,以及一个简单的乐观锁和悲观锁的例子展示。下面威哥带领大家一起从Java层面来看看乐观锁、悲观锁。
Java中乐观锁对应的实现 是CAS ;悲观锁对应Synchronized、ReentrantLock。
我们首先来看看什么是CAS:从字面看,Compare And Swap,比较并且替换。是乐观锁的一种实现方式,一种轻量级锁。
讲到这里,好奇宝宝可能会关注CAS是怎么实现线程安全的?
CAS是这样做的:线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
童鞋们从看完上图,应该就理解了CAS的原理。但是细心的同学会发现我有一块使用虚线框出来了,会有什么问题呢?
按照上图,CAS会出现循环时间长开销大的问题,极端情况下甚至出现死循环。看到这里部分同学可能会觉得除了自旋问题外,似乎整个过程也是ok,先不着急,再看下面威哥写的一段代码:
上面是我写的一个例子,来看看执行结果:
从结果看,威哥和鹏飞都没做错,但是实际这和预期是有出入的。整个是Java中的ABA问题,实际上鹏飞在操作修改为12时,值其实不是最原始的10了。威哥话一个图简单描述下吧。
上面是这样的顺序:
- 线程1读取了数据A
- 线程2读取了数据A
- 线程2通过CAS比较,发现值是A没错,可以把数据A改成数据B
- 线程3读取了数据B
- 线程3通过CAS比较,发现数据是B没错,可以把数据B改成了数据A
- 线程1通过CAS比较,发现数据还是A没变,就写成了自己要改的值
所以CAS有3个问题:
1.循环时间长开销大的问题
2.ABA问题;
3.只能保证一个共享变量的原子操作
上面第3点在JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。威哥也针对AtomicReference写了个例子。
执行结果:
compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)。
(1)第一个参数expectedReference:表示预期值。
(2)第二个参数newReference:表示要更新的值。
(3)第三个参数expectedStamp:表示预期的时间戳。
(4)第四个参数newStamp:表示要更新的时间戳。
再来看看悲观锁,悲观锁我们就想象他是个PUA渣男,你认为他每次都会来渣你,所以我们每次都要提防他。
Java中synchronized就是悲观锁,也是Java中最常用的线程同步手段之一。
synchronized分为类锁(修饰静态方法,锁住类)、对象锁(方法锁,修饰方法,锁住对象实例)、块锁(修饰代码块)。
类锁和对象锁这里简单说明下:
类锁:synchronized加在一个类的普通方法上,那么相当于synchronized(this)。
对象锁:synchronized加载一个类的静态方法上,那么相当于synchronized(Class对象)。
威哥此处只针对性的讲解下synchronized对象锁下。
在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(InstanceData)和对齐填充(Padding)。
对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
提下Monitor是什么,Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象,有兴趣的同学后面可以自己再去了解下。
当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。
Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。
synchronized在多线程编程中是元老级角色,通常我们称它为重量级锁。但是其实在Java SE 1.6后,synchronized已经脱离重量级锁的范畴了,因为Java SE 1.6后为其引入了偏向锁和轻量级锁的概念,减少了获得锁和释放锁带来的性能消耗。
在介绍synchronized的偏向锁和轻量级锁之前,先介绍个概念,锁只能升级,而不能降级。
基于上面的概念,我来synchronized怎么执行锁的,是如何一步一步升级为重量级锁的:
威哥的分享题目是乐观锁&悲观锁&AQS,其实讲到synchronized基本就结束了悲观锁的讲解,为何还会有AQS这个东东。
这是因为synchronized是非公平锁,而实际使用时往往我们还需要使用公平锁,公平锁就需要介绍ReentrantLock了,但是介绍这个东东,就得介绍AQS了,我们接着往下看。
先介绍下公平锁和非公平锁的概念:
1、公平锁能保证:老的线程排队使用锁,新线程仍然排队使用锁。
2、非公平锁保证:老的线程排队使用锁;但是无法保证新线程抢占已经在排队的线程的锁。
synchronized和ReentrantLock都是加锁方式同步,而且都是阻塞式的同步,而且都是可重入锁(某个线程已经获得某个锁,可以再次获取锁而不会出现死锁)。ReentrantLock支持公平锁和非公平锁(默认非公平)。
ReentrantLock实现了Java锁的核心接口Lock,它主要基于CAS+AQS队列来实现。CAS上面已经介绍过了,童鞋们应该理解了,现在来说下AQS。
AQS:FIFO的队列同步器。可以参照下图:
上图就是AQS的一个FIFO的排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态。
ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后CAS再次尝试获取锁。
最后提一下,ReentrantLock加锁的时候,一定要手动释放锁,并且加锁次数和释放次数要一样。