一、背景
对于Java来说我们知道,Java代码首先会编译成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上进行执行。
Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
下边我们对常见的实现同步的两个关键字volatile和synchronized进行底层原理的分析,分析之余我们就会了解到JVM在对锁的优化所做的事情,这样的话我们以后在使用这两个关键字的时候还可以游刃有余。
二、volatile实现原理
相关文章:
Java多线程编程-(2)-可重入锁以及Synchronized的其他基本特性
在这一篇文章中,我们知道了volatile的两个主要作用:一个是volatile可以禁止指令的重排序优化,另一个作用是提供多线程访问共享变量的内存可见性。 禁止指令重排序优化是JVM内存模型的知识点,这里不做学习,着重说一下可见性。
Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝(虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
volatile是轻量级的synchronized,他的意思是:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
有volatile变量修饰的共享变量进行写操作的时候会引发了两件事情:
(1)将当前处理器缓存行的数据写回到系统内存。
(2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
这是因为,Java支持多个线程同时访问一个对象或者对象的成员变量,每个线程可以拥有这个变量的拷贝(虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。
我们的处理器为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。
关键字volatile可以用来修饰字段,就是告知程序任何对该变量的访问均需要从共享内存获取(读取时将本地内存置为无效,从共享内存读取),而对它的改变必须同步刷新回共享内存。保证所有线程对变量访问的可见性。
具体的实现细节如下(不需深究其原理,把握住上述两点即可):
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
当然,volatile的实现原理远不止这些,作为入门的理解我们牢牢把握住这两点就行:一是强制把修改的数据写回内存,另一个是在多处理器情况下使多处理器缓存的数据失效。这两点对于一般的面试已经足以称下场面,如果还相对其有跟深入的理解,可以另行搜索资源进行学习。
二、synchronized实现原理
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
示例代码:
public class Synchronized {
public static void main(String[] args) {
synchronized (Synchronized.class) {
}
method();
}
public static synchronized void method() {
}
}
编译运行,然后使用命令:javap.exe -v Synchronized.class,(javap在“Java\jdk1.8.0_131\bin”目录下)查看结果:
大致可以看出,对于上述代码中的同步块 的实现是通过monitorenter和monitorexit 指令,而同步方法是依靠方法修饰符上的ACC_SYNCHRONIZED 来完成的。
上述的两种方式,无论采用的是哪一种方式,其本质是对一个对象的监视器(monitor) 进行获取,而这个获取过程是排他的,也就是说同一时刻只有一个线程获取到由synchronized所保护对象的监视器。
synchronized允许使用任何的一个对象作为同步的内容,因此任意一个对象都应该拥有自己的监视器(monitor),当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入BLOCKED状态。
下图描述了对象、对象的监视器、同步队列和执行线程之间的关系:
从上图中我们可以看到,任意线程对Object(Object由synchronized保护)的访问,首先要获得Object的监视器。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
三、Java虚拟机对synchronized的优化
synchronized相对于volatile是重量了很多,因此在以前很让人诟病,但是从JDK 1.6版本以后为了减少获得锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁,以及锁的存储结构和升级过程。
从JDK对synchronized的优化,可以看出Java虚拟机对锁优化所做出的努力,下边我们就分别学习一下什么是偏向锁、轻量级锁、重量级锁、自旋锁。
在理解这四种锁之前,我们先看一下synchronized锁的存放位置,synchronized用的锁是存在Java对象头里的 ,如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如下图:
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下图所示:
在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
下边分别研究一下这几个状态。
四、偏向锁
1、偏向锁核心思想
偏向锁是一种针对加锁操作的优化手段,他的核心思想是:如果一个线程获得了锁,那么锁就进入了偏向模式。当这个线程再次请求锁时,无需再做任何同步操作,这样就节省了大量有关锁申请的操作,从而提高了程序的性能。
2、偏向锁设计初衷
为什么会出现这种设计的方式那?这是因为根据HotSpot的作者研究,他发现锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了的偏向锁这个概念。
3、偏向锁获取锁流程
偏向锁获取锁流程如下:
(1)当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁;
(2)如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁);
(3)如果没有设置,则使用CAS竞争锁;
(4)如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
具体流程图如下:
3、偏向锁升级为轻量锁
对于只有一个线程访问的同步资源场景,锁的竞争不是很激烈,这时候使用偏向锁是一种很好的选择,因为连续多次极有可能是同一个线程请求相同的锁。
但是在锁竞争比较激烈的场景,最有可能的情况是每次不同的线程来请求相同的锁,这样的话偏向锁就会失效,倒不如不开启这种模式,幸运的是Java虚拟机提供了参数可以让我们有选择的设置是否开启偏向锁。
如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用轻量级锁进行操作。
五、轻量级锁
如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用轻量级锁进行操作。轻量级锁他只是简单的将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先夺到锁,那么当前线程的轻量级锁就会膨胀为重量级锁。
六、自旋锁
轻量级锁就会膨胀为重量级锁后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还会在做最后的努力–自旋锁。
由于当前线程暂时无法获得锁,但是什么时候可以获得锁时一个未知数。也许在几个CPU时钟周期之后,就可以获得锁。如果是这样的话,直接把线程挂起肯定是一种得不偿失的选择,因此系统会在进行一次努力:他会假设在不就的将来,限额和从那个可以得到这把锁,因此虚拟机会让当前线程做几个空循环(这也就是自旋锁的意义),若经过几个空循环可以获取到锁则进入临界区,如果还是获取不到则系统会真正的挂起线程。
那么为什么锁的升级无法逆向那?
这是因为,自旋锁无法预知到底会空循环几个时钟周期,并且会很消耗CPU,为了避免这种无用的自旋操作,一旦锁升级为重量锁,就不会再恢复到轻量级锁,这也是为什么一旦升级无法降级的原因所在。
七、三种锁的优缺点的对比
八、Java虚拟机对锁优化所做的努力
从Java虚拟机在优化synchronized的时候引入了:偏向锁、轻量级锁、重量级锁以及自旋锁,都可以看出Java虚拟机通过各种方式,尽量减少获取所和释放锁所带来的性能消耗。
但这还不全是Java虚拟机锁做的努力,另外还有:锁消除 、 CAS等等,更重要的还有一个无锁的概念,包括上文中提到的自旋锁,这些内容会在后续的文章中继续学习。
另外,关于本篇主题的讲解,可能偏向了volatile的原理讲解、Java虚拟机对synchronized的优化以及中间的几种锁,这几种锁的具体含义也是面试的常客,因此需要花时间静下心来仔细研究一二。