掘金 后端 ( ) • 2024-04-01 23:00

分享是最有效的学习方式。
博客:https://blog.ktdaddy.com/

故事

地铁上,小帅双目空洞地望着窗外...绝望,发自内心地感到绝望...

距离失业已经过去两个月了,这是小帅接到的第四次面试邀请。“回去等通知吧...”,简简单单的六个字,把小帅的心再次打入了冰窖。

上次“【ThreadLocal问出花】”,小帅其实也有吸取教训得,这次对于多线程的问题还是做了很多准备的...可是没想到这次的结果居然也还是这样。

“Java中的锁了解吧?介绍一下吧”,面试官不紧不慢地问到。

“乐观锁、悲观锁、公平锁、非公平锁,然后平时咱们的synchronized是基于.....”小帅把知道的所有关于锁的基本都回答了一遍。

面试官对他笑了笑,“就这些吗?还有呢?比如自旋锁、可重入锁、独占锁....并且说一下你的理解,或者聊一下使用场景的优劣吧。”

“额.....以前好像看到过...”小帅语无伦次地回答到。

“嗯,行吧,之前的那些答得可以的,不过一会我这边有个会,要不今天咱们就聊到这里?回去等通知吧...”

Java中让人眼花缭乱的锁你是否真的一一清楚了?

试问这样一个大而宽的问题,大家能够总结全吗,如果让各位来回答,能否回答完全呢?

我们在实际的并发编程中,常常遇到多个线程访问一个共享变量的情况,当同时对共享变量进行读写操作的时候,就会产生数据不一致的情况。为了保证资源获取的有序性,我们就常常会用到并发锁。

那么接下来咱们就来聊聊这些Java并发锁的理解吧。我们将从以下这些方面来一起回顾一下Java中的并发锁。 00.png

乐观锁和悲观锁:线程是否锁住同步资源

大家其实对乐观锁和悲观锁听说的比较多一些,所以咱们就先来聊聊这两种类型的锁。这两种类型的锁,本质区分是要看线程是否锁住同步资源。

先来看一下悲观锁。悲观锁就是每次去拿数据的时候都会认为别人会修改数据,所以在读取数据的时候都会上锁。这样就会导致线程临时阻塞。

01.png

再来看一下乐观锁,乐观锁就是每次在拿数据的时候都假设别人不会修改数据,所以都不会进行上锁;只有在更新数据的时候才去判断之前有没有别的线程更新了这条数据。如果没有更新,那么当前线程会自己修改数据并且写入成功。如果数据已经被其他线程更新了,那么会报错或者自动重试,例如下图。

02.png

上述两种锁,并没有优劣之分。只是看相关的场景然后分别去使用。

乐观锁:适用于写少读多的场景。因为不用上锁,释放锁,省去了锁的开销,从而提升了吞吐量。

悲观锁:适用于写多读少的场景。因为线程竞争激烈,如果使用乐观锁会导致线程不断进行重试,反而降低吞吐量。

共享锁和独占锁:多个线程是否共享同一把锁

并发场景下,如果多个线程能够共享一把锁,那么就是所谓的共享锁,如果不能,那么则为独占锁(其他命名:排他锁或者独享锁)。

共享锁指锁可以被多个线程持有。如果一个线程对数据加上共享锁,那么其他线程只能对数据再加共享锁,不能加独占锁。另外的共享锁的线程只能读数据,不能修改数据。如下图。

03.png

独占锁是指锁一次只能被一个线程持有,如果一个线程对数据加上独占锁,那么其他的线程则不能对该数据再加任何类型的锁。如果一个线程获取独占锁,那么则该线程既可以读数据又可以修改数据。

04.png

对于独占锁来说,大家比较熟悉的就是synchronized和J.U.C包中的Lock实现类。

大家可能也听说过互斥锁,其实互斥锁就是独占锁的一种常规实现。

读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。

