Java那么多锁,能锁住灭霸吗?

Java中的锁有不少种,常常会听到“锁”这个词。面试

犹如天天出门时,🔑就是一种“锁”,拿不到🔑,就进不去了。算法

Java那么多种类的锁,都是按不一样标准来分类的。就像商店里的各类商品,能够按式样,也能够按颜色或者尺寸。编程

其实它们都是一种思想。安全

为何会有锁?

一个进程能够包含多个线程,那么多个线程就会有竞争资源的问题出现,为了互相不打架,就有了锁的概念了。 一个线程也能够本身完成任务,但就像一个小组能够互相配合、共同完成任务,比一我的要快不少是否是?bash

分类

整理个大图~多线程

其实这么多分类只是从特性、表现、实现方式等不一样的侧重点来讲的,不是绝对的分类,例如,不可重入锁和自旋锁,实际上是同一种锁。并发

要绕晕了是否是?下面就分别来讲说。函数

1. 悲观 Vs 乐观

林妹妹比较悲观,宝玉比较乐观~高并发

1.1 悲观锁

看名字便知,它是悲观的,老是想到最坏的状况。 锁也会悲观,它并非难过,它只是很谨慎,怕作错。性能

每次要读data的时候,老是以为其余人会修改数据,因此先加个🔐,让其余人不能改数据,再慢慢读~

要是你在写一篇日记,怕别人会偷看了,就加了个打开密码,别人必须拿到密码才能打开这篇文章。这就是悲观锁了。

应用: synchronized关键字和Lock的实现类都是悲观锁。

1.2 乐观锁

它很乐观,老是想着最好的状况。 它比较大条,不会太担忧。若是要发生,总会发生,若是不会发生,那就不会。为何要担忧那么多?

每次读data时,老是乐观地想没有其余人会同时修改数据,不用加锁,放心地读data。 但在更新的时候会判断一下在此期间别人有没有去更新这个数据。

就像和别人共同编辑一篇文章,你在编辑的时候别人也能够编辑,并且你以为别人不会改动到你写的部分,那就是乐观锁了。

事事无绝对,悲观也好乐观也好,没有绝对的悲观,也没有绝对的乐观。只是在这个当时,相信,仍是不相信。

1.3 悲观 Vs 乐观

类型 实现 使用场景 缺点
悲观锁 synchronized关键字和Lock的实现类 适合写操做多的场景,能够保证写操做时数据正确 若是该事务执行时间很长,影响系统的吞吐量
乐观锁 无锁编程,CAS算法 适合读操做多的场景,可以大幅提高其读操做的性能 若是有外来事务插入,那么就可能发生错误

1.4 应用

乐观锁 —— CAS(Compare and Swap 比较并交换)

是乐观锁的一种实现方式。

简单来讲,有3个三个操做数:

  • 须要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

2 公平 Vs 非公平

没有绝对的公平,也没有绝对的不公平。

公平,就是按顺序排队嘛。 公平锁维护了一个队列。要获取锁的线程来了都排队。

waitQueue.png

非公平,上来就想抢到锁,好像一个不讲道理的,抢不到的话,只好再去乖乖排队了。 非公平锁没有维护队列的开销,没有上下文切换的开销,可能致使不公平,可是性能比fair的好不少。看这个性能是对谁有利了。

举个栗子

3 可重入锁 Vs 不可重入锁

3.1 可重入锁

广义上的可重入锁,而不是单指JAVA下的ReentrantLock。

可重入锁,也叫作递归锁,指的是同一线程外层函数得到锁以后,内层递归函数仍然有获取该锁的代码,但不受影响。

这句话神马意思?

这种锁是能够反复进入的。

当一个线程执行到某个synchronized方法时,好比说method1,而在method1中会调用另一个synchronized方法method2,此时线程没必要从新去申请锁,而是能够直接执行方法method2。

