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

乐观锁和悲观锁

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

示例

悲观锁直接给对象加锁即可,不写示例了。
乐观锁示例 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;
   }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容

  • 本文是我自己在秋招复习时的读书笔记,整理的知识点,也是为了防止忘记,尊重劳动成果,转载注明出处哦!如果你也喜欢,那...
    波波波先森阅读 11,150评论 4 56
  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,790评论 2 89
  • Java继承关系初始化顺序 父类的静态变量-->父类的静态代码块-->子类的静态变量-->子类的静态代码快-->父...
    第六象限阅读 2,081评论 0 9
  • 离开学校以后,很久不写文章了,其实学生时候也没有写文章的习惯,以至于到了现在,想提笔写点东西,发现竟然什么也写不出...
    yuzj阅读 141评论 0 0
  • 会有突然就讨厌自己的时候,收拾桌子的时候想掀桌,把东西肆意的丢掉。一股破坏欲油然而生,想毁掉些什么。 大概是求而不...
    小杏仁阅读 94评论 0 0