[TOC]
简介:
可能在很多人眼里,在java中提到锁、安全性、同步,首先想到的则是java提供的大佬(synchronized
)。那么为什么在多线程下,单单靠一个关键字修饰代码块就可以实现所谓的安全性呢?可以说是对初学者而言及神奇又强大的存在。也成了大多数初学者百试不爽的良药。
但是在逐渐对java认知的深入,我们认识到synchronized
对于jvm来说是一个重量级的锁。其笨重无比,在如今人们对速度和性能极致要求的现在,现在此时并不能满足性能上的要求。
诚然SUN
公司也认识到了这一点,在Java SE 1.6对synchronized
进行了各种优化后,有些情况下它就并不那么笨重🐖
了。在Java SE 1.6中为了减少获得锁和释放锁带来的性能开销而引入偏向锁和轻量级锁。
Synchonized实现同步的基础
Java中每一个对象都可以作为锁。具体有如下三种形式:
对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的
Class
对象。对于同步代码块,锁是
synchronized
括号里配置的对象。
那么线程是怎么获取上述各种锁对象的呢?
先看一段简单的三种同步方式。
public class SynchronizedTest {
/**
* 同步修饰普通方法
*/
public synchronized void test01() {
// 同步修饰代码块
synchronized (this) {
System.out.println("hello synchronized");
}
}
/**
* 同步修饰静态方法
*/
public synchronized static void test02() {
}
}
使用javap 查看生成的class 文件
javap -verbose ***.class
JVM会在monitorenter
监视器入口处获取锁,然后执行完对应操作后,在monitorexit
监视器出口释放锁。在class文件中synchronized
被ACC_SYNCHRONIZED
标记,表明该方法为同步方法。
从JVM规范中可以看到Synchronized
在JVM里的实现原理,JVM基于进入和推出monitor
对象来实现方法同步和代码块同步的,但两者的实现细节不一样。
代码块同步: 是使用monitorenter
和monitorexit
指令实现的。而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
静态同步方法: 使用javap 可以看出synchronized
被编译为普通的命令invokevirtual
、areturn
字节码指令。在JVM层面并没有任何特别的指令来实现被synchronized
修饰的方法,而是在Class
文件的方法表中将该方法的access_flags
字段中的synchronized
标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class
在JVM的内部对象表示Klass作为锁对象。(引用(详细介绍了1.6后锁的各种优化))
monitorenter指令
monitorenter
指令时在编译后插入到同步代码块的开始位置的。而monitorexit
是插入到方法的结束处和异常处,JVM要保证每个monitorenter
必须都有对应的monitorexit
与之对应。任何对象都有一个monitor
对象与之关联,并且一个monitor
被持有后,它将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor
所有权,即尝试获取对象的锁。
Java对象头
synchronized
用的锁是存在Java对象头里的。所以这里对Java对象头做详细介绍。
对象的内存布局
在HotSpot
虚拟机中,对象在内存中存储的布局可以分为3块区域:
对象头(Header)
实例数据(Instance Data)
-
对齐填充(Padding)
HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(
HashCode
)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别位32bit和64bit,官方称它位“Mark Word
” (标记字段)。对象需要存储的运行时数据很多,其实已经超出了定义的位数。Mark Word
被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位HotSpot虚拟机中,如果对象处于被锁定状态下,那么Mark Word
的32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定位0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见表存储内容 标志位 状态 对象哈希码、对象分代年龄 01 未锁定 指向锁记录的指针 00 轻量级锁定 指向重量级锁的指针 10 膨胀(重量级锁定) 空,不需要记录信息 11 GC标记 偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向 对象头的另外一部分是类型指针(
Klass Pointer
)。即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数据长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的原数组中却无法确定数组的大小。 在运行期间,
Mark Word
标记字段里存储的数据会随着锁的标志位的变化而变化
无锁状态下Mark Word
: 对象的hashCode
+对象分代年龄+(是否位偏向锁)0+(所标志位)01
Monitor Record
Monitor
从字面意义上理解为监控、监视的意思。在Java中可以把它看作为一个同步工具,相当于操作系统中的互斥量,即值为1的信号量。它内置与每一个对象。在java世界里,每一个对象天生都拥有一把内置锁(Monitor
)。这相当于一个许可证,只有你拿到许可证之后才可以进行操作,没有拿到则需要进行阻塞等待。
Monitor Record
从字面意义上理解为:监视器记录。Monitor Record
是线程私有的数据结构,每一个线程都有一个可用Monitor Record
列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Monitor Record的内部结构
Monitor Record | |
---|---|
Owner | 初始时为NULL 表示当前没有任何线程拥有该Monitor Record 当线程成功拥有该锁后,记录该线程ID作为唯一标识,当锁被释放时又设置成NULL
|
EntryQ | 关联一个系统互斥锁(semaphore 信号量),阻塞所有试图锁住Monitor Recoed 失败的线程 |
RcThis | 表示blocked 或者waiting 在该Monitor Record 上所有的线程的个数 |
Nest | 用来实现重入锁的计数 |
HashCode | 保存从对象头拷贝过来的HashCode值(可能还包含GC分代年龄) |
Candidate | 用来避免不必要的阻塞或者等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,那么当执行线程结束任务释放锁后,如果唤醒所有等待的线程,会造成不必要的上下文切换(影响性能,因为在所有唤醒的线程中,只有一个能够真正的获取到锁,所以其他的线程在从阻塞到就绪到因为竞争锁失败又被阻塞,这中间都是一些不必要的资源浪费)。所以Candidate只提供了两种可能,0 表示当前没有需要唤醒的线程。1 表示在阻塞的线程中,唤醒一个继任线程来竞争锁 |
锁优化
高效并发是从JDK 1.5 到 JDK 1.6的一个重要改进,HotSpot
虚拟机在这个版本上花费了大量精力去实现各种锁优化技术,如:适应性自旋(Adaptive Spinning
)、锁消除(Lock Eliminate
)、锁粗化(Lock Coarsening
)、轻量级锁(Lightweight Locking
)和偏向锁(Biased Locking
)等,这些技术都是为了在先咸亨之间更搞笑地共享数据,以及解决竞争问题,从而提高程序的执行效率。
锁的类型
在Java SE 1.6里Synchronied
同步锁,一共有四种状态:无锁、偏向锁、轻量级所、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
自旋锁与自适应自旋锁
自旋锁
引入背景:大家都知道,在没有加入锁优化时,大佬Synchronized
时一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作个i系统的并发性能带来了很大的压力。同时HotSpot
团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)
自适应自旋锁
在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。
锁消除
锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如如下操作:在操作String
类型数据时,由于String
是一个不可变类,对字符串的连接操作总是通过生成的新的String
对象来进行的。因此Javac编译器会对String
连接做自动优化。在JDK 1.5之前会使用StringBuffer
对象的连续appen()
操作,在JDK 1.5及以后的版本中,会转化为StringBuidler
对象的连续append()
操作。
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}
众所周知,StringBuffer
是安全同步的。但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,则并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)
锁粗话
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
这里贴上根据上述Javap 编译地情况编写地实例java类
public static String test04(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在上述地连续append()
操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的 外部,使整个一连串地append()
操作只需要加锁一次就可以了。
轻量级锁
在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
如果要理解轻量级锁,那么必须先要了解HotSpot
虚拟机中对象头地内存布局。上面介绍Java对象头也详细介绍过。在对象头中(Object Header
)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode
、GC Age
、锁标记位、是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word
,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point
),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
轻量级锁加锁
在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word
的拷贝(JVM会将对象头中的Mark Word
拷贝到锁记录中,官方称为Displaced Mark Ward
)这个时候线程堆栈与对象头的状态如图:
如上图所示:如果当前对象没有被锁定,那么锁标志位位01
状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record
的空间用于存储锁对象目前的Mark Word
的拷贝。
然后,虚拟机使用CAS操作将标记字段Mark Word
拷贝到锁记录中,并且将Mark Word
更新位指向Lock Record
的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word
的锁标志位更新位(Mark Word中最后的2bit
)00
,即表示此对象处于轻量级锁定状态,如图:
如果这个更新操作失败,JVM会检查当前的Mark Word
中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为10
.Mark Word
中存储的时指向重量级锁的指针。
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word
替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀的流程图如下:
偏向锁
引入背景:在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。
为了解决这一问题,HotSpot
的作者在Java SE 1.6 中对Synchronized
进行了优化,引入了偏向锁。当一个线程访问同步快并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS
操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word
里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。
偏向锁的撤销
偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
锁的优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步快的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 追求响应时间,同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步快执行速度较长 |
参考资料
《深入理解Java虚拟机》
《Java并发编程的艺术》
本文仅供本人学习之用!!!有欠缺的地方还望指正!!!祝各位工作顺利 😊