class MyClass {
    public synchronized void method1() {
        enterNextRoom();
    }

    public synchronized void method2() {
        // todo
    }
}
复制代码

两个方法method1和method2都用synchronized修饰了。

假设某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而因为method2也是synchronized方法,假如synchronized不具有可重入性,此时线程A须要从新申请锁。可是这就会形成一个问题,由于线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。

若是不是可重入锁的话,method2可能不会被当前线程执行,可能形成死锁。

可重入锁最大的做用是避免死锁。

实现类:

  • synchronized
  • ReentrantLock

3.2 不可重入锁

按上面的例子,线程A从method1执行到method2的时候,不能直接获取到锁,要执行下去,必须先解锁。

实现不可重入锁有什么方式呢?那就是自旋~

(什么是自旋锁?等下详细说,先有个概念,就是当一个线程在获取锁的时候,若是锁已经被其它线程获取,那么该线程将循环等待)

  • 代码以下:
public class UnreentrantLock {

private AtomicReference<Thread> owner = new AtomicReference<Thread>();

public void lock() {
    Thread current = Thread.currentThread();
    //这句是很经典的“自旋”语法,AtomicInteger中也有
    for (;;) {
        if (!owner.compareAndSet(null, current)) {
            return;
        }
    }
}

    public void unlock() {
        Thread current = Thread.currentThread();
        owner.compareAndSet(current, null);
    }
}
复制代码

同一线程两次调用lock()方法,若是不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁。

4 自旋锁 & 自适应旋转锁

4.1 自旋锁

自旋锁就是,若是此时拿不到锁,它不立刻进入阻塞状态,而愿意等待一段时间。

相似于线程在那里作空循环,若是循环必定的次数还拿不到锁,那么它才会进入阻塞的状态,这个循环次数是能够人为指定的。

  • 栗子时间

有一天去全家买咖啡,服务员说真不巧,前面咖啡机坏了,如今正在修,要等10分钟喔,刚好没什么急事,那就等吧,坐到一边休息区等10分钟(其它什么事都没作)。介就是自旋锁~

那是否是有点浪费?若是你等了15分钟,还没修好,那你可能不肯意继续等下去了(15分钟就是设定的自旋等待的最大时间)

4.2 自适应旋转锁

上面说自旋锁循环的次数是人为指定的,而自适应旋转锁,厉害了,它不须要人为指定循环次数,它本身自己会判断要循环几回,并且每一个线程可能循环的次数也是不同的。

若是这个线程以前拿到过锁,或者常常拿到一个锁,那它本身判断下来再次拿到这个锁的几率很大,循环次数就大一些;若是这个线程以前没拿到过这个锁,那它就没把握了,怕消耗CPU,循环次数就小一点。

它解决的是“锁竞争时间不肯定”的问题,但也不必定它本身设定的必定合适。

  • 栗子时间

仍是前面去全家等咖啡的栗子吧~ 要是等到5分钟,还没修好,你目测10分钟里也修很差,就再也不等下去了(循环次数小);要是等了10分钟了,服务员说很是抱歉,快了快了,再1分钟就能够用了,你也还不急,都已经等了10分钟了,就多等一下子嘛(循环次数大)

4.3 怎么实现自旋锁?

简单实现以下:

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}
复制代码

lock()方法利用CAS,当第一个线程A获取锁的时候,可以成功获取到,不会进入while循环;

若是此时线程A没有释放锁,另外一个线程B又来获取锁,此时因为不知足CAS,因此就会进入while循环,不断判断是否知足CAS,直到A线程调用unlock方法释放了该锁,线程B才能获取锁。

  • 存在的问题
  1. 若是某个线程持有锁的时间过长,就会致使其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会形成CPU使用率极高。
  2. 自己没法保证公平性,即没法知足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
  3. 也没法保证可重入性。基于自旋锁,能够实现具有公平性和可重入性质的锁。

4.4 自旋锁 Vs 阻塞锁

