【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){
             }
        }
    }
}
相关推荐
像污秽一样4 分钟前
Spring MVC初探
java·spring·mvc
计算机-秋大田5 分钟前
基于微信小程序的乡村研学游平台设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
LuckyLay8 分钟前
Spring学习笔记_36——@RequestMapping
java·spring boot·笔记·spring·mapping
醉颜凉44 分钟前
【NOIP提高组】潜伏者
java·c语言·开发语言·c++·算法
阿维的博客日记1 小时前
java八股-jvm入门-程序计数器,堆,元空间,虚拟机栈,本地方法栈,类加载器,双亲委派,类加载执行过程
java·jvm
qiyi.sky1 小时前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat
lapiii3581 小时前
图论-代码随想录刷题记录[JAVA]
java·数据结构·算法·图论
RainbowSea1 小时前
4. Spring Cloud Ribbon 实现“负载均衡”的详细配置说明
java·spring·spring cloud
程序员小明z1 小时前
基于Java的药店管理系统
java·开发语言·spring boot·毕业设计·毕设
爱敲代码的小冰1 小时前
spring boot 请求
java·spring boot·后端