目录
前言
接下来讲解的锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容.
这些特性主要是给锁的实现者来参考的. 我们虽然不实现锁, 但是了解这些特性可以更好的使用锁.
常见的锁策略
乐观锁和悲观锁
- 乐观锁: 乐观锁这种策略是在加锁前,预估当前出现锁冲突的概率不大,因此在这种锁策略下进行的加锁就不会做太多的工作,由于加锁过程做的事情比较少,加锁的速度可能就会更快,但是此时更容易引起一些其他的问题(如:消耗更多 CPU 资源)。持乐观的态度,它假定在自己读取数据的过程中,其他线程不会对数据进行修改。所以,在读取数据时并不会加锁,只有在进行数据更新操作时,才会去检查在自己读取数据之后,是否有其他线程对数据进行了修改。
- 悲观锁这种策略是在加锁前,预估当前出现锁冲突的概率比较大,因此在这种锁策略下进行的加锁就会做更多的工作,由于加锁过程中做的事情更多,加锁的速度可能就会更慢,但是此时整个过程中不容易出现其他的问题。它秉持一种悲观的态度,认为在自己使用数据的时候,其他线程很可能会对数据进行修改。因此,在每次读取数据时,都会先对数据进行加锁,以防止其他线程对其进行修改。只有获取到锁的线程,才能对数据进行读写操作,在操作完成并释放锁之前,其他线程只能等待。
重量级锁和轻量级锁
- 轻量级锁这种策略的加锁机制会尽可能不使用操作系统(OS)中提供的 mutex 所以加锁开销会更小,加锁速度更快,可以这么认为,轻量级锁一般就是乐观锁。
- 重量级锁的开销则会更大, 加锁速度就更慢. 所以重量级锁一般就是悲观锁
- 以上轻量级锁与重量级锁都是加锁之后对结果进行的评价,而悲观锁与乐观锁是在加锁之前,对未发生的事情做一个预估,整体来看,这两组锁策略的这两种角度是在描述同一个事情。
挂起等待锁和自旋锁
- 挂起等待锁:如果遇到锁已经被占用了, 我们就挂起等待(调度到等待队列), 等待未来某个时间被唤醒. 这个涉及到线程调度, 线程调度是内核态操作, 开销很大, 所以挂起等待锁一般是重量级锁. 同时自旋锁一般也是乐观锁, 因为我们自旋锁使用的前提就是我们预测竞争这把锁的线程较少, 冲突概率小. 意味着我们等待释放锁的时间较短, 只要等其他线程释放了, 我们就可以第一时间拿到这把锁. 但是一旦线程多了, 那么自旋锁占着CPU要等待很长时间才能拿到锁, 这里就浪费了CPU资源, 所以自旋锁一般适用于竞争小的场景
- 自旋锁: 遇到锁冲突, 先不着急挂起等待. 而是进行重试, 很有可能重试两下, 锁就释放了. 那么我们就可以第一时间拿到锁. 这个不涉及线程调度这种内核操作, 是用户态操作. 所以自旋锁一般是轻量级锁.同时挂起等待锁一般也是悲观锁, 因我们挂起等待锁使用的前提就是我们预测竞争这把锁的线程比较多, 冲突概率大. 意味着等待释放锁的时间比较长, 那么我们就没必要一直占着CPU等待这把锁. 而是放弃CPU去做其他事情, 等其他线程竞争完了, 再唤醒我们这个线程去拿到锁.适用于线程竞争锁多的场景.
自旋锁例子:抢车位
假设有一个小区,里面有一个公共停车位(共享资源),小区里有很多居民(线程)都想把车停进去。小明是其中一个居民,当他开车回家发现停车位被别人占了的时候,他没有选择离开,而是一直在停车位附近绕圈(自旋),每隔一会儿就看看停车位上的车有没有开走。只要停车位一有空,他就立刻把车停进去。
- 在这个例子里,小明不断绕圈查看停车位是否可用的行为就类似于自旋锁。线程(小明)在获取锁(停车位)失败后,不会放弃 CPU,而是持续尝试获取锁,直到成功为止。自旋锁适用于锁被占用的时间较短的情况,因为如果一直占用 CPU 去自旋等待,而锁长时间不被释放,会浪费 CPU 资源。
当车主 A 到达时,发现车位是空的,直接把车停了进去(获取锁成功)。紧接着车主 B 也来了,看到车位被占,他没有在旁边转圈等待,而是在小区门口的登记处留下了自己的联系方式(进入等待队列),然后就开车去附近的超市购物了(线程挂起,释放 CPU 资源),不再占用小区内的道路资源。过了一会儿,车主 A 离开,把车位空了出来(释放锁)。小区保安(操作系统调度器)查看登记本,联系了最早登记的车主 B,告诉他车位空了(唤醒线程)。
车主 B 收到通知后,开车回到小区,顺利把车停进了车位(重新获取锁并执行操作)。
- 这种 “登记信息后离开,等通知再回来” 的模式就是挂起等待锁的工作方式。线程在获取锁失败后会主动放弃 CPU,进入等待状态,直到锁被释放并收到唤醒通知,才会再次尝试获取锁。这避免了无效的资源占用,适合锁被持有时间较长的场景。
公平锁与非公平锁
- 公平锁这种策略可以很好的避免“线程饿死”这种情况的发生(线程饿死指一个线程在锁冲突中一直都无法获取到锁也就无法执行出现的饿死情况),在公平锁的这种锁策略下会按照先来后到的顺序来获取锁,所以想要实现公平锁就需要引入额外的数据结构(比如引入队列,然后在队列中记录每个线程的先后顺序),才能实现公平锁。
- 非公平锁这种策略就属于我们系统原生的锁的角度,系统线程的调度本身就是无序随机的,所以在上一个线程释放锁之后,接下来唤醒哪个线程就不好说了,我们使用的 synchronized 就是一种非公平锁。
可重入锁和非可重入锁
- 不可重入锁这种策略是不允许一个线程对同一把锁连续加锁两次的,在不可重入锁这种策略下如果一个线程对同一把锁连续加锁两次就会出现“死锁”,系统中的自带的锁就是不可重入锁,但是在Java语言中,锁都是可重入锁,所以并不能通过代码演示出“死锁”的效果,不过相关的逻辑可以用Java代码进行表示,如下图所示:
- 可重入锁这种策略是允许一个线程对一把锁连续加锁两次的,并且不会出现死锁的情况,像我们使用的 synchronized 就属于可重入锁,对于可重入锁来说,它的内部会持有两个信息:
- 当前这个锁是哪个线程持有的;
- 记录加锁次数的计数器。
- 在上述对同一把锁进行第二次加锁时,就会先判断当前加锁的线程是否是持有锁的线程,如果不是同一个线程,那么就会进入阻塞,如果是同一个线程就只会进行计数器 +1 的操作。
读写锁与普通互斥锁
- 读写锁这种策略会把加锁分成两种情况,一种是加读锁,另一种是加写锁,这两种锁之间的锁冲突情况如下:
(1)读写锁
读写锁这种策略会把加锁分成两种情况,一种是加读锁,另一种是加写锁,这两种锁之间的锁冲突情况如下:
读锁与读锁之间,不会出现锁冲突(不会阻塞);
写锁与读锁之间,会出现锁冲突(会阻塞);
写锁与写锁之间,会出现锁冲突(会阻塞)。
- 上面的这几种情况也可以理解为:在一个线程加读锁的时候,另一个线程只能进行读操作,不能进行写操作;在一个线程加写锁的时候另一个线程不能进行读操作也不能进行写操作。
- 为什么要使用读写锁这样的策略呢? 因为我们在多线程的场景下执行读操作本身是线程安全的. 不需要互斥, 如果使用synchronized这种方式进行加锁, 两个线程之间进行读操作的时候也会互斥. 产生阻塞, 此时我们的代码性能就有一定的损失. 但是如果完全不给读操作加锁的话, 也会出现问题. 万一当前线程进行读操作, 另外一个线程进行写操作. 就可能会出现读到写了一半的数据, 就出现线程安全问题了. 所以我们Java标准库中提供了读写锁.
- 加读锁, 和加写锁. t1线程拿到读锁, t2线程拿到读锁. 两个线程之间不会互斥, t1线程拿到度搜, t2线程拿到写锁. 两个线程直接就会互斥. t1写锁和t2写锁也是互斥
- 并且,在我们生活中使用各种应用程序的过程中可以发现,读操作是一个非常频繁的操作,非常的常见,读写锁可以把这些并发读之间的锁冲突的开销给省下来,这对于代码的性能就提升非常明显了。
(2)普通互斥锁
普通互斥锁这种策略类似于 synchronized 加锁的操作,就是加锁和解锁。
synchronized 原理
- 通过上面介绍的锁策略,我们可以来总结一下 synchronized 具有的特性,如下所示:
synchronized 是乐观锁/悲观锁自适应的;
synchronized 是轻量级锁/重量级锁自适应的;
synchronized 是自旋锁/挂起等待锁自适应的;
synchronized 不是读写锁;
synchronized 是一种非公平锁;
synchronized 是一种可重入锁。
加锁过程
- 在上面介绍 synchronized 的基本特点时,其中的“自适应”是怎么回事呢?这是当线程执行到 synchronized 的时候,如果当前对象处于未加锁的状态就会经历以下的过程,在下面过程中就会体现出 synchronized 自适应的特性了
偏向锁阶段
synchronized 在偏向锁阶段的核心思想和我在前面文章介绍的单例模式中的“懒汉模式”思想相似,偏向锁阶段就是能不加锁就不加锁,能晚加锁就晚加锁,所谓的偏向锁就是指并非真正的加锁了,而只是做了一个非常轻量的标记,此时一旦有其他线程来竞争这把锁,就会在另一个线程之前先把锁获取到,此时就会从偏向锁升级到自旋锁(轻量级锁)了,就是真的加锁了,就会产生互斥,但是这个过程中,如果没有线程来竞争这把锁,那整个过程的加锁操作就会完全省略。不会真正加锁
- 偏向锁的工作过程比较简单,在每个锁对象都有自己的一个偏向锁标记的属性,当这个锁对象首次被加锁的时候就会先进入偏向锁,在这个过程中如果没有涉及到锁竞争,下次对这个锁对象进行加锁就还是偏向锁,但是这个过程中一旦涉及到了锁竞争,偏向锁就会升级成轻量级锁,后续再对这个锁对象进行加锁就都是自旋锁(轻量级锁)了。
- 偏向锁就是非必要不加锁,在遇到竞争的情况下,偏向锁不会提高效率,但是如果在没有竞争的情况下,偏向锁就会大幅度提高效率了。
自旋锁阶段
- synchronized 在自旋锁阶段就是假设此时有锁竞争,但是竞争不激烈,此处轻量级锁的实现方式就是以自旋锁的方式来实现的,此时的优势就是当另外的线程把锁释放了,就会第一时间拿到锁,劣势在前面也提到过,就是比较消耗 CPU 的资源。
重量级锁阶段
-
synchronized 在重量级锁阶段时,拿不到锁的线程就不会继续自旋了,而是进入“阻塞等待”,此时就会让出 CPU 了,不会时 CPU 占用率太高,如果当前线程释放锁,这时就会由系统随机唤醒一个线程来获取锁了。
以上的几个阶段,就是 synchronized 加锁的过程,这个过程可以看出 synchronized 自适应的特性,只不过目前这里自适应只能自适应的升级,不能做到自适应的降级。
其他优化
锁消除
- 锁消除这种操作是 synchronized 中内置的一种优化策略,也是编译器的一种优化方式,这里是指在编译器编译代码的过程中,如果发现这个代码不需要加锁,就会自动把加锁的操作给省略,当然这里编译器的优化是比较保守的,比如当只有一个线程,在这一个线程中进行了加锁操作,或者加锁代码中没有涉及到对“成员变量的修改”(成员变量可以被多个线程使用,就会存在多个线程修改同一个变量的情况)只是对一些“局部变量”进行修改(局部变量只会被本线程修改,其他线程无法使用),这两种情况下就都不需要加锁,编译器就会省略这里的加锁操作,像其他的很多“模棱两可”的加锁操作,编译器不知道这里的加锁操作能不能省略,就都不会进行省略。
锁粗化
-
锁粗化这种操作就是把多个细粒度的锁合并成一个粗粒度的锁,如何看此处的锁粒度是粗还是细呢?其中的评判标准可以以 synchronized 代码块中的代码数量为准,我们可以粗略的认为,synchronized 代码块中的代码越多锁的粒度越粗,代码块中代码越少锁的粒度越细。
-
通常情况下,我们更偏好让锁的粒度更细一些,这样有利于多个线程的并发执行,但是有的情况下,我们也希望锁的粒度粗点也好,比如下图这种场景:
-
上图这所示的这种情况频繁涉及到加锁与解锁,在这里我们需要明确一点,每次加锁时是有可能产生阻塞的此时代码的效率就会很低,锁粗化这种操作就会把这些细粒度的锁合并成粗粒度的锁,经过锁粗化后,情况如下图所示:
- 关于锁粗化,其实也可以举一个生活中的小例子,比如在我们写作业的过程中,会遇到不会的题,此时就会涉及到两种找老师问题的策略,第一种就是我写到一道不会的题就立即找老师询问,问完回来继续做作业,再有问题再去询问,第二种就是我把整个作业都写完,把所有遇到的问题都拿给老师去问,让老师一起帮你解决,对于这两种方式,第一种就属于细粒度的锁,第二种就属于粗粒度的锁,很显然,当把所有问题汇总到一起去问老师,会节省很多找老师过程的开销。