Java面试系列-Java基础-锁(原理篇)

298 阅读9分钟

概念

为保障资源的原子性操作而采用的相应机制。

程序中概念

锁,顾名思义,即在程序执行时将用到的资源上锁以避免其他资源抢占该资源。

数据库中概念

保证相同记录的原子性操作即ACID[原子性(atomicity,或称不可分割性)、一致性(consistency)、隔离性(isolation,又称独立性)、持久性(durability)]中A的概念。

实现方式为MVCC,利用隐藏字段及版本号,个人理解有点类似CAS.

乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败

悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block 直到拿到锁。Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

Synchronized 同步锁

synchronized 它可以把任意一个非NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

Synchronized 作用范围

  • 1.作用于方法时,锁住的是对象的实例(this);
    1. 当作用于静态方法时,锁住的是Class 实例,又因为Class 的相关数据存储在永久带PermGen(jdk1.8 则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
    1. synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

Synchronized 核心组件

    1. Wait Set:哪些调用wait 方法被阻塞的线程被放置在这里;
    1. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
    1. Entry List:Contention List 中那些有资格成为候选资源的线程被移动到Entry List 中;
    1. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
    1. Owner:当前已经获取到所资源的线程被称为Owner;
    1. !Owner:当前释放锁的线程。

Synchronized 实现

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用CPU做无用功,占着XX 不XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。所以这种情况下我们要关闭自旋锁;

自旋锁时间阈值(1.6 引入了适应性自旋锁)

自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!

JVM 对于自旋周期的选择,JDK1.5 这个限度是一定的写死的,在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM 还针对当前CPU 的负荷情况做了较多的优化,如果平均负载小于CPUs 则一直自旋,如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现Owner 发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果CPU 处于节电模式则停止自旋,自旋时间的最坏情况是CPU的存储延迟(CPU A 存储了一个数据,到CPU B 得知这个数据直接的时间差),自旋时会适当放弃线程优先级之间的差异。

自旋锁的开启

JDK1.6 中-XX:+UseSpinning 开启;

-XX:PreBlockSpin=10 为自旋次数;

JDK1.7 后,去掉此参数,由jvm 控制;

CAS 什么是CAS(比较并交换-乐观锁机制-锁自旋)

概念

CAS(Compare And Swap/Set)比较并交换,CAS 算法的过程是这样:它包含3 个参数CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当V 值等于E 值时,才会将V 的值设为N,如果V 值和E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前V 的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时使用CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

锁自旋

JDK1.5 的原子包:java.util.concurrent.atomic 这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM 从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。

相对于对于synchronized 这种阻塞算法,CAS 是非阻塞算法的一种常见实现。由于一般CPU 切 换时间比CPU 指令集操作更加长, 所以J.U.C 在性能上有了很大的提升。如下代码:

public class AtomicInteger extends Number implements java.io.Serializable {
    private volatile int value;
    public final int get() {
        return value;
    }

    public final int getAndIncrement() {
        for (;;) { //CAS 自旋,一直尝试,直达成功
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
    }

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
}

getAndIncrement采用了CAS 操作,每次从内存中读取数据然后将此数据和+1 后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。而compareAndSet利用JNI来完成CPU指令的操作。

image.png

ABA问题

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

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

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

其他锁形式

分布式锁

由于程序中锁的实现,一般解决的是当前主线程中运行的多个子线程资源共享时的原子性问题。而设计多用户操作、甚至跨语言时单独程序锁是不可能实现的,所以引入分布式锁以解决。

常见形式

  • Redis
    • 单机版 - C
    • 集群版 - AP
    • 数据安全强要求时,不宜采用集群版实现。
      • 由于Redis集群数据同步模式为gossip协议。
      • 主从同步问题也有问题。
      • 以上两个过程中不能保障从节点完全同步到位即如果主节点挂掉从节点可能没有完成锁同步。
  • ZooKeeper (CAP)
  • Etcd (CAP)

参考

blog.csdn.net/zqz_zqz/art…