文章目录
- 前言
- 并发控制
- 并发访问控制是什么?
- 如何实现并发访问控制?
- 并发访问控制 与 线程安全
- 锁是什么?
- 1. 加锁操作
- 2. 解锁操作
- 锁状态是什么?
- 如何实现一个锁?
- 笔者相关博客连接
- 结语
前言
多线程编程中,锁是最重要的一个概念,但也是最容易理解错误的概念之一,理解好锁和并发控制是掌握多线程编程的重中之重,笔者将用本文去讲解锁以及并发控制的本质,以及尝试去实现一个锁。
并发控制
在讲解锁之前,有必要先讲解其前置知识:并发控制。
并发控制,英:Concurrency Control,也被称为并发访问控制,英:Concurrency Access Control。
在多线程环境下,当多个线程同时访问共享数据(堆内存里的数据)时,因为线程缓存机制很容易发生数据错误。一个比较典型的例子就是多线程计数器。如下:
可以看到执行两次结果分别是 61011 和 57257,那么我们期待的结果很明显是100000。这就是不做并发访问控制的严重后果。
并发访问控制是什么?
那么究竟如何理解并发访问控制呢?我们程序员主动的去控制多个线程去有序地访问共享数据的这么个处理呢被叫做并发访问控制。
如何实现并发访问控制?
前面我们提到了并发访问控制的本质其实是程序员主动的控制多线程有序地访问共享数据。那么如何实现并发访问控制呢?答案很简单,就是 利用锁来实现 多个线程在时间上有序地访问共享数据。
什么意思呢?试想一下一堆人不排队去领盒饭和一个有人组织排队去领盒饭的区别。一堆人不排队去领盒饭则是多线程不做并发访问控制的情况,有人组织排队去领盒饭呢,则是在这个资源的操作在很微小的时间尺度上做到了串行,等于是多线程做了并发访问控制。但也因为有排队所以其实数据整体处理效率是会降低的,这个是加锁必不可少的开销。
并发访问控制的本质呢就是当某线程操作共享一定要获取到锁(资格)才进行修改,否则就一直等待直到获取到锁,修改完成后释放锁(资格),让其他线程也能去获取锁去进行数据的访问。
并发访问控制 与 线程安全
线程安全其实是并发访问控制的一个产物,一旦我们的组件对其内部的数据做了 完善的(注意是完善的) 并发访问控制,那么我们可以说这个组件是 (多)线程安全 的
那么是不是做了并发访问控制就一定线程安全呢?答案是不一定。如果组件的开发者对于共享数据的并发访问控制逻辑有漏洞,那么其也不能算是线程安全的。比如:
那么上面的代码呢,就是典型的做了并发访问控制,但是有漏洞(add方法的存在)。导致MutiThreadCounter组件其实并非是线程安全的。
所以其实锁并不是真正意义上的锁,你锁了其他线程就真的无法访问到数据了,而是抽象意义上的锁,你即使加锁了,开发者依然能够使用其他线程任意访问被锁保护的数据。线程安全的真谛是当开发者发现没有获取到锁时停止对数据的访问。所以不难想象锁是个类似符号一样的东西,只有修改了这个符号成功的线程才主动去修改线程则是锁工作的原理了(下面章节讲)
锁是什么?
上面我们提到了,多线程需要做并发控制来保证线程安全。而做并发控制需要依赖一种工具,这个工具就叫锁。不难想象锁这个工具的最核心的两个功能如下:
- 加锁(Lock)
- 解锁(Unlock)
下面我们分别介绍一下这两个操作的核心思想。
1. 加锁操作
加锁操作是一个并发操作,意味着通常需要考虑多个线程同时会执行加锁操作。而常规锁(特殊设计的锁除外)的设计是同一时间只能有一个线程成功获取到锁(也叫锁竞争成功)。
对于锁竞争成功的线程 锁这个工具类是直接返回 让线程能够继续执行指令。
对于锁竞争失败的线程 锁这个工具类是会阻塞当前线程 让线程卡在加锁的操作直到获取锁成功。
不难看出在这里锁工具类的职责就是帮我们去竞争锁以及在失败时阻塞线程(这是锁的开发者需要实现的)。
加锁成功这个在程序实现上也是非常简单,无非就是标记当前线程为锁的主人。比如JUC里大名鼎鼎的AQS里就有相关的标记为主人的代码。
而线程阻塞的方式也很简单,有重量级的OS级别的实现也有轻量级的进程级别的实现。
- OS级别的重量级实现是:OS支持线程休眠然后通过内核唤醒线程的方式来实现,就比如Java的内置锁的重量级锁模式(Java关键字 synchronized)
- 进程级别的轻量级实现是:无限循环,直到获取锁成功。比如下面的截图里的for (;😉,也是出自AQS类。
两种实现在锁持有时间上的不同场景下,有不同的表现,比如可以看出轻量级实现是会一直循环获取锁的。这种情况下分配给线程的CPU时间片会全部用于执行锁获取代码,直到锁获取成功,这意味着较大的CPU使用率,这会使得在其他线程会长时间持有(占用)锁时,轻量级锁有明显的劣势。而重量级锁与轻量级锁相对,因为线程会休眠,休眠时是不会占用CPU资源的。但因为线程休眠到唤醒会有线程 上下文切换(Context Switch) 的开销,这个开销是比较昂贵的,通常是微秒(µs)级别的开销。所以如果锁持有时间很短的话是推荐使用无限循环这种实现方式,可以节省很多上下文切换的开销。Java里面内置锁有锁膨胀机制,会自动根据锁的使用情况去选择轻量级锁亦或是重量级锁。
2. 解锁操作
和加锁操作不同,解锁操作不是并发操作,不过其工作和加锁类似,加锁是标记当前线程为锁的主人,而解锁则是标记锁为无主状态(即:null)。
Java的ReentrantLock类中你能在解锁的流程中看到这样的设null的代码:
锁状态是什么?
前面我们提到了 锁是一个工具,用于帮助我们的开发者实现锁竞争以及竞争锁失败时阻塞线程的功能。无状态(Stateless)相信大家都很熟悉了,那么其相对的有状态(Stateful)相信大家也很熟悉。其实简单来说就是一个组件的属性从组件外部能观测到变化的,那么这个组件就是Stateful的。
锁这个工具也一样,我们不同线程都能观测到锁的当前状态。锁的状态(内部属性)也会因为不同的线程锁竞争成功而变化(比如刚才的setExclusiveOwnerThread方法会改变exclusiveOwnerThread属性的值一样)。
除开我们最基础的exclusiveOwnerThread属性属于广义的锁状态之外,还有一些锁会有特殊的信息需要保存。存储这些信息的属性也属于锁状态(狭义的锁状态)。大名鼎鼎的AQS内部呢就是维护了一个state属性
AQS提供了维护这个state的API接口,外部不同的锁设计需求则可以根据自己的需求去利用这32位的空间去存储不同的锁状态。就比如:
- ReentrantLock:可重入锁,state为0代表未上锁,state > 1 代表已上锁,已上锁时state也代表重入次数。
- ReentrantReadWriteLock:可重入读写锁,state的高16位用于存储读锁个数,state的低16位用于存储写锁重入次数信息。
如何实现一个锁?
看到现在,其实我们已经了解了锁最重要的信息。在Java中大部分锁的实现都是基于AQS实现的,不过既然AQS是JDK开发者实现的,其实我们自己也可以开发AQS,或者做一个简化版的,比如只有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万。
笔者相关博客连接
笔者在本章简单列举之前写过的相关文章,有兴趣的读者可以去额外阅读一下:
- 《[Database] 关系型数据库中的MVCC是什么?怎么理解?原理是什么?MySQL是如何实现的?》
- 《[Java] 乐观锁?公平锁?可重入锁?盘点Java中锁相关的概念》
结语
锁、并发控制和线程安全这几个概念是相辅相成的,通过本篇文章我们知道了锁其实是一种工具类,也知道其主要职责主要是负责维护锁状态以及加锁失败时阻塞线程,我们也简单地用Java自定义了一个我们自己的锁实现。理解锁的本质是理解多线程编程的基础。理解了锁的基础之后在未来的文章中笔者将会去带大家去实现自己的分布式锁
我是虎猫,希望本文对你有帮助。(=・ω・=)