前言:
本节内容不局限于JavaEE,主要是介绍多线程中锁相关的一些专业术语以及概念。
1、乐观锁vs悲观锁
概念
这里所讲的乐观\悲观指的是线程去获取锁时, 出现锁竞争的概率小还是大。
- 乐观锁: 这种锁比较“乐观”,它默认锁竞争的概率比较小,所以在读取数据时不加锁,在更新变量的时候才上锁,并没有长期持有锁,提高了并发性,进而这种锁是资源开销相对小。
- 悲观锁 这种锁“悲观的”认为锁竞争是比较激烈的,所以安全起见,它决定在去取数据的时候就上锁,因此锁的占用时间比较大,其他线程出现阻塞的概率就变大了,并发性就会降低,进而这种所资源开销比较大。
注意: 乐观锁和悲观锁各有优缺点,谁有谁劣不能一概而论,要根据场景分析。
应用场景:
- 乐观锁:适合在低冲突高并发的场景下使用。
- 悲观锁:适合在高冲突、数据严格要求一致的场景下使用。
2、轻量级锁vs重量级锁
这一组概念与上面的悲观锁&乐观锁是比较重合的。你可以近似的认为:轻量级锁就是乐观锁,重量级锁就是悲观锁,只是这两组概念描述锁性质的角度不同而已。
锁是“原子性”的原因:
锁的核心特质是“原子性”,不同的编程语言之所以都能通过锁,实现原子性的原因归根结底来自于CPU这种硬件设备的支持:
重量级锁: 较为悲观,所以未雨绸缪,做的工作多。
- 大量的内核态用户态切换,重度依赖刚才所讲的mutex
- 它容易引发线程调度
以上两种操作,成本是比较高的,它让线程更安全,但效率也降低了。
轻量级锁: 较为乐观,所以活在当下,做的工作比较少。
- 少量的用户态内核态切换,能不用mutex,就不用。
- 不太容易引发线程调度。
如果不理解这两个操作也没关系,直到重量级做的工作多,开销大,轻量级做的工作少,开销小即可。
3、自旋锁vs挂起等待锁
自旋锁&挂起等待锁,实际上就是乐观锁(轻量级锁)&悲观锁(重量级锁)的典型实现。
自旋锁:
- 定义: 一个线程尝试获取自旋锁,如果锁被持有,那么这个线程不会进入睡眠状态,而是一直循环检查所是否被释放,直到锁被释放,这种“循环检查”的锁,称之为自旋锁。
- 特点:
忙等
,一个线程在循环等待锁释放的过程中,此线程不会释放CPU资源,即使锁被持有的时间会很长,这种一致检查锁是否被释放,不计资源开销的行为就叫忙等。 - 优点:
- 低延时: 在每个线程持有锁的时间都很短的情况下,线程的响应速度是非常快的。
- 实现简单: 自旋锁实现通常比较简单,比较轻量,只需要进行原子操作。
- 缺点: 在锁竞争比较激烈的情况下或者锁持有时间比较长的情况下,自旋锁会本身会占用大量的CPU资源,降低运行效率。
挂起等待锁:
- 定义: 一个线程在尝试获取一个被持有的锁时,不会忙等,而是被挂起然后进入睡眠状态,直到锁被释放,这种机制通常依赖操作系统的支持。
- 优点: 当无法获取锁时,线程会被挂起等待,不在消耗CPU资源,把CPU资源腾给别的线程用,高效利用了CPU资源。
- 缺点: 实现锁本身就比较复杂,并且涉及上下文切换3,需要内核态支持,较为重量,运行效率不高。
总结:
自旋锁和挂起等待锁各有专长,根据实际情况使用。如果锁持有时间短,低锁竞争,采用自旋锁设计。如果持有锁时间长,高锁竞争,同步性要求高,采用挂起等待锁的设计。
4、公平锁
公平锁是一种锁机制,这里讲的公平是先到先得。
主要特点:
- 先来先服务: 锁按照请求的先后顺序分配;
- 防止饥饿: 每个线程都有机会拿到锁,一般不会等待太长时间;
- 实现复杂: 需要引入额外的数据结构(如队列)来确保公平性;
应用场景:
需要严格要求顺序访问资源的多线程环境,避免某些线程没有机会或者长时间拿不到锁。
5、读写锁
概念:
- 给读加锁(共享锁): 共享锁是“没有互斥性”的,这意味着多个线程之间可以共同读取共享的数据,而不会相互阻塞。
- 给写加锁(独占锁): 这和一般的锁性质一样具有互斥性,多线程中只要独占锁参与,那么就会涉及锁竞争。
注意事项:
- 读加锁和读加锁之间,不互斥。
- 读加锁和写加锁之间,互斥。
- 写加锁和写加锁之间,互斥。
应用场景:
读锁的开销比写锁的开销小很多,非常适用于读操作频率远大于写操作的频率的情景。这样可以支持高并发(读是共享的),提升程序效率。
6、锁粗化
锁粗化是编译器的一种优化手段。粗细的评判标准在于锁的粒度,粒度粗就是粗化,粒度细,就是细化。
粒度粗细的评判标准:
锁粗化:
锁粗化的优点:
- 减少锁开销: 每次加锁解锁有一点过开销,锁粗化后,加锁次数减少;
- 减少锁竞争: 使用锁的频率降低,所冲突减少;
- 提高代码可读性: 在书写代码的时候,尽量增加锁粒度,减少加锁次数,可以降低代码复杂度,提升可读性,利于代码维护;
7、可重入
在逻辑上讲,锁是不可重入的,不然会造成死锁4。
不过在有些编程语言中,会对加锁操作进行优化,可以避免重复锁造成的死锁的情况发生,程序员不用关心是否有重复上锁造成死锁的威胁。
比如说Java中的synchronized关键字。但是想C++/Python这写编程语言就不支持可重入。
8、锁消除
与锁粗化一样,锁消除也是编译器的一种优化手段。
如下代码,在单线程环境下,执行这个含有synchronized关键字的代码,如果没有编译器优化,执行效率会很低:
public class Test1 {
public static void main(String[] args) {
StringBuffer sb=new StringBuffer();
sb.append('a');
sb.append('b');
sb.append('c');
sb.append('d');
}
}
像这种没有必要加锁的地方,编译器会消除这个锁,避免不必要的资源开销。
mutex可以理解为操作系统为其他程序提供锁机制的一组API。 ↩︎
synchronized的实现不止是依靠互斥锁实现的,它实际上还做了很多工作,关于synchronized的一些性质特点请参阅JavaEE 第13节。 ↩︎
简要的理解就是线程调度的过程。这里的上下文指的是某一个线程/进程在某一个时间点的状态,以及相关信息。要切换线程/进程调度,首先需要保存当前上下文,然后加载下一个上下文,最后才切换到另一个线程/进程。上下文切换的开销是比较大的。 ↩︎
重复对一个地方加锁,产生死锁的原因,具体请看JavaEE 第4节中,“synchronized的可重入(Reentrant)”,里面有详细的图文解释。 ↩︎