史上最全 Java 中各类锁的介绍

更多精彩原创内容请关注:JavaInterview,欢迎 star,支持鼓励如下做者,万分感谢。前端

锁的分类介绍

乐观锁与悲观锁

锁的一种宏观分类是乐观锁悲观锁。乐观锁与悲观锁并非特定的指哪一个锁(Java 中也没有那个具体锁的实现名就叫 乐观锁或悲观锁),而是在并发状况下两种不一样的策略。java

乐观锁(Optimistic Lock)就是很乐观,每次去拿数据的时候都认为别人不会修改。因此不会上锁。可是若是想要更新数据, 则会在更新以前检查在读取至更新这段时间别人有没有修改过这个数据。若是修改过,则从新读取,再次尝试更新,循环上述 步骤直到更新成功(固然也容许更新失败的线程放弃更新操做)。git

悲观锁(Pessimistic Lock)就是很悲观,每次去拿数据的时候都认为别人会修改。因此每次都在拿数据的时候上锁。 这样别人拿数据的时候就会被挡住,直到悲观锁释放,想获取数据的线程再去获取锁,而后再获取数据。github

悲观锁阻塞事务,乐观锁回滚重试,它们个有优缺点,没有好坏之分,只有适应场景的不一样区别。好比:乐观锁适合用于写 比较少的状况下,即冲突真的不多发生的场景,这样能够省去锁的开销,加大了系统的整个吞吐量。可是若是常常产生冲突,上层 应用会不断的进行重试,这样反而下降了性能,因此这种场景悲观锁比较合适。 总结:乐观锁适合写比较少,冲突不多发生的场景;而写多,冲突多的场景适合使用悲观锁算法

乐观锁的基础 --- CAS

在乐观锁的实现中,咱们必需要了解的一个概念:CAS。编程

什么是 CAS 呢? Compare-and-Swap,即比较并替换,或者比较并设置bash

  • 比较:读取到一个值 A,在将其更新为 B 以前,检查原值是否为 A(未被其它线程修改过,这里忽略 ABA 问题)。网络

  • 替换:若是是,更新 A 为 B,结束。若是不是,则不会更新。并发

上面两个步骤都是原子操做,能够理解为瞬间完成,在 CPU 看来就是一步操做。函数

有了 CAS,就能够实现一个乐观锁:

public class OptimisticLockSample{
    
    public void test(){
        int data = 123; // 共享数据
        
        // 更新数据的线程会进行以下操做
        for (;;) {
            int oldData = data;
            int newData = doSomething(oldData);
            
            // 下面是模拟 CAS 更新操做,尝试更新 data 的值
            if (data == oldData) { // compare
                data = newData; // swap
                break; // finish
            } else {
                // 什么都不作,循环重试
            }
        }   
    }
    
    /** * * 很明显,test() 里面的代码根本不是原子性的,只是展现了下 CAS 的流程。 * 由于真正的 CAS 利用了 CPU 指令。 * * */ 
    

}

复制代码

在 Java 中也是经过 native 方法实现的 CAS。

public final class Unsafe {
    
    ...
    
    public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
    
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    
    public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);  
    
    ...
} 


复制代码

上面写了一个简单直观的乐观锁(确切的来讲应该是乐观锁流程)的实现,它容许多个线程同时读取(由于根本没有加锁操做),若是更新数据的话, 有且仅有一个线程能够成功更新数据,并致使其它线程须要回滚重试。CAS 利用 CPU 指令,从硬件层面保证了原子性,以达到相似于锁的效果。

从乐观锁的整个流程中能够看出,并无加锁解锁的操做,所以乐观锁策略也被称做为无锁编程。换句话说,乐观锁其实不是"锁", 它仅仅是一个循环重试的 CAS 算法而已。

自旋锁

synchronized 与 Lock interface

Java 中两种实现加锁的方式:一种是使用 synchronized 关键字,另外一种是使用 Lock 接口的实现类。

在一篇文章中看到一个好的对比,很是形象,synchronized 关键字就像是自动挡,能够知足一切的驾驶需求。 可是若是你想要作更高级的操做,好比玩漂移或者各类高级的骚操做,那么就须要手动挡,也就是 Lock 接口的实现类。

而 synchronized 在通过 Java 每一个版本的各类优化后,效率也变得很高了。只是使用起来没有 Lock 接口的实现类那么方便。

synchronized 锁升级过程就是其优化的核心:偏向锁 -> 轻量级锁 -> 重量级锁

class Test{
    private static final Object object = new Object(); 
    
    public void test(){
        synchronized(object) {
            // do something 
        }   
    }
    
}

复制代码

