文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

我是这样学Synchronized关键字的

2024-12-03 13:38

关注

 前言

大家好,我是狂聊。

今天来聊synchronized关键字,高频面试问题。

这篇文章构思 + 画图 + 文字花了好几天的时间,我已经彻底废了,看完希望你能有所收获。

话不多说,直接干货。

正文

一、synchronized的用法

1.1、三种使用方式

  1. 静态方法
  2. 非静态方法
  3. 代码块

代码示例:

  1. public class Test { 
  2.     //对象 
  3.     Object object=new Object(); 
  4.     //共享变量 
  5.     private static int num; 
  6.     //静态方法 
  7.     public synchronized static void lock1(){ 
  8.         num ++; 
  9.     } 
  10.     //普通方法 
  11.     public synchronized  void lock2(){ 
  12.         num ++; 
  13.     } 
  14.  
  15.     public void lock3(){ 
  16.         //代码块 
  17.         synchronized (object){ 
  18.             num ++; 
  19.         } 
  20.     } 

1.2、作用范围

面试时经常会问:synchronized 关键字锁的是什么?或者说它的作用范围是什么?

总结一下:

  1. 非静态方法锁的是当前对象 (就是 this)
  2. 静态方法锁的是类对象 Test.class
  3. 代码块锁的是自定义的 Object 对象

1.3、原子性、可见性、有序性

我们都知道并发编程需要考虑三个问题:原子性、可见性、有序性。

那么,使用 synchronized 关键字是如何解决这三个问题的?

二、对象内存布局

上面说了,这三种方式都是锁的是对象、对象、对象(说三遍),但是听起来好像很抽象的样子,对象还能被锁?该如何操作?

其实是和对象内存布局有关系。

耳听为虚,眼见为实,下面让你亲眼看到对象是由啥组成的。

示例代码:

  1. //1、需要导入包 
  2. import org.openjdk.jol.info.ClassLayout; 
  3. //2、定义Lock类 
  4. public class Lock { 
  5.     int i; 
  6.     boolean flag; 
  7. //3、将Lock对象打印出来 
  8. public class Test { 
  9.     public static void main(String[] args){ 
  10.         Lock lock = new Lock(); 
  11.         System.out.println(ClassLayout.parseInstance(lock).toPrintable()); 
  12.     } 

打印出来的结果是这样的:

  1.  OFFSET  SIZE      TYPE DESCRIPTION                               VALUE 
  2.       0     4           (object header)                           01 47 70 9d (00000001 01000111 01110000 10011101) (-1653586175) 
  3.       4     4           (object header)                           11 00 00 00 (00010001 00000000 00000000 00000000) (17) 
  4.       8     4           (object header)                           43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253) 
  5.      12     4           int L.i                                   0 
  6.      16     1           boolean L.flag                            false 
  7.      17     7           (loss due to the next object alignment) 
  8. Instance size: 24 bytes 
  9. Space losses: 0 bytes internal + 7 bytes external = 7 bytes total 

对打印结果,详细解释一下:

2.1、对象头(Object Header)

Object Header 是 MarkWord 和 Class Pointer 组成的,后面会详细解释。

打印结果:占用 4+4+4=12 个 bytes。

2.2、实例数据(Interface Data)

对象实例数据包括了对象的所有成员变量,其大小由各个成员变量大小决定的。

当然,不包括静态成员变量,因为它是在方法区维护的!

打印结果:可以看到 int L.i 和 boolean L.flag 就是实例数据,占用 4+1=5 个 bytes。

2.3、填充数据(Padding)

Java 对象占用空间是 8 字节对齐的,即所有 Java 对象占用 bytes 数必须是 8 的倍数,因为当我们从磁盘中取一个数据时,不会是一个字节的去读,都是按照一整块来读取的,这一块大小就是 8 个字节,所以为了完整,padding 的作用就是补充字节,保证对象是 8 字节的整数倍。

打印结果:可以看到(loss due to the next object alignment) 这个就是填充数据,占用 7个字节。

这样的话,12+5+7=24 一共是 24 个 bytes,正好是 8 的倍数。

所以说,一个对象的内存布局是由对象头、实例数据、填充数据组成的。

接下来:重点关注这个对象头。

三、细说对象头

上面提到了对象头,直接看官网上的解释,官网地址在文末:

3.1、对象头(object header)

3.2、Klass Point

3.3、Mark Word

总结一下:其实对象头就是 MarkWord 和 Klass Point 组成的。MarkWord 是用来存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息。Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

那么问题来了!!

问题:那上面说的 MarkWord 是存储的 hashcode、锁信息或分代年龄或 GC 标志是在那定义的呢?

你可以下载 OpenJDK 的源码,在 markOop.hpp 的文件中可以看到 Mark Word 的状态信息:

markOop.hpp

可以看到还是写的非常清晰的,画图总结一下:

Mark Word空间

四、synchronized 深入分析

把 Test.java 编译为 Test.class ,并在对应目录下执行javap -v Test.class 这个命令,你能看到对应的字节码,如下:

字节码

上图可以看到 JVM 对于同步方法和同步代码块的处理方式是不同的。

对于同步代码块:采用 monitorenter 和 monitorexit 两个指令来实现同步。


monitorenter 指令可以理解为加锁,monitorexit 可以理解为释放锁。

进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

对于方法:出现了ACC_SYNCHRONIZED 标识。

当出现了 ACC_SYNCHRONIZED 标识符的时候,Jvm 会隐式调用 monitorenter 和 monitorexit。在执行同步方法前会调用 monitorenter,在执行完同步方法后会调用 monitorexit,释放 Monitor 对象。

你可以发现,不管是同步代码块还是同步方法,都和 Monitor 对象有关系。

那么问题又来了!!

问题:这个 Monitor 对象是啥呢?monitorenter 和 monitorexit 又是什么呢?

4.1、monitorenter

直接看 JVM 规范里对它的描述,地址在文末:

执行过程如下:

  1. 若 Monior 的进入数为 0,线程可以进入 Monitor,并将 monitor 的进入数置为 1。当前线程成为 Monitor 的 owner 拥有者。
  2. 若线程已拥有 Monitor 的所有权,允许它重入 Monitor,则进入 Monitor 的进入数加 1。
  3. 若其他线程已经占有 Monitor 的所有权,那么当前尝试获取 Monitor 的所有权的线程会被阻塞,直到 Monitor 的进入数变为 0,才能重新尝试获取 Monitor 的所有权。

4.2、monitorexit

看 JVM 规范里对它的描述,地址在文末:

执行过程如下:

  1. 能执行 monitorexit 指令的线程一定是拥有当前对象的 Monitor 的所有权的线程。
  2. 执行 monitorexit 时会将 Monitor 的进入数减 1。当 Monitor 的进入数减为 0 时,当前线程退出 Monitor,不再拥有 Monitor 的所有权,此时其他被这个 Monitor 阻塞的线程可以尝试去获取这个 Monitor 的所有权。

4.3、Monitor 监视器

每个对象都会关联一个 Monitor 对象,也叫做监视器。

在 HotSpot 虚拟机中,Monitor 是由 ObjectMonitor 实现的。其源码是用 c++来实现的,位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件中(路径:src/share/vm/runtime/objectMonitor.hpp)

ObjectMonitor 主要数据结构如下:

  1. ObjectMonitor() { 
  2.     _header       = NULL
  3.     _count        = 0; 
  4.     _waiters      = 0, 
  5.     _recursions   = 0;     //线程的重入次数 
  6.     _object       = NULL;  //存储该monitor对象 
  7.     _owner        = NULL;  //标识拥有该monitor的线程 
  8.     _WaitSet      = NULL;  //处于wait状态的线程会被加入到_WaitSet 
  9.     _WaitSetLock  = 0 ; 
  10.     _Responsible  = NULL ; 
  11.     _succ         = NULL ; 
  12.     _cxq          = NULL ; //多线程竞争锁时的单向列表 
  13.     FreeNext      = NULL ; 
  14.     _EntryList    = NULL ; //等待获取锁的线程,会放到这里 
  15.     _SpinFreq     = 0 ; 
  16.     _SpinClock    = 0 ; 
  17.     OwnerIsThread = 0 ; 
  18.   } 

看到这里,我相信你就能明白为啥之前要解释对象内存布局、对象头,因为这三者之间是有对应关系的。

画图总结一下:


可以看到 ObjectMonitor 的数据结构中包含:_owner、_WaitSet 和_EntryList。

它们之间的关系转换如下:

  1. 当多个线程同时访问同一段代码块或者某个同步方法的时候,这些线程会首先被放进_EntryList 队列中,处于 blocked 状态的线程,都会放入该队列中。
  2. 当某个线程获取到对象的 Monitor 时,此时就就可以进入 running 状态,执行代码逻辑,此时,ObjectMonitor 对象的_owner 指向当前线程,_count 加 1 表示当前对象锁被一个线程获取。而没有获取到锁的线程,会再次进入_EntryList 被挂起。
  3. 当 running 状态的线程调用 wait()方法,当前线程就会释放 Monitor 对象,进入 waiting 状态,ObjectMonitor 对象的_owner 变为 null,_count 减 1,同时线程进入_WaitSet 队列,直到有线程调用 notify()方法唤醒该线程,则该线程再次进入_EntryList 队列,直到再次竞争到锁再进入_owner 区。
  4. 如果当前线程执行完毕,那么也释放 monitor 对象,ObjectMonitor 对象的_owner 变为 null,_count 减 1。

这个过程大致就是在 JDK6 之前 实现的原理。

但是,JDK6 之前,synchronized关键字的效率是非常低的。

原因如下:

Monitor 对象是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。

既然 Mutex Lock 涉及到底层操作系统,那这个时候就存在操作系统用户态和核心态的转换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等。

所以,在JDK 6 之后,从Jvm层面进行了优化,分为了偏向锁,轻量级锁,自旋锁,重量级锁。

五、锁升级

下面就依此来说锁是如何一步步升级的。

5.1、偏向锁

什么是偏向锁?

HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

偏向锁的“偏”,就是偏心的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

偏向锁Mark Word

不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作的性能消耗,不然就得不偿失了。

2、偏向锁原理

无锁到偏向锁的转换流程图:


偏向锁流程图

参数:-XX:+UseBiasedLocking 开启偏向锁

简单来说:

  1. 线程访问同步代码块,使用 CAS 操作将 Thread ID 放到 MarkWord 当中
  2. 如果线程 CAS 成功,此时线程就会获取到偏向锁
  3. 如果线程 CAS 失败,证明已经有别的线程持有锁,这个时候启动偏向锁撤销,执行下面的操作

3、偏向锁的撤销

流程如下:

  1. 偏向锁的撤销动作必须等待全局安全点
  2. 暂停原持有偏向锁的线程
  3. 将 Thread ID置为null,使其变成无锁状态
  4. 恢复原持有偏向锁线程,开始进行轻量级加锁流程

5.2 轻量级锁

1、什么是轻量级锁?

轻量级锁是JDK 6之中加入的锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。需要强调一点的是,轻量级锁并不是用来代替重量级锁的。

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

2、轻量级锁原理

当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

流程图如下:

 

轻量级锁升级过程

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加了一个 Displaced 前缀,即Displaced Mark Word),将对象的 Mark Word复制到栈帧中的 Lock Record 中,将 Lock Reocrd 中的 owner 指向当前对象。
  2. JVM利用CAS操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果成功,表示竞争到锁,则将锁标志位变成 00,执行同步操作。
  3. 如果失败,则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

5.3 自旋锁

1、为什么会有自旋锁?

前面聊 monitor 实现锁的时候,知道 monitor 会阻塞和唤醒线程,线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。

同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个循环(自旋) , 这就是所谓的自旋锁。

2、自旋锁的优缺点

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的。

如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

所以,自旋等待的时间必须要有一定的限度,如果在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,你可以使用参数 -XX : PreBlockSpin 来更改。

5.4 适应性自旋锁

在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一锁上的自选时间及锁的拥有者的状态来决定。

如果在同一个对象锁上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。

如果,对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时可能会省略掉自旋过程,避免浪费服务器处理资源。

有了自适应自旋锁,虚拟机对程序的状况预测就会变得准确,性能也会有所提升。

总结

还总结啥?说的都这么明白啦!

其实就想说可以多看看官网,比如说monitorenter和monitorexit,虽然都是英文,但是这些都是第一手资料,可以去尝试读一下,看完是真的不容易忘记。

官网地址“

openjdk地址:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html

monitorenter:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

monitorexit:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit

 

来源:狂聊Java内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