面试官问:synchronized是什么锁?
答:阻塞锁,非公平锁
面试官问:什么时候处于偏向锁?原理是什么?
答:...(不知道从哪里说起😭)
日常开发中,我们经常使用到synchronized。但是大多只知道他是多线程开发过程中的一种锁。但是实现原理可能似懂非懂。今天我们就来从对象头和源码搞清楚它,下次被问以及写代码的时候就可以胸有成竹了💪💪💪😁。
一、ClassLayout 内存布局查看对象头
在Java中,一个对象如图所示,包含一下几个部分:
对象头(Object Header)主要由两部分组成:Mark Word 和Class Metadata Address(对应类信息的klass指针)
Mark Word是对象头的一部分,主要存储对象自身的运行时数据,比如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等信息。其大小和CPU架构有关,通常是32位或64位。
- 在无锁状态,数据格式如下:
markdown
64位JVM:
----------------------------------------------------------------------------------------------------------------------------
| unused(25 bit) | hashcode (31 bit) |unused(1bit) |age(4bit) |is_biased_lock(1bit)|state(2bit)|
----------------------------------------------------------------------------------------------------------------------------
|00000000 00000000 00000000 0|0000000 00000000 00000000 00000000 | 0 |0000 | 0 | 01 |
----------------------------------------------------------------------------------------------------------------------------
- 偏向锁状态,数据格式如下:
markdown
64位JVM:
-----------------------------------------------------------------------------------------------------------------------------
| Thread ID (54 bit) |Epcoh (2 bit)|unused(1bit) | age(1bit) |is_biased_lock(1bit)|state(2bit)|
-----------------------------------------------------------------------------------------------------------------------------
|00000000 00000000 00000000 00000000 00000000 00000000 000000|00 | 0| 0000 | 1 | 01 |
-----------------------------------------------------------------------------------------------------------------------------
- 轻量级状态,数据格式如下:
markdown
64位JVM:
-------------------------------------------------------------------------------------
| 指向栈中锁记录(Lock Record)的指针 (62 bit) | state(2 bit)|
-------------------------------------------------------------------------------------
|00000000 00000000 00000000 00000000 00000000 00000000 000000 00000000| 00 |
-------------------------------------------------------------------------------------
- 重量级状态,数据格式如下:
markdown
64位JVM:
-------------------------------------------------------------------------------------
| 指向互斥量(Monitor)的指针 (62 bit) | state(2 bit)|
-------------------------------------------------------------------------------------
|00000000 00000000 00000000 00000000 00000000 00000000 000000 00000000| 01 |
-------------------------------------------------------------------------------------
其中各位的意义为:
unused
:表示未使用age
:GC分代年龄state
:锁的状态(轻量级锁定、重量级锁定、无锁等)is_biased_lock
: 是否开启偏向锁hash_code
:对象的哈希码Thread_ID
:偏向锁的线程IDEpcoh
: 当对象头的Mark Word标记为偏向锁状态时,Epoch字段用来标识当前偏向锁的版本。当JVM全局的Epoch值改变时(例如,在某些垃圾回收或者系统调用的过程中可能会更新Epoch值),只有当对象头中的Epoch值与JVM全局的Epoch值不一致时,该对象上的偏向锁才需要被撤销。如果一个线程要竞争之前被偏向的对象,它需要检查这个Epoch值。如果对象头中的Epoch与JVM全局的Epoch相匹配,则意味着偏向锁仍然有效,竞争线程需要执行偏向锁撤销过程;如果不匹配,则表示偏向锁已经被撤销,线程可以尝试获取锁而无需执行进一步的撤销动作。
对于一个对象内存中的具体数据分配,我们来实际验证一下,添加工具jol使用ClassLayout打印内存信息。
gradle
implementation 'org.openjdk.jol:jol-core:0.14'
java
//空的class
public class BTClassTest {
}
public class SampleTest {
public static void main(String[] args) {
BTClassTest classTest = new BTClassTest();
System.out.println("Test:"+ClassLayout.parseInstance(classTest).toPrintable());
}
}
运行结果如图,因为是为了cpu方便读写,采取大端模式,数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址。倒过来数据对应偏向锁状态。
我们发现类元信息地址只有4个字节,由于这个地址属于引用类型,应该有8个字节的,因为JVM默认开了指针压缩,我们加上
-XX:-UseCompressedOops
,再次运行,结果如下: 可以看到对齐没有了,klass指针为8个字节了,你可能疑惑那个压缩和不压缩有什么区别呢,答案是有的,比如对于这个空对象,我刚好加了一个4个字节的int变量,对齐就不需要了,如图:
同样的JVM默认是开启偏向锁的,我们可以关掉,添加-XX:-UseBiasedLocking
如图步骤操作。
最高位是00000001
对应无锁状态。
也就是什么在没有锁竞争状态,是偏向锁或者无锁状态。我们加上synchronized再试试
java
public class SampleTest {
public static void main(String[] args) throws InterruptedException {
BTClassTest classTest = new BTClassTest();
System.out.println("Test 1:"+ClassLayout.parseInstance(classTest).toPrintable());
synchronized (classTest){
System.out.println("Test 2:"+ClassLayout.parseInstance(classTest).toPrintable());
}
Thread t1= new Thread(()->{
synchronized (classTest){
System.out.println("Test 3:"+ClassLayout.parseInstance(classTest).toPrintable());
}
});
t1.start();
Thread t2 = new Thread(()->{
synchronized (classTest){
System.out.println("Test 4:"+ClassLayout.parseInstance(classTest).toPrintable());
}
});
t2.start();
t1.join();
t2.join();
System.out.println("Test off :"+ClassLayout.parseInstance(classTest).toPrintable());
Thread.sleep(2000);
System.out.println("Test off 2:"+ClassLayout.parseInstance(classTest).toPrintable());
}
}
运行结果:
加上synchronized以后,偏向锁的thread_id不为空,我们开启两个线程执行synchronized(classTest)
,classTest的对象头重WarkWord信息从轻量级锁 变成重量级锁。然后我们回到主线程,发现对象依然是重量级锁,等待2秒以后,再次打印信息,变成了无锁状态。
所以一个对象头信息中带有synchronized()中该对象锁状态,当第一个线程调用synchronized这个对象的时候,对象状态变成偏向锁,以及带上当前线程的id,此时如果又来一个线程,就会变成轻量级锁,带上指向栈中锁记录(Lock Record)的指针 ,此时又来一个线程,就会变成重量级锁,此时是指向互斥量(Monitor)的指针,这两个指针又是啥呢?我们只能去源码找答案。
二、synchronized字节码后的指令
synchronized采用互斥方式让同一时刻最多只有一个线程持有对象锁,其他线程在获取这个对象锁会被阻塞,不用担心线程上下文切换。
指令分析
首先我们看看synchronized关键字使用了以后,翻译成字节码指令是什么? 举例的方法:
java
public class WorkSingleton {
private static volatile WorkSingleton instance;
private WorkSingleton() {
}
public static WorkSingleton getInstance() {
if (instance == null) {
synchronized (WorkSingleton.class) {
if (instance == null) {
instance = new WorkSingleton();
}
}
}
return instance;
}
}
java字节码如图所示:
Dex字节码如图所示:
可以看到对应synchronized的代码块都一个monitorenter
和monitorexit
, monitor-enter v1
和monitor-exit v1
。
源码分析
顺着monitorenter我们搜索源码,发现了对应的方法Monitor::MonitorEnter
,源码 即无锁状态,
为了方便,我们就只看art虚拟机的源码
如图,对应Object的native方法
上图中我们看到有我们发现有4种条件判断逻辑,首先获取对象的LockWord。
获取lock的状态。 如果当前是第一种kUnlocked
状态,那么就会升级轻量级锁,如果已经是kThinLocked
,根据判断当前线程id是否是是当前对象lockword的线程相同,不相同则升级重量级锁。
而Monitor中的变量,有wait_set
是等待线程队列,以及wake_set
等待被唤醒的线程队列。
总结
通过字节码指令以及源码分析,验证了synchronized
关键字依托于对象头的markWord
中的锁状态信息,来实现线程并发的同步操作,有这个4中锁状态,synchronized
在最新的java中已经有相对较好性能。在属于轻量级锁的时候,等待线程会自旋等待,当有第三个线程进来,或者自旋一定数量(根据jvm的优化判断是需要自旋转或者直接升级重量级锁),就会升级为重量级锁,重量级锁通过Monitor
变量来维护等待线程队列。当等待线程等待一定时间或者own_thread
退出解锁的时候,等待队列被唤醒,进入唤醒队列,唤醒队列中的各个线程通过竞争获得锁,获得成功就变成own。不成功继续进入等待队列,等待下一次唤醒。所以synchronized最终效果是阻塞和非公平锁。
现在你可能搞懂90%了,但是需要好好提炼总结一下,我们再来组织组织语言,再来好好回答面试官的问题😁😁😁
答 :synchronized
关键字字节码指令是MonitorEnter
,MonitorExit
,当一个对象没有锁的时候synchronized
触发获取锁,会将对象头的markWord
中的信息锁状态变成偏向锁,如果此时又来一有一个线程获取锁,该对象会升级轻量级锁,这个线程会自旋等待,直到获取锁,如果此时又有一个线程或者自旋一定数量以后,该对象会升级为重量级锁状态,重量级锁状态指向一个Monitor
对象指针,Monitor
维护线程等待队列,处于等待队列中的线程也处于阻塞状态,当等待线程等待一定时间或者own_thread
退出解锁的时候,等待队列被唤醒,进入唤醒队列,唤醒队列中的各个线程通过竞争获得锁,获得成功就变成own
。不成功继续进入等待队列继续Blocked
,等待下一次唤醒。所以综合来说synchronized
是阻塞锁和非公平锁。
三、synchronized的正确打开方式
- 一不小心阻塞的主线程 这个Android开发主线程中可能比较容易触发,并且很容易被忽略的阻塞。举个例子(为了方便使用kotlin代码,实际业务代码不可能是这样明显或者这么写哈,原理相同,模拟场景而已)
kotlin
class MainActivity : ComponentActivity() {
private var productlist: HashMap<String, Product> = HashMap()
private var tagList: HashMap<String, Tag> = HashMap()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
startLoad()
findViewById<Button>(R.id.btn_action)?.setOnClickListener {
startLoad()
}
}
private fun startLoad() {
Thread{
Thread.sleep(1000)
synchronized(productlist){
productlist.clear()
for (i in 1..1000){
productlist[i.toString()] = Product(i.toString())
}
fuishData()
}
}.start()
Thread {
Thread.sleep(1000)
synchronized(tagList){
tagList.clear()
for (i in 1..1000){
tagList[i.toString()] = Tag(i.toString())
}
if(productlist.isNotEmpty()) {
fuishData()
}
}
}.start()
}
private fun fuishData() {
synchronized(productlist) {
if(tagList.isNotEmpty()) {
productlist.map {
it.value.recommand = tagList[it.key]
}
}
}
refreshListView()
}
private fun refreshListView() {
runOnUiThread{
Log.d("MAIN","refreshListView 1")
// val time = System.currentTimeMillis()
synchronized(productlist) {
// Log.d("MAIN","refreshListView wait "+(System.currentTimeMillis()-time))
}
Log.d("MAIN","refreshListView 2")
}
}
}
data class Product(var id: String, var recommand: Tag? = null)
data class Tag(var id: String)
当我们快速的点击刷新按钮,我们看到结果是被阻塞了。这个代码模拟的是一个View的数据通过两个数据源获取然后拼接,异步处理加了synchronized处理数据集合对象,刷新UI的时候主线程也加上synchronized获取锁再刷新。由于阻塞的时间很短,很容易被忽略。如果在低性能或者类似启动app过程中主线程任务繁重。UI卡顿丢帧就大大触发。
- 如下几种使用方式,都一样,synchronized都是锁的对象的klass。
java
public class SampleTest {
public static synchronized void fun(){
//todo
}
public void fun2(){
synchronized(SampleTest.class){
//todo
}
}
}
- 如果是普通类,锁的对象的klass会有隐患,那么使用普通的成员变量obj锁即可,除非是是单例可以使用。
java
public class SampleTest {
private Object lock = new Object();
public void fun(){
synchronized(lock){
//todo
}
}
}
- 使用synchronized(this)和非静态方法synchronized是一样的,都是同一个对象。
java
public class SampleTest {
public synchronized void fun(){
}
public void fun(){
synchronized(this){
//todo
}
}
private class InnnerClass {
public void fun2(){
//和上面一样都是同一个对象的锁
synchronized(SampleTest.this){
//todo
}
}
}
}
- 尽量不要使用嵌套synchronized,可能触发死锁。
java
public class SampleTest {
private Object data1 = new Object();
private Object data2 = new Object();
public void fun(){
synchronized(data1){
//todo
synchronized(data2){
}
}
}
public void fun(){
synchronized(data2){
//todo
synchronized(data1){
}
}
}
}