我是这样学Synchronized关键字的

开发 前端
今天来聊synchronized关键字,高频面试问题。这篇文章构思 + 画图 + 文字花了好几天的时间,我已经彻底废了,看完希望你能有所收获。

[[375200]]

 前言

大家好,我是狂聊。

今天来聊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 关键字是如何解决这三个问题的?

  • 原子性:synchronized 关键字能保证只有一个线程能拿到锁,能够进入同步代码块,不会出现原子性问题
  • 可见性:执行 synchronized 时,对应 lock 原子操作将会清空工作内存中此变量的值,并重新 read 来刷新内存,不会出现可见性的问题
  • 有序性:执行 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)

  • object header:Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object's layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.
  • 翻译:在每个 gc 管理的堆对象开始处的公共结构。(每个 oop 都指向一个对象头)包括关于堆对象的布局、类型、GC 状态、同步状态和标识哈希码的基本信息。由两个词组成。在数组中,紧随其后的是长度字段。注意,Java 对象和 vm 内部对象都有一个通用的对象头格式。

3.2、Klass Point

  • The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the "klass" contains a C++ style "vtable".
  • 翻译:每个对象头的第二个字。指向另一个对象(元对象),该对象描述原始对象的布局和行为。对于 Java 对象,“klass”包含一个 c++风格的“虚函数表”。

3.3、Mark Word

  • The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.
  • 翻译:每个对象头的第一个字。通常是一组位域,包括同步状态和身份哈希码。也可能是同步相关信息的指针(具有低比特编码特征)。在 GC 期间,可能包含 GC 状态位。

总结一下:其实对象头就是 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 规范里对它的描述,地址在文末:

  • Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
  • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
  • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
  • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
  • 翻译:每一个对象都会和一个监视器 Monitor 关联。监视器被占用时会被锁住,其他线程无法来获取该 Monitor。当 JVM 执行某个线程的某个方法内部的 onitorenter 时,它会尝试去获取当前对象对应的 Monitor 的所有权。

执行过程如下:

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

4.2、monitorexit

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

  • The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
  • The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

执行过程如下:

  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、偏向锁

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,虽然都是英文,但是这些都是第一手资料,可以去尝试读一下,看完是真的不容易忘记。

官网地址“

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

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

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

 

责任编辑:姜华 来源: 狂聊Java
相关推荐

2024-03-15 15:12:27

关键字底层代码

2019-12-20 15:19:41

Synchroinze线程安全

2021-03-10 15:59:39

JavaSynchronize并发编程

2022-01-26 00:03:00

关键字线程JVM

2009-08-12 13:37:01

Java synchr

2017-05-27 20:59:30

Java多线程synchronize

2021-08-15 08:11:54

AndroidSynchronize关键字

2021-07-17 15:48:13

Rust编程语言关键字

2009-06-29 18:26:11

Java多线程Synchronize同步类

2011-03-09 14:36:44

synchronizevolatile

2023-12-25 07:28:24

PythonSelf对象编程

2023-05-15 09:39:10

Java监视器锁

2022-05-06 08:32:40

Pythonwith代码

2021-02-01 13:10:07

Staticc语言UNIX系统

2022-02-17 08:31:38

C语言staic关键字

2020-04-06 16:52:01

else关键字编程语言

2022-11-12 18:32:50

Golangomitemptyjson

2022-01-04 16:35:42

C++Protected关键字

2009-09-17 09:30:00

Linq LET关键字

2009-09-02 09:24:03

C# this关键字
点赞
收藏

51CTO技术栈公众号