锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。
就相当于给资源加了一扇门,获取到锁的线程可以进入门里面使用资源,获取不到锁的线程在门外排队等候。
1、锁的 7 大分类
偏向锁/轻量级锁/重量级锁
这三种锁特指 synchronized
锁的状态,通过在对象头中的 mark word
来表明锁的状态。
- 偏向锁
如果这把锁一直都没有竞争,就没必要上锁,打个标记就行了。
一个对象被初始化后,此时处于无锁状态,当有第一个线程来访问它并尝试获取锁的时候,此时就是偏向锁状态,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。
- 轻量级锁
如果只有短时间的锁竞争,用 CAS
的方式就可以解决,这种情况下用完全互斥的重量级锁是没必要的。
轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
- 重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
锁升级过程:无锁→偏向锁→轻量级锁→重量级锁。锁的升级过程是不可逆的,也就是重量级锁不能退化成轻量级锁,其他同理。
总结:偏向锁性能最好,可以避免执行 CAS
操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
可重入锁/非可重入锁
可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。
不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。
ReentrantLock
和 synchronized
都是可重入锁。
共享锁/独占锁
共享锁指的是我们同一把锁可以被多个线程同时获得,而独占锁指的就是,这把锁只能同时被一个线程获得。
读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
公平锁/非公平锁
公平锁指的是排队获取锁,先来先得。
非公平锁指的是不排队,获取锁没有顺序。
悲观锁/乐观锁
悲观锁:在获取资源之前,必须先拿到锁,就是很悲观,意思就是如果我不拿到这个锁,那么我的操作就会有问题。
乐观锁:并不要求在获取资源前拿到锁,也不会锁住资源,就是很乐观。意思就是我不加锁我的操作也没问题。乐观锁可以利用 CAS
达到在不独占资源的情况下,完成对资源的操作。比悲观锁性能好。
自旋锁/非自旋锁
自旋锁:如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU
资源,而是开始利用循环,不停地尝试获取锁。
非自旋锁:如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。
可中断锁/不可中断锁
在 Java 中,synchronized
关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理。
ReentrantLock
是一种典型的可中断锁,例如使用 lockInterruptibly
方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开。
2、悲观锁和乐观锁
悲观锁
悲观锁比较悲观,它认为如果不锁住这个资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失。
乐观锁
乐观锁比较乐观,认为自己在操作资源的时候不会有其他线程来干扰,所以并不会锁住被操作对象,不会不让别的线程来接触它。
为了确保数据正确性,在更新之前,会去对比在我修改数据期间,数据有没有被其他线程修改过:如果没被修改过,就说明真的只有我自己在操作,那我就可以正常的修改数据;如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内修改过数据,那说明我迟了一步,所以我会放弃这次修改,并选择报错、重试等策略。
乐观锁的实现一般都是利用 CAS
算法实现的(可能会出现 ABA
问题)。
典型案例
- 悲观锁:
synchronized
关键字和Lock
接口
处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁,这就是非常典型的悲观锁思想。
- 乐观锁:原子类
使用了乐观锁的思想,多个线程可以同时操作同一个原子变量,使用 CAS
算法来修改变量。
- 两者都有:数据库
数据库中同时拥有悲观锁和乐观锁的思想。
在 MySQL
中执行 select for update
语句,那就是悲观锁,在提交之前不允许第三方来修改该数据,这当然会造成一定的性能损耗,在高并发的情况下是不可取的。
乐观锁就是我们可以自己定义一个字段 version
(版本号),每次更新的时候比对这个版本号,版本号正确就更新,否则就失败。
注意点
虽然悲观锁确实会让得不到锁的线程阻塞,但是这种开销是固定的。悲观锁的原始开销确实要高于乐观锁,但是特点是一劳永逸,就算一直拿不到锁,也不会对开销造成额外的影响。
反观乐观锁虽然一开始的开销比悲观锁小,但是如果一直拿不到锁,或者并发量大,竞争激烈,导致不停重试,那么消耗的资源也会越来越多,甚至开销会超过悲观锁。
使用场景
悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗。
乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。
3、synchronized 和 monitor 锁
每个
Java
对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或monitor
锁,获得monitor
锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被synchronized
保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
可通过 javap
反编译使用了 synchronized
关键字的代码查看是如何加锁的。
(1)同步代码块
同步代码块是使用 monitorenter
和 monitorexit
指令实现的。
monitorenter
理解为加锁,执行 monitorexit
理解为释放锁。
(2)同步方法
同步方法会有一个 ACC_SYNCHRONIZED
标志。
当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。
4、synchronized 和 Lock
相同点
- synchronized
和
Lock` 都是用来保护资源线程安全的。 - 都可以保证可见性。(
Java
内存模型) synchronized
和ReentrantLock
都拥有可重入的特点。(ReentrantLock
是Lock
接口的主要实现类)
不同点
- 用法区别
synchronized
关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this
),也可以新建一个同步代码块并且自定义 monitor
锁对象;
Lock
接口必须显式地加锁 lock()
和解锁 unlock()
,并且一般会在 finally
块中确保用 unlock()
来解锁,以防发生死锁。
与 Lock
显式的加锁和解锁不同的是 synchronized
的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁(可用 javap
反编译字节码查看,JVM
帮我们做了加锁和解锁的操作)
- 加解锁顺序不同
如果有多把 Lock
锁,Lock
可以不完全按照加锁的反序解锁。比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2。
synchronized
解锁的顺序和加锁的顺序必须完全相反。因为 synchronized
加解锁是由 JVM
实现的,在执行完 synchronized
块后会自动解锁,所以会按照 synchronized
的嵌套顺序加解锁,不能自行控制。
Lock
比synchronized
更灵活
一旦 synchronized
锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。
Lock
类在等锁的过程中,如果使用的是 lockInterruptibly
方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock()
等方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活。
synchronized
锁只能同时被一个线程拥有,但是Lock
锁没有这个限制
例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized
做不到。
- 原理区别
synchronized
是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。
Lock
根据实现不同,有不同的原理,例如 ReentrantLock
内部是通过 AQS
来获取和释放锁的。
- 是否可以设置公平/非公平
ReentrantLock
等 Lock
实现类可以根据自己的需要来设置公平或非公平,synchronized
则不能设置。
- 性能区别
在 Java 5 以及之前,synchronized
的性能比较低,到了 Java 6
以后,发生了变化,因为 JDK
对 synchronized
进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java
版本里的 synchronized
的性能并不比 Lock
差。
如何选择
- 如果能不用最好既不使用
Lock
也不使用synchronized
。因为在许多情况下你可以使用java.util.concurrent
包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。 - 如果
synchronized
关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在finally
里unlock
,代码可能会出很大的问题,而使用synchronized
更安全。 - 如果特别需要
Lock
的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock。
5、Lock 接口
Lock
接口源码:
public interface Lock {
void lock();
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
lock() 方法
lock()
是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。
tryLock() 方法
tryLock()
用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true
,否则返回 false
,代表获取锁失败。
tryLock(long time, TimeUnit unit) 方法
这个方法和 tryLock()
类似,区别在于会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false
;如果一开始就获取锁或者等待期间内获取到锁,则返回 true
。
lockInterruptibly() 方法
除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。
顾名思义,lockInterruptibly()
是可以响应中断的。
unlock() 方法
用于解锁。
6、公平锁和非公平锁
公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。
一定情况是指:
假设当前线程在请求获取锁的时候,恰巧前一个持有锁的线程释放了这把锁,那么当前申请锁的线程就可以不顾已经等待的线程而选择立刻插队。但是如果当前线程请求的时候,前一个线程并没有在那一时刻释放锁,那么当前线程还是一样会进入等待队列。
为什么要设置非公平策略
公平是一种很好的行为,而非公平是一种不好的行为。所有为什么会有非公平这个策略呢?
假设线程 A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态,那么当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这把锁,那么根据非公平的策略,会把这把锁给线程 C,这是因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完任务释放了这把锁。相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,
所以 Java
设计者设计非公平锁,是为了提高整体的运行效率。
公平锁和非公平锁对比
公平锁:
(1)优点:各个线程公平平等,每个线程等待一段时间后,都有执行的机会。
(2)缺点:整体执行速度更慢,吞吐量更小。
非公平锁:
(1)优点:整体执行速度更快,吞吐量更大。
(2)缺点:可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。
源码分析
在 Java 中,ReentranLock 类的构造方法可以指定是否为公平锁。
// true 表示公平锁,false 表示为非公平锁,有一个无参构造方法默认为非公平锁。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
公平锁
通过一路追踪,发现公平锁加锁的源码为 tryAcquire()
:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 判断等待队列中是否有线程在排队,如果有就不再尝试获取锁
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
非公平锁
通过一路追踪,发现公平锁加锁的源码为 trynonfairTryAcquireAcquire()
:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//这里和公平锁的源码不一样,不需要查看队列中是否有线程排队,直接尝试获取锁
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
注意 tryLock() 方法
先看源码:
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
这就意味着不管你指定的是否是非公平锁,tryLock
方法默认是按照不公平的规则执行。
总结
公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,所以存在后申请却先获得锁的情况,但由此也提高了整体的效率。
7、读写锁 ReadWriteLock
如果多个读操作同时进行,其实并没有线程安全问题,我们可以允许让多个读操作并行,以便提高程序效率。
读写锁获取规则
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作。
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写。
读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。
在 Java
中,ReadWriteLock
接口就是读写锁接口。常用的实现类为:ReentrantReadWriteLock
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
ReadWriteLock
适用于读多写少的情况,合理使用可以进一步提高并发效率。
读锁可以插队吗
第一种策略:允许插队
这种策略看上去增加了效率,但是有一个严重的问题,如果想要读取的线程不停地增加,就会导致读锁长时间内不会被释放,需要拿到写锁的线程会陷入“饥饿”状态,它将在长时间内得不到执行。
第二种策略:不允许插队
这种策略就是先来先得,不允许插队,这对于程序的健壮性是很有好处的。
ReentrantReadWriteLock
的实现选择了第二种策略。
锁的升降级
在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
只能降级,不能升级。
8、自旋锁
“自旋”就是不停地循环,直到获取锁。而不像普通的锁那样,如果获取不到锁就进入阻塞。
自旋锁的好处
阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。
自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable
状态,节省了线程状态切换带来的开销。
AtomicLong 源码
getAndSet()
方法:
public final long getAndSet(long newValue) {
return unsafe.getAndSetLong(this, valueOffset, newValue);
}
实际就是调用了 getAndSetLong()
方法:这是一个经典的自旋锁
public final long getAndSetLong(Object var1, long var2, long var4) {
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while(!this.compareAndSwapLong(var1, var2, var6, var4)); // while 循环一直自旋
return var6;
}
缺点和适用场景
缺点:
虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。
如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。
适用场景:
自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。
临界区很多的话意味着持有锁的时间会更久。
9、JVM 对锁的优化
在
JDK 1.6
中HotSopt
虚拟机对synchronized
内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。
自适应的自旋锁
“自旋”就是不释放 CPU,一直循环尝试获取锁。
自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。
比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。
自旋的时间不能我们设置,是 JVM
自己帮我们调度的。
锁消除
锁消除:经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
Java
虚拟机栈是线程私有的,也就是每个线程都有自己的虚拟机栈,执行的方法就抽象成栈帧,每个栈帧中保存了局部变量表、操作数栈、动态链接、方法出口等信息。
锁粗化
举个例子:
public void lockCoarsening() {
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
}
如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized
块合并为一个较大的同步块。
锁粗化:就是把锁的范围扩大,同步区域变大。
偏向锁/轻量级锁/重量级锁
这三种锁特指 synchronized
锁的状态,通过在对象头中的 mark word
来表明锁的状态。
- 偏向锁
如果这把锁一直都没有竞争,就没必要上锁,打个标记就行了。
一个对象被初始化后,此时处于无锁状态,当有第一个线程来访问它并尝试获取锁的时候,此时就是偏向锁状态,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。
- 轻量级锁
如果只有短时间的锁竞争,用 CAS
的方式就可以解决,这种情况下用完全互斥的重量级锁是没必要的。
轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
- 重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
锁升级过程:无锁→偏向锁→轻量级锁→重量级锁。锁的升级过程是不可逆的,也就是重量级锁不能退化成轻量级锁,其他同理。
总结:偏向锁性能最好,可以避免执行 CAS
操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
JVM
默认优先使用偏向锁。