临界资源
记得上学的时候学操作系统的时候,里面有个临界资源的概念,通俗点说就是同时只允许一个进程或服务使用的资源就是临界资源,比如打印机等。还有一个概念叫临界区(critical section),
A Critical Section is a code segment that accesses shared variables and has to be executed as an atomic action. It means that in a group of cooperating processes, at a given point of time, only one process must be executing its critical section.
以上转子维基百科,翻译过来就是“访问共享变量并且必须被作为原子操作来执行的代码片段,它意味着在一组合作进程里在某个时刻,只有一个进程可以进入临界区。”
进程和线程
进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
简单来讲进程是操作系统中的运行的服务,有自己的生命周期,存活在内存中,通过底层接口与硬件等打交道,由操作系统的进程代为管理,互相之间也可以通过各种方式通讯,比如管道,信号量,消息队列,共享内存,套接字等,具体可以参考《Unix高级环境编程》中进程通信部分。
线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。
线程比进程更为轻量,切换线程开销小,如果做个类比的话,那么线程之于进程就像Docker至于虚拟机,线程共享进程里的所有数据,并且拥有自己的缓存和栈,但都比较小,一般为1024KB,如果做递归操作比较容易溢出。
并发和多线程
其实在多进程时代如果大家都要去操作一个临界资源的话,比如打印机,那么也是要通过比如信号量这种东西来同步数据的,因为两个一起操作的话肯定会出问题。类似的在操作系统有了线程这种更轻量的实现后也会存在类似的问题。并发不是并行,当前的CPU主流算法还是以时间片轮转为主,即你用一会我用一会,比如我们平时在听歌的同时还可以编辑文档,这就是并发,其实由于时间片比较小且CPU的计算能力比较强大,所以可能每个任务每隔几毫秒甚至几微妙执行一次用户也丝毫感受不出卡顿。
说回多线程,每个进程可以包含多个线程,操作系统对每个进程可拥有的最大线程数有限制,不过一般不用操心这个,绝对够用,如果不够用那也是进程所需的内存先不够用。每个线程有自己的生命周期,如下图:
线程启动后,进入运行状态,运行状态可以被通过wait,sleep等操作挂起,再被notify唤醒,也可以被中断后退出,或正常结束。在我们创建完线程后我们需要对线程的状态做跟踪,确保我们的任务被正常执行从而拿到结果。
那么在执行的过程中难免会遇到同步的问题,比如3个任务,要一个接着一个串行执行,或者前两个执行完后再执行最后一个,又比如10个线程一起做累加操作,那么在某个线程在修改累加值的时候需要锁住累加变量。
锁
有经验的Java开发人员应该知道,只要涉及到并发,那么synchronized,volatile和lock是少不了的,因为本篇主题为锁,所以这里暂不深入介绍synchronized和volatile,只讲各种锁。
锁的原理其实很简单,通过一个boolean
或者计数变量,到某个条件时让线程阻塞等待被唤醒:
class MyLock {
private boolean isLocked = false;
synchronized void lock() throws InterruptedException {
while(isLocked) {
System.out.println("[spin lock] is working in " + Thread.currentThread().getName());
wait();
}
isLocked = true;
}
synchronized void unlock() {
isLocked = false;
notify();
}
}
可以看到利用synchronized关键字,jvm会帮我们自动锁定某个对象或代码片段,然后在对象获得synchronized的锁时修改内部变量使后续的线程判断失败后进入到wait
状态等待唤醒,后续notify会随机唤醒一个在沉睡的线程。这是最简单的一种锁的实现。
有个实现的关键点要搞清楚,就是判断条件然后wait
必须用while不能用if
。为什么?
因为为了防止spurious wakeup,中文叫虚假唤醒,就是即使线程已经进入了阻塞或睡眠状态,操作系统还是有可能会让这个线程未受到任何信号变为Running
状态。加个while
就可以避免这种情况。
下面我们看看Java中的锁,首先了解下锁的分类,如果按照不同维度切分锁可以分为:
- 可重入锁和不可重入锁
- 互斥锁和读写锁
- 公平锁和非公平锁
- 独享锁和共享锁
- 乐观锁和悲观锁
- 分段锁
- 自旋锁
可重入锁/不可重入锁
ReentrantLock就是可重入锁,那我们不禁要问这么几个问题,可重入锁是干嘛的,为什么需要这种锁?
首先我们考虑如下场景,如果有一个线程获取了一把锁,当它尝试再次得到锁的时候就会被卡住,因为之前的锁并没有释放,所以再次获取会让线程无限制等待下去,哪里会有这种场景?递归,对,就是在递归的时候如果有锁的操作,那么这个锁必须是允许可重入的。可重入锁就是解决了这种外层获得锁的时候,内层再去尝试获得然后卡死的问题,可重入锁会判断尝试获得锁的线程是不是和之前的线程一致,一致的话则允许重入,简单的代码实现如下:
class MyReentranceLock {
boolean isLocked = false;
private Thread lockedBy = null;
private int lockCount = 0;
int getLockCount() {
return lockCount;
}
synchronized void lock() throws InterruptedException {
Thread callingThread = Thread.currentThread();
while (isLocked && lockedBy != callingThread) {
System.out.println("=====Waiting the lock to be released=====");
wait();
}
isLocked = true;
lockCount++;
lockedBy = callingThread;
}
synchronized void unlock() {
if(this.lockedBy != Thread.currentThread()){
System.out.println("Calling thread has not locked this lock");
throw new IllegalMonitorStateException(
"Calling thread has not locked this lock");
}
if (Thread.currentThread() == this.lockedBy) {
lockCount--;
if (lockCount == 0) {
isLocked = false;
notify();
}
}
}
}
互斥锁和读写锁
就像我们之前第一个简单锁的例子,如果一旦某个线程获得了这个锁,那么对其他的线程都具有排他性,这就是互斥锁,其他的线程在尝试获得锁的时候会被阻塞。而读写锁与互斥锁不一样的地方在于,读写锁只是对写互斥,对读开放,看下面简单实现:
class MyReadWriteLock {
private int readers = 0;
private int writers = 0;
private int writeRequests = 0;
public synchronized void lockRead() throws InterruptedException {
while (writers > 0 || writeRequests > 0) {
wait();
}
readers++;
}
public synchronized void unlockRead() {
readers--;
notifyAll();
}
public synchronized void lockWrite() throws InterruptedException {
writeRequests++;
while (readers > 0 || writers > 0) {
wait();
}
writeRequests--;
writers++;
}
public synchronized void unlockWrite() {
writers--;
notifyAll();
}
}
有两把锁,读和写,读的锁在判断没有在写的请求的时候是不会被阻塞的,写的请求不仅要判断当前没有读锁,并且没有写才可以获得锁,这就是对读开放,而对读阻塞强制同步等待。这个锁被创造的意义在于处理读写分离并且读的请求非常大的场景,这样大部分时候读线程不会被阻塞,可以极大的提升效率。
这里需要注意的是,在释放锁的时候必须用notifyAll()
,为什么?为什么notify()
不可以?想想如下场景,有3个线程,一个读,两个写,其中一个写已经获得了锁并且完成更新操作,此时它要释放锁,如果用notify()
那么会有很大概率把读线程唤醒,然后这个读线程得到锁,导致写线程持续被阻塞,这并不是我们期望看到的,读写锁默认的设置就是读的优先级高于写,所以必须用notifyAll()
。
公平锁和非公平锁
锁存在公平和不公平吗?存在。
是什么导致的?是操作系统的线程唤醒策略,对应到Java中就是notify()
,notify
会随机的唤醒一个正在等待的线程,那么就会有一种极端的情况,某个线程由于操作系统的随机唤醒导致它长时间无法被唤醒,一直处于阻塞状态,这就是不公平。
一般来讲,对于大多数公司的大多数场景是不会遇到问题的,因为首先并发量没那么大,其次概率非常非常低,但对于少数并发量大的应用如果采用这种策略,确实有概率遇到某紧急需求无法及时得到处理的情况,那么,与其对应的就是公平锁。
我们可以想象公平锁的策略是什么,然后再想想它应该如果实现,策略可以有很多种,比如FIFO,优先级权重排序,但论公平一定是队列的FIFO,先来先处理,后来的排队按顺序处理,什么可以做到这种效果?ArrayList,LinkedList等可排序的队列,甚至是动态数组。
class QueueObject {
private boolean isNotified = false;
public synchronized void doWait() throws InterruptedException {
while (!isNotified) {
this.wait();
}
this.isNotified = false;
}
public synchronized void doNotify() {
this.isNotified = true;
this.notify();
}
public boolean equals(Object o) {
return this == o;
}
}
class MyReentranceFairLock {
boolean isLocked = false;
private Thread lockingThread = null;
private List waitingThreads = new ArrayList<>();
void lock() {
QueueObject queueObject = new QueueObject();
boolean isLockedForThread = true;
synchronized (this) {
System.out.println("[" + Thread.currentThread().getName() + "] Adding new object to waiting Q.");
waitingThreads.add(queueObject);
}
while (isLockedForThread) {
synchronized (this) {
isLockedForThread = isLocked || waitingThreads.get(0) != queueObject;
if (!isLockedForThread) {
isLocked = true;
waitingThreads.remove(queueObject);
lockingThread = Thread.currentThread();
return;
}
}
try {
System.out.println(Thread.currentThread().getName() + " Waiting");
queueObject.doWait();
System.out.println(Thread.currentThread().getName() + " Awakened.");
} catch (InterruptedException e) {
waitingThreads.remove(queueObject);
e.printStackTrace();
}
}
}
synchronized void unlock() {
if (this.lockingThread != Thread.currentThread()) {
System.out.println("Calling thread has not locked this lock.");
throw new IllegalMonitorStateException("Calling thread has not locked this lock.");
}
isLocked = false;
lockingThread = null;
if (waitingThreads.size() > 0) {
waitingThreads.get(0).doNotify();
}
}
}
在获得锁的时候除判断锁标志外还要判断当前线程是不是队列里的第一个,不是则继续等待,是则取出把锁给它。QueueObject
是一个互斥锁,这样做是为了避免丢失信号(missed singal),即两个线程,一个线程判断标志为false
继续往下走退出synchronized包裹的代码块,另一个线程此进来判断也发现标志位是false
也会继续往下走而不是被阻塞因为第一个线程已经获得了锁,这就是missed singal。把synchronized
关键字放到方法中而不是方法声明里是为了避免Nested Monitor Lockout,即notify只会解锁一层锁,而如果有多层锁,则会持续卡死。
独享锁和共享锁
一个ReadWriteLock
就是一个经典的锁,他既支持独享锁也支持共享锁,读锁为共享锁,没有线程会被阻塞,写锁为独享锁,在写操作的时候没有其他线程可以访问相关资源。
乐观锁和悲观锁
这个和具体的实现类没有关系了,是在用锁解决问题的时候的两种不同方法,乐观锁,顾名思义,对不同线程同时操作资源的情况比较乐观,认为概率很低,它只需要在更新操作前做时间戳或者版本号的检查,确保它更新的值是最新的值,避免造成丢失掉数据。
悲观锁的话比较谨慎,每次更新操作的时候都要锁定目标,比如锁表,锁记录,锁对象等等,这样虽然在写操作频繁时虽然会很大降低吞吐量,但却是非常安全的做法。
关于乐观锁,多说一点,就是如果发现版本号或者时间戳不是最新的怎么处理?有两种办法,一种是不断重试,类似while
操作,另一种是设置最大尝试次数,次数到达返回更新失败信息。
分段锁
分段锁也是一个很好的概念,它是为了解决同时访问资源造成卡死的情况,典型的应用就是ConcurrentHashMap
,它不允许key和value为null,创建的时候被分为多个Segment,每个Segment里有很多的节点,这每个Segment就是互相独立的,通过对key进行hash计算得到对应的编号,再对每个Segment各自加锁就是分段锁,这样做可以极大的提升性能。
自旋锁
Spin Lock就是自旋锁,通过while
实现,如果线程发现所需条件没有满足则继续等待,这就是自旋锁,非常占用CPU,也叫busy-waiting
,好处是减少线程切换上下文时的代价。
void spinLock() throws InterruptedException {
while(isLocked) {
System.out.println("[spin lock] is working in " + Thread.currentThread().getName());
}
isLocked = true;
}