【并发编程】synchronized关键字最全详解,看这一篇就够了
目录
引入
编写一个类似银行、医院的叫号程序(要求:多个窗口叫号,不重号、不跳号)
这个用到多线程来实现多个窗口叫号的功能,首先要解决的就是资源共享问题,因为不同线程(不同窗口)所使用的叫号计数器应该是同一个,否则就会出现重号的问题。
资源共享的解决方案有两种:
- 使用static关键字修饰要共享的变量,将其变为全局静态变量,也就是放到了JMM的主内存中,这要就实现了资源的共享。
- 实现Runnable接口,这个接口和Thread类的区别之一就是可以实现资源的共享,因为实现Runnable接口的线程所操作的资源对象本质是是同一个对象
解决完资源共享问题之后,还有一个新的问题,那就是并发量比较大的时候会出现:跳号、重号、超过最大值。因为在加号过程中对index的增加操作在工作空间,就需要将index从主内存复制到工作空间进行操作,操作完再更新主内存中的index数据。很明显这不是一个原子操作。这就造成了有可能线程1正在对index进行操作,还没有操作完线程2也对index进行操作,线程2无法感知index已经被更新,这就不符合原子性和可见性原则。这就需要使用到synchronized关键字来保证原子性和可见性。
代码:
public class TicketDemo extends Thread{
private static int index=1;//
private static final int MAX=5000;
@Override
public void run() {
synchronized (this){
while(index<=MAX){
System.out.println(Thread.currentThread().getName()+"叫到号码是:"+(index++));
}
}
}
public static void main(String[] args) {
TicketDemo t1=new TicketDemo();
TicketDemo t2=new TicketDemo();
TicketDemo t3=new TicketDemo();
TicketDemo t4=new TicketDemo();
t1.start();
t2.start();
t3.start();
t4.start();
}
}
如果某一个资源被多个线程共享,为了避免因为资源抢占导致资源数据错乱,我们需要对线程进行同步,那么synchronized就是实现线程同步的关键字,可以说在并发控制中是必不可少的部分,今天就来看一下synchronized的使用和底层原理。可以通过synchronized关键字实现互斥同步,进而来实现线程安全。
一、synchronized的特性
synchronized是利用锁的机制来实现同步的。下面synchronized的特性也就是该关键字在并发编程中能保证的特性。
1.1 原子性
所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。
被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。
注意!面试时经常会问比较synchronized和volatile,它们俩特性上最大的区别就在于原子性,volatile不具备原子性。
1.2 可见性
可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。
而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。
1.3 有序性
有序性值程序执行的顺序按照代码先后执行。
synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
1.4 可重入性
synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁,同一个线程外层函数获取到锁之后,内层函数可以直接使用该锁。这样的好处就是避免死锁,如果不可重入,假设method1拿到锁之后,在method1中又调用了method2,如果method2没办法使用method1拿到的锁,那method2将一直等待,但是method1由于未执行完毕,又无法释放锁,就导致了死锁,可重入正好避免这这种情况。
可重入性就是monitor中的锁计数器来实现的,下面对monitor原理有讲解
二、synchronized的用法
synchronized可以修饰静态方法、成员函数(非静态方法),同时还可以直接定义代码块,但是归根结底它上锁的资源只有两类:一个是对象,一个是类。
synchronized修饰的对象有几种:
- 修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
- 修饰一个静态的方法:其作用的范围是整个方法,作用的对象是这个类的所有对象;
- 修饰一个代码块,指定加锁对象:被修饰的代码块称为同步语句块,其作用范围是大括号{}括起来的代码块,如果synchronized后面括号括起来的是一个类,那么作用的对象是这个类的所有实例对象;如果synchronized后面括号括起来的是一个对象实例,那么作用的对象是这个对象实例
根据修饰对象分类
1.同步方法
- 同步非静态方法
必须先获得该类的实力对象的锁才能进入同步块
public synchronized void methodName(){
……
}
- 同步静态方法
必须先获得该类的锁才能进入同步块
public synchronized static void methodName(){
……
}
2.同步代码块
- 修饰对象实例
必须先获得该对象实例的锁才能进入同步代码块
synchronized(this|object) {}
- 修饰类
必须先获得该类锁才能进入同步代码块
synchronized(类.class) {}
根据获取的锁分类
1.获取对象锁
synchronized(this|object) {}
如果有多个对象就有相对应的多个锁。
在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。
2.获取类锁
synchronized(类.class) {}
也叫全局锁,不管有几个对象就公用一把锁。
在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。
参考资料:【并发编程】类锁和对象锁的区别
三、synchronized锁的实现
synchronized关键字的功能是通过锁机制来实现的,也就是通过之前讲过的八种Java内存模型操作中的lock操作和unlock操作来实现的。注意,synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量),作用粒度是对象。Synchronized可以把任何一个非null对象(包括类的对象和类对象)作为"锁"。
3.1 为什么synchronized通过锁机制可以保证原子性,可见性和有序性,其原理是什么?
之前这一篇文章写过JMM规定执行8中基本操作时必须满足的准则,详见【Java内存模型】Java内存模型(JMM)详解以及并发编程的三个重要特性(原子性,可见性,有序性)
- 用之前讲过的执行8中操作必须满足的条件,e条中一个变量在同一个时刻只允许一个线程对其进行lock,这就说明了持有一个锁的两个同步块只能串行进行,这也就保证了原子性和有序性。
- 可以看到最后一条synchronized 实现其实是靠lock和unlock实现的。在对一个变量unlock操作前。必须也先把此变量同步回主内存中。这就实现了可见性
所以synchronized之所以能保证三个特性,是因为它是通过Java内存模型的lock操作和unlock操作来实现的,Java内存模型规定了执行这些操作必须满足的规则,通过这些机制synchronized就保证了这三个特性。
了解完synchronized为什么能够通过锁机制满足三种特性之后,下面我们就来看一看锁机制在底层是怎么实现的。
synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。
3.2 同步方法
首先来看在方法上上锁,我们就新定义一个同步方法然后使用 javap -v 反编译进行反编译,查看其字节码
可以看到在add方法的flags里面多了一个ACC_SYNCHRONIZED标志,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就线程就会进入阻塞(BLOCK)状态,直到该锁被释放,当锁的计数器为0时,线程就会释放该锁。
同步方法直接就在头部标志位用ACC_SYNCHRONIZED标志标识,内部没有使用monitorenter和monitorexit指令操作,因为整个方法都是一个同步块。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
3.3 同步代码块
我们新定义一个同步代码块,编译出class字节码,然后找到method方法所在的指令块,可以清楚的看到其实现上锁和释放锁的过程,截图如下:
代码:
字节码:
头部只有一个方法的普通标识ACC_PUBLIC表明这是一个普通的成员方法。
如果还有一个ACC_STATIC标识,表明这是一个静态方法
Monitorenter和Monitorexit指令,会让线程在执行时,使其持有的锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个线程在尝试获得与对象相关联的Monitor锁的所有权的时候:
monitorenter指令会发生如下3种情况之一:
- monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
- 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
monitorexit指令:
- 释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。
从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞(BLOCK),一直等待锁被释放。因为synchronized的锁是重入锁,所以锁的计数器可以大于1,只有当锁的计数器为0的时候,线程才会释放所持有的锁。
但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
四、synchronized锁的底层实现
在理解锁实现原理之前先了解一下Java的对象头和Monitor。
在 Java 中,每个对象都会有一个 monitor 对象(监视器)。
在JVM中,对象是分成三部分存在的:对象头(Object Header)、实例数据、对其填充。
实例数据和对其填充与synchronized无关,这里简单说一下(我也是阅读《深入理解Java虚拟机》学到的,读者可仔细阅读该书相关章节学习)。
- 实例变量存放类的属性数据信息(就是成员属性的值),包括父类的属性信息;
- 填充数据不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
4.1 对象头
对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。HotSpot虚拟机的对象头主要结构是由Mark Word 和 Class Metadata Address组成,其中Mark Word存储一些自身运行时数据,如对象的hashCode、锁信息或分代年龄或GC标志等信息,这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特;Class Metadata Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例。如果是数组对象,对象头还会有一个额外的部分用于存储数组长度。
- 对象头中的数据:
锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。
- Mark Word中的数据:
由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效 率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。上图为32位系统Mark Word的数据结构图,看出Mark Word的数据长度是一定的,但是里面的内容会根据锁状态的改变而进行改变(横向看不同的锁状态在Mark Word中存储的数据是不同的)。
例如在32位的HotSpot虚拟机中,对象未被锁定的状态下, Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄(就是新生代to区和from区之间转移的次数),2 个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记(标识该对象需要被GC清除)、可偏向等几种不同状态
4.2 monitor对象
在HotSpot虚拟机中monitor对象是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,monitor对象存在于每个Java对象的对象头中(对象头的MarkWord中的LockWord指向monitor的起始地址),对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,对象便处于锁定状态。所以说monitor是实现锁机制的基础,线程获取锁本质是线程获取Java对象对应的monitor对象。重量级锁就是通过ObjectMonitor实现的,也就是说重量级锁是基于对象的monitor来实现的。
例子:
ObjectMonitor() {
_header = NULL;
_count = 0; //锁计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;// 标记当前持有Monitor的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒
- 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),同时notify/notifyAll/wait等方***使用到Monitor锁对象,所以必须在同步代码块中使用。
下面通过此图讲解为什么notify/notifyAll/wait等方法必须在同步代码块中使用
如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。
注意:
当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态,在持有监视器的时候才有可能进入到等待区,所以只有在同步代码块中才能使用wait()方法,因为进入到同步代码块中才能保证线程一定持有监视器。
监视器Monitor有两种同步方式:互斥与协作。
- 互斥的同步方式:在多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
- 协作的同步方式:一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
五、JVM对synchronized的优化
从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 )
在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock(互斥锁) 来实现的。Java 的线程是映射到操作系统的原生线程之上的(详见Java线程和操作系统线程的关系)。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化等优化方法,又新增了两个锁的状态:偏向锁、轻量级锁。现在synchronized一共有四种锁的状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。给synchronized性能带来了很大的提升。在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下面讲解对synchronized锁进行优化的几种方法。
5.1 锁膨胀
上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。也就是说对象对应的锁是会根据当前线程申请,抢占锁的情况自行改变锁的类型。
5.1.1 偏向锁(Biased Locking)
一句话总结它的作用:减少同一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,使用偏向锁也就去掉了这一部分的负担,也取消掉了加锁和解锁的过程消耗。
核心思想:
引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。
如果该锁第一次被一个线程持有,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位是否为偏向锁以及当前线程ID是否等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作,减少不必要的CAS操作。申请获取偏向锁的时间非常短,这种锁在竞争不激烈的时候比较适用。如果程序中大多数的锁都总是被多个不同的线程访 问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:UseBiasedLocking来禁止偏向锁优化反而可以提升性能。
原理:
① 线程申请锁的时候首先都会检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;因为当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式,表示对象处于可偏向的状态,并且ThreadId为0,这时该对象是biasable&unbiased状态。
如果当前对象处于可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤5);否则执行步骤2),尝试获取偏向锁。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,由于锁竞争应该直接进入步骤4)
② 若当前对象的Mark Word中指向的持有锁的线程ID不是该线程ID,则该线程就尝试用CAS操作将自己的ThreadID放置到Mark Word中相应的位置,如果CAS操作成功,说明该线程成功获取偏向锁,进入到步骤3),否则进入步骤4)
③ 进入到这一步代表当前没有锁竞争,此时ThreadID已经不为0了,而是持有锁的线程ID。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等),只需要检查Mark Word的锁标记位是否为偏向锁以及当前线程ID是否等于Mark Word的ThreadID,如果都满足则进入步骤5)执行同步代码块。
④ 当线程执行CAS失败,表示另一个线程当前正在竞争该对象上的锁。当到达全局安全点时(cpu没有正在执行的字节,即获得偏向锁的线程当前没有执行,这个时间点是上没有正在执行的代码,注意当前持有偏向锁的线程不执行并不一定就是它的操作已经执行完成,要释放锁了)之前持有偏向锁的线程将被暂停,撤销偏向(偏向位置0)
然后判断锁对象是否还处于被锁定状态,如果没有被锁定,说明当前资源没有被线程使用,则恢复到无锁状态(01),以允许其余线程竞争。如果处于被锁定状态,说明当前资源正在被线程使用,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址(Lock Record)的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;后续的同步操作就按照轻量级锁那样去执行。同时被撤销偏向锁的线程继续往下执行。
注意:此处将 当前线程挂起再恢复的过程 中并没有发生锁的转移 ,锁仍然在当前线程手中,只是穿插了个 “将对象头中的线程 ID 变更为指向锁记录地址的指针” 这么个事(将偏向锁转换成轻量级锁)。
⑤ 执行同步代码块;
对象的哈希码去哪了?
当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原 来对象的哈希码怎么办呢?
在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因 为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。
5.1.2 轻量级锁
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。(意思就是线程之间获取锁是没有争抢的,线程A持有了资源X的锁,当时用完资源X之后,A线程释放掉资源X的锁,当线程B也想使用资源X去申请它的锁的时候,就再次申请获取资源X的锁,两个线程之间没有发成争抢,也就没有必要使用以前的互斥量还要休眠进程白白降低效率)
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢。
核心思想:
如果说偏向锁是只允许一个线程获得锁,那么轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况。轻量级锁的加锁和解锁都是通过CAS操作是现实。
原理:
① 线程1在执行同步代码块之前,如果此同步对象没有被锁定(锁标志位为“01”状态),JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间用来存储锁记录,然后再把对象头中的MarkWord复制到该锁记录中,官方称之为Displaced Mark Word。然后线程尝试使用CAS将对象头中的MarkWord 替换为指向锁记录的指针(锁状态为轻量级锁的Mark Word中存储的就是指向持有锁的线程的所记录的指针,这个操作详细就是使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word)。如果成功,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的 最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。进入步骤3)。如果该操作失败,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入步骤5)执行同步块就可以了,否则就说明这个锁对象已经被其他线程抢占了,执行步骤2)
Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。也就是对象头中是Mark Word与线程中是Lock Record
② 如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,但是对锁进行了优化,线程会先进行一段时间的自旋状态(轮询申请锁),先并不会进入阻塞状态,如果在自旋期间成功获得锁,则进入步骤3)。如果自旋结束也没有获得锁,则膨胀成为重量级锁,并把锁标志位变为10,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针(Mark Word中重量级锁状态就存储指向重量级锁的指针),所有等待该锁的线程也必须进入阻塞状态,进入步骤3)
③ 锁的持有线程执行同步代码,执行完之后如果对象的 Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word用线程中复制的Displaced Mark Word替换回来(也就是执行了compare and swap 比较然后交换操作),即CAS替换Mark Word释放锁,如果CAS执行成功,那整个同步过程就顺利完成了,则流程结束;CAS执行失败则进行步骤4)
④ CAS执行失败说明期间有线程尝试获得锁并自旋失败,轻量级锁升级为了重量级锁,此时释放锁之后,还要唤醒等待的线程
⑤ 执行同步代码块;
为什么升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?
因为在申请对象锁时 需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。
为什么会尝试CAS不成功以及什么情况下会不成功?
CAS本身是不带锁机制的,其是通过比较而来。假设如下场景:线程A和线程B都在对象头里的锁标识为无锁状态进入,那么如线程A先更新对象头为其锁记录指针成功之后,线程B再用CAS去更新,就会发现此时的对象头已经不是其操作前的对象HashCode了,所以CAS会失败。也就是说,只有两个线程并发申请锁的时候会发生CAS失败。
然后线程B进行CAS自旋,等待对象头的锁标识重新变回无锁状态或对象头内容等于对象HashCode(因为这是线程B做CAS操作前的值),这也就意味着线程A执行结束(参见后面轻量级锁的撤销,只有线程A执行完毕撤销锁了才会重置对象头),此时线程B的CAS操作终于成功了,于是线程B获得了锁以及执行同步代码的权限。如果线程A的执行时间较长,线程B经过若干次CAS时钟没有成功,则锁膨胀为重量级锁,即线程B被挂起阻塞、等待重新调度。
5.1.3 重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
原理:
Synchronized是通过对象内部的监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。实现Mutex Lock又需要进行两个线程之间的切换,而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。它通过操作系统的互斥量和线程的阻塞和唤醒来实现锁机制。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
三种索各自的优缺点和适用场景:
5.2 锁消除(Lock Elision)
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。锁消除可以节省毫无意义的请求锁的时间。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有局部变量,不存在所得竞争关系。
原理:
“锁消除”,是JIT编译器对内部锁的具体实现所做的一种优化。锁消除是借助逃逸分析实现的。
在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。
如以下代码:
public void f() {
bject hollis = new Object();
synchronized(hollis) {
ystem.out.println(hollis);
}
}
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
这里,可能有读者会质疑了,代码是程序员自己写的,程序员难道没有能力判断要不要加锁吗?就像以上代码,完全没必要加锁,有经验的开发者一眼就能看的出来的。其实道理是这样,但是还是有可能有疏忽,虽然没有显示使用锁,但是在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,他们的方法很多都被进行了加锁处理,会存在隐形的加锁操作。比如我们经常在代码中使用StringBuffer作为局部变量,而StringBuffer中的append是线程安全的,有synchronized修饰的,这种情况开发者可能会忽略。再比如说Vector的add()方法:
public void vectorTest(){ Vector<String> vector = new Vector<String>(); for(int i = 0 ; i < 10 ; i++){ vector.add(i + ""); } System.out.println(vector); }
在运行这段代码时,vector是这段代码的局部变量,整个生命周期都是跟随vectorTest()方法得,并没有出现逃逸现象,那么vector源码中对add方法进行的加锁操作也就失去了意义,所以JVM检测到变量vector没有逃逸出方法vectorTest()后,JVM就将vector内部的加锁操作消除。这时候,JIT就可以帮忙优化,进行锁消除。
总之,在使用synchronized的时候,如果JIT经过逃逸分析之后发现同步块中使用的锁对象并没有逃逸出去,不可能被其他线程所使用,并无线程安全问题的话,就会做锁消除。
5.3 锁粗化(Lock Coarsening)
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。
对于锁粗化的的理解:
很多人都知道,在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。
这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,这其实是很有道理的。
还是我们去银行柜台办业务,最高效的方式是你坐在柜台前面的时候,只办和银行相关的事情。如果这个时候,你拿出手机,接打几个电话,问朋友要往哪个账户里面打钱,这就很浪费时间了。最好的做法肯定是提前准备好相关资料,在办理业务时直接办理就好了。
加锁也一样,把无关的准备工作放到锁外面,锁内部只处理和并发相关的内容。这样有助于提高效率。
那么,这和锁粗化有什么关系呢?可以说,大部分情况下,减小锁的粒度是很正确的做法,只有一种特殊的情况下,会发生一种叫做锁粗化的优化。
就像你去银行办业务,你为了减少每次办理业务的时间,你把要办的五个业务分成五次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号、排队、被唤醒的时间。
如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。
当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。
如以下代码:
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
会被粗化成:
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
这其实和我们要求的减小锁粒度并不冲突。减小锁粒度强调的是不要在银行柜台前做准备工作以及和办理业务无关的事情。而锁粗化建议的是,同一个人,要办理多个业务的时候,可以在同一个窗口一次性办完,而不是多次取号多次办理。
5.4 自旋锁与自适应自旋锁
自旋锁在 JDK1.4.2 实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。另外,在 JDK1.6 中引入了自适应的自旋锁。
之前如果线程尝试获得锁失败,就会进入到阻塞状态,线程进入到阻塞状态是需要操作系统来讲线程进行挂起,挂起和唤醒都是一个消耗时间和资源的操作,所以为了避免这种情况,就出现了自旋锁的概念。
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要操作系统从用户态转入内核态中完成(用户态转换到内核态会耗费时间)。
自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来更改。
自适应自旋锁(Adaptive Locking):这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。那它如何进行适应性自旋呢?
- 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
- 反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
自旋锁和阻塞锁最大的区别就是,到底要不要放弃处理器的执行时间。对于阻塞锁和自旋锁来说,都是要等待获得共享资源。但是阻塞锁是放弃了CPU时间,进入了等待区,等待被唤醒。而自旋锁是一直“自旋”在那里,时刻的检查共享资源是否可以被访问。
其他相关文章:【并发编程】volatile关键字最全详解,看这一篇就够了
【并发编程】线程安全和线程不安全的定义以及实现线程安全的方法有哪些
【并发编程】Java中的锁有哪些?各自都有什么样的特性?
参考资料:
- 微信公众号 - 北风IT之路(beifengtz)
- 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)- 周志明