读锁可以再没有写锁的时候被多个线程同时持有,而写锁是独占的,于此同时写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁更新的内容。

读写锁和互斥锁对比,其性能更高,每次只有一个写线程,但是有多个线程可以并发读。

05.png

例如,ReentrantReadWriteLock。具体伪代码如下:

import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
 * 公众号:程序员老猫
 **/
public class ReadWriteLockDemo {

    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void readData() {
        lock.readLock().lock(); // 获取读锁
        try {
            // 读取共享数据
        } finally {
            lock.readLock().unlock(); // 释放读锁
        }
    }

    public void writeData() {
        lock.writeLock().lock(); // 获取写锁
        try {
           // 修改或写入数据
        } finally {
            lock.writeLock().unlock(); // 释放写锁
        }
    }
}

公平锁和非公平锁:多线程竞争时是否要排队

我们根据多线程在竞争锁的时候是否需要排队从来判断其锁的类型是公平锁还是非公平锁。

公平锁指多个线程按照申请锁的顺序来获取锁。类似食堂排队打饭,先到的可以先打饭。

06.png

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序进行的,有可能后申请的比先申请的优先获得锁,高并发场景下,优先级就有可能发生反转。如下图:

07.png

咱们在日常开发的过程中经常用到synchronized,其底层其实就是非公平锁。当然如果我们要使用公平锁的情况下,我们也可以使用ReentrantLock。 伪代码如下:

Lock lock = new ReetrantLock(false);

ReentrantLock默认为非公平锁,设置为true的时候表示公平锁。当设置为false的时候表示非公平锁。

可重入锁和不可重入锁:同一个线程中多个流程是否能够获取同一把锁。

如果一个线程中的多个流程能够获取同一把锁,那么我们就叫该所为可重入锁,反之则为不可重入锁。咱们光看文字描述的话可能比较抽象。我们看一下下图。

08.png

在Java中可重入锁一般有ReentrantLock,其命名就已经很明确了。另外的synchronized也是可重入锁。 可重入锁的优势是可以一定程度上避免死锁发生。上面的示意图转换为如下demo:

public synchronized void methodA() {
  methodB()
}

public synchronized void methodB() {
  methodC()
}

public synchronized void methodC(){
  doSomeThing()
}

自旋锁或者自适应自旋锁:线程锁定同步资源失败,如该线程没有被阻塞场景下发生

如果一个线程锁住同步资源失败,但是又希望这个线程不被阻塞,那么此时咱们就可以使用自旋锁或者自适应自旋锁。 自旋锁指线程没有获得锁的情况下不被挂起,而是执行一个忙循环。那么这个忙循环的话就成为自旋。如下:

09.png

目的:减少线程被挂起的概率,因为线程被挂起和唤醒也是消费资源。

Java中AtomicInteger类就有自旋的操作,如下源代码:

@HotSpotIntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }

上述方法中weakCompareAndSetInt(),就可以被称为是CAS操作,如果失败,那么会一直循环获取当前的value值然后进行重试操作。那么这个过程其实就是自旋了。

其他分类的锁。

上述我们聊到的这系列的锁应该是大家听到比较多的。其实还有其他的分类。在此不做一一展开了,有兴趣的小伙伴当然也可以深入去了解一下。 例如根据线程竞争同步资源的时候,细节流程是否发生变化,分为偏向锁、轻量级锁和重量级锁。 在比如,相信大家对HashMap底层原理倒背如流吧,对ConcurrentHashMap应该也有了解,那么ConcurrentHashMap底层其实将锁的粒度进一步细化了,存在了分段锁的概念等等。

总结

这些让人眼花缭乱的锁,如果面试官问到的话,大家是否能够说出一二呢?相信看完上面的解释,大家心里多多少少也有数了吧。当然关于最后一点其他分类的锁,老猫没有展开。有兴趣的小伙伴可以自行查阅一下这些分类。