之所以会有锁的出现,主要问题是为了解决并发编程出现可见性、原子性和有序性的问题,如果你对这些问题心存疑惑,可以先查看一下此篇文章
并发编程 Bug 的源头:可见性、原子性和有序性。
Java 中的锁大致分为两派,一个是基于 JVM 实现的 synchronized 以及基于 Lock 接口的一些列子类实现,首先文章先会去介绍下所有锁的基本概念以及用法,然后基于这些会派分出来很多锁的分类
基础
synchronized
synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里
synchronized 锁可以修饰非构造方法,也可修饰代码块。synchronized 的锁其实就是对象,当修饰方法时,锁是此类的当前类本身实例;当修饰代码块时,锁就是在小括号里指定的对象,下面是个例子:
public class SynchronizedMain {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10);
final Calculate calculate = new Calculate();
executor.execute(() -> calculate.add(50));
executor.execute(() -> calculate.multi(0));
new Timer().schedule(new TimerTask() {
@Override
public void run() {
System.out.println(calculate.getNum());
}
}, 0, 1000);
}
}
class Calculate {
private final Calculate lock = this;
private int num = 10;
public synchronized void add(int plusNum) {
try {
Thread.sleep(5 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
num += plusNum;
}
public void multi(int minNum) {
synchronized (lock) {
num *= minNum;
}
}
public int getNum() {
return num;
}
}
在 JDK1.6 以前,被 synchronized 修饰的锁都是重量级锁,在 1.6 以后 JVM 对锁进行了优化,所以也派分出来几种状态,而对应这几种状态就是 synchronized 锁的分类,分为
- 无锁
- 偏向锁
- 轻量级锁
- 自旋
- 重量级锁
其中无锁、偏向锁、轻量级锁、重量级锁都是由对象头中的 Mark Word 进行标记。而自旋锁是在轻量级锁的基础上加上了一层逻辑实现,以让轻量级锁没有那么容易的升级到重量级锁
锁的升级过程如下:
下面的小节我会逐个验证下每个锁的存在,验证的依据就是输出锁对象头中的 Mark Word,那么在此之前,就是势必要了解下 Mark Word ,可以参考这篇文章Object header 浅析
匿名偏向锁
如果 JVM 开了偏向锁的话,对象会在创建后,进入到匿名偏向锁状态(就算没有 synchronized 也会)此时对象头里的线程 ID 为 0
偏向锁
当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销
- 当一个对象已经计算过 identity hash code(例如存在有执行 hashcode 的操作),它就无法进入偏向锁状态
- 当一个对象正在处于偏向锁状态,如果需要计算其 identify hash code 的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁
- 重量锁的实现中,ObjectMonitor 类里有字段可以记录非加锁状态下的 mark word,其中可以存储 identity hash code 的值
轻量级锁
如果有其他线程试图锁定某个已经被偏向过的锁对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则会进行自旋获取锁(有次数限制),如果自旋获取锁成功则使用普通的轻量级锁;否则升级为重量级锁
总结下来就是两步:
- 尝试使用 CAS 获取锁
- 尝试使用自旋获取锁
重量级锁
重量级锁是通过 Monitor 来实现线程同步,Monitor 是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现线程同步。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象,每一个 Java 对象就有一把看不见的锁,成为内部锁或者 Monitor 锁。 Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
重量级锁的执行流程图如下
- Contention List:所有请求锁的线程将被首先放置到该竞争队列,这个队列是先进后出的,当 Entry List 为空时,Owner 线程会直接从 Contention List 的队列尾部取一个线程,让它成为 OnDeck 线程去竞争锁。(主要是轻量级锁的线程是会进行自旋操作来获取锁,获取不到才会进入 Contention List 升级为重量级锁,所以 OnDeck 线程主要与刚进来还在自旋,还没有进入到 Contention List 的线程竞争)
- Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List,主要是为了减少对 Contention List 的并发访问
- Wait Set:那些调用 wait 方法被阻塞的线程被放置到了 Wait Set
- OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
- Owner:获得锁的线程称为 Owner
Lock
除了 synchronized 以外,Java 还提供了 Lock 这种形式的锁,相比于 synchronized 锁,Lock 锁提供了很多灵活性例如支持了多条件,支持了中断操作等等。速度的话,如果是针对特殊的锁场景,那么肯定是 Lock 快些,但是一般情况下,两者相差无几。
这章就是去了解一下 Lock 及其相关的实现锁,首先看下类图以及方法描述,如下:
看上边的图可能会有点乱,其实 ReentrantLock 和 ReentrantReadWriteLock 本质上都是通过继承 AbstractQueuedSynchronizer
类的静态内部类来实现的公平锁与非公平锁,关于 AbstractQueuedSynchronizer
StampedLock 则是 ReadWriteLock 的优化版,加上了乐观锁的实现,但是它是不可重入锁
方法 | 说明 |
---|---|
void lock(); | 获取锁,线程无法获取锁会进入休眠状态,直到获取成功 |
void lockInterruptibly_() _throws InterruptedException; | 如果获取成功立即返回,否则一直休眠到线程被中断或者是获取成功 |
boolean tryLock(); | 不会造成线程休眠,方法执行会立即返回,获取到锁返回 true,否则 false |
boolean tryLock(_long time, TimeUnit unit) _throws InterruptedException; | 在等待时间内没有发生过终断,并且没有获取到锁,就一直等待,当获取到了,或者是线程中断了,或者是超时,其中之一发生就会返回,并记录是否有获取到锁 |
void unlock(); | 释放锁 |
Condition newCondition(); | 每次调用创建一个锁的等待条件,也就是说一个锁可以拥有多个条件 |
Condition
接口 Condition 把 Object 的监视器方法 wait 和 notify 分离出来,使得一个对象可以有多个等待的条件来执行等待。配合 Lock 的 newCondition 来实现。这个在讲到 ReentrantLock 时会举个例子,下面先来看下其 API 的内容
方法 | 说明 |
---|---|
void await_() _throws InterruptedException; | 使当前线程休眠,不可调度。以下三种情况下会恢复 - 其他线程调用了 signal/signalAll - 其他线程中断 (interrupt) - spurious wakeup (假醒) |
无论什么情况,在 await 方法返回之前,当前线程必须重新获取锁 | | void awaitUninterruptibly(); | 跟 await 大部分类似,只是少了一个‘被其他线程中断’的恢复条件,也就是说不响应 (interrupt) | | long awaitNanos(_long nanosTimeout) throws InterruptedException; | 跟 await 大部分类似,多了一个超时条件 | | boolean await(long time, TimeUnit unit) throws InterruptedException; | 与 awaitNanos 相似,只是换了个时间单位 | | boolean awaitUntil(Date deadline) throws InterruptedException; | 与 awaitNanos 相似,只是指定日期之后返回,而不是指定的一段时间 | | void signal(); | 唤醒一个等待的线程 | | void signalAll()_; | 唤醒所有等待的线程 |
ReentrantLock
ReentrantLock 是个再入锁,表示当一个线程试图获取一个它已经获取的锁时,这个获取动作就自动成功,这是对锁获取粒度的一个概念,也就是锁的持有是以线程为单位而不是基于调用次数。Java 锁实现强调再入性是为了和 pthread 的行为进行区分
再入锁可以设置公平性,我们可以在创建再入锁时选择是否是公平的
ReentrantLock fairLock = new ReentrantLock(true);
公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”情况发生的一个办法
synchronized 是不公平的 不过在通用的场景中,公平性未必有想象中的那么重要,Java 默认的调度策略很少会导致“饥饿”发生。与此同时,若要保证公平性则会引入额外开销,自然会导致一定的吞吐量下降。所以建议只有**当程序确实有公平性需求时,才有必要指定它**
ReentrantLock 一个经典的应用就是 ArrayBlockingQueue 阻塞队列,其原理就是使用一个可重入锁和这个锁生成的两个条件对象进行并发控制。通过分析 ArrayBlockingQueue 就可以了解到 ReentrantLock 以及 Condition
的主要用法,BlockingQueue 接口提供了 3 个添加方法和 3 个删除方法,如下
- 添加方法
- add:添加元素到队列里,添加成功返回 true,由于容量满了添加失败会抛出 IllegalStateException 异常
- offer:添加元素到队列里,添加成功返回 true,添加失败返回 false
- put:添加元素到队列里,如果容量满了会阻塞直到容量不满
- 删除方法
- poll:删除队列头元素。如果队列为空,返回 null,否则返回元素
- remove:基于对象找到对应的元素,并删除。删除成功返回 true,否则返回 false
- take:删除队列头元素,如果队列为空,一直阻塞到队列有元素并删除
阻塞行为主要体现在 put 和 take ,它俩分别是通过两个锁条件去实现的
notEmpty 锁条件对应的 take 方法,notFull 锁条件对应的 put 方法,情况类似接下来看其中一个即可,就拿 put 来举例,首先看下 put 的方法源码如下:
可以看到首先是执行了锁的 lockInterruptibly
方法,如果队列已经满了,则会调用锁的 notFull 条件执行 await 等待操作,那么在出队列的时候就一定得调用 notFull 的 signal
唤醒操作。
ArrayBlockingQueue 出队列的方法有 3 个上边已经列出了,接着去找这三个出队列的方法,追到 pull 和 take 方法都会走到内部方法 dequeue
这个方法会执行 notFull 的 signal 方法,而 remove 方法会走到 removeAt
方法中,其最终会执行 notFull 的 signal 方法。
ReadWriteLock
多线程并发场景中对同一份数据进行读写操作会涉及到线程并发安全问题,常规的解决办法就是在读写操作上加入互斥锁,但是大多数情况下我们对同一数据进行读操作频率会高于写频率,而读-读操作并不涉及到线程安全问题,所以没有必要给读-读行为再上锁,由此读写锁就诞生了。
ReadWriteLock 是个接口,只有两个方法:获取读锁 readLock
和获取写锁 writeLock
。它的实现类有两个这里主要介绍 ReentrantReadWriteLock
ReentrantReadWriteLock 通过持有 ReadLock类对象 和 WriteLock类对象 引用来实现的读写锁,ReadLock 和 WriteLock 是 ReentrantReadWriteLock 的静态内部类,它俩实现了 Lock 接口,也是通过实现了 AbstractQueuedSynchronizer 的 Sync 子类(此 Sync 和 ReentrantLock 类中的 Sync 不是一个类) 来实现的公平锁与非公平锁
对于同一份数据的操作可以分为
- 读-读:不进行锁操作
- 读-写:锁读操作,然后再执行写操作
- 写-读:锁写操作,然后再执行读操作
- 写-写:锁写操作,然后再执行写操作 | | 读 | 写 | | —- | :—-: | :—-: | | 读 | 不锁 | 锁 | | 写 | 锁 | 锁 |
接下来就是写个测试,测一下上边的行为
public class ReentrantReadWriteLockMain {
static ExecutorService executor = Executors.newFixedThreadPool(100);
static ReentrantReadWriteLock reentrantReadWriteLock;
static ReentrantReadWriteLock.ReadLock readLock;
static ReentrantReadWriteLock.WriteLock writeLock;
public static int text = 0;
public static void main(String[] args) throws InterruptedException {
reentrantReadWriteLock = new ReentrantReadWriteLock();
readLock = reentrantReadWriteLock.readLock();
writeLock = reentrantReadWriteLock.writeLock();
System.out.println("------- 读-读 -------");
read(1000);
read(0);
Thread.sleep(1500);
System.out.println("------- 读-写 -------");
read(1000);
write(0);
Thread.sleep(1500);
System.out.println("------- 写-读 -------");
write(1000);
read(0);
Thread.sleep(1500);
System.out.println("------- 写-写 -------");
write(1000);
write(0);
Thread.sleep(1500);
}
public static void read(long millis) {
executor.execute(() -> {
readLock.lock();
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("睡眠 " + millis / 1000 + " 秒后读取 text " + text);
readLock.unlock();
});
}
public static void write(long millis) {
executor.execute(() -> {
writeLock.lock();
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("睡眠 " + millis / 1000 + " 秒后写操作 text " + ++text);
writeLock.unlock();
});
}
}
// 输出如下
------- 读-读 -------
睡眠 0 秒后读取 text 0
睡眠 1 秒后读取 text 0
------- 读-写 -------
睡眠 1 秒后读取 text 0
睡眠 0 秒后写操作 text 1
------- 写-读 -------
睡眠 1 秒后写操作 text 2
睡眠 0 秒后读取 text 2
------- 写-写 -------
睡眠 1 秒后写操作 text 3
睡眠 0 秒后写操作 text 4
同时 ReadWriteLock 也是可重入锁,例如如下的代码是可以正常执行的
writeLock.lock();
System.out.println("进入写锁");
readLock.lock();
System.out.println("进入读重入锁");
readLock.unlock();
writeLock.unlock();
StampedLock
StampedLock 同样也是个读写锁,它相比于 ReadWriteLock 相比增加了乐观的实现,具体体现在于 ReadWriteLock 是如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写(这是一种悲观的读取)。而 StampedLock 改进之处在于在读的过程中也允许获取写锁后写入。这样一来我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
下面的代码,对比了 StampedLock 和 ReadWriteLock 的乐观与悲观的实现
public class StampedLockMain {
static ExecutorService executor = Executors.newFixedThreadPool(100);
static StampedLock stampedLock = new StampedLock();
static int text = 0;
static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
static java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
public static void main(String[] args) throws InterruptedException {
System.out.println("----- 乐观锁 StampedLock -----");
readForStampedLock(2000);
writeForStampedLock(0);
Thread.sleep(4000);
System.out.println("----- 悲观锁 ReentrantReadWriteLock -----");
read(2000);
write(0);
}
public static void readForStampedLock(long millis) {
executor.execute(() -> {
long stamp = stampedLock.tryOptimisticRead();
System.out.println("StampedLock: 获取到了乐观读取锁");
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
int printNum = text;
if (!stampedLock.validate(stamp)) {
System.out.println("StampedLock: 乐观读取锁悲观以至");
stamp = stampedLock.readLock();
System.out.println("StampedLock: 悲观读取持有");
printNum = text;
stampedLock.unlockRead(stamp);
}
System.out.println("StampedLock: 读取了 text 的值为 " + printNum);
});
}
public static void writeForStampedLock(long millis) {
executor.execute(() -> {
long writeLock = stampedLock.writeLock();
System.out.println("StampedLock: 获取到了写入锁");
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("StampedLock: 睡眠 " + millis / 1000 + " 秒后写操作 text " + ++text);
stampedLock.unlockWrite(writeLock);
});
}
public static void read(long millis) {
executor.execute(() -> {
readLock.lock();
System.out.println("ReentrantReadWriteLock: 获取到了读取锁");
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ReentrantReadWriteLock: 读取了 text 的值为 " + text);
readLock.unlock();
});
}
public static void write(long millis) {
executor.execute(() -> {
writeLock.lock();
System.out.println("ReentrantReadWriteLock: 获取到了写入锁");
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ReentrantReadWriteLock: 睡眠 " + millis / 1000 + " 秒后写操作 text " + ++text);
writeLock.unlock();
});
}
}
//输出
----- 乐观锁 StampedLock -----
StampedLock: 获取到了乐观读取锁
StampedLock: 获取到了写入锁
StampedLock: 睡眠 0 秒后写操作 text 1
StampedLock: 乐观读取锁悲观以至
StampedLock: 悲观读取持有
StampedLock: 读取了 text 的值为 1
----- 悲观锁 ReentrantReadWriteLock -----
ReentrantReadWriteLock: 获取到了读取锁
ReentrantReadWriteLock: 读取了 text 的值为 1
ReentrantReadWriteLock: 获取到了写入锁
ReentrantReadWriteLock: 睡眠 0 秒后写操作 text 2
通过代码的输出,可以大致看出来乐观锁与悲观锁执行流程上的区别
分类
悲观锁&乐观锁
在上边介绍 StampedLock 时,其实已经提出了乐观锁的概念,这个小结会系统的说下,首先先了解下概念吧
- 😭 悲观锁:认为自己在使用数据的时候一定会有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java 中,
synchronized
关键字和Lock
的实现类都是悲观锁 - 😁 乐观锁:认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
悲观锁在 Java 里接触的太多了,这里主要还是说下乐观锁,除了 StampedLock 提供了乐观锁的实现以外,Java 并发包里的原子类(AtomicXX
)都属于乐观锁,它们是使用 CAS
技术来实现的。
CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步
CAS 算法涉及到三个操作数:
- 当前内存值 V
- 旧的预期值 A
- 即将更新的值 B
当且仅当预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false
CAS 虽然高效,但是也存在三大问题
- ABA 问题:这个 JDK 从 1.5 开始提供了 AtomicStampedReference 类用来解决此问题,具体操作封装在 compareAndSet 中
- 循环时间长开销大:CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。
- 只能保证一个共享变量的原子操作:Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。