这个问题也是面试比较高频的一个问题,也是比较难理解的,理解 synchronized 需要一定的Java虚拟机的知识。
在jdk1.6之前, synchronized 被称为重量级锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。下面先介绍jdk1.6之前的 synchronized 原理。
在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分: 对象头 、 实例数据 和 填充对齐 。因为 synchronized 用的锁是存在对象头里的,这里我们需要重点了解对象头。如果对象头是数组类型,则对象头由 Mark Word 、 Class MetadataAddress 和 Array length 组成,如果对象头非数组类型,对象头则由 Mark Word 和 Class MetadataAddress 组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:
这里我们需要重点掌握的是 Mark Word 。
在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:
其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的。
在jdk1.6之前, synchronized 只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的。找到ObjectMonitor.hpp文件,看下其数据结构
ObjectMonitor() {
_header = NULL;
_count = 0; //锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
_waiters = 0, //等待线程数
_recursions = 0; //锁的重入次数
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor对象的线程地址
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //阻塞在EntryList上的单向线程列表
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
复制代码
其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图
entryList(锁池)
owner(持锁者)
waitSet(等待集合)
从上图可以总结获取Monitor和释放Monitor的流程如下:
wait()
这就是为什么 wait() 、 notify() 等方法要在同步方法或同步代码块中来执行呢,这里就能找到原因,是因为 wait() 、 notify() 方法需要借助ObjectMonitor对象内部方法来完成。
前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。这里为了更方便看程序字节码执行指令,我先在IDEA中安装了一个 jclasslib Bytecode viewer 插件。我们先来看这个 synchronized 作用于同步代码块的代码。
public void run() {
//其他操作.......
synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
for (int j = 0; j < 10000; j++) {
i++;
}
}
}
复制代码
查看代码字节码指令如下:
1 dup
2 astore_1
3 monitorenter //进入同步代码块的指令
4 iconst_0
5 istore_2
6 iload_2
7 sipush 10000
10 if_icmpge 27 (+17)
13 getstatic #2
16 iconst_1
17 iadd
18 putstatic #2
21 iinc 2 by 1
24 goto 6 (-18)
27 aload_1
28 monitorexit //结束同步代码块的指令
29 goto 37 (+8)
32 astore_3
33 aload_1
34 monitorexit //遇到异常时执行的指令
35 aload_3
36 athrow
37 return
复制代码
从上述字节码中可以看到同步代码块的实现是由 monitorenter 和 monitorexit 指令完成的,其中 monitorenter 指令所在的位置是同步代码块开始的位置,第一个 monitorexit 指令是用于正常结束同步代码块的指令,第二个 monitorexit 指令是用于异常结束时所执行的释放Monitor指令。
private synchronized void add() {
i++;
}
复制代码
查看字节码如下:
0 getstatic #2
3 iconst_1
4 iadd
5 putstatic #2
8 return
复制代码
发现这个没有 monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了 Access flags 后边的synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。
原理大概就是这样,最后总结一下,面试中应该简洁地如何回答 synchroized 的底层原理这个问题。
答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是 monitorenter 和 monitorexit 指令实现的,而方法同步是通过 Access flags 后面的标识来确定该方法是否为同步方法。
因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的 Mutex Lock 来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。
mutex lock互斥锁主要用于实现内核中的互斥访问功能。mutex lock内核互斥锁是在原子 API 之上实现的,但这对于内核用户是不可见的。对它的访问必须遵循一些规则:同一时间只能有一个任务持有互斥锁,而且只有这个任务可以对互斥锁进行解锁。互斥锁不能进行递归锁定或解锁。一个互斥锁对象必须通过其API初始化,而不能使用memset或复制初始化。一个任务在持有互斥锁的时候是不能结束的。互斥锁所使用的内存区域是不能被释放的。使用中的互斥锁是不能被重新初始化的。
在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,如下图所示。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级。这种只能升级不能降级的策略是为了提高获得锁和释放锁的效率。
常见面试题:偏向锁的原理(或偏向锁的获取流程)、偏向锁的好处是什么(获取偏向锁的目的是什么)
引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。
偏向锁的获取流程:
偏向锁的获取流程如下图:
同时底层获取Monitor和释放Monitor过程中会发生锁的计数器变化以及获取过锁的会发生重入次数的变化,如下图
偏向锁在JDK1.6是默认开启的,通过参数进行关闭 xx:-UseBiasedLocking=false 。
偏向锁可以提高带有同步但无竞争的程序性能,但如果大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。
偏向锁的撤销:
只有等到竞争,持有偏向锁的线程才会撤销偏向锁。偏向锁撤销后会恢复到无锁或者轻量级锁的状态。
一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。
引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。
轻量级锁流程:
轻量级锁的解锁
轻量级的解锁同样是通过CAS操作进行的,线程会通过CAS操作将Lock Record中的Mark Word(官方称为Displaced Mark Word)替换回来。如果成功表示没有竞争发生,成功释放锁,恢复到无锁的状态;如果失败,表示当前锁存在竞争,升级为重量级锁。
一句话总结轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并将对象的Mark Word更新为指向Lock Record的指针。
引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。
当轻量级锁获取失败后,就会升级为重量级锁,但是重量级锁之前也介绍了是很耗资源的,JVM开发团队注意到许多程序上,共享数据的二锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。
所以想到了一个策略,那就是当线程请求一个已经被锁住的对象时,可以让未获取锁的线程“稍等一会”,但不放弃处理器执行时间,只需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
如图所示:
自旋锁在JDK1.4.2中引入,默认关闭,可以通过-XX:UserSpinning参数来开启,默认自旋次数是10次,用户可以自定义次数,配置参数是-XX:PreBockSpin。
JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。
锁 | 优点 | 缺点 | 实用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级差距 | 如果线程存在竞争,会额外带来锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,相应时间缓慢 | 追求吞吐量,同步执行速度较慢 |
锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。
锁消除的主要判定依据来源于逃逸分析的数据支持, 如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待 ,认为它们是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。下面这段非常简单的代码仅仅是输出两次字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。
public class Append extends Thread{
public static void main(String[] args) {
appendHello("hello", "world",)
}
public static String appendHello(String s1, String s2) {
return new StringBuffer().append(s1).append(s2).toString();
}
}
复制代码
StringBuffer的append ( ) 是一个同步方法,锁就是this也就是 (new StringBuilder()) 。虚拟机发现它的动态作用域被限制在 cappendHello( ) 方法内部。也就是说, new StringBuilder() 对象的引用永远不会“逃逸”到 appendHello ( ) 方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
append方法是同步的,进行了两次的字符串的拼接,方法的调用者是StringBuffer对象,它是一个局部变量没有逃逸出这个方法内部,即使是多个线程来执行对象也没有逃逸出这个方法,此时append方法的锁就可以消除掉。
一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。
如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。如下:
for (int i=0;i<10;i++) {
synchronized(lock) {
System.out.print('i:' i++);
}
}
复制代码
这段代码会导致频繁地加锁和解锁,锁粗化后
synchronized(lock) {
for (int i=0;i<10;i++) {
System.out.print('i:' i++);
}
}
复制代码
将同步代码块的范围放大,放到for循环的外面,这样只需要加一次锁即可。
(1)减少synchronized的范围
同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。单位时间内执行的线程就会变多,等待的线程变少。
(2)降低 synchronized锁的粒度
将一个锁拆分为多个锁提高并发度,尽量不要用类名点class来创建锁。
(3)HashTable中的锁
put、remove、get方法都使用了synchronized关键字,也就说在对哈希表进行插入数据的时候就不能获取或移除数据。
(4)读写分离
读取时不加锁,写入和删除时加锁
原文链接:https://juejin.cn/post/7123405679938764837
留言与评论(共有 0 条评论) “” |