锁系列:一、悲观 / 乐观锁原理与运用

一、乐观锁:

不会对资源加锁,只是更新共享资源时,判断是否允许更新。

1.1 CAS(Compare and Swap)思想:

它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。

如果内存位置的值与预期原值相等,则将该位置的值更新为新值,否则不做任何操作

1.1.1 使用 Unsafe.compareAndSwapObject() 保证线程安全用例

如下的用例中 UNSAFE.compareAndSwapObject(unsafeCASTest, I_OFFSET, unsafeCASTest.i, unsafeCASTest.i + 1); 完成了多线程安全的 i+1 操作。

/**
 * @Author Snail
 * @Describe 通过Unsafe对多个线程操作i++实现线程安全问题
 * @CreateTime 2020/3/11
 */
public class UnsafeCASTest {
    private int i = 0;
    private static Unsafe UNSAFE;
    static long I_OFFSET;//i的偏移量

    static {
        try {
            //该方法无法获取到UNSAFE类,需要通过下面的反射去获取
//            UNSAFE = Unsafe.getUnsafe();
            // 获取 Unsafe 内部的私有的实例化单例对象
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            // 无视权限
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);

            I_OFFSET = UNSAFE.objectFieldOffset(UnsafeCASTest.class.getDeclaredField("i"));
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    @Test
    public void main() {
        UnsafeCASTest unsafeCASTest = new UnsafeCASTest();
        new Thread(new ThreadAdd(unsafeCASTest)).start();
        new Thread(new ThreadAdd(unsafeCASTest)).start();

        new Scanner(System.in).nextLine();//bio阻塞当前执行线程
    }

    class ThreadAdd implements Runnable {

        private final UnsafeCASTest unsafeCASTest;

        public ThreadAdd(UnsafeCASTest unsafeCASTest) {
            this.unsafeCASTest=unsafeCASTest;
        }

        @Override
        public void run() {
            while (true) {
//                i++;
                boolean b = UNSAFE.compareAndSwapObject(unsafeCASTest, I_OFFSET, unsafeCASTest.i, unsafeCASTest.i + 1); 
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "::" + i);
            }

        }
    }
}


1.1.2 Java基于 CAS 提供的操作类

java.util.concurrent.atomic 包下的 AtomicInteger、AtomicBoolean、AtomicLong 等,使用了 CAS 机制,保证了在多线程环境下,操作同一个变量的安全性。

//关于AtomicInteger的一个简单用例
        AtomicInteger atomicInteger=new AtomicInteger(0);
        int i = atomicInteger.addAndGet(2);
        System.out.println(i);//2
        System.out.println(atomicInteger);//2
        int andAdd = atomicInteger.getAndAdd(3);
        System.out.println(andAdd);//2
        System.out.println(atomicInteger);//5

CAS的思考:

Q1:CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?

A:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

Q2:CAS 有那些缺点?
A:

1. ABA问题

2. 高竞争环境下,自旋对CPU资源的消耗

3. 不够灵活,只能保证一个共享变量的原子操作,涉及到多个变量的同步时,CAS无法保证安全

Q3:如何解决 CAS 的缺点?
A:

1.ABA 问题的出现,是由于对变量的版本缺少了比较,所以我们可以使用 AtomicStampedReference(V initialRef,int initalStamp) ,加入了版本号对比,避免出现 ABA 问题

2. 控制自旋的次数:JDK 6后引入了自适应型自旋锁,同时也可以在代码中控制自旋的时间或次数。

1.2 版本号控制:

   当要提交一个更新的时候,比较读取时候的版本号,如果版本号一致,允许提交更新,否则返回失败。多用于 DB 的数据安全控制,如MVCC机制。


二、悲观锁:

开始操作时,就对共享资源加锁,操作完成后再释放锁。

2.1 Lock(ReentrantLock)与 Synchronized 的对比

类别synchronizedLock
存在层次Java的关键字,在jvm层面上是一个类
锁的获取关键字即可完成加锁lock.lock() , lock.tryLock() , lock.tryLock(timeout, unit)
锁的释放1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 (在其底层原理可以看到)必须在finally中释放锁,不然容易造成线程死锁
锁当前状态无法判断(可能会出现A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 )可以判断(trylock,避免长时间等待)
锁类型可重入、不可中断、非公平可重入、可中断、可公平(初始化时传入布尔值)
唤醒notify()/notifyAll()随机唤醒一个线程或唤醒全部线程使用 Condition 结合 await()/singal() 实现线程的精确唤醒
适用场景少量同步竞争大量同步竞争

2.2 ReentrantLock

Lock 接口及其特点:
在这里插入图片描述
1. 可重入锁

   同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程等待锁而出现卡死的情况。但需要开发人员保证加锁和解锁的操作成对出现。

   重入锁源码中,通过 private volatile int state 变量来记录重入次数。state==0 时,表示锁是空闲的(任意线程都可以加锁成功),大于零表示锁已经被占用, 其数值表示当前线程重复占用这个锁的次数。

java.util.concurrent.locks.ReentrantLock.NonfairSync#lock

