Java中的锁有很多种,经常会听到“锁”这个词。
犹如每天出门时,?就是一种“锁”,拿不到?,就进不去了。
Java那么多种类的锁,都是按不同标准来分类的。就像商店里的各种商品,可以按式样,也可以按颜色或者尺寸。
其实它们都是一种思想。
为什么会有锁?
一个进程可以包含多个线程,那么多个线程就会有竞争资源的问题出现,为了互相不打架,就有了锁的概念了。 一个线程也可以自己完成任务,但就像一个小组可以互相配合、共同完成任务,比一个人要快很多是不是?
分类
整理个大图~
其实这么多分类只是从特性、表现、实现方式等不同的侧重点来说的,不是绝对的分类,例如,不可重入锁和自旋锁,其实是同一种锁。
要绕晕了是不是?下面就分别来说说。
1. 悲观 Vs 乐观
林妹妹比较悲观,宝玉比较乐观~
1.1 悲观锁
看名字便知,它是悲观的,总是想到最坏的情况。 锁也会悲观,它并不是难过,它只是很谨慎,怕做错。
每次要读data的时候,总是觉得其他人会修改数据,所以先加个?,让其他人不能改数据,再慢慢读~
要是你在写一篇日记,怕别人会偷看了,就加了个打开密码,别人必须拿到密码才能打开这篇文章。这就是悲观锁了。
应用: synchronized关键字和Lock的实现类都是悲观锁。
1.2 乐观锁
它很乐观,总是想着最好的情况。 它比较大条,不会太担心。如果要发生,总会发生,如果不会发生,那就不会。为什么要担心那么多?
每次读data时,总是乐观地想没有其他人会同时修改数据,不用加锁,放心地读data。 但在更新的时候会判断一下在此期间别人有没有去更新这个数据。
就像和别人共同编辑一篇文章,你在编辑的时候别人也可以编辑,而且你觉得别人不会改动到你写的部分,那就是乐观锁了。
事事无绝对,悲观也好乐观也好,没有绝对的悲观,也没有绝对的乐观。只是在这个当时,相信,还是不相信。
1.3 悲观 Vs 乐观
类型 | 实现 | 使用场景 | 缺点 |
---|---|---|---|
悲观锁 | synchronized关键字和Lock的实现类 | 适合写操作多的场景,可以保证写操作时数据正确 | 如果该事务执行时间很长,影响系统的吞吐量 |
乐观锁 | 无锁编程,CAS算法 | 适合读操作多的场景,能够大幅提升其读操作的性能 | 如果有外来事务插入,那么就可能发生错误 |
1.4 应用
乐观锁 —— CAS(Compare and Swap 比较并交换)
是乐观锁的一种实现方式。
简单来说,有3个三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
2 公平 Vs 非公平
没有绝对的公平,也没有绝对的不公平。
公平,就是按顺序排队嘛。 公平锁维护了一个队列。要获取锁的线程来了都排队。
非公平,上来就想抢到锁,好像一个不讲道理的,抢不到的话,只好再去乖乖排队了。 非公平锁没有维护队列的开销,没有上下文切换的开销,可能导致不公平,但是性能比fair的好很多。看这个性能是对谁有利了。
举个栗子
3 可重入锁 Vs 不可重入锁
3.1 可重入锁
广义上的可重入锁,而不是单指JAVA下的ReentrantLock。
可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。
这句话神马意思?
这种锁是可以反复进入的。
当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。
class MyClass {
public synchronized void method1() {
enterNextRoom();
}
public synchronized void method2() {
// todo
}
}
复制代码
两个方法method1和method2都用synchronized修饰了。
假设某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。
如果不是可重入锁的话,method2可能不会被当前线程执行,可能造成死锁。
可重入锁最大的作用是避免死锁。
实现类:
- synchronized
- ReentrantLock
3.2 不可重入锁
按上面的例子,线程A从method1执行到method2的时候,不能直接获取到锁,要执行下去,必须先解锁。
实现不可重入锁有什么方式呢?那就是自旋~
(什么是自旋锁?等下详细说,先有个概念,就是当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待)
- 代码如下:
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
//这句是很经典的“自旋”语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
复制代码
同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁。
4 自旋锁 & 自适应旋转锁
4.1 自旋锁
自旋锁就是,如果此时拿不到锁,它不马上进入阻塞状态,而愿意等待一段时间。
类似于线程在那里做空循环,如果循环一定的次数还拿不到锁,那么它才会进入阻塞的状态,这个循环次数是可以人为指定的。
- 栗子时间
有一天去全家买咖啡,服务员说真不巧,前面咖啡机坏了,现在正在修,要等10分钟喔,恰好没什么急事,那就等吧,坐到一边休息区等10分钟(其它什么事都没做)。介就是自旋锁~
那是不是有点浪费?如果你等了15分钟,还没修好,那你可能不愿意继续等下去了(15分钟就是设定的自旋等待的最大时间)
4.2 自适应旋转锁
上面说自旋锁循环的次数是人为指定的,而自适应旋转锁,厉害了,它不需要人为指定循环次数,它自己本身会判断要循环几次,而且每个线程可能循环的次数也是不一样的。
如果这个线程之前拿到过锁,或者经常拿到一个锁,那它自己判断下来再次拿到这个锁的概率很大,循环次数就大一些;如果这个线程之前没拿到过这个锁,那它就没把握了,怕消耗CPU,循环次数就小一点。
它解决的是“锁竞争时间不确定”的问题,但也不一定它自己设定的一定合适。
- 栗子时间
还是前面去全家等咖啡的栗子吧~ 要是等到5分钟,还没修好,你目测10分钟里也修不好,就不再等下去了(循环次数小);要是等了10分钟了,服务员说非常抱歉,快了快了,再1分钟就可以用了,你也还不急,都已经等了10分钟了,就多等一会儿嘛(循环次数大)
4.3 怎么实现自旋锁?
简单实现如下:
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
复制代码
lock()方法利用CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环;
如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁,线程B才能获取锁。
- 存在的问题
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 本身无法保证公平性,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
- 也无法保证可重入性。基于自旋锁,可以实现具备公平性和可重入性质的锁。
4.4 自旋锁 Vs 阻塞锁
自旋锁是不阻塞锁,但是它也会等一段时间,那和阻塞锁有什么区别?
- 举栗子~
去一个热门饭店吃饭,到了门口一看,门口的座位坐满了人……这咋整……服务员说,您可以先拿个号~小票上扫个二维码,关注咱们,轮到您了,服务号里就会有提示哒~(很熟悉是不是?) 然后你就先取了号去逛逛周围小店去了,等轮到你了,手机里收到一条服务提醒消息,到你啦~这时你再去,就可以进店了。
这就是阻塞的过程~
如果是自旋锁呢? 就是你自己其它事情都不做,等在那里,就像去超市排队结账一样,你走开的话是没有人会通知你的,只能重新排队,需要自己时刻检查有没有排到(能不能访问到共享资源)。
插播一下:
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。
复制代码
自旋锁 | 阻塞锁 | |
---|---|---|
改变线程状态? | 不改变线程运行状态,一直处于用户态,即线程一直都是active的 | 改变线程运行状态,让线程进入阻塞状态进行等待 |
占用CPU? | 占用CPU时间 | 不会占用CPU时间,不会导致 CPU占用率过高,但进入时间以及恢复时间都要比自旋锁略慢 |
适用场景 | 线程竞争不激烈,并且保持锁的时间段 | 竞争激烈的情况下 阻塞锁的性能要明显高于自旋锁 |
5 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
5.1 锁的状态
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时),它们会随着竞争的激烈而逐渐升级,并且是不可逆的升级。
- 偏向锁 -> 轻量级锁 -> 重量级锁
P.S., 无锁,即没有锁~
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
CAS算法 即compare and swap(比较与交换),就是有名的无锁算法。
5.2 各状态比较
状态 | 描述 | 优点 | 缺点 | 应用场景 | 适用场景 |
---|---|---|---|---|---|
偏向锁 | 无实际竞争,让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁 | 加锁解锁不需要额外消耗 | 如果线程间存在竞争,会有撤销锁的消耗 | 只有一个线程进入临界区 | 适用于只有一个线程访问同步块场景。 |
轻量级 | 无实际竞争,多个线程交替使用锁;允许短时间的锁竞争 | 竞争的线程不会阻塞 | 如果线程一直得不到锁,会一直自旋,消耗CPU | 多个线程交替进入临界区 | 追求响应时间。同步块执行速度非常快。 |
重量级 | 有实际竞争,且锁竞争时间长 | 线程竞争不使用自旋,不消耗CPU | 线程阻塞,响应时间长 | 多个线程同时进入临界区 | 追求吞吐量。同步块执行速度较长。 |
- 来看栗子~
你经常去一家店坐在同一个位置吃饭,老板已经记住你啦,每次你去的时候,只要店里客人不多,老板都会给你留着那个座位,这个座位就是你的“偏向锁”,每次只有你这一个线程用。
有一天你去的时候,店里已经坐满了,你的位置也被别人坐了,你只能等着(进入竞争状态),这时那个座位就升级到“轻量级”了。
要是那个座位特别好(临窗风景最佳,能隔江赏月~)每次你到的时候,都有其他好几个人也要去抢那个位置,没坐到那个位置就不吃饭了>_< 那时那个座位就升级到“重量级”了。
5.3 主要区别
轻量级锁和重量级锁的重要区别是: 拿不到“锁”时,是否有线程调度和上下文切换的开销。
简单来说:如果发现同步周期内都是不存在竞争,JVM会使用CAS操作来替代操作系统互斥量。这个优化就被叫做轻量级锁。
相比重量级锁,其加锁和解锁的开销会小很多。重量级锁的“重”,关键在于线程上下文切换的开销大。
6 独享 Vs 共享
共享 Vs 独享 图~ 是不是很形象? ?
类型 | 描述 | 实现类 |
---|---|---|
共享(读锁) | 可被多个线程所持有,其他用户可以并发读取数据。如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。 | ReentrantReadWriteLock里的读锁 |
独享(排他锁,写锁) | 一次只能被一个线程所持有。如果事务T对数据A加上排它锁后,则其他事务不能再对A加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。 | synchronized、ReentrantLock、ReentrantReadWriteLock里的写锁 |
排它锁是悲观锁的一种实现。
问题来了:共享锁为什么要加个“读锁”?
防止数据在被读取的时候被别的线程加上写锁。
复制代码
而独占锁的原理是:
如果有线程获取到锁,那么其它线程只能是获取锁失败,然后进入等待队列中等待被唤醒。
复制代码
- 栗子来了
小组每个礼拜都要各个成员共同填一份周报表格,要是每个人打开的时候,可以加一个写锁,即你在写的时候,别人不能修改,这就是独享锁(写锁); 但是这份表格大家可以同时打开,看到表格内容(读取数据),正在改数据的人可以对这份表格加上共享锁,那这个锁就是共享锁。
6.1 共享锁的代码实现
共享锁的获取方法为acquireShared,源码为:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
复制代码
当返回值为大于等于0的时候方法结束说明获得成功获取锁,否则,表明获取同步状态失败即所引用的线程获取锁失败,会执行doAcquireShared方法.
6.2 获取独占锁方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
复制代码
- 解读:
- 尝试获取锁,这个方法需要实现类自己实现获取锁的逻辑,获取锁成功后则不执行后面加入等待队列的逻辑了;
- 如果尝试获取锁失败后,则执行 addWaiter(Node.EXCLUSIVE) 方法将当前线程封装成一个 Node 节点对象,并加入队列尾部;
- 把当前线程执行封装成 Node 节点后,继续执行 acquireQueued 的逻辑,该逻辑主要是判断当前节点的前置节点是否是头节点,来尝试获取锁,如果获取锁成功,则当前节点就会成为新的头节点,这也是获取锁的核心逻辑。
简单来说 addWaiter(Node mode) 方法做了以下事情:
创建基于当前线程的独占式类型的节点; 利用 CAS 原子操作,将节点加入队尾。
锁的实现类
ReentrantLock
ReentrantLock 是一个独占/排他锁。
特性
- 公平性:支持公平锁和非公平锁。默认使用了非公平锁。
- 可重入
- 可中断:相对于 synchronized,它是可中断的锁,能够对中断作出响应。
- 超时机制:超时后不能获得锁,因此不会造成死锁。
ReentrantLock构造函数
提供了是否公平锁的初始化:
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
复制代码
使用ReentrantLock必须在finally控制块中进行解锁操作。
在资源竞争不激烈的情形下,性能稍微比synchronized差点点。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍,而ReentrantLock确还能维持常态。
高并发量情况下使用ReentrantLock。
优点: 可一定程度避免死锁。
- Semaphore
- AtomicInteger、AtomicLong等
小总结
对Java的各种锁概念做了下整理,写了些自己的理解, 还有很多基础方面,比如Java的对象头、对象模型(都比较基础)、锁的优化、各类锁代码实现等,后续再补充下。 有很多公号有很多高水平的文章,需要理解和练习的有太多。