华为二面,被synchronized赢麻了

本文详细解释了Java中的synchronized关键字的用法,包括对象锁定、静态锁定和this锁定,同时介绍了synchronized的优化过程,从偏向锁、轻量级锁到重量级锁的转变,以及锁的执行流程、自旋锁与重量级锁的选择。还讨论了锁重入的概念。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

大家好,我是话猫。前几天话猫的读者华为一面通过了,我对后续结果跟进了一下,据读者反馈二面刚好考了之前咨询过我的synchronized,当时是我把面试笔记发给了读者,读者根据我的笔记对面试官的问题对答如流,二面直接就给过了!!!今天话猫彻底披露synchronized的私人面试笔记。

synchronized的使用

先介绍下synchronized的几种简单用法:

1)代码中锁定的是一个对象

public class T {
    private Integer count = 10;
    private Object o = new Object();
    public void testSyncObject() {
        // 锁的是对象o
        synchronized (o) { 
            count--;
        }
    }
}

2)修饰静态方法,锁定的是当前类的class对象

public class T{
    private static Integer count = 10;
    private  Object o = new Object();
    // 锁定的T.class对象
    public static synchronized void testSyncObject(){ 
        count--;
    }
}

3)修饰普通方法,锁定的是this对象

public class T{
    private Integer count = 10;
    private  Object o = new Object();
    // 锁定的当前this对象
    public synchronized void testSyncObject(){ 
        count--;
    }
}

注意:我们在选择synchronized锁定的对象时,不能用String、Integer、Long等基础的数据类型的对象。这是因为基础类型值的变化,会引起对象的变化。比如Integer类型,i++实际上是i = new Integer(i+1),所以执行完i++后,i已经不是原来的对象了,因此锁定的对象就发生了改变。多个线程锁的对象各不相同,同步块自然就无效了,Long类型也同理。对于String类型,是因为String定义的变量会放在常量池中,如果多个线程定义的String变量的值相等,则锁无效,他们看起来锁的是不同对象,其实是同一个对象。

synchronized优化

在jdk6之前,syncronized称为重量级锁,之所以称为重量级是因为申请锁资源必须向操作系统申请。我们都知道jvm是工作在用户态的,那申请锁资源需要通过用户态到内核态的调用,申请到锁之后,再从内核态返回到用户态。涉及用户态到内核态的转换,这个过程是很耗费资源,性能低。

后来经过观察实际使用情况,发现synchronized代码在执行过程中,大多数情况下只有一个线程在执行,根本没有必要每次都去向内核申请锁资源,设置竞争机制。

在jdk1.6的时候,jdk的开发者对syncronized进行了优化。基于使用场景的大多数情况而言,只需要在用户态就可以解决问题,不需要与内核交互,从而可以提升程序的性能。因此syncronized由之前的重量级锁优化为四种锁状态的升级。

在介绍锁升级的过程之前,我们先来了解一个前置知识–对象的内存布局。

对象的内存布局

我们作为程序员,每天都在new对象,那你们清楚当new出一个对象之后,在内存中是怎样分布的吗?

下面我以64位的hospot的实现来介绍下对象的内存布局:

public class T{
}

T t = new T(); 

在堆内存中,对象的布局有4部分:

1)8字节的markword(markword也叫对象头,用于存储对象的元数据信息,如对象的哈希码、锁状态、GC标记等)

2)4字节的classpoint(通过该指针可以找到class类的T.class对象)

3)成员变量

4)对齐填充(对象占用必须是8的整数倍,不够的需要添加剩余字节的对齐填充)

例如上述t对象内存占用情况:8字节markword+4字节classpoint+0字节成员变量+4字节对齐填充,总共占用16字节。我们所说的锁对象,优化后的锁信息就被记录在markword中,给对象加锁指的就是修改markword(感兴趣的可以看下hospot源码markOop.hpp文件)。

在markword中记录的信息有锁信息、hasCode、垃圾回收信息。在下面的锁升级过程中,你会详细的看到锁信息在对象头中是怎么记录的。

锁升级

Object o = new Object();
public void f(){
      synchronized (o){
            
      }
}

在对象刚被创建时,还没有任何线程尝试获取它的锁,锁状态通常是无锁状态。在markword中,有一位是代表偏向锁位,有两位代表锁标志位,偏向锁位为0,锁标志位为01代表无锁。
在这里插入图片描述

1)在有线程尝试获取锁,没有竞争时,会加偏向锁。偏向锁是把自己的线程ID写在markword中。此时,偏向锁位为1,锁标志位为01代表偏向锁。注意:偏向锁有4s的延迟,如果启动后不到4s,开始尝试加锁时,会直接略过偏向锁,升级到轻量级锁。当启动超过4s后,创建的对象会默认加匿名偏向锁,也就是锁的标志位为偏向锁,但是markword中记录当前线程指针的位置为空,当有线程获取锁时,会把线程ID写在markword中。

(选读:这里为什么会启动延迟4s呢?由于在JVM虚拟机启动的时候,有一些默认启动的线程有很多synchronized代码,这些synchronized代码启动时明确知道肯定会有竞争,如果使用偏向锁,则会造成偏向锁不断的进行锁撤销和锁升级的操作,效率很低,所以启动时会有一个偏向锁时延。我们也可以通过设置JVM的参数:-XX:BiasedLockingStartupDelay = 4,用于控制启动时偏向锁的延迟时间。)

在这里插入图片描述

2)只要有线程竞争时,就要升级为轻量级锁。首先将偏向锁撤销,然后进行自旋竞争,两个线程分别在自己的线程栈中生成自己的LR(lock record),然后自旋竞争将自己的LR指针设置到markword中,竞争成功的线程,锁对象markword中记录着指向该线程的LR的指针,轻量级锁的锁标志位为00。然后另一个线程继续进行CAS竞争。

