多线程——锁策略及 synchronized 原理
目录
·前言
前面我们介绍了加锁是解决线程安全问题的方法,在本篇文章中会对详细的介绍一些常见的锁策略,这里的锁策略不再仅仅局限于 Java 了,可以说,任何和“锁”有关的话题都可能涉及我下面介绍的内容,了解常见的锁策略对于我们合理的使用锁会有很大的帮助,同时,在本篇文章还会根据介绍的锁策略,来进一步介绍一下在我们前面文章中经常用到的加锁方式使用 synchronized 关键字它的原理,前言少说,下面开始本篇文章的内容分享吧。
一、常见的锁策略
1.乐观锁与悲观锁
(1)乐观锁
乐观锁这种策略是在加锁前,预估当前出现锁冲突的概率不大,因此在这种锁策略下进行的加锁就不会做太多的工作,由于加锁过程做的事情比较少,加锁的速度可能就会更快,但是此时更容易引起一些其他的问题(如:消耗更多 CPU 资源)。
(2)悲观锁
悲观锁这种策略是在加锁前,预估当前出现锁冲突的概率比较大,因此在这种锁策略下进行的加锁就会做更多的工作,由于加锁过程中做的事情更多,加锁的速度可能就会更慢,但是此时整个过程中不容易出现其他的问题。
2.重量级锁与轻量级锁
(1)重量级锁
重量级锁这种策略的加锁机制重度依赖了操作系统(OS)中提供的 mutex 所以加锁开销会更大,加锁速度更慢,可以这么认为,重量级锁一般就是悲观锁。
(2)轻量级锁
轻量级锁这种策略的加锁机制会尽可能不使用操作系统(OS)中提供的 mutex 所以加锁开销会更小,加锁速度更快,可以这么认为,轻量级锁一般就是乐观锁。
以上轻量级锁与重量级锁都是加锁之后对结果进行的评价,而悲观锁与乐观锁是在加锁之前,对未发生的事情做一个预估,整体来看,这两组锁策略的这两种角度是在描述同一个事情。
3.自选锁与挂起等待锁
(1)自旋锁
自旋锁这种策略就是上面提到的轻量级锁的一种典型实现,它在进行加锁的时候会搭配一个 while 循环,如果加锁成功循环就会结束,如果加锁不成功,不会说是阻塞放弃 CPU 而是进行下一次的循环,再尝试获取锁。
这种反复又快速的执行过程,就称为“自选”,这样一旦其他线程释放了锁,它就能够第一时间拿到锁,同时这样的自选锁也就可以看作乐观锁,因为在我们使用自旋锁的前提就是预期当前的锁冲突的概率并不大,其他线程释放了锁就可以第一时间拿到,但是也正如前面所说,乐观锁与自旋锁的加锁速度是会更快但是也会消耗 CPU 资源,比如当前加锁的线程很多,自旋的意义就不大了,此时每次的循环就相当于白白浪费 CPU 的资源了。
(2)挂起等待锁
挂起等待锁这种策略就是上面提到的重量级锁的一种典型实现,它会在进行挂起等待的时候需要内核调度器的介入,此时要完成的操作就会非常多了,真正获取到锁花费的时间也就会更多一些了,但是这种锁策略可以用于锁冲突比较激烈的情况。
同时,这里的挂起等待锁也是一种悲观锁。
4.公平锁与非公平锁
(1)公平锁
公平锁这种策略可以很好的避免“线程饿死”这种情况的发生(线程饿死指一个线程在锁冲突中一直都无法获取到锁也就无法执行出现的饿死情况),在公平锁的这种锁策略下会按照先来后到的顺序来获取锁,所以想要实现公平锁就需要引入额外的数据结构(比如引入队列,然后在队列中记录每个线程的先后顺序),才能实现公平锁。
(2)非公平锁
非公平锁这种策略就属于我们系统原生的锁的角度,系统线程的调度本身就是无序随机的,所以在上一个线程释放锁之后,接下来唤醒哪个线程就不好说了,我们使用的 synchronized 就是一种非公平锁。
5.可重入锁与不可重入锁
(1)不可重入锁
不可重入锁这种策略是不允许一个线程对同一把锁连续加锁两次的,在不可重入锁这种策略下如果一个线程对同一把锁连续加锁两次就会出现“死锁”,系统中的自带的锁就是不可重入锁,但是在Java语言中,锁都是可重入锁,所以并不能通过代码演示出“死锁”的效果,不过相关的逻辑可以用Java代码进行表示,如下图所示:
上面这段代码,在不可重入锁这种策略下就会出现“死锁”,因为第二次对 locker 进行加锁时由于 locker 还没有被第一次加锁操作释放所以会进入阻塞等待,然而想要释放 locker 必须执行完第一个 synchronized 代码块中的代码,此时就会进入“死锁”,利用生活中的例子就像你把钥匙锁在了屋里一样,想要开门就需要钥匙,可是钥匙确被门锁了起来。
(2)可重入锁
可重入锁这种策略是允许一个线程对一把锁连续加锁两次的,并且不会出现死锁的情况,像我们使用的 synchronized 就属于可重入锁,对于可重入锁来说,它的内部会持有两个信息:
- 当前这个锁是哪个线程持有的;
- 记录加锁次数的计数器。
这里我们还是使用上面的代码来对可重入锁的执行过程进行一个详细的介绍,如下图所示:
在上述对同一把锁进行第二次加锁时,就会先判断当前加锁的线程是否是持有锁的线程,如果不是同一个线程,那么就会进入阻塞,如果是同一个线程就只会进行计数器 +1 的操作。
6.读写锁与普通互斥锁
(1)读写锁
读写锁这种策略会把加锁分成两种情况,一种是加读锁,另一种是加写锁,这两种锁之间的锁冲突情况如下:
- 读锁与读锁之间,不会出现锁冲突(不会阻塞);
- 写锁与读锁之间,会出现锁冲突(会阻塞);
- 写锁与写锁之间,会出现锁冲突(会阻塞)。
上面的这几种情况也可以理解为:在一个线程加读锁的时候,另一个线程只能进行读操作,不能进行写操作;在一个线程加写锁的时候另一个线程不能进行读操作也不能进行写操作。
为什么要引入读写锁这样的策略呢?这是因为多线程在执行读操作时本身就是线程安全的,不需要互斥,如果使用 synchronized 这种方式进行加锁,两个线程在进行读操作时也会产生互斥,产生阻塞,此时对我们编写代码的性能会有一定的损失,但是如果完全不给读操作加锁,也会出现问题,此时当一个线程正在进行读操作,另一个线程正在进行写操作时,就可能会出现读到写了一半的数据,所以在此我们需要引入读写锁来解决这种问题。
并且,在我们生活中使用各种应用程序的过程中可以发现,读操作是一个非常频繁的操作,非常的常见,读写锁可以把这些并发读之间的锁冲突的开销给省下来,这对于代码的性能就提升非常明显了。
(2)普通互斥锁
普通互斥锁这种策略类似于 synchronized 加锁的操作,就是加锁和解锁。
二、synchronized 原理
1.基本特点
通过上面介绍的锁策略,我们可以来总结一下 synchronized 具有的特性,如下所示:
- synchronized 是乐观锁/悲观锁自适应的;
- synchronized 是轻量级锁/重量级锁自适应的;
- synchronized 是自旋锁/挂起等待锁自适应的;
- synchronized 不是读写锁;
- synchronized 是一种非公平锁;
- synchronized 是一种可重入锁。
2.加锁过程
在上面介绍 synchronized 的基本特点时,其中的“自适应”是怎么回事呢?这是当线程执行到 synchronized 的时候,如果当前对象处于未加锁的状态就会经历以下的过程,在下面过程中就会体现出 synchronized 自适应的特性了。
(1)偏向锁阶段
synchronized 在偏向锁阶段的核心思想和我在前面文章介绍的单例模式中的“懒汉模式”思想相似,偏向锁阶段就是能不加锁就不加锁,能晚加锁就晚加锁,所谓的偏向锁就是指并非真正的加锁了,而只是做了一个非常轻量的标记,此时一旦有其他线程来竞争这把锁,就会在另一个线程之前先把锁获取到,此时就会从偏向锁升级到轻量级锁了,就是真的加锁了,就会产生互斥,但是这个过程中,如果没有线程来竞争这把锁,那整个过程的加锁操作就会完全省略。
偏向锁的工作过程比较简单,在每个锁对象都有自己的一个偏向锁标记的属性,当这个锁对象首次被加锁的时候就会先进入偏向锁,在这个过程中如果没有涉及到锁竞争,下次对这个锁对象进行加锁就还是偏向锁,但是这个过程中一旦涉及到了锁竞争,偏向锁就会升级成轻量级锁,后续再对这个锁对象进行加锁就都是轻量级锁了。
偏向锁就是非必要不加锁,在遇到竞争的情况下,偏向锁不会提高效率,但是如果在没有竞争的情况下,偏向锁就会大幅度提高效率了。
(2)轻量级锁阶段
synchronized 在轻量级锁阶段就是假设此时有锁竞争,但是竞争不激烈,此处轻量级锁的实现方式就是以自旋锁的方式来实现的,此时的优势就是当另外的线程把锁释放了,就会第一时间拿到锁,劣势在前面也提到过,就是比较消耗 CPU 的资源。
在轻量级锁阶段时,synchronized 内部会统计当前锁对象上有多少线程参与竞争,当发现参与竞争的线程比较多,就会进一步升级到重量级锁,这是因为对于自旋锁来说,如果同一个锁竞争者很多,大量的线程就都在自旋,整体 CPU 的消耗太大,所以需要进一步升级。
(3)重量级锁阶段
synchronized 在重量级锁阶段时,拿不到锁的线程就不会继续自旋了,而是进入“阻塞等待”,此时就会让出 CPU 了,不会时 CPU 占用率太高,如果当前线程释放锁,这时就会由系统随机唤醒一个线程来获取锁了。
以上的几个阶段,就是 synchronized 加锁的过程,这个过程可以看出 synchronized 自适应的特性,只不过目前这里自适应只能自适应的升级,不能做到自适应的降级。
3.其他优化操作
(1)锁消除
锁消除这种操作是 synchronized 中内置的一种优化策略,也是编译器的一种优化方式,这里是指在编译器编译代码的过程中,如果发现这个代码不需要加锁,就会自动把加锁的操作给省略,当然这里编译器的优化是比较保守的,比如当只有一个线程,在这一个线程中进行了加锁操作,或者加锁代码中没有涉及到对“成员变量的修改”(成员变量可以被多个线程使用,就会存在多个线程修改同一个变量的情况)只是对一些“局部变量”进行修改(局部变量只会被本线程修改,其他线程无法使用),这两种情况下就都不需要加锁,编译器就会省略这里的加锁操作,像其他的很多“模棱两可”的加锁操作,编译器不知道这里的加锁操作能不能省略,就都不会进行省略。
锁消除就是针对一眼看上去就完全不涉及线程安全问题的代码把锁消除掉,而上面介绍的偏向锁是在运行起来后才能知道是否有锁冲突,所以虽然这里他们工作中可能都没有真正加锁,但是所蕴含的逻辑与思想是截然不同的。
(2)锁粗化
锁粗化这种操作就是把多个细粒度的锁合并成一个粗粒度的锁,如何看此处的锁粒度是粗还是细呢?其中的评判标准可以以 synchronized 代码块中的代码数量为准,我们可以粗略的认为,synchronized 代码块中的代码越多锁的粒度越粗,代码块中代码越少锁的粒度越细。
通常情况下,我们更偏好让锁的粒度更细一些,这样有利于多个线程的并发执行,但是有的情况下,我们也希望锁的粒度粗点也好,比如下图这种场景:
上图这所示的这种情况频繁涉及到加锁与解锁,在这里我们需要明确一点,每次加锁时是有可能产生阻塞的此时代码的效率就会很低,锁粗化这种操作就会把这些细粒度的锁合并成粗粒度的锁,经过锁粗化后,情况如下图所示:
此时就相当于把三次细粒度的锁合并成一个粗粒度的锁了,锁粗化也就是为了提高效率。
关于锁粗化,其实也可以举一个生活中的小例子,比如在我们写作业的过程中,会遇到不会的题,此时就会涉及到两种找老师问题的策略,第一种就是我写到一道不会的题就立即找老师询问,问完回来继续做作业,再有问题再去询问,第二种就是我把整个作业都写完,把所有遇到的问题都拿给老师去问,让老师一起帮你解决,对于这两种方式,第一种就属于细粒度的锁,第二种就属于粗粒度的锁,很显然,当把所有问题汇总到一起去问老师,会节省很多找老师过程的开销。
·结尾
文章到此也就要结束了,本篇文章重点介绍了一些常见的锁策略,还有 synchronized 的原理,在 synchronized 的背后涉及了很多的优化手段如:锁的自适应升级(偏向锁->轻量级锁->重量级锁),锁消除(自动消除不必要的锁),锁粗化(合并多个细粒度锁为一个粗粒度锁,减少竞争开销),这些知识背后的思想非常值得我们去学习,在理解这些知识后,会对我们编写代码有很大的帮助,那么如果本篇文章对你有所帮助,还是希望得到你的三连支持,同样如果对文章内容有什么疑惑,欢迎在评论区进行留言,那么我们下一篇文章再见吧~~~
更多推荐



所有评论(0)