Java怎么有那么多锁……

Java中的锁有很多种,经常会听到“锁”这个词。

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

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

其实它们都是一种思想。

为什么会有锁?

一个进程可以包含多个线程,那么多个线程就会有竞争资源的问题出现,为了互相不打架,就有了锁的概念了。 一个线程也可以自己完成任务,但就像一个小组可以互相配合、共同完成任务,比一个人要快很多是不是?

分类

整理个大图~

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

要绕晕了是不是?下面就分别来说说。

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 非公平

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

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

非公平,上来就想抢到锁,好像一个不讲道理的,抢不到的话,只好再去乖乖排队了。 非公平锁没有维护队列的开销,没有上下文切换的开销,可能导致不公平,但是性能比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. 深入理解多线程

转载于:https://juejin.im/post/5cf4d520f265da1b6a347ed1

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值