前言
锁机制不仅仅是面试中是一个很高频的面试问题,而且是我们开发中不得不了解的一个内容,今天我们就来聊聊Java中的各中锁
锁的分类
共享锁 VS 排他锁
共享锁和排它锁是针对锁的共享这方面来说的,即共享锁是可以被多个线程共享的,而排它锁不是
共享锁
共享锁又被称为读锁,可以被多个线程所持有,如果线程A对共享资源T加了共享锁,则线程A只能读取共享资源T,并不能对其进行修改,其他线程只能对共享资源T加共享锁,不能加排它锁
排它锁
排它锁又被称为写锁,如果线程A对共享资源T加了排它锁,则线程A既能对共享资源T读又能进行写操作,其他线程不能对共享资源T加任何类型的锁,其中JDK中的synchronized和Lock中写锁的实现类都是排它锁
自旋锁 VS 自适应自旋锁
自旋锁
自旋锁实际上是按照在线程获取锁失败的时候是否会是否挂起该线程来划分的,自旋锁是指在线程获取共享资源的时候获取锁失败了,认为等一小会儿(进行固定次数的自旋),就可以获得该资源的锁,而不是通过CPU阻塞线程,切换线程的时间片这种方式,通常情况下这比CPU进行线程切换(涉及到用户态和内核态的转换)的开销要小得多,如果自旋完成后,前面锁定资源的线程已经释放了锁,那么当前线程可以拿到锁,不过这是一个不太确定的情况,有可能自旋完成后,前面的线程还没有释放该资源锁。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程可能只是浪费CPU的时间片,所以自旋的等待时间需要有一定的限度,如果自旋超过了限定次数,没有获得资源锁,就应该挂起线程。
自旋是通过CAS实现的,类似AtomicInteger中调用unsafe进行自增(do-while循环)就是一个自旋操作,如果修改失败就通过循环修改值,直至修改成功
自适应自旋锁
自适应自旋锁是在自旋锁上面进行的改进,它的自旋时间不再是固定值,而是由在同一个自旋锁上一次的自选时间和拥有者的状态来决定的,如果同一个锁对象上,刚刚成功获取过锁,则虚拟机认定它很有可能再次成功,那么它的自旋时间可以允许变得更长,反之则更短
可重入锁
可重入锁又被称为是递归锁,是指同一个线程在外层方法已经拿到锁的情况下,在进入内层方法的时候就会自动拿到锁,java中的Synchronized和ReentrantLock都是可重入锁,可重入锁在一定程度上可以避免死锁的发生。
比如类A有两个实例方法C,D,这两个方法都被sychronized修饰,在C方法内部调用了D,那么某一个线程在进入C已经拿到锁的情况下进入D方法就会自动拿到锁
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这些锁实际上是按照锁的状态来区分,并且是专门针对synchronized关键字来说的,但是在对这四种锁描述之前需要对一些概念进行了解:对象头和Monitor
一些基本概念
Hotspot虚拟机中,对象在虚拟机中的布局分为3部分,分别是对象头、实例数据、对齐填充
普通对象的对象头包括两部分:MarkWord和ClassMetaData Address(类型指针),如果是数组对象还额外包括一个额外的数组长度部分
Markword
用于存储对象自身的运行数据,如HashCode,GC分代年龄,锁状态标志,线程持有的锁、偏向线程ID,偏向时间戳等等,占用内存大小跟虚拟机位长一致
Kclass Pointer
类型指针,指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例
Array Length
数组长度
对象需要存储的数据很多,这已经超出了32bit或者是64bit能表示的限度,此外对象头信息是对象自定义的数据无关的额外存储成本,在考虑虚拟机空间效率的时候,MarkWord被设计成一个非固定的数据结构用来在极小的空间里面存储尽量多的信息,它会根据对象的状态复用自己的存储空间,也就是说MarkWord中存储的内容会伴随着锁的状态变化而变化。
例如在32bit的hotspot的虚拟机中,其各个锁状态下的存储内容如下所示
图片引用自Synchronized与三种锁态
Monitor
Monitor可以理解为一个同步工具或一种同步机制。每一个Java对象都有一个看不见的锁,称为内部锁或Monitor锁,这个Monitor锁实际上就存在于对象的对象头中,对象头中的若干标志位用于标识锁的锁定状态和被哪个线程拥有,在一个线程需要使用一个对象之前,需要先获得它的内置锁,使用之后还需要释放这个内置锁,在使用过程中其它线程无法获取这个锁。
Synchronized概述
Synchronized在JVM里面的实现是基于进入和退出Monitor对象来获取对象锁从而实现方法同步和代码块同步,不同虚拟机的实现细节可能不一样,但都可以通过成对的MonitorEnter和MonitorExit指令来实现,而MonitorEnter和MonitorExit的执行是通过调用操作系统的互斥原语Mutex Lock来实现的,被阻塞的线程会被挂起等待重新调度,会导致CPU在用户态和内核态两个态之间进行切换,比较耗性能,这也是为什么大家对synchronized的一贯印象就是性能较差的原因,jdk在1.6之后对sychronized进行了一系列调整,后来实际上跟Lock的性能不相上下,其实默认还是推荐用synchronized的,语义清晰、操作简单、无需手动关闭
同步方法是通过ACC_SYNCHRONIZED标识符来实现同步的
同步代码块是通过MonitorEnter和MonitorExit两个指令来实现的
MonitorEnter
插入在同步代码块的起始位置,当代码执行到该指令时,将会尝试获取该对象的monitor的所有权,即尝试获取该对象的锁
MonitorExit
MonitorExit插入在方法结束和异常处,JVM保证每个MonitorEnter必须有相应的MonitorExit
无锁
无锁没有对共享资源进行锁定,所有的线程都能访问并修改资源,但是只有一个线程能修改成功,如果多个线程同时修改同一个值,一定会有一个线程会成功,其他修改失败的线程会不断重试(自旋)直到修改成功,这种无锁的情况实际上适用于竞争度不高(读多写少)的情况下,这样自旋一会儿就能获取到资源的修改权,否则自旋非常浪费CPU资源
偏向锁
简介
Hotspot虚拟机的作者发现在大多数情况下不仅不存在锁的竞争,甚至锁总是同一个线程多次获得,所以为了降低获取锁的代价而引入了偏向锁。偏向锁就是指一段代码一直被一个线程访问,那么线程会自动获取锁,直接执行同步代码块,从而降低获取锁的代价
使用场景
只有一个线程进入临界区
锁的获取
- 获取对象的markword
- 检测MarkWord是否为可偏向状态
- 如果为可偏向,并且markword中指向的线程是当前线程则执行同步代码
- 如果为可偏向,但指向的线程不是当前线程,通过cas竞争,若竞争成功,则执行同步代码,如果不成功执行5
- 偏向锁竞争不成功,证明存在多线程竞争情况,此时偏向锁不再适用,到达全局安全点,获得偏向锁的线程将被挂起,偏向锁升级为轻量级锁,被阻塞在安全点的线程继续往下执行同步代码
锁的释放
线程拥有的偏向锁并不会主动释放,需要等待其他线程来竞争,偏向锁的撤销需要等待全局安全点(没有正在执行的代码的时间点),步骤如下
- 判断锁对象是否还处于锁定的状态,如果否,则将其恢复到无锁状态,允许其它线程竞争,如果还处于锁定状态,则挂起拥有偏向锁的线程,并将指向该线程的lock record的指针放入对象头的mark word中,升级为轻量级锁(00),然后恢复刚才拥有偏向锁的线程,进入轻量级锁的竞争模式
缺点
如果存在锁的竞争,会带来锁撤销的消耗
轻量级锁
简介
当锁是偏向锁的时候,被其他线程访问出现锁的竞争的时候,就会升级为偏向锁,或者显式关闭偏向锁(jdk1.6以后默认开启,并且默认加的是偏向锁,显式关闭后,默认加的就是轻量级锁),其他线程会通过自旋的方式尝试获取锁,不会阻塞,从而提高性能,一般来说,轻量级锁认为竞争存在,但是竞争的程度较轻,一般两个线程对同一个锁的操作都会错开,或者一个没有拿到锁的线程稍微自旋一会儿就可以拿到锁,如果超过一定自旋的次数后还是没有拿到锁,或者一个线程持有锁,一个线程在自旋的时候,这时候又有第三个线程来竞争锁的时候,轻量级锁就会升级为重量级锁
使用场景
多个线程交替进入临界区,同步代码执行速度较快
锁的获取
- 判断当前对象是否为无锁状态(是否为偏向锁位0,锁标志位01),若是,JVM会在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象目前MarkWord的拷贝
- 将对象头中的MarkWord拷贝到LockRecord中
- 拷贝成功后,JVM利用CAS尝试将对象头中MarkWord中设置为指向LockRecord的指针,如果成功执行4,否则执行5
- 更新成功,这个线程就拥有了这个对象的锁,并且将对象MarkWord的标志位转为00,表示此对象处于轻量级锁状态
- 更新失败,虚拟机会检查对象头中MarkWord是否指向当前线程的栈帧,如果是,代表当前线程已经获取到了这个对象的锁,可以直接执行同步代码,否则自旋执行步骤3,如果自旋结束还没有获得锁,则说明锁的竞争比较激烈,需要膨胀为重量级锁,将MarkWord里面的锁标志位置为10,MarkWord里面这时存放的是重量级锁的指针
锁的释放
- 使用CAS用线程中MarkWord的拷贝替换对象头中的MarkWord,替换成功则执行2,否则执行3
- 替换成功,则锁释放成功,整个同步过程完成,对象恢复到无锁的状态
- 替换失败,说明有其他线程正在竞争锁,在释放锁的同时,唤醒被挂起的线程
缺点
始终得不到锁的线程,自旋会消耗CPU资源,造成浪费
重量级锁
重量级锁依靠对象的Monitor锁实现,而Monitor锁又依赖操作系统的Mutex Lock(互斥锁)来实现的
- 在同步代码块中,jvm通过monitorenter和monitorexit实现同步锁的获取和释放。
- monitorenter在编译后插入到同步代码块的起始位置,monitorexit被插入到方法结束和异常处。
- 线程执行monitorenter的时候会尝试获取对象对应的monitor的所有权,即尝试获对象锁
线程执行monitorexit的时候将会把进入次数-1直到进入次数为0的时候释放锁 - 同一时刻只有一个线程能够成功,其他失败的线程会放弃锁的竞争被阻塞,放到同步队列中并且等待锁的释放,状态变为Blocked状态,当这个对象锁被释放的时候,会通知队列中等待这个对象锁的线程,使其可以重新竞争锁
使用场景
多个线程同时进入临界区,同步代码执行时间较长
Synchronized用法
修饰实例方法
获取的是对象锁,锁住的是类的实例对象
修饰静态方法
被锁住的是类的class对象
修饰代码块
被锁住的是实例对象
参考文章
JAVA锁优化和膨胀过程
彻底理解synchronized
Java 8 并发篇 - 冷静分析 Synchronized(下)