[Java] 什么是锁?什么是并发控制?线程安全又是什么?锁的本质是什么?如何实现一个锁?

前言


多线程编程中,锁是最重要的一个概念,但也是最容易理解错误的概念之一,理解好锁和并发控制是掌握多线程编程的重中之重,笔者将用本文去讲解锁以及并发控制的本质,以及尝试去实现一个锁。

并发控制


在讲解锁之前,有必要先讲解其前置知识:并发控制。
并发控制,英:Concurrency Control,也被称为并发访问控制,英:Concurrency Access Control。

在多线程环境下,当多个线程同时访问共享数据(堆内存里的数据)时,因为线程缓存机制很容易发生数据错误。一个比较典型的例子就是多线程计数器。如下:

多线程不做并发访问控制导致的数据不一致错误

可以看到执行两次结果分别是 61011 和 57257,那么我们期待的结果很明显是100000。这就是不做并发访问控制的严重后果

并发访问控制是什么?

那么究竟如何理解并发访问控制呢?我们程序员有意识地主动地去控制多个线程有序地访问共享数据的这么个处理呢被叫做并发访问控制。

如何实现并发访问控制?

前面我们提到了并发访问控制的本质其实是程序员主动的控制多线程有序地访问共享数据。那么如何实现并发访问控制呢?答案很简单,就是 利用锁来实现 多个线程在时间线上有序地访问共享数据。

什么意思呢?试想一下一堆人不排队去枪盒饭和一个有人组织排队去领盒饭的区别。

  • 不排队去抢盒饭是类比多线程不做并发访问控制(是并行的)。
  • 有人组织排队去领盒饭则是类比多线程做了并发访问控制,在盒饭这个资源的操作(是串行的)。

不难理解串行的效率是低于并行的,因为串行会有额外的排队(等待)开销的,要想保障数据的正确性,就得做并发访问控制,而做并发访问控制就不得不做串行处理,因此效率的降低是必不可少的代价。等于是用时间换取数据的正确性。

上面的例子我们提到了 有人组织排队(维持秩序),对应到我们程序里就是锁了,这个我们后面章节讲。

对于如何实现并发访问控制这个问题呢,也很简单了,就是当多个线程操作共享数据时一定要获取到锁(资格)才进行修改,否则就一直等待直到成功获取到锁,修改完成后释放锁(资格),让其他线程也能去获取锁去进行数据的操作。

并发访问控制 与 线程安全

线程安全其实是并发访问控制的一个产物,一旦我们的组件对其内部的数据做了 完善的(注意是完善的) 并发访问控制,那么我们可以说这个组件是 (多)线程安全

那么是不是做了并发访问控制就一定线程安全呢?答案是 不一定如果组件的开发者对于共享数据的并发访问控制逻辑有漏洞,那么其也不能算是线程安全。如下面的例子:

做了并发访问控制但不是线程安全的案例

上面的代码呢,就是典型的做了并发访问控制(syncAdd方法),但又没完全做(add方法)。导致MutiThreadCounter组件并非是线程安全的。你可以在这里看到样例源码

所以其实锁并不是真正意义上的锁,你锁了其他线程就真的无法操作数据了,而是抽象意义上的锁,你即使加锁了,开发者依然能够使用其他线程任意操作被锁保护的数据。线程安全的真谛是当开发者发现没有获取到锁时停止对数据的访问。所以不难想象锁是个类似符号一样的东西,只有修改了这个符号成功的线程才主动去修改线程则是锁工作的原理了(下面章节讲)

锁是什么?


上面我们提到了,多线程需要做并发控制来保证线程安全。而做并发控制需要依赖一种工具这个工具就叫。不难想象锁这个工具的最核心的两个功能如下:

  1. 加锁(Lock)
  2. 解锁(Unlock)

下面我们分别介绍一下这两个操作的核心思想。

1. 加锁操作

加锁操作是一个并发操作,意味着通常需要考虑多个线程同时会执行加锁操作。而常规锁(特殊设计的锁除外)的设计是同一时间只能有一个线程成功获取到锁(也叫锁竞争成功)

对于锁竞争成功的线程 锁这个工具类是直接返回 让线程能够继续执行指令。
对于锁竞争失败的线程 锁这个工具类是会阻塞当前线程 让线程卡在加锁的操作直到获取锁成功。

不难看出在这里锁工具类的职责就是帮我们去竞争锁以及在失败时阻塞线程(这是锁的开发者需要实现的)

加锁成功这个在程序实现上也是非常简单,无非就是标记当前线程为锁的主人。比如JUC里大名鼎鼎的AQS里就有相关的标记某线程为主人的代码。

AQS的setExclusiveOwnerThread(Thread)方法

而线程阻塞的方式也很简单,有重量级的OS级别的实现也有轻量级的进程级别的实现。

  1. OS级别的重量级实现是:OS支持线程休眠然后通过内核唤醒线程的方式来实现,就比如Java的内置锁的重量级锁模式(Java关键字 synchronized)
  2. 进程级别的轻量级实现是:无限循环,直到获取锁成功。比如下面的截图里的for ( ; ; ),也是出自AQS类。