在这里插入图片描述

3)当锁竞争激烈时,会进行锁膨胀,升级到重量级锁。重量级锁会向操作系统去申请锁,重量级锁的锁标志位为10。注意:在偏向锁状态时,如果竞争很激烈,会略过轻量级锁,直接升级为重量级锁。

在这里插入图片描述

JVM并没有规定如何实现锁,只是规定了拿到锁后,才能执行锁内的代码,在对象头上拿出两位进行标记是hospot的实现。

锁升级过程的实质就是通过markword后面几位来标志的。偏向锁、轻量级锁是用户空间的锁,作用在用户态。重量级锁是向操作系统申请的锁,作用在内核态。下面这张图是对锁升级的过程进行的总结。

在这里插入图片描述

synchronized在编译成的汇编指令时,加了一个moniotorenter、monitorexit,moniotorenter 指令代表锁开始,monitorexit 指令代码代表代码块执行结束或者代码遇到异常,释放锁。

synchronized在字节码层级的实现是moniotorenter,在hospot层级上是锁的升级过程。

锁的执行流程

偏向锁的获取和撤销流程

1.首先获取锁对象的markword,判断是否处理匿名偏向状态(偏向锁位=1、且thread为空)

2.如果是可偏向状态,则通过cas操作,把当前线程ID写入到markword。1)若cas成功,则表示成功获取了偏向锁,接着执行同步代码块。2)若cas失败,说明有其他线程已经获得了偏向锁,说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并把它持有的锁升级为轻量级锁才能执行。

3.如果是已偏向状态,需要检查markword中存储的ThreadID是否等于当前线程的ThreadID 。1)若相等,不需要再次获得锁,可直接执行同步代码块。2)若不等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁。

对于偏向锁的撤销并不是把对象简单恢复到无锁可偏向状态,而是针对原持有偏向锁的线程的执行进度有两种情况:1)原获得偏向锁的线程,同步代码块已经执行完,此时,把对象头设置成无锁状态,并且争抢锁的线程可以基于CAS重新偏向当前线程。2)如果原获得偏向锁的线程的同步代码块还没执行完,此时把原获得偏向锁的线程升级为轻量级锁后,继续执行同步代码块。

重量级锁的执行流程

ObjectMonitor称为对象的内置锁,也称为对象监视器,每个对象都有一个关联的ObjectMonitor,是对象头markword中的重量级锁指针指向的对象。

当一个线程要获取一个对象的重量级锁时,它会尝试获取该对象的监视器-ObjectMonitor,如果该对象的ObjectMonitor已经被其他线程持有,那么该线程就会进入同步队列阻塞等待,线程状态变为BLOCKED。直到该对象的ObjectMonitor被释放,该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试获取该对象的监视器。

自旋锁什么时候升级为重量级锁

1)在jdk1.6之前,有线程超过10次自旋;在1.6之后,加入了自适应自旋,JVM根据算法自己控制自旋次数,底层实现是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定自旋次数。

2)可人为通过JVM的参数 -XX:PreBlockSpin调整自旋次数;

3)自旋线程数超过CPU核数的一半;

为什么有自旋锁还需要重量级锁

自旋是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞。大部分同步代码块执行的时间都是很短的,因此线程原地等待很短时间就能获得锁,可以很大的提升锁的性能。但是自旋是消耗CPU资源的,如果锁的时间长,或者自旋线程多,自旋会大量消耗CPU。

而重量级锁,会把那些未抢占到锁的线程放到等待队列中,在此期间不消耗CPU资源,等待操作系统的调度才能再次去竞争锁,所以在竞争比较激烈/锁时间很长时,重量级锁更适合。

总结来说自旋是发生在用户态,不涉及OS的操作,当执行时间短,线程数少时,适合用自旋锁,效率比较高。当竞争比较激烈/锁时间很长时,为了避免一直自旋带来的CPU资源消耗,重量级锁更适合。

偏向锁是否一定比自旋锁的效率高

当然不一定,在明确知道会有多线程竞争的情况下,偏向锁会涉及锁撤销,此时直接使用自旋锁效率会更高。我们可通过JVM参数UseBiasedLocking来设置开启或关闭偏向锁。

锁重入

我们看下面的代码,同步代码块调用了另一个方法,而在该方法中又有一个同步代码块,并且锁的同一个对象。如果synchronized不能重入,那么调用n方法持有锁后,在n方法内调用m方法时,再次获取锁,会被自己锁死了。因此sychronized必须是可重入锁,并且重入次数必须记录,因为要解锁几次要与加锁次数对应。对于偏向锁、轻量级锁,信息是记录在线程栈中,每重入一次LR+1。当释放锁时,将线程栈中的LR依次弹出即可。重量级锁是记录在ObjectMonitor字段上。

public synchronized void n(){
m();
}

public synchronized void m(){}
好了,synchronized的分析就到这里了。

碎碎念

我输出的文章都是我多年面试大厂总结的经验,将各系列面试高频知识点抽出来进行全面解析,还有一些个人感悟等等。如果恰好对你有用处,**【点赞、关注、在看】**是我创作的最大动力!!!

我这里有份非常全的面试题,长达20万字,算是 Java 一整套技术栈都写了,包括 Java 基础,虚拟机,消息队列,框架等等。当然,还有通用基础知识,例如计算机网络,操作系统,Mysql,Redis 也都整理了,给大家看一下目录。

在这里插入图片描述

如何获取这份资料

关注公众号「话猫」,回复「1024」即可获取下载链接哦,以下是公众号

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值