自旋锁是不阻塞锁,可是它也会等一段时间,那和阻塞锁有什么区别?

  • 举栗子~

去一个热门饭店吃饭,到了门口一看,门口的座位坐满了人……这咋整……服务员说,您能够先拿个号~小票上扫个二维码,关注我们,轮到您了,服务号里就会有提示哒~(很熟悉是否是?) 而后你就先取了号去逛逛周围小店去了,等轮到你了,手机里收到一条服务提醒消息,到你啦~这时你再去,就能够进店了。

这就是阻塞的过程~

若是是自旋锁呢? 就是你本身其它事情都不作,等在那里,就像去超市排队结帐同样,你走开的话是没有人会通知你的,只能从新排队,须要本身时刻检查有没有排到(能不能访问到共享资源)。

插播一下:

阻塞或唤醒一个Java线程须要操做系统切换CPU状态来完成,这种状态转换须要耗费处理器时间。
复制代码
自旋锁 阻塞锁
改变线程状态? 不改变线程运行状态,一直处于用户态,即线程一直都是active的 改变线程运行状态,让线程进入阻塞状态进行等待
占用CPU? 占用CPU时间 不会占用CPU时间,不会致使 CPU占用率太高,但进入时间以及恢复时间都要比自旋锁略慢
适用场景 线程竞争不激烈,而且保持锁的时间段 竞争激烈的状况下 阻塞锁的性能要明显高于自旋锁

5 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

5.1 锁的状态

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

这四种状态都不是Java语言中的锁,而是Jvm为了提升锁的获取与释放效率而作的优化(使用synchronized时),它们会随着竞争的激烈而逐渐升级,而且是不可逆的升级。

  • 偏向锁 -> 轻量级锁 -> 重量级锁

P.S., 无锁,即没有锁~

若是一个方法原本就不涉及共享数据,那它天然就无须任何同步措施去保证正确性,所以会有一些代码天生就是线程安全的。

CAS算法 即compare and swap(比较与交换),就是有名的无锁算法。

5.2 各状态比较

状态 描述 优势 缺点 应用场景 适用场景
偏向锁 无实际竞争,让一个线程一直持有锁,在其余线程须要竞争锁的时候,再释放锁 加锁解锁不须要额外消耗 若是线程间存在竞争,会有撤销锁的消耗 只有一个线程进入临界区 适用于只有一个线程访问同步块场景。
轻量级 无实际竞争,多个线程交替使用锁;容许短期的锁竞争 竞争的线程不会阻塞 若是线程一直得不到锁,会一直自旋,消耗CPU 多个线程交替进入临界区 追求响应时间。同步块执行速度很是快。
重量级 有实际竞争,且锁竞争时间长 线程竞争不使用自旋,不消耗CPU 线程阻塞,响应时间长 多个线程同时进入临界区 追求吞吐量。同步块执行速度较长。
  • 来看栗子~

你常常去一家店坐在同一个位置吃饭,老板已经记住你啦,每次你去的时候,只要店里客人很少,老板都会给你留着那个座位,这个座位就是你的“偏向锁”,每次只有你这一个线程用。

有一天你去的时候,店里已经坐满了,你的位置也被别人坐了,你只能等着(进入竞争状态),这时那个座位就升级到“轻量级”了。

要是那个座位特别好(临窗风景最佳,能隔江赏月~)每次你到的时候,都有其余好几我的也要去抢那个位置,没坐到那个位置就不吃饭了>_< 那时那个座位就升级到“重量级”了。

5.3 主要区别

轻量级锁和重量级锁的重要区别是: 拿不到“锁”时,是否有线程调度和上下文切换的开销。

简单来讲:若是发现同步周期内都是不存在竞争,JVM会使用CAS操做来替代操做系统互斥量。这个优化就被叫作轻量级锁。