AQS acquireQueued 方法

两种实现在锁持有时间上的不同场景下,有不同的表现,比如可以看出轻量级实现是会一直循环去尝试获取锁。这种情况下分配给线程的CPU时间片会全部用于执行锁获取代码,直到锁获取成功,这意味着较大的CPU使用率,这会使得在其他线程会长时间持有(占用)锁时,轻量级锁有明显的劣势。

而重量级锁与之相对,因为线程会休眠,休眠时是不会占用CPU资源的。但因为线程休眠到唤醒会有线程 上下文切换(Context Switch) 的开销,这个开销是比较昂贵的,通常是微秒(µs)级别的开销。如果不理解微秒级是多昂贵的开销的话,参考QPS 10万这个高性能指标,1秒10万请求,平均1个请求不超过10微秒。你就知道上下文切换有多昂贵了。所以如果锁持有时间很短的话是推荐使用无限循环这种实现方式,可以节省很多上下文切换的开销。Java里面内置锁有锁膨胀机制,会自动根据锁的使用情况去选择轻量级锁亦或是重量级锁。

对于两种锁的选择,可以参考下表:

锁持有时间轻量级锁重量级锁

2. 解锁操作

和加锁操作不同,解锁操作不是并发操作,不过其工作和加锁类似,加锁是标记当前线程为锁的主人,而解锁则是标记锁为无主状态(即:null)。

Java的ReentrantLock类中你能在解锁的流程中看到这样的设null的代码:

ReentrantLock类中的解锁代码

锁状态是什么?

前面我们提到了 锁是一个工具,用于帮助我们的开发者实现锁竞争以及竞争锁失败时阻塞线程的功能。无状态(Stateless)相信大家都很熟悉了,那么其相对的有状态(Stateful)相信大家也很熟悉。其实简单来说就是一个组件的属性从组件外部能观测到变化的,那么这个组件就是Stateful的。

锁这个工具也一样,我们不同线程都能观测到锁的当前状态。锁的状态(内部属性)也会因为不同的线程锁竞争成功而变化(比如刚才的setExclusiveOwnerThread方法会改变exclusiveOwnerThread属性的值一样)。

除开我们最基础的exclusiveOwnerThread属性属于广义的锁状态之外,还有一些锁会有特殊的信息需要保存。存储这些信息的属性也属于锁状态(狭义的锁状态)。大名鼎鼎的AQS内部呢就是维护了一个state属性

AQS的state属性

AQS提供了维护这个state的API接口,外部不同的锁设计需求则可以根据自己的需求去利用这32位的空间去存储不同的锁状态。就比如:

  1. ReentrantLock:可重入锁,state为0代表未上锁,state > 1 代表已上锁,已上锁时state也代表重入次数。
  2. ReentrantReadWriteLock:可重入读写锁,state的高16位用于存储读锁个数,state的低16位用于存储写锁重入次数信息。

如何实现一个锁?


看到现在,其实我们已经了解了锁最重要的信息。在Java中大部分锁的实现都是基于AQS实现的,不过既然Java的锁、以及AQS是JDK开发者实现的,其实我们自己也可以实现一个简易的锁,比如只有ownerThread属性的锁,那么我们来试一下实现一个自己的锁。

public final class CustomizeYourOwnLockSampe {
    
    private static final class CustomizeLock {

        private final AtomicReference<Thread> ownerThread = new AtomicReference<>();

        public void lock() {
            final Thread current = Thread.currentThread();
            for (;;) {
                if (null == ownerThread.compareAndExchange(null, current)) break;
            }
        }

        public void unlock() {
            final Thread current = Thread.currentThread();
            if (!ownerThread.get().equals(current)) throw new IllegalStateException("Current thread is not the owner thread of this lock instance.");
            ownerThread.set(null);
        }

    }

    static int count = 0;
    static CustomizeLock lock = new CustomizeLock();

    private static Thread genWorkerThread() {
        final Thread thread = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                lock.lock();
                count++;
                lock.unlock();
            }
        });
        return thread;
    }

    public static void main(String[] args) throws InterruptedException {
        final Thread thread1 = genWorkerThread();
        final Thread thread2 = genWorkerThread();

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }
}

执行一下,可以看到结果就是期望中的10万。

自定义锁的实现

笔者相关博客连接


笔者在本章简单列举之前写过的相关文章,有兴趣的读者可以去额外阅读一下:

  1. [Database] 关系型数据库中的MVCC是什么?怎么理解?原理是什么?MySQL是如何实现的?
  2. [Java] 乐观锁?公平锁?可重入锁?盘点Java中锁相关的概念

结语


锁、并发控制和线程安全这几个概念是相辅相成的,通过本篇文章我们知道了锁其实是一种工具类,也知道其主要职责主要是负责维护锁状态以及加锁失败时阻塞线程,我们也简单地用Java自定义了一个我们自己的锁实现。理解锁的本质是理解多线程编程的基础。理解了锁的基础之后在未来的文章中笔者将会去带大家去实现自己的分布式锁

我是虎猫,希望本文对你有帮助。(=・ω・=)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值