使用 synchronized 关键字锁住某个代码块的时候,一开始锁对象(就是上述代码中的 object)并非重量级锁,而是偏向锁。 偏向锁的字面意思就是"偏向于第一个获取它的线程"的锁。线程执行完同步代码块以后,并不会主动释放偏向锁。当第二次到达同步 代码块时,线程会判断此时持有锁的线程是否就是本身(持有锁的线程 ID 在对象头里存储),若是是则正常往下执行。因为以前没有释放, 这里就不须要从新加锁,若是从头至尾都是一个线程在使用锁,很明显偏向锁几乎没有额外开销,性能极高。

一旦有第二个线程加入锁竞争,偏向锁转换为轻量级锁自旋锁)。锁竞争:若是多个线程轮流获取一个锁,可是每次获取的时候 都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程获取锁的时候,发现锁已经被占用,须要等待其释放,则说明发生了锁竞争。

在轻量级锁状态上继续锁竞争,没有抢到锁的线程进行自旋操做,即在一个循环中不停判断是否能够获取锁。获取锁的操做,就是经过 CAS 操 做修改对象头里的锁标志位。先比较当前锁标志位是否为释放状态,若是是,将其设置为锁定状态,比较并设置是原子性操做,这个 是 JVM 层面保证的。当前线程就算持有了锁,而后线程将当前锁的持有者信息改成本身。

假如咱们获取到锁的线程操做时间很长,好比会进行复杂的计算,数据量很大的网络传输等;那么其它等待锁的线程就会进入长时间的自旋操做,这个 过程是很是耗资源的。其实这时候至关于只有一个线程在有效地工做,其它的线程什么都干不了,在白白地消耗 CPU,这种现象叫作忙等 (busy-waiting)。因此若是多个线程使用独占锁,可是没有发生锁竞争,或者发生了很轻微的锁竞争,那么 synchronized 就是轻量 级锁,容许短期的忙等现象。这是一种择中的想法,短期的忙等,换取线程在用户态和内核态之间切换的开销

显然,忙等是有限度的(JVM 有一个计数器记录自旋次数,默认容许循环 10 次,能够经过虚拟机参数更改)。若是锁竞争状况严重, 达到某个最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是经过 CAS 修改锁标志位,但不修改持有锁的线程 ID)。当后续线程尝试获取 锁时,发现被占用的锁是重量级锁,则直接将本身挂起(而不是上面说的忙等,即不会自旋),等待释放锁的线程去唤醒。在 JDK1.6 以前, synchronized 直接加剧量级锁,很明显如今经过一系列的优化事后,性能明显获得了提高。

JVM 中,synchronized 锁只能按照偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有把这个称为锁膨胀的过程),不容许降级。

可重入锁(递归锁)

可重入锁的字面意思是"能够从新进入的锁",即容许同一个线程屡次获取同一把锁。好比一个递归函数里有加锁操做,递归函数里这个锁会阻塞本身么? 若是不会,那么这个锁就叫可重入锁(由于这个缘由可重入锁也叫作递归锁)。

Java 中以 Reentrant 开头命名的锁都是可重入锁,并且 JDK 提供的全部现成 Lock 的实现类,包括 synchronized 关键字锁都是可重入的。 若是真的须要不可重入锁,那么就须要本身去实现了,获取去网上搜索一下,有不少,本身实现起来也很简单。

若是不是可重入锁,在递归函数中就会形成死锁,因此 Java 中的锁基本都是可重入锁,不可重入锁的意义不是很大,我暂时没有想到什么场景下会用到; 注意:有想到须要不可重入锁场景的小伙伴们能够留言一块儿探讨

下图展现一下 Lock 的相关实现类:

ilock.png

公平锁和非公平锁

若是多个线程申请一把公平锁,那么得到锁的线程释放锁的时候,先申请的先获得,很公平。若是是非公平锁,后申请的线程可能先得到锁,是 随机获取仍是其它方式,都是根据实现算法而定的。

对 ReentrantLock 类来讲,经过构造函数能够指定该锁是不是公平锁,默认是非公平锁。由于在大多数状况下,非公平锁的吞吐量比公平锁的大, 若是没有特殊要求,优先考虑使用非公平锁。

而对于 synchronized 锁而言,它只能是一种非公平锁,没有任何方式使其变成公平锁。这也是 ReentrantLock 相对于 synchronized 锁的一个 优势,更加的灵活。

如下是 ReentrantLock 构造器代码:

/** * Creates an instance of {@code ReentrantLock} with the * given fairness policy. * * @param fair {@code true} if this lock should use a fair ordering policy */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

复制代码

