一、前言
在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在多线程环境中强制对资源的访问限制。锁旨在强制实施互斥排他、并发控制策略。
锁通常需要硬件支持才能有效实施。这种支持通常采取一个或多个原子指令的形式,如"red-and-set"、"compare-and-swap"”。这些指令允许单个进程测试锁是否空闲,如果空闲,则通过单个原子操作获取锁。
先来了解几个锁的术语:
- 锁开销(lock overhead):锁占用内存空间、 cpu初始化和销毁锁、获取和释放锁的时间。程序使用的锁越多,相应的锁开销越大。
- 锁竞争(lock contention):一个进程或线程试图获取另一个进程或线程持有的锁,就会发生锁竞争。锁粒度越小,发生锁竞争的可能性就越小。
- 死锁(deadlock):两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
- 锁粒度(lock granularity):衡量锁保护的数据量大小。通常选择粗粒度的锁(锁的数量少,每个锁保护大量的数据),在当单进程访问受保护的数据时锁开销小,但是当多个进程同时访问时性能很差,因为增大了锁的竞争。相反,使用细粒度的锁(锁数量多,每个锁保护少量的数据)增加了锁的开销但是减少了锁竞争。
二、锁分类
1、悲观锁 VS 乐观锁
悲观锁与乐观锁并不特指某两种类型的锁,而是一种广义的概念或思想,只要指看待并发同步的角度。
1.1、悲观锁
悲观锁总是假设最坏的情况,认为对于同一个数据的并发操作,一定是会发生修改的,因此对于同一个数据的并发操作,都会采取加锁的方式,悲观的认为不加锁的并发操作一定会有问题。
在对任意记录进行修改之前,先尝试为该记录加上排它锁,如果加锁失败,说明记录正在被修改,那么当前查询可能需要等待或者抛出异常,具体的响应方式由开发者根据业务需要而定。
如果加锁成功,那么就可以对记录做修改,业务处理完成后需要解锁。加锁期间如果有其他对该记录做修改或加锁的操作,等需要等待我们解锁或者抛出异常。
图片源于网络,如有侵权,请联系删除!!!
悲观锁基本都是在显式的锁定之后再操作同步资源,非常适合写操作比较多的场景,在Java中的使用就是利用各种锁,例如Java原语中的synrchonized关键字、J.U.C包下的ReetrantLock等。看下调用示例:
// synchronized public synchronized void testMethod() { // 操作同步资源 } // ReentrantLock private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁 public void modifyPublicResources() { lock.lock(); // 操作同步资源 lock.unlock(); }
1.2、乐观锁
顾名思义,就是很乐观,总是认为不存在并发问题,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用“数据版本机制”或“CAS操作”来实现。
图片源于网络,如有侵权,请联系删除!!!
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。看下调用示例:
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger a(); //执行自增1
1.2.1、数据版本机制
实现数据版本一般有两种方式,一种是使用版本号,一种是使用时间戳。
比如在数据库的数据表中加上一个数据版本号version的字段标识数据被修改的次数,当数据被修改时,version值就会+1。
如果有多个线程并发更新某个数据行,在读取数据的同时也会读取version值,在提交更新时,只有刚才读取到的version值与当前数据库中的version值一致时才能更新,否则重试或抛出异常。
使用时间戳也是类似。
1.2.2、CAS操作
CAS(Compare and Swap 比较并交换),是一种无锁算法。当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
J.U.C包下的原子类就是通过CAS来实现的。
CAS操作中包含三个操作数:
- 需要读写的内存位置(V)
- 进行比较的预期原值(A)
- 拟写入的新值(B)。
当且仅当内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。
CAS虽然很高效,但是它也存在三大问题:
- ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。从JDK1.5开始,J.U.C下新增了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()方法中,方法逻辑是首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
- 循环时间长开销大:CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 只能保证单个共享变量的单个操作的原子性:CAS能够保证单个共享变量的单个操作的原子性,但是对多个共享变量的操作或者单个共享变量的复合操作(例如i++等),CAS是无法保证操作的原子性的。可以使用J.U.C包下的AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
2、共享锁 VS 独占锁
Java并发包提供的加锁模式分为独占锁和共享锁,独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁。
共享锁则允许多个线程同时获取锁,并发访问共享资源,如:ReadWriteLock。
AQS的内部类Node定义了两个常量SHARED和EXCLUSIVE,它们分别标识 AQS队列中等待线程的锁获取模式。
很显然,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。 java的并发包中提供了ReadWriteLock,读-写锁。它允许一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。
3、公平锁 VS 非公平锁
- 公平锁是指多个线程会按照申请锁的顺序来获取锁,线程直接进入CLH队列中排队,排在队头的线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
- 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
举个例子,以食堂打饭为例。
假设现在非就餐事件,食堂只有一个窗口有打饭大妈在,大妈有一把锁,只有拿到锁才能打饭,打完饭要将锁还给大妈。每个要打饭的人都要得到大妈的允许并且拿到锁之后才能打饭,如果前面有人正在打饭,那么这个想要打饭的人就必须排队。
大妈会看下一个打饭的人是不是排在对头的人,如果是就给你锁让你去打饭,如果不是就必须到队尾排队,这就是公平锁。整个流程如下图:
对于非公平锁,大妈对打饭的人没有要求。即便有人在排队,如果上一个打饭的人打完饭刚把锁还给大妈,这时正好又来了一个打饭的人,这个人可以直接从大妈手里拿到锁去打饭,而不用排队,原来在排队的人只能继续等待,这就是非公平锁。整个过程如下图:
4、可重入锁 VS 不可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。来看个示例代码:
synchronized void setA() throws Exception{ T(1000); setB(); } synchronized void setB() throws Exception{ T(1000); }
代码中的两个方法都是被内置锁synchronized修饰的,setA()方法中调用了setB()方法。因为内置锁是可重入的,所以同一个线程在调用setB()时是可以直接获取到锁的。
如果是一个不可重入锁,那么当前线程在调用setB()方法之前需要将执行setA()方法时获得的锁给释放掉,实际上该锁已经被当前线程持有,且无法释放。所以此时会出现死锁。
可重入锁的一个好处是可一定程度避免死锁,需要注意的是,可重入锁加锁和解锁的次数要相等。
为什么可重入锁可以在嵌套调用时自动获取锁呢?通过一个例子来解析。
还是打饭的例子,有多个童鞋在排队打饭,有的童鞋可能会帮其他童鞋带饭,就会拿多个饭盒。此时大妈允许锁和同一个人的多个饭盒绑定。这个人用多个饭盒打饭时,第一个饭盒和锁绑定并打完饭之后,
第二个饭盒也可以直接和锁绑定并开始打饭,所有的饭盒都打完饭之后这个童鞋才会将锁还给大妈。这个人的所有打饭流程都能成功执行,后续等待的人也能够打饭,这就是可重入锁。
但如果是非可重入锁的话,此时大妈只允许锁和同一个人的一个饭盒绑定。第一个饭盒和锁绑定打完饭之后并不会释放锁,导致第二个饭盒不能和锁绑定也无法打饭。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
5、自旋锁 VS 适应性自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU的状态来完成,这种状态切换比较耗费CPU时间。如果同步代码块中的内容过于简单(比如只是简单地判断下某个字段是否符合条件等),状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
为了让当前线程“稍等一下”,需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不进行阻塞而是直接获取同步资源,从而避免了线程切换的开销,这就是自旋锁。
图片源于网络,如有侵权,请联系删除!!!
自旋锁的好处是减少了线程上下文切换的开销,缺点是一直占用CPU资源。如果锁被占用的时间非常短,自旋等待的效果就会非常好。
但是如果锁被占用的时间很长,那么自旋只会白白浪费CPU资源。所以,自旋等待的时间要有限制,如果自旋超过了限定的次数(默认是10次,可以使用-XX:PreBlockSpin来更改)还没有获取到锁,那么就应该挂起线程,进入阻塞。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
6、无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁其实是指锁的状态,专门针对内置锁synchronized而言的,在介绍这四种锁状态之前,首先需要了解一下Java对象和Monitor。
6.1、Java对象头
Java对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,GC分代年龄和锁标志位等信息。Mark Word中存储的内容与锁的标志位的关系如下:
图片源于网络,如有侵权,请联系删除!!!
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
6.2、Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
6.3、无锁
无锁是指线程通过无限循环来执行更新操作,如果执行成功就退出循环,如果执行失败(有其他线程更新了值),则继续执行,直到成功为止。CAS操作就属于无锁。如果从性能的角度来看,无锁状态的性能是非常高的。
6.4、偏向锁
HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。
当一个线程访问同步块并获取锁时,会在对象头Mark Wod和栈帧锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 如果测试成功,标识线程已经获得了锁;
- 如果测试失败,则测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的释放,需要等待全局安全点(在这个时间点上没有正在执行的字节码),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果线程仍然活着,拥有偏向锁的栈会被执行。如果线程不处于活动状态,则将锁对象的MarkWord设置成无锁状态。栈帧中的lockRecord和对象头的MarkWord要么重新偏向其他线程,要么恢复到无锁,或者标记对象不适合作为偏向锁(锁升级)。最后唤醒暂停的线程。
偏向锁的升级过程:
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头MarkWord成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,操作系统检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。
图片源于网络,如有侵权,请联系删除!!!
可以通过jvm的参数-XX:UseBiasedLocking=false关闭偏向锁,则默认会进入轻量级锁。
6.5、轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
图片源于网络,如有侵权,请联系删除!!!
6.6、重量级锁
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
重量级锁就是让争抢锁的线程从用户态转换成内核态。让cpu借助操作系统进行线程协调。
6.7、锁状态转换
图片源于网络,如有侵权,请联系删除!!!
总结偏向锁、轻量级锁、重量级锁的优缺点如下:
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
三、锁粗化与锁消除
1、锁粗化
锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次操作。将多个联系的锁扩展为一个范围更大的锁。
一般在开发中,我们尽可能使同步的操作数量变小,只在共享数据的实际作用范围中才进行同步,这样如果存在锁竞争,那等待锁的线程能够尽快拿到锁。大部分情况下这么做是没什么问题的,
但是如果遇到一系列的连续操作都是对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有多线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
例如下面的代码:
public class Test{ //全局变量有线程安全问题 public static StringBuffer sb = new StringBuffer(); public static void main(String[] args) { ("a"); ("b"); ("c"); } } //-------------下面这段是StringBuffer类append的源码-------------------- @Override public synchronized StringBuffer append(String str) { toStringCache = null; (str); return this; }
每次调用StringBuffer的append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一对象加锁和解锁操作,就会在在第一次append方法时进行加锁,在最后一次append方法结束后进行解锁。
2、锁消除
锁消除就是虚拟机根据一个对象是否真正存在同步情况,若不存在同步情况,则对该对象的访问无需经过加锁解锁的操作。(比如说程序员使用了StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁的竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面代码进行优化,也就是锁消除。)
例如下面的代码:
public class Test{ public static void main(String[] args) { //sb变量是局部变量,不会有线程安全问题,加锁、解锁没有意义 StringBuffer sb = new StringBuffer(); ("a"); ("b"); ("c"); } }
虽然append是同步方法,但是这段程序中StringBuffer属于一个局部变量,即每个线程进入此方法都会拥有一个StringBuffer的变量,互不影响,线程安全。