【Android面试-java基础】synchronized什么时候处于偏向锁?

面试官问:synchronized是什么锁?

:阻塞锁,非公平锁

面试官问:什么时候处于偏向锁?原理是什么?

:...(不知道从哪里说起😭)

日常开发中,我们经常使用到synchronized。但是大多只知道他是多线程开发过程中的一种锁。但是实现原理可能似懂非懂。今天我们就来从对象头和源码搞清楚它,下次被问以及写代码的时候就可以胸有成竹了💪💪💪😁。

一、ClassLayout 内存布局查看对象头

在Java中,一个对象如图所示,包含一下几个部分:

对象头(Object Header)主要由两部分组成:Mark WordClass 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:偏向锁的线程ID
  • Epcoh: 当对象头的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的代码块都一个monitorentermonitorexit, monitor-enter v1monitor-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关键字字节码指令是MonitorEnterMonitorExit,当一个对象没有锁的时候synchronized触发获取锁,会将对象头的markWord中的信息锁状态变成偏向锁,如果此时又来一有一个线程获取锁,该对象会升级轻量级锁,这个线程会自旋等待,直到获取锁,如果此时又有一个线程或者自旋一定数量以后,该对象会升级为重量级锁状态,重量级锁状态指向一个Monitor对象指针,Monitor维护线程等待队列,处于等待队列中的线程也处于阻塞状态,当等待线程等待一定时间或者own_thread退出解锁的时候,等待队列被唤醒,进入唤醒队列,唤醒队列中的各个线程通过竞争获得锁,获得成功就变成own。不成功继续进入等待队列继续Blocked,等待下一次唤醒。所以综合来说synchronized是阻塞锁和非公平锁。


三、synchronized的正确打开方式

  1. 一不小心阻塞的主线程 这个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卡顿丢帧就大大触发。

  1. 如下几种使用方式,都一样,synchronized都是锁的对象的klass。
java 复制代码
public class SampleTest {
    public static synchronized void fun(){
        //todo
    }
    public  void fun2(){
        synchronized(SampleTest.class){
              //todo
        }
    }

}
  1. 如果是普通类,锁的对象的klass会有隐患,那么使用普通的成员变量obj锁即可,除非是是单例可以使用。
java 复制代码
public class SampleTest {
    private Object lock = new Object(); 

    public  void fun(){
        synchronized(lock){
            //todo
        }
    }
}
  1. 使用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
        }
      }
    }
  
}
  1. 尽量不要使用嵌套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){
             }
        }
    }
}
相关推荐
Java水解14 分钟前
Java 中间件:Dubbo 服务降级(Mock 机制)
java·后端
砖厂小工2 小时前
用 GLM + OpenClaw 打造你的 AI PR Review Agent — 让龙虾帮你审代码
android·github
张拭心3 小时前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心3 小时前
Android 17 来了!新特性介绍与适配建议
android·前端
SimonKing4 小时前
OpenCode AI辅助编程,不一样的编程思路,不写一行代码
java·后端·程序员
FastBean4 小时前
Jackson View Extension Spring Boot Starter
java·后端
Kapaseker5 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴6 小时前
Android17 为什么重写 MessageQueue
android
Seven976 小时前
剑指offer-79、最⻓不含重复字符的⼦字符串
java
皮皮林55115 小时前
Java性能调优黑科技!1行代码实现毫秒级耗时追踪,效率飙升300%!
java