可重入锁、乐观锁、悲观锁

乐观锁和悲观锁

  • 乐观锁:使用无锁结构,无非是对发生冲突保有乐观态度,觉得大多数情况下冲突不会发生,一旦发生就采取重来一次的策略。
  • 悲观锁:则是对冲突采取悲观策略,认为冲突经常发生,所以在操作共享字段时,锁住资源独享操作。
    最终,都必须等前一件事情做完,才能接着做下一件事。
  • 乐观锁结构的程序逻辑往往显得复杂,那么它的好处是什么呢?
    乐观无锁结构在乐观情况下,可以让处理过程尽量并行,只在可能发生冲突的那一刻才用系统的原子指令锁住一个字长的内存写入,然后立即放开。加锁和解锁是原子的,这样就可以回避死锁的问题。同时也不会因为悲观锁锁住的指令过多(如果线程数多于核心数,就有可能在锁住的过程中发生线程挂起),而导致其它线程等待时间过长。

示例

悲观锁直接给对象加锁即可,不写示例了。
乐观锁示例 CAS实现

public class OptimisticLock {

    private AtomicInteger value = new AtomicInteger(0);

    private void increment() {
        for (; ; ) {
            int v = value.get();
            // CAS操作
            if (value.compareAndSet(v, v + 1)) {
                break;
            }
        }
    }

    public static void main(String[] args) {
        OptimisticLock obj = new OptimisticLock();
        Executor executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 2000; i++) {
            executor.execute(obj::increment);
        }
        System.out.println(obj.value);   // 正常情况输出2000
    }
}

CAS弊端
CAS会导致“ABA问题”。

CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

乐观锁的也可以通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。

数据库锁应用

乐观锁
假设数据库操作的并发非常少,多数情况下是没有并发的,更新是按照顺序执行的,少有的一些并发通过版本控制来防止脏数据的产生。具体过程为,在操作数据库数据的时候,对数据不加显式的锁,而是通过对数据的版本或者时间戳的对比来保证操作的有序性和正确性。一般是在更新数据之前,先获取这条记录的版本或者时间戳,在更新数据的时候,对比记录的版本或者时间戳,如果版本或者时间戳一样,则继续更新,如果不一样,则停止更新数据记录,这说明数据已经被其他线程或者其他客户端更新过了。这时候需要获取最新版本的数据,进行业务逻辑的操作,再次进行更新。

其伪代码如下:

int version = executeSql("select version from... where id = $id");
// process business logic
boolean succ = executeSql("update ... where id = $id and version = $version");
if (!succ) {
    // try again
}

乐观锁在同一时刻,只有一个更新请求会成功,其他的更新请求会失败,因此,适用于并发不高的场景,通常是在传统的行业里应用在 ERP 系统,防止多个操作员并发修改同一份数据。在某些互联网公司里,使用乐观锁在失败的时候再尝试多次更新,导致并发量始终上不去,是一个反模式。而且这种模式是应用层实现的,阻止不了其他程序对数据库数据的直接更新。
悲观锁
假设数据库操作的并发很多,多数情况下是有并发的,在更新数据之前对数据上锁,更新过程中防止任何其他的请求更新数据而产生脏数据,更新完成之后,再释放锁,这里的锁是数据库级别的锁。

通常使用数据库的 for update 语句来实现,代码如下:

executeSql("select ... where id = $id for update");
try {
    // process business logic
    commit();
} catch (Exception e) {
    rollback();
}

悲观锁是在数据库引擎层次实现的,它能够阻止所有的数据库操作。但是为了更新一条数据,需要提前对这条数据上锁,直到这条数据处理完成,事务提交,别的请求才能更新数据,因此,悲观锁的性能比较低下,但是由于它能够保证更新数据的强一致性,是最安全的处理数据库的方式,因此,有些账户、资金处理系统仍然使用这种方式,牺牲了性能,但是获得了安全,规避了资金风险。

行级锁
不是所有更新操作都要加显示锁的,数据库引擎本身有行级别的锁,本身在更新行数据的时候是有同步和互斥操作的,我们可以利用这个行级别的锁,控制锁的时间窗口最小,一次来保证高并发的场景下更新数据的有效性。

行级锁是数据库引擎中对记录更新的时候引擎本身上的锁,是数据库引擎的一部分,在数据库引擎更新一条数据的时候,本身就会对记录上锁,这时候即使有多个请求更新,也不会产生脏数据,行级锁的粒度非常细,上锁的时间窗口也最少,只有更新数据记录的那一刻,才会对记录上锁,因此,能大大减少数据库操作的冲突,发生锁冲突的概率最低,并发度也最高。

通常在扣减库存的场景下使用行级锁,这样可以通过数据库引擎本身对记录加锁的控制,保证数据库更新的安全性,并且通过 where 语句的条件,保证库存不会被减到0以下,也就是能够有效的控制超卖的场景,如下代码。

