Java中的乐观锁与悲观锁

原文章来自我的语雀知识库

什么是乐观锁和悲观锁?

乐观锁和悲观锁实际上是两种设计思想。

悲观锁的设计思想

悲观锁认为并发冲突的发生非常频繁,适合在写多读少的并发环境使用。在Java中,悲观锁的的实现有synchronized、ReentrantLock等。当某一线程独占悲观锁时,被加锁的代码段处于被锁定的状态,同一时间只有持有锁的线程才能执行锁块中的代码段,保证整个代码段是一个原子操作,而且锁块中的所有变量都具有内存可见性。但这存在一种弊端,其它获取不到锁的线程就会被阻塞挂起,这会导致上下文切换和重新调度开销。
为啥要这么设计呢?因为悲观锁假定并发冲突非常频繁,既然冲突非常频繁,那就有可能长时间竞争共享资源,这时候CPU资源是被浪费掉的,按ReentrantLock的实现,既然当前线程获取不到锁,那就将当前线程阻塞挂起并加入这个ReentrantLock实例的AQS阻塞队列中,等到锁被释放、该线程被唤醒之后再尝试获取锁。

乐观锁的设计思想

乐观锁认为并发冲突发生频率较低,而CAS就是乐观锁思想的一种具体实现。Java中的CAS操作是用Unfase类中的一系列CAS方法实现的,例如compareAndSwapLong方法。
boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update)
这个方法有四个操作数,分别为:对象在内存中的位置,要修改的这个long型变量在对象中的偏移量,long变量的预期值以及更新后的值。其操作含义是,如果对象obj中内存偏移量为valueOffset的变量的值为expect,则用update替换旧值expect。这从逻辑上实现了对这个变量的原子修改操作。
在实际使用这种CAS操作时,会在一个while循环中读取要修改的变量的旧值作为预期值,然后进行CAS操作,直到CAS操作成功位置。这个while循环的过程称为自旋。由于乐观锁假定并发冲突的频率低,所以也许只需要几次自旋就可以CAS操作成功,从而避免了悲观锁的上下文切换和重新调度开销。这相当于用少量的CPU资源为代价避免了线程阻塞带来的开销,当然了,如果实际上并发冲突非常多,自旋操作就会很多,这是一种无效循环,会导致CPU开销过大,所以说在并发冲突非常多的场景下,也许悲观锁的性能会优于乐观锁。

CAS操作的ABA问题

使用Unsafe类中的getAndAddInt方法来说明ABA问题

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            // 获取对象o中偏移量为offset的变量对应的volatile语义的值
            // 也就是从主内存中取到v的值
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }

可以看到,这个方法首先从主内存中获取要修改的变量的旧值,然后以这个旧值为预期值,试图将其修改为v+delta。如果不符合预期值,则重复上述过程(自旋)直到CAS操作成功。
这个方法存在什么样的问题呢?假设说,线程①从主内存中读取到值A之后,另一个线程②率先通过CAS将主内存中的这个变量从值A改成了值B,然后又马上把这个值从值B改回值A,这时候线程①才进行CAS操作,发现主内存中这个值确实等于预期值A,于是CAS操作也成功了,但实际上,这里主内存中的值A已经不是线程①之前读取的值A了。
也就是说,从逻辑上来看,线程①读取旧值的目的是为了确保他在修改值A时,值A没有被修改过,从而达成逻辑上的原子修改操作。但ABA问题,让线程①误以为值A没有被修改过,于是线程①误以为没有跟其他线程发生并发冲突。

怎么解决ABA问题呢?

方法①:增加一个版本号字段,之后以这个版本号为标准判断这个变量是否被改变过。CAS操作优化为先获取其版本号作为预期版本号,真正修改变量的值时,只有其版本号与预期版本号相同才去修改,修改变量的同时将版本号加一。由于版本号只能递增,所以可以避免ABA问题。
方法②:只允许单向地改变某个变量的值,例如只允许递增、或者只允许递减,这样也可以避免ABA问题。

ReentrantLock的lock、unlock方法解析

前置知识 LockSupport工具类

LockSupport工具类的主要作用是挂起和唤醒线程,改工具类是构建锁类的基础。
在默认情况下,调用LockSupport类的方法的线程不持有许可证,在某个线程不持有与LockSupport关联的许可证的情况下调用LockSupport的park方法时会被禁止参与线程调度,即阻塞挂起。如果某个线程持有许可证,则调用park方法后会立即返回。
在其他线程调用unpark(Thread thread)方法,如果thread不持有与LockSupport关联的许可证,则让其持有。如果thread因调用park方法阻塞,那么这个被阻塞的线程会被唤醒。

前置知识 AQS

ReentrantLock底层依赖AQS。
AQS全称抽象同步队列,简单来说,AQS是一个FIFO的双向队列,内部维护了head、tail节点,队列元素的类型为Node,Node中的thread变量保存了某个被放入AQS的线程。
AQS中维护了一个private volatile int state;,对ReentrantLock来说,state记录了锁的可重入次数。另外,对于CountDownlatch来说,state表示计数器当前的值,对于semaphore来说,state代表当前可用的信号的个数。
当某个线程成功获取ReentrantLock类型的锁后,实际上会用CAS操作将那个锁实例中的state状态值从0变为1,然后设置当前线程独占这个锁。如果发生重入的话,就增加这个state值。

ReentrantLock的lock方法

lock方法首先会尝试通过CAS操作将AQS的state从0变为1,然后设置锁为当前线程独占。如果CAS失败,则将当前线程封装为类型为Node.EXCLUSIVE的Node节点然后插入到AQS的阻塞队列的尾部,并调用LockSupport.park(this)方法挂起自己。

ReentrantLock的tryLock方法

        final boolean tryLock() {
            Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                // 如果CAS操作成功,直接返回true
                if (compareAndSetState(0, 1)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (getExclusiveOwnerThread() == current) {
                // 如果发生重入,则将state加1,然后直接返回
                if (++c < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(c);
                return true;
            }
            // 如果获取锁失败则直接返回false,所以这个方法不会导致线程阻塞
            return false;
        }

ReentrantLock的trylock方法

ReentrantLock的trylock方法调用了AQS的release(int arg)方法,其中arg参数为1。release方法中,首先调用ReentrantLock实现的tryRealease方法,然后调用LockSupport.unpark(Thread)唤醒AQS队列中的一个线程。
ReentrantLock实现的tryRealease方法实际上是将AQS中的volatile修饰的state状态量减1。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值