final void lock() {
 // compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
    //如果修改不成功,说明 有线程 已经在使用了这个锁,那么就 可能 需要等待
        acquire(1);
}


java.util.concurrent.locks.AbstractQueuedSynchronizer#acquire

public final void acquire(int arg) {
 //tryAcquire() 再次尝试获取锁,
 //如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
 //同时宣布获得所成功,这正是重入的关键所在
 if (!tryAcquire(arg) &&
     // 如果获取失败,那么就在这里入队等待
     acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
     //如果在等待过程中 被中断了,那么重新把中断标志位设置上
     selfInterrupt();
}

2. 公平锁

   ReentrantLock 默认非公平锁,可在实例化时传入 boolean 值决定锁的类型。

   tryAcquire 是获取锁的一个操作,在公平锁的实现中,按进入锁队列的先后顺序为线程分配锁,先进入的线程优先获取锁。

java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire

 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;
 }

3. 可中断锁

   在等待锁的过程中可以响应中断,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况

   可中断锁的使用: lock.lockInterruptibly() 或者 lock.tryLock(timeout, unit) throws InterruptedException

关于Lock和synchronized的比较可参考

关于使用lock中的方法用例

2.2.1 ReentrantReadWriteLock

   ReentrantReadWriteLock 中包含读锁和写锁,其中读锁是可以多线程共享的,即共享锁,而写锁是排他锁,一把排他锁不予许与额外的锁同时存在。

ReentrantLock 与 ReentrantReadWriteLock 的区别:

   1. ReentrantReadWriteLock 细分读锁和写锁是为了提高效率,将读和写分离,对比 ReentrantLock 可以发现,无论并发读还是写,它总会先锁住全部再说。
   2. ReentrantReadWriteLock 进一步提高了多线程锁竞争环境下,代码的吞吐量。

2.2.2 底层的实现

   ReentrantLock:主要使用 AbstractQueuedSynchronizer(AQS)中的 volatile 修饰的同步状态位state、CAS 修改 state 和 CLH 的线程队列实现的一种独占锁。

Lock的更多分析与介绍

2.3 Synchronized

2.3.1 JDK 6 后对 Synchronized 的优化

   Synchronized 是从 JVM 层面为我们提供的单机锁,当线程访问同步块时首先需要获得锁并把相关信息存储在对象头中,对象头主要有由两部分组成:

  1. Mark Word :存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
  2. Klass Pointer:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
    在这里插入图片描述

Mark Word在不同的锁状态下存储的内容不同,64 位虚拟机 Mark Word 是 64bit ,其结构如下:
在这里插入图片描述

  • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 分代年龄(age):表示对象在 YoungGeneration 被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。4bit表示最大GC次数为1111(二进制)=15次。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

从 Mark Word 中可以看到,锁有3种状态:

1. 偏向锁:减少同一线程多次获取同一个锁的性能消耗

   检查 Mark Word 中的线程 id 是否为本线程,是则加锁成功,否则撤销偏向锁

   适用带有同步但只有一个线程处理的程序性能,但如果存在竞争会带来额外的锁撤销操作。

   偏向锁在 JDK 6 及以后的 JVM 里是默认启用的。可以通过参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

2. 轻量级锁:多个线程竞争偏向锁升级为轻量级锁

   是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

   在 JVM 中的实现是:在当前线程的栈帧中创建 Lock Reocrd,并将对象头中的 Mark Word 复制到 Lock Reocrd 中。然后使用 CAS 尝试将 Mark Word 替换为指向 Lock Reocrd 的指针。

   适用多线程交替执行同步块的情况,但如果多个线程同时 抢占锁 就会升级为重量级锁

3. 重量级锁:将除了拥有锁的线程以外的线程都阻塞

锁状态升级总结:

  1. 上述的3种锁状态,只能升级不能降级
  2. 偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞

除了上述对锁状态的优化,还有如下的一些处理:

自适应性自旋锁:为避免线程间的频繁切换,JVM按情况给出适合的自旋次数

   自旋是一种获取锁的机制,它会占用 CPU 的时间。

   自旋避免了线程被挂起,提高了线程的响应速度,但如果线程长期无法获取锁,自旋时间过长时,会升级为重量级锁。

锁消除:对检测到不可能存在共享数据竞争的锁进行消除

锁粗化:当一连串的操作都在对同一个对象加锁时,JVM会扩大锁范围

参考资料:
更多锁优化信息的详细介绍
美团-锁特性的对比
小米-synchronized的分析

底层的实现

   synchronized 为重量级锁时,没有竞争到锁的对象会 park 被挂起,退出同步块时 unpark 唤醒后续线程

   ObjectMonitor 为其底层使用的对象,包含两个同步队列,一个阻塞队列。多线程竞争时,不断的从同步队列中取任务来执行

适用场景

   乐观锁适用于线程间竞争较少的情况,因为这种场景下,乐观锁可以通过重试的机制,去避免了线程的阻塞和加锁操作。

   悲观锁适用线程间竞争较多的情况,因为这种场景下,乐观锁的大量重试,会导致 CPU 被占用,故而可以使用悲观锁提前锁住资源,会有更高的效率。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值