乐观锁与悲观锁

乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想,所以这里还是有必要讲解下。

悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。

下面我们看一个典型的例子,看它如何使用悲观锁来避免多线程同时对一个记录进行修改。

  1. public int updateEntrylong id){
  2. //(1)使用悲观锁获取指定记录
  3. EntryObject entry = query(「select from table1 where id = #{id} for
  4. update」, id);
  5. //(2)修改记录内容,根据计算修改 entry 记录的属性
  6. String name = generatorNameentry);
  7. entry.setNamename);
  8. ……
  9. //(3)update 操作
  10. int count = update(「update table1 set name=#{name}, age=#{age} where id
  11. =#{id}」, entry);
  12. return count
  13. }

对于如上代码,假设 updateEntry、query、update 方法都使用了事务切面的方法,并且事务传播性被设置为 required。执行 updateEntry 方法时如果上层调用方法里面没有开启事务,则会即时开启一个事务,然后执行代码(1)。代码(1)调用了 query 方法,其根据指定 id 从数据库里面查询出一个记录。由于事务传播性为 requried,所以执行 query 时没有开启新的事务,而是加入了 updateEntry 开启的事务,也就是在 updateEntry 方法执行完毕提交事务时,query 方法才会被提交,就是说记录的锁定会持续到 updateEntry 执行结束。

代码(2)则对获取的记录进行修改,代码(3)把修改的内容写回数据库,同样代码(3)的 update 方法也没有开启新的事务,而是加入了 updateEntry 的事务。也就是 updateEntry、query、update 方法共用同一个事务。

当多个线程同时调用 updateEntry 方法,并且传递的是同一个 id 时,只有一个线程执行代码(1)会成功,其他线程则会被阻塞,这是因为在同一时间只有一个线程可以获取对应记录的锁,在获取锁的线程释放锁前(updateEntry 执行完毕,提交事务前),其他线程必须等待,也就是在同一时间只有一个线程可以对该记录进行修改。

乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。具体来说,根据 update 返回的行数让用户决定如何去做。将上面的例子改为使用乐观锁的代码如下。

  1. public int updateEntrylong id){
  2. //(1)使用乐观锁获取指定记录
  3. EntryObject entry = query(「select from table1 where id = #{id}」, id);
  4. //(2)修改记录内容,version 字段不能被修改
  5. String name = generatorNameentry);
  6. entry.setNamename);
  7. ……
  8. //(3)update 操作
  9. int count = update(「update table1 set name=#{name}, age=#{age}, version=${versi
  10. on}+1 where id =#{id} and version=#{version}」, entry);
  11. return count
  12. }

在如上代码中,当多个线程调用 updateEntry 方法并且传递相同的 id 时,多个线程可以同时执行代码(1)获取 id 对应的记录并把记录放入线程本地栈里面,然后可以同时执行代码(2)对自己栈上的记录进行修改,多个线程修改后各自的 entry 里面的属性应该都不一样了。然后多个线程可以同时执行代码(3),代码(3)中的 update 语句的 where 条件里面加入了 version=#{version}条件,并且 set 语句中多了 version=${version}+1 表达式,该表达式的意思是,如果数据库里面 id =#{id} and version=#{version}的记录存在,则更新 version 的值为原来的值加 1,这有点 CAS 操作的意思。

假设多个线程同时执行 updateEntry 并传递相同的 id,那么它们执行代码(1)时获取的 Entry 是同一个,获取的 Entry 里面的 version 值都是相同的(这里假设 version=0)。当多个线程执行代码(3)时,由于 update 语句本身是原子性的,假如线程 A 执行 update 成功了,那么这时候 id 对应的记录的 version 值由原始 version 值变为了 1。其他线程执行代码(3)更新时发现数据库里面已经没有了 version=0 的语句,所以会返回影响行号 0。在业务上根据返回值为 0 就可以知道当前更新没有成功,那么接下来有两个做法,如果业务发现更新失败了,下面可以什么都不做,也可以选择重试,如果选择重试,则 updateEntry 的代码可以修改为如下。

  1. public boolean updateEntrylong id){
  2. boolean result = false
  3. int retryNum = 5
  4. whileretryNum>0){
  5. //(1.1)使用乐观锁获取指定记录
  6. EntryObject entry = query(「select from table1 where id = #{id}」, id);
  7. //(2.1)修改记录内容,version 字段不能被修改
  8. String name = generatorNameentry);
  9. entry.setNamename);
  10. 。。。。
  11. //(3.1)update 操作
  12. int count = update(「update table1 set name=#{name}, age=#{age}, version=${versi
  13. on}+1 where id =#{id} and version=#{version}」, entry);
  14. ifcount == 1){
  15. result = true
  16. break
  17. }
  18. retryNum--;
  19. }
  20. return result;
  21. }

如上代码使用 retryNum 设置更新失败后的重试次数,如果代码(3.1)执行后返回 0,则说明代码(1.1)获取的记录已经被修改了,则循环一次,重新通过代码(1.1)获取最新的数据,然后再次执行代码(3.1)尝试更新。这类似 CAS 的自旋操作,只是这里没有使用死循环,而是指定了尝试次数。

乐观锁并不会使用数据库提供的锁机制,一般在表中添加 version 字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。

公平锁与非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。

ReentrantLock 提供了公平和非公平锁的实现。

● 公平锁:ReentrantLock pairLock = new ReentrantLock(true)。

● 非公平锁:ReentrantLock pairLock = new ReentrantLock(false)。如果构造函数不传递参数,则默认是非公平锁。

例如,假设线程 A 已经持有了锁,这时候线程 B 请求该锁其将会被挂起。当线程 A 释放锁后,假如当前有线程 C 也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程 B 和线程 C 两者之一可能获取锁,这时候不需要任何其他干涉,而如果使用公平锁则需要把 C 挂起,让 B 获取当前锁。

在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

独占锁与共享锁

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。

独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

什么是可重入锁

当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(在高级篇中我们将知道,严格来说是有限次数)地进入被该锁锁住的代码。

下面看一个例子,看看在什么情况下会使用可重入锁。

  1. public class Hello{
  2. public synchronized void helloA(){
  3. System.out.println("hello");
  4. }
  5. public synchronized void helloB(){
  6. System.out.println("hello B");
  7. helloA();
  8. }
  9. }

在如上代码中,调用 helloB 方法前会先获取内置锁,然后打印输出。之后调用 helloA 方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被阻塞。

实际上,synchronized 内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。

但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加 +1,当释放锁后计数器值-1。当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被阻塞的线程会被唤醒来竞争获取该锁。

自旋锁

由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用-XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。