1、概述
在并发编程中,经常遇到多个线程访问同一个共享资源 ,这时候作为开发者必须考虑如何维护数据一致性,在java中synchronized关键字被常用于维护数据一致性。synchronized机制是给共享资源上锁,只有拿到锁的线程才可以访问共享资源,这样就可以强制使得对共享资源的访问都是顺序的。一般在java中所说的锁就是指的内置锁,每个java对象都可以作为一个实现同步的锁,虽然说在java中一切皆对象, 但是锁必须是引用类型的,基本数据类型则不可以 。每一个引用类型的对象都可以隐式的扮演一个用于同步的锁的角色,执行线程进入synchronized块之前会自动获得锁,无论是通过正常语句退出还是执行过程中抛出了异常,线程都会在放弃对synchronized块的控制时自动释放锁。 获得锁的唯一途径就是进入这个内部锁保护的同步块或方法 。当多个线程对共享资源访问的时候,只能有一个线程可以获得该共享资源的锁,当线程A尝试获取目前在线程B手上的锁的时候,线程A必须等待或者阻塞,直到线程B释放该锁为止,否则线程A将一直等待下去,因此java内置锁也称作互斥锁,也即是说锁实际上是一种互斥机制。根据使用方式的不同一般我们会将锁分为对象锁和类锁,两个锁是有很大差别的,对象锁是作用在实例方法或者一个对象实例上面的,而类锁是作用在静态方法或者Class对象上面的。一个类可以有多个实例对象,因此一个类的对象锁可能会有多个,但是每个类只有一个Class对象,所以类锁只有一个。
2、锁相关概念
从不同角度可以将锁进行分类,一种具体的锁操作,在不同分类方式下一般会有对应的所属。
2.1 乐观锁和悲观锁
乐观锁是正如其名字,很乐观,默认觉得不会有人在其进行操作的时候进行修改操作,遇到并发写的可能性低,即认为读多写少。所以不会上锁,但是在更新的时候会判断一下在此期间数据有么有被其他人更新,方法可以是在写时先读出当前版本号,然后加锁操作,再比较跟上一次的版本号,如果没变则更新。如果版本号变了,说明在此期间有人修改了数据,则要重复读-比较-写的操作。
悲观锁是就是就不一样了,它总认为有人要和它争抢操作的数据,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会被阻塞直到拿到锁。
2.2 公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,先到先得。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。这就可能,有的申请者老是被插队而造成优先级反转或者饥饿现象。
2.3 可重入锁和不可重入锁
可重入锁又名递归锁,是指当一个线程已经拥有某一对象锁时候,就可以递归的调用该对象的带锁方法。比如说线程调用对象A的带锁方法a,而方法a中要调用这个对象A的带锁方法b,这时如果这时可重入锁,线程由于已经在调用a方法时获得了对象A的锁,所以调用b方法时也自然可以进入到b方法中,而不需要这个线程释放调用a方法时候的锁,再重新在调用b方法时候获取锁。
不可重入锁恰好相反。所以在上述情况下,就会出现问题,即要调用b方法就要释放a方法时候获取的锁再重新获取对象A的锁,而此时a方法还没有执行完毕,故a方法的锁不能释放。这就出现了死锁的情况。
所以一般来说Java里面的锁设计都是可重入锁。一个线程一旦获得了对象的锁,就可以调用这个对象的所有方法而不需要重新获取这个对象的锁。
2.4 独享锁和共享锁
独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。一般来说读锁是共享锁,而写锁是独享锁。因为读的时候不会改变内容,所以多个读线程可以同时读取一个对象。但是一般来说这时候就不能让写线程进来进行操作了。而写线程如果好几个线程同时写一个对象,那就会造成混乱,所以写操作时候一般都是独享对象的。同时也可以理解,共享锁的执行效率是要高于独享锁的。
2.5 分段锁
分段锁是一种锁的设计思想,它的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。比如本来要锁整个数据库的,改成锁其中一张表,这样的好处就是对于同一个数据库但不是同一张表的操作可以同时进行,而不用一个等另一个操作完。ConcurrentHashMap内部也是通过分段锁的形式来实现高效的并发操作。ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了并行的插入。这么做的缺点是,在统计size的时候,需要获取hashmap全局信息,这时就需要获取所有的分段锁才能统计。
2.6 自旋锁
自旋锁是指Java一种等待获取锁的策略。自旋锁原理是如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗,可以简单理解为不停的while循环去获取锁,没有获取到就一直循环,获取到了就调出循环进行下一步操作。所以说线程自旋是需要消耗cup的,说白了就是让cup在做无用功。所以一般来说自旋锁操作出现在估计能马上获取到锁的情况下进行。自旋了一段时间如果还没有拿到锁,一般来说还是要取消自选进入阻塞挂起状态。这个零界点就是切换用户内核线程的消耗和自旋对CPU的消耗那个损失更大。
3、synchronized
前面介绍了锁在不同角度下的分类,现在讨论一下线程同步中很重要的synchronized。先将它实现的锁进行一个归类,synchronized操作内部的锁是:悲观锁、非公平锁、可重入锁、独享锁、就对象而言不是分段锁、内部策略中存在自旋锁。接下来详细说下synchronized的锁机制。
任何一个对象都有一个Monitor与之关联,当一个Monitor被某一线程持有后,它将处于锁定状态。Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorExit指令则插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit。
3.1 Java对象头
synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord。MarkWord是java对象数据结构中的一部分。markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容。MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,如下表所示:
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄、偏向锁标志位 |
偏向锁 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄、偏向锁标志位 |
轻量级锁定 | 00 | 指向锁记录的指针 |
重量级锁定) | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空(不需要记录信息) |
3.2 Monitor Record
Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
3.3 CAS
CAS,compare and swap的缩写,中文翻译成比较并交换。在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商老早就在芯片中加入了大量处理并发操作的原语,从而在硬件层面提升效率。在intel的CPU中,使用cmpxchg指令。在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。而随着java不断的发展,Java本地方法(JNI)的出现,使得java程序越过JVM直接调用本地方法提供了一种便捷的方式,因而java在并发的手段上也多了起来。而在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基石。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。
3.4 锁的不同状态
对于synchronized,锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
3.4.1 偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程(此时会引发竞争,偏向锁会升级为轻量级锁)。如果当前线程执行CAS获取偏向锁失败(这一步是偏向锁的关键),表示在该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁所有权。当那个竞争失败的线程运行到全局安全点(safepoint)时会让持有偏向锁的线程暂停,并让持有偏向锁的线程的私有Monitor Record列表中获取一个空闲的记录,将对象设置为LightWeight Lock(轻量级锁)状态并且Mark Word中的LockRecord指向刚才持有偏向锁线程的Monitor record,最后在安全点暂停的竞争失败的线程进入竞争轻量级锁的路径中,同时之前持有偏向锁的被暂停的线程恢复,继续向下执行代码。
3.4.2 轻量级锁和重量级锁
轻量级锁实现的背后基于这样一种假设,即在真实的情况下程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。也就是说其实轻量级锁是一把自旋锁。整个流程如下:
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋操作来获取锁。当没有获取到锁的线程自旋到一定程度,还没有获取到锁,说明这个对象不能用轻量级锁来处理了,这时候就会将锁升级为重量级锁。没有获取到锁的线程会被阻塞。一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3.4.3 不同锁状态比较
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 枷锁解锁没有额外消耗,和执行非同步快的速度近乎一样 | 如果线程间存在 竞争,会有额外的撤销消耗 | 基本上不存在多个线程访问同步快的场景 |
轻量级锁 | 基于自旋锁, 竞争的线程不会阻塞减少了用户线程与核心线程间切换的消耗 | 自旋操作会消耗CPU | 同步快处理速度快,能马上处理完毕让出锁的场景 |
重量级锁 | 线程竞争不消耗CPU | 线程阻塞,响应时间慢 | 同步代码块执行慢的场景 |
4、死锁
死锁是指两个或两个以上的线程或进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。死锁产生必须满足如下四个条件:
① 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
② 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
③ 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
④ 循环等待:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
而解除死锁的办法就是打破上述四个条件的任意一个或几个条件。