ReentrantLock 内部实现了 FairSync 和 NonfairSync 两个内部类来实现公平锁和非公平锁。具体源码分析会在接下来的章节给出,敬请关注 该项目,欢迎 forkstar

可中断锁

字面意思是"能够响应中断的锁"。

首先,咱们须要理解的是什么是中断。 Java 中并无提供任何能够直接中断线程的方法,只提供了中断机制。那么何为中断机制呢? 线程 A 向线程 B 发出"请你中止运行"的请求,就是调用 Thread.interrupt() 的方法(固然线程 B 自己也能够给本身发送中断请求, 即 Thread.currentThread().interrupt()),但线程 B 并不会当即中止运行,而是自行选择在合适的时间点以本身的方式响应中断,也能够 直接忽略此中断。也就是说,Java 的中断不能直接终止线程,只是设置了状态为响应中断的状态,须要被中断的线程本身决定怎么处理。这就像 在读书的时候,老师在晚自习时叫学生本身复习功课,但学生是否复习功课,怎么复习功课则彻底取决于学生本身。

回到锁的分析上来,若是线程 A 持有锁,线程 B 等待持获取该锁。因为线程 A 持有锁的时间过长,线程 B 不想继续等了,咱们可让线程 B 中断 本身或者在别的线程里面中断 B,这种就是 可中段锁

在 Java 中, synchronized 锁是不可中断锁,而 Lock 的实现类都是 可中断锁。从而能够看出 JDK 本身实现的 Lock 锁更加的 灵活,这也就是有了 synchronized 锁后,为何还要实现那么些 Lock 的实现类。

Lock 接口的相关定义:

public interface Lock {

    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();
    
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    
    void unlock();

    Condition newCondition();
}

复制代码

其中 lockInterruptibly 就是获取可中断锁。

共享锁

字面意思是多个线程能够共享一个锁。通常用共享锁都是在读数据的时候,好比咱们能够容许 10 个线程同时读取一份共享数据,这时候咱们 能够设置一个有 10 个凭证的共享锁。

在 Java 中,也有具体的共享锁实现类,好比 Semaphore。 该类的源码分析会在后续章节进行分析,敬请关注该项目,欢迎 forkstar

互斥锁

字面意思是线程之间互相排斥的锁,也就是代表锁只能被一个线程拥有。

在 Java 中, ReentrantLock、synchronized 锁都是互斥锁。

读写锁

读写锁实际上是一对锁,一个读锁(共享锁)和一个写锁(互斥锁、排他锁)。

在 Java 中, ReadWriteLock 接口只规定了两个方法,一个返回读锁,一个返回写锁。

public interface ReadWriteLock {
    /** * Returns the lock used for reading. * * @return the lock used for reading */
    Lock readLock();

    /** * Returns the lock used for writing. * * @return the lock used for writing */
    Lock writeLock();
}

复制代码

文章前面讲过[乐观锁策略](#乐观锁的基础 --- CAS),全部线程能够随时读,仅在写以前判断值有没有被更改。

读写锁其实作的事情是同样的,可是策略稍有不一样。不少状况下,线程知道本身读取数据后,是不是为了更改它。那么为什么不在加锁的时候直接明确 这一点呢?若是我读取值是为了更新它(SQL 的 for update 就是这个意思),那么加锁的时候直接加写锁,我持有写锁的时候,别的线程 不管是读仍是写都须要等待;若是读取数据仅仅是为了前端展现,那么加锁时就明确加一个读锁,其它线程若是也要加读锁,不须要等待,能够 直接获取(读锁计数器加 1)。

虽然读写锁感受与乐观锁有点像,可是读写锁是悲观锁策略。由于读写锁并无在更新前判断值有没有被修改过,而是在加锁前决定 应该用读锁仍是写锁。乐观锁特指无锁编程。

JDK 内部提供了一个惟一一个 ReadWriteLock 接口实现类是 ReentrantReadWriteLock。经过名字能够看到该锁提供了读写锁,而且也是 可重入锁。

总结

Java 中使用的各类锁基本都是悲观锁,那么 Java 中有乐观锁么?结果是确定的,那就是 java.util.concurrent.atomic 下面的 原子类都是经过乐观锁实现的。以下:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

复制代码

经过上述源码能够发现,在一个循环里面不断 CAS,直到成功为止。

参数介绍

-XX:-UseBiasedLocking=false 关闭偏向锁

JDK1.6 

-XX:+UseSpinning 开启自旋锁

-XX:PreBlockSpin=10 设置自旋次数 

JDK1.7 以后 去掉此参数,由 JVM 控制


复制代码
相关文章
相关标签/搜索