java与数据库中的锁 及 MVCC
一、Java中的锁
- 公平锁/非公平锁
- 可重入锁/不可重入
- 独享锁/共享锁
- 读写锁
- 分段锁
- 偏向锁/轻量级锁/重量级锁
- 自旋锁
1.1 公平锁,非公平锁:
公平锁就是保障了多线程下各线程获取锁的顺序,先到的线程优先获取锁,当一个线程获取到锁后,这时如果其他多个线程同时请求获取锁,会将其他线程按到达顺序排成队列,当持有锁的线程释放锁后,队列中的线程会依次按照队列顺序获取锁。
非公平锁则无法提供这个保障。与公平锁的区别时,当一个线程持有锁时,其他线程请求时会加入队列中,当一个持有锁的线程释放锁后,其他多个线程获取锁的顺序没有保证,是按照抢占机制实现的,谁先得到就是谁的。
public class LockDemo {
// 公平锁
Lock lock = new ReentrantLock(true);
// 非公平锁
// Lock lock = new ReentrantLock(false);
public static void main(String[] args) {
final LockDemo lockDemo = new LockDemo();
long startDate = new Date().getTime();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
public void run() {
lockDemo.write(Thread.currentThread());
}
}).start();
}
long endDate = new Date().getTime();
System.out.println("时间差"+ (endDate - startDate));
}
public void write(Thread thread) {
lock.lock();
try {
System.out.println(thread.getName() + "获取了锁");
} catch (Exception e) {
} finally {
lock.unlock();
System.out.println(thread.getName() + "释放了锁");
}
}
}
Thread-0获取了锁
时间差1
Thread-0释放了锁
Thread-1获取了锁
Thread-1释放了锁
Thread-2获取了锁
Thread-2释放了锁
Thread-3获取了锁
Thread-3释放了锁
Thread-4获取了锁
Thread-4释放了锁
非公平锁:(将true改为false,运行结果如下:)
时间差1
Thread-0获取了锁
Thread-0释放了锁
Thread-3获取了锁
Thread-3释放了锁
Thread-1获取了锁
Thread-1释放了锁
Thread-2获取了锁
Thread-2释放了锁
Thread-4获取了锁
Thread-4释放了锁
每次运行的时间差有可能不一样,但多次运行后,会发现,公平锁消耗的时间比非公平锁消耗的时间要多,因此非公平锁效率高于公平锁,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。
1.2 可重入锁,不可重入锁(ReentrantLock)
可重入锁是对于同一个类的对象,线程在执行一个任务时,会获取一次锁,当执行完会释放锁,如果这个线程还要继续执行这个对象的其他任务,是不需要重新获取锁的,但执行完任务就要释放锁,顾名思义,锁的重入性。
举一个二狗子看门的例子,现有一只二狗子“旺财”,它的任务就是在自家门口看门,它能听懂各种语言,当陌生人去他家时,只要告诉它是主人的朋友就可以进去,出门时也要告诉它要离开,才会放你出去。
可以把这户人家看成一个对象,这户人家院子里有三间房子,代表对象的三个方法,这个女访客代表一个线程,“旺财”代表一把锁。当访客第一次进入院子里面时,要经过二哈(旺财)的同意(获取锁),进入院子后,访客可以随便去哪间房间了,不用征求二哈的同意(不用再次获取锁),但是,从每一间房子出来,都要告诉二哈逛完了(释放锁)。
1.3 独享锁,共享锁
独享锁(互斥锁):同时只能有一个线程获得锁。
共享锁:可以有多个线程同时获得锁。
1.4 读写锁(ReadWriteLock)
读写锁把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。写则是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。
综合有以下规则:
(1)多个线程读,可以同时读。
(2)多个线程写,不能同时写,只能一个线程写完,其他线程才能开始。
(3)读写同时进行,读的同时不能写,写的同时不能读,只能读完再写,或写完再读
1.5 分段锁
在 Java 5 之后,JDK 引入了 java.util.concurrent 并发包 ,其中最常用的就是 ConcurrentHashMap 了, 它的原理是引用了内部的 Segment ( ReentrantLock ) 分段锁,保证在操作不同段 map 的时候, 可以并发执行, 操作同段 map 的时候,进行锁的竞争和等待。从而达到线程安全, 且效率大于 synchronized。
但是在 Java 8 之后, JDK 却弃用了这个策略,重新使用了 synchronized,关于ConcurrentHashMap的详细讲解,请看https://www.cnblogs.com/xyzyj/p/11283559.html。
1.6 偏向锁/轻量级锁/重量级锁
偏向锁,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
轻量级锁,线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。
重量级锁,重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。
1.7 自旋锁
自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
在JDK1.6中,Java虚拟机提供-XX:+UseSpinning参数来开启自旋锁,默认开启,使用-XX:PreBlockSpin参数来设置自旋锁等待的次数。在JDK1.7开始,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁,自旋锁总是会执行。
二、数据库中的锁
- 表级锁定
- 行级锁定
- 页级锁定
- 共享锁/排他锁
- 修改锁
- 结构锁
- 意向锁
- 批量修改锁
- 间隙锁
- 乐观锁/悲观锁
2.1 行级锁定
偏向InnoDB存储引擎,开销大,加锁慢,会出现死锁,锁定粒度小,发送锁冲突的概率最低,并发度也最高。
当选中某一行时,如果是通过主键或者索引选中的,这个时候是行级锁。
如果是通过其它条件选中的,这个时候行级锁会升级成表锁,其它事务无法对当前表进行更新或插入操作。
2.2 表级锁定
表锁更适用于以查询为主,只有少量按索引条件更新数据的应用;
行锁更适用于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用。
2.3 页级锁定
行锁锁指定行,表锁锁整张表,页锁是折中实现,即一次锁定相邻的一组记录。
oracle没有页锁,和其他数据库的并发机制不一样oracle基于多版本机制、意向锁,提供高并发能力。
开销和加锁时间介于表锁和行锁之间:会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
2.4 共享锁/排他锁
同独享锁/共享锁。
排它锁:又称写锁(x锁)。若事务T对数据对象A加上X锁,则只允许T读取和修改A,其他任何事务都不能再对A加任何类型的锁,直到T释放A上的锁,这就保证了其他事务在T释放A上的锁之前不能再读取和修改A。
共享锁:又称读锁(S锁)。若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其它事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁,这就保证了其他事务可以读A,但在T释放A上的s锁之前不能对A做任何修改。
2.5 修改锁
修改锁在修改操作的初始化阶段用来锁定可能要被修改的资源,这样可以避免使用共享锁造成的死锁现象。因为使用共享锁时,修改数据的操作分为两步,首先获得一 个共享锁,读取数据,然后将共享锁升级为独占锁,然后再执行修改操作。这样如果同时有两个或多个事务同时对一个事务申请了共享锁,在修改数据的时候,这些事务都要将共享锁升级为独占锁。这时,这些事务都不会释放共享锁而是一直等待对方释放,这样就造成了死锁。如果一个数据在修改前直接申请修改锁,在数据修改的时候再升级为独占锁,就可以避免死锁。修改锁与共享锁是兼容的,也就是说一个资源用共享锁锁定后,允许再用修改锁锁定。
2.6 结构锁
结构锁分为结构修改锁(Sch-M)和结构稳定锁(Sch-S)。执行表定义语言操作时,SQL Server采用Sch-M锁,编译查询时,SQL Server采用Sch-S锁。
2.7 意向锁
当一个事务在需要获取资源的锁定时,如果该资源已经被排他锁占用,则数据库会自动给该事务申请一个该表的意向锁。如果自己需要一个共享锁定,就申请一个意向共享锁。如果需要的是某行(或者某些行)的排他锁定,则申请一个意向排他锁。
2.8 间隙锁
当我们用范围条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做”间隙(GAP)”。InnoDB也会对这个”间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。
2.9 乐观锁,悲观锁
乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。一般通过数据版本和时间戳来实现。
悲观锁是当一个线程每次去拿数据的时候都认为其他线程会修改数据,所以每次在拿数据的时候都会上锁,这样其他线程想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
三、ReentrantLock
java除了使用关键字synchronized外,还可以使用ReentrantLock实现独占锁的功能。
而且ReentrantLock相比synchronized而言功能更加丰富,使用起来更为灵活,也更适合复杂的并发场景,区别如下:
(1)synchronized是独占锁,加锁和解锁的过程自动进行,易于操作,但不够灵活。ReentrantLock也是独占锁,加锁和解锁的过程需要手动进行,不易操作,但非常灵活。
(2)synchronized可重入,因为加锁和解锁自动进行,不必担心最后是否释放锁;ReentrantLock也可重入,但加锁和解锁需要手动进行,且次数需一样,否则其他线程无法获得锁。
(3)synchronized不可响应中断,一个线程获取不到锁就一直等着;ReentrantLock可以响应中断,比如定义两个锁lock1和lock2,然后使用两个线程thread和thread1构造死锁场景。正常情况下,这两个线程相互等待获取资源而处于死循环状态。但调用其中一个线程的interrupt()方法中断该线程,另外一个线程就可以获取资源,正常地执行了。
ReentrantLock好像比synchronized关键字没好太多,我们再去看看synchronized所没有的,一个最主要的就是ReentrantLock还可以实现公平锁机制。也就是在锁上等待时间最长的线程将获得锁的使用权。通俗的理解就是谁排队时间最长谁先执行获取锁。
先看ReentrantLock类的源码结构:
这两个构造方法分别控制实现的是公平还是非公平锁。前面在说公平锁和非公平锁的时候也已经举过例子。下面举个限时等待的例子:
public class LockDemo1 implements Runnable {
Lock lock1;
Lock lock2;
@Override
public void run() {
try {
if (!lock1.tryLock()) {
Thread.sleep(10);
}
if (!lock2.tryLock()) {
Thread.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock1.unlock();
lock2.unlock();
}
}
}
通过tryLock方法选择传入时间参数,表示等待指定的时间,无参则表示立即返回锁申请的结果:true表示获取锁成功,false表示获取锁失败。我们可以将这种方法用来解决死锁问题。
上面,一个线程获取lock1时候第一次失败,那就等10毫秒之后第二次获取,就这样一直不停的调试,一直等到获取到相应的资源为止。可以设置tryLock的超时等待时间tryLock(long timeout,TimeUnit unit),也就是说一个线程在指定的时间内没有获取锁,那就会返回false,就可以再去做其他事了。
四、SQL的加锁分析
五、MVCC
事务隔离的实现方案有两种,LBCC和MVCC。LBCC,基于锁的并发控制,英文全称Based Concurrency Control。这种方案比较简单粗暴,就是一个事务去读取一条数据的时候,就上锁,不允许其他事务来操作。主要介绍下MVCC。
5.1 数据库并发场景
数据库并发场景有三种,分别为:
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能会存在更新丢失问题
5.2 什么是MVCC?
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。
MVCC主要为数据库解决以下问题:
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题(但不能解决更新丢失问题)
MVCC 只在 REPEATABLE READ 和 READ COMMITIED 两个隔离级别下工作。其他两个隔离级别都和 MVCC 不兼容 ,因为 READ UNCOMMITIED 总是读取最新的数据行,而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。
5.3 当前读 和 快照读
在学习MVCC多版本并发控制之前,我们必须先了解一下,什么是MySQL InnoDB下的当前读和快照读。
当前读:
就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。快照读:
像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制(MVCC),可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
快照读,读取的是记录的可见版本(可能是历史版本,即最新的数据可能正在被当前执行的事务并发修改),不会对返回的记录加锁;而当前读,读取的是记录的最新版本,并且会对返回的记录加锁,保证其他事务不会并发修改这条记录。
在MySQL InnoDB中,简单的select操作,如 select * from table where ? 都属于快照读;
属于当前读的包含以下操作:
- select * from table where ? lock in share mode; (加S锁)
- select * from table where ? for update; (加X锁,下同)
- insert, update, delete操作
MVCC是为了实现读-写冲突不加锁,MVCC中的读指的是快照读, 而当前读实际上是一种加锁的操作,是悲观锁的实现。
MVCC就是为了避免让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案,单纯的MVCC还无法解决 写-写 冲突,但MVCC可以结合悲观锁和乐观锁来解决,所以我们可以形成两个组合:
- MVCC + 悲观锁
MVCC解决读写冲突,悲观锁解决写写冲突 - MVCC + 乐观锁
MVCC解决读写冲突,乐观锁解决写写冲突
5.4 MVCC 实现原理
MVCC实现原理主要是依赖 隐式字段,undo日志 ,Read View 来实现。
5.4.1 隐式字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的 DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID 等字段:
- DB_TRX_ID
最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID - DB_ROLL_PTR
回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
DB_TRX_ID是当前操作该记录的事务ID,InnDB 中每个事务都有一个唯一的事务 ID,记为 transaction_id。它在事务开始时向 InnDB 申请,按照时间先后严格递增。
DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本。
每行数据其实都有多个版本,这就依赖 undo log 来实现了。每次事务更新数据就会生成一个新的数据版本,并把 transaction_id 记为 row trx_id。同时旧的数据版本会保留在 undo log 中,而且新的版本会记录旧版本的回滚指针,通过它直接拿到上一个版本。
所以,InnDB 中的 MVCC 其实是通过在每行记录后面保存两个隐藏的列来实现的。一列是事务 ID:trx_id;另一列是回滚指针:roll_pt。
5.4.2 undo日志
undo log主要分为两种: insert undo log 和 update undo log。
insert 操作产生的 insert undo log,因为 insert 操作记录没有历史版本只对当前事务本身可见,对于其他事务此记录不可见,所以 insert undo log 可以在事务提交后直接删除。
所以MVCC中主要是update undo log,它是事务在进行update或delete时产生的undo log。
undo log实际上就是存在rollback segment中旧记录链,它的执行流程如下:
(1)比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL
(2)现在来了一个事务1对该记录的name做出了修改,改为Tom
- 在事务1修改该行(记录)数据时,数据库会先对该行加排他锁
- 然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
- 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
-
事务提交后,释放锁
(3)又来了个事务2修改person表的同一个记录,将age修改为30岁
- 在事务2修改该行数据时,数据库也先为该行加锁
- 然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
- 修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
-
事务提交,释放锁
这样的同一条记录在数据库中存在多个版本,就是上面提到的多版本并发控制 MVCC。
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然undo log的节点可能被purge线程清除掉,比如图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)。
5.4.3 Read View(读视图)
Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。
Read View是用来做可见权限判断的, 即当某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。不是真实存在的,只是一个概念,undo log 才是它的体现。
数据版本的可见性规则:
Read view 中主要包含当前系统中还有哪些活跃的读写事务,在实现上 InnDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正活跃(还未提交)的事务。、
前面说了事务 ID 随时间严格递增的,把系统中已提交的事务 ID 的最大值记为数组的低水位,已创建过的事务 ID + 1记为高水位。
这个视图数组和高水位就组成了当前事务的一致性视图(read view):
规则如下:
- 1 如果 trx_id 在灰色区域,表明被访问版本的 trx_id 小于数组中低水位的 id 值,即生成该版本的事务在生成 read view 前已经提交,所以该版本可见,可以被当前事务访问。
- 2 如果 trx_id 在橙色区域,表明被访问版本的 trx_id 大于数组中高水位的 id 值,也即生成该版本的事务在生成 read view 后才生成,所以该版本不可见,不能被当前事务访问。
- 3 如果在绿色区域,就会有两种情况:
- a) trx_id 在数组中,证明这个版本是由还未提交的事务生成的,不可见
- b) trx_id 不在数组中,证明这个版本是由已提交的事务生成的,可见