boolean result = executeSql("update ... set amount = amount - 1 where id = $id and amount > 1");
if (result) {   
    // process sucessful logic
} else { 
    // process failure logic
}

另外一种场景是在状态转换的时候使用行级锁,例如交易中,状态只能从 init 流转到 doing 状态,任何重复的从 init 到 doing 的流转,或者从 init 到 finished 等其他状态的流转都会失败,代码如下。

boolean result = executeSql("update ... set status = 'doing' where id = $id and status = 'init'");
if (result) {
    // process sucessful logic
} else {
    // process failure logic
}

行级锁的并发性较高,性能是最好的,适用于高并发下扣减库存和控制状态流转的方向的场景。

但是,有人说这种方法是不能保证幂等的,比如说,在扣减余额场景,多次提交可能会扣减多次,这确实是实际存在的,但是,我们是有应对方案的,我们可以记录扣减的历史,如果有非幂等的场景出现,通过记录的扣减历史来核对并矫正,这种方法也适用于账务历史等场景,代码如下。

boolean result = executeSql("update ... set amount = amount - 1 where id = $id and amount > 1");

if (result) {
    int amount = executeSql("select amount ... where id = $id");
    executeSql("insert into hist (pre_amount, post_amount) values ($amount + 1, $amount)");
    // process successful logic
} else {
    // process failure logic
}

可重入锁

此类锁最大的作用是避免死锁。
Java中被人熟知的synchronized和ReentrantLock都是可重入锁。
而ReentrantLock的实现不仅可以替代隐式的synchronized关键字,而且能够提供超过关键字本身的多种功能。不过目前JVM对synchronized关键字的优化已经很好了,以至于J.U.C的作者Doug Lea在改造Java8的ConcurrentHashMap时部分加锁代码直接用synchronized代替了原来Java7所用的ReentrantLock。

ReentrantLock公平锁和不公平锁

锁获取的公平性问题
如果在绝对时间上,先对锁进行获取的请求一定被先满足,那么这个锁是公平的,反之,是不公平的,也就是说等待时间最长的线程最有机会获取锁,也可以说锁的获取是有序的。ReentrantLock这个锁提供了一个构造函数,能够控制这个锁是否是公平的。
而锁的名字也是说明了这个锁具备了重复进入的可能,也就是说能够让当前线程多次的进行对锁的获取操作,这样的最大次数限制是Integer.MAX_VALUE,约21亿次左右。
事实上公平的锁机制往往没有非公平的效率高,因为公平的获取锁没有考虑到操作系统对线程的调度因素,这样造成JVM对于等待中的线程调度次序和操作系统对线程的调度之间的不匹配。对于锁的快速且重复的获取过程中,连续获取的概率是非常高的,而公平锁会压制这种情况,虽然公平性得以保障,但是响应比却下降了,但是并不是任何场景都是以TPS作为唯一指标的,因为公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
是否公平性设置

//定义成final型的成员变量,在构造方法中进行初始化 
private final Sync sync;
//无参数默认非公平锁
public ReentrantLock() 
{
    sync = new NonfairSync();
}
//根据参数初始化为公平锁或者非公平锁 
public ReentrantLock(boolean fair) 
{
    sync = fair ? new FairSync() : new NonfairSync();
}

二者的区别在于尝试获取获取锁的时候逻辑不同。

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {     // 注意这里
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

公平锁在获取锁之前会判断是否有先到的线程在等待该锁(避免饥饿),实现为:

public final boolean hasQueuedPredecessors() {
   // The correctness of this depends on head being initialized
   // before tail and on head.next being accurate if the current
   // thread is first in queue.
   Node t = tail; // Read fields in reverse initialization order
   Node h = head;
   Node s;
   return h != t &&
       ((s = h.next) == null || s.thread != Thread.currentThread());
}

而非公平锁直接进行强占。

    /**
    * Performs lock.  Try immediate barge(闯入), backing up to normal
    * acquire on failure.
    */
   final void lock() {
         if (compareAndSetState(0, 1))    // 注意这里
             setExclusiveOwnerThread(Thread.currentThread());
         else
             acquire(1);
   }

   protected final boolean tryAcquire(int acquires) {
       return nonfairTryAcquire(acquires);
   }

// tryLock 也会调用这个方法

final boolean nonfairTryAcquire(int acquires) {
       final Thread current = Thread.currentThread();
       int c = getState();
       if (c == 0) {
           if (compareAndSetState(0, acquires)) {
               setExclusiveOwnerThread(current);
               return true;
           }
       }
       else if (current == getExclusiveOwnerThread()) {
           int nextc = c + acquires;
           if (nextc < 0) // overflow
               throw new Error("Maximum lock count exceeded");
           setState(nextc);
           return true;
       }
       return false;
   }

你可能感兴趣的:(可重入锁、乐观锁、悲观锁)