相比重量级锁,其加锁和解锁的开销会小不少。重量级锁的“重”,关键在于线程上下文切换的开销大。

6 独享 Vs 共享

共享 Vs 独享 图~ 是否是很形象? 😄

类型 描述 实现类
共享(读锁) 可被多个线程所持有,其余用户能够并发读取数据。若是事务T对数据A加上共享锁后,则其余事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。 ReentrantReadWriteLock里的读锁
独享(排他锁,写锁) 一次只能被一个线程所持有。若是事务T对数据A加上排它锁后,则其余事务不能再对A加任何类型的锁。得到排它锁的事务即能读数据又能修改数据。 synchronized、ReentrantLock、ReentrantReadWriteLock里的写锁

排它锁是悲观锁的一种实现。

问题来了:共享锁为何要加个“读锁”?

防止数据在被读取的时候被别的线程加上写锁。
复制代码

而独占锁的原理是:

若是有线程获取到锁,那么其它线程只能是获取锁失败,而后进入等待队列中等待被唤醒。
复制代码
  • 栗子来了

小组每一个礼拜都要各个成员共同填一份周报表格,要是每一个人打开的时候,能够加一个写锁,即你在写的时候,别人不能修改,这就是独享锁(写锁); 可是这份表格你们能够同时打开,看到表格内容(读取数据),正在改数据的人能够对这份表格加上共享锁,那这个锁就是共享锁。

6.1 共享锁的代码实现

共享锁的获取方法为acquireShared,源码为:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
复制代码

当返回值为大于等于0的时候方法结束说明得到成功获取锁,不然,代表获取同步状态失败即所引用的线程获取锁失败,会执行doAcquireShared方法.

6.2 获取独占锁方法

public final void acquire(int arg) {
  if (!tryAcquire(arg) && 
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}
复制代码
  • 解读:
  1. 尝试获取锁,这个方法须要实现类本身实现获取锁的逻辑,获取锁成功后则不执行后面加入等待队列的逻辑了;
  2. 若是尝试获取锁失败后,则执行 addWaiter(Node.EXCLUSIVE) 方法将当前线程封装成一个 Node 节点对象,并加入队列尾部;
  3. 把当前线程执行封装成 Node 节点后,继续执行 acquireQueued 的逻辑,该逻辑主要是判断当前节点的前置节点是不是头节点,来尝试获取锁,若是获取锁成功,则当前节点就会成为新的头节点,这也是获取锁的核心逻辑。

简单来讲 addWaiter(Node mode) 方法作了如下事情:

建立基于当前线程的独占式类型的节点; 利用 CAS 原子操做,将节点加入队尾。

锁的实现类

ReentrantLock

ReentrantLock 是一个独占/排他锁。

特性

  • 公平性:支持公平锁和非公平锁。默认使用了非公平锁。
  • 可重入
  • 可中断:相对于 synchronized,它是可中断的锁,可以对中断做出响应。
  • 超时机制:超时后不能得到锁,所以不会形成死锁。

ReentrantLock构造函数

提供了是否公平锁的初始化:

/**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * 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必须在finally控制块中进行解锁操做。

在资源竞争不激烈的情形下,性能稍微比synchronized差点点。可是当同步很是激烈的时候,synchronized的性能一会儿能降低好几十倍,而ReentrantLock确还能维持常态。

高并发量状况下使用ReentrantLock。

优势: 可必定程度避免死锁。

  • Semaphore
  • AtomicInteger、AtomicLong等

小总结

对Java的各类锁概念作了下整理,写了些本身的理解, 还有不少基础方面,好比Java的对象头、对象模型(都比较基础)、锁的优化、各种锁代码实现等,后续再补充下。 有不少公号有不少高水平的文章,须要理解和练习的有太多。

参考

  1. 关于Java锁机制面试官会怎么问,深入易懂
  2. 不可不说的Java“锁”事
  3. 深刻理解多线程
相关文章
相关标签/搜索