前言
一篇文章了解 Java 工程师的一生之敌 synchronized,面试基本必问。简单一点会问如何使用,难一点直接解释其原理。下面详细一层层剥开 synchronized 的面纱。
什么是synchronized,有什么作用
synchronized可以理解它是一个互斥锁
,用于控制多线程环境下的并发访问,防止多个线程
同时访问某个共享资源
,从而避免数据的不一致性
,被synchronized修饰的代码块在同一时刻
,最多只有一个线程
能执行该段代码。
基础用法
-
synchronized 修饰代码块时,它会锁定指定的对象,只有获得该对象的锁才能执行该代码块中的内容。例如:
javasynchronized (this) {} synchronized (Object) {} synchronized (类class) {}
-
synchronized 修饰方法时,它会锁定与对象关联的监视器(可以理解为锁定
对象本身
),锁定成功后才可以继续执行。例如:arduinopublic synchronized void method() {...}
-
synchronized 修饰静态方法时,它会锁定类的
Class 对象
(类锁),只有获得该 Class 对象的锁才能执行该静态方法。例如:arduinopublic static synchronized void method() {...}
对象锁(monitor)机制
现在来进一步分析synchronized的具体底层实现,有如下一个简单的示例代码:
java
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
System.out.println("hello synchronized!");
}
}
}
上述代码通过synchronized"锁住"当前类对象来进行同步,将java代码进行编译之后通过javap -v SynchronizedDemo.class来查看对应的main方法字节码如下:
yaml
public static void main(java.lang.String[]);
• descriptor: ([Ljava/lang/String;)V
• flags: ACC_PUBLIC, ACC_STATIC
• Code:
• stack=2, locals=3, args_size=1
• 0: ldc #2 // class com/codercc/chapter3/SynchronizedDemo
• 2: dup
• 3: astore_1
• 4: **monitorenter**
• 5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
• 8: ldc #4 // String hello synchronized!
• 10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
• 13: aload_1
• 14: monitorexit
• 15: **goto** 23
• 18: astore_2
• 19: aload_1
• 20: **monitorexit**
• 21: aload_2
• 22: **athrow**
• 23: **return
- 重要的字节码已经在原字节码文件中进行了标注,再进入到
synchronized
同步块中,需要通过monitorenter
指令获取到对象的monitor
(也通常称之为对象锁
)后才能往下进行执行 - 在处理完对应的方法内部逻辑之后通过
monitorexit
指令来释放
所持有的monitor,以供其他并发实体进行获取。 - 代码后续执行到第15行
goto
语句进而继续到第23行return
指令,方法成功执行退出。 - 另外当方法异常的情况下,如果monitor
不进行释放
,对其他阻塞对待的并发实体来说就一直没有机会获取到了,系统会形成死锁状态很显然这样是不合理。 - 因此针对
异常的情况
,会执行到第20行指令通过monitorexit
释放monitor锁,进一步通过第22行字节码athrow抛出
对应的异常。 - 从字节码指令分析也可以看出在使用synchronized是具备隐式加锁和释放锁的操作便利性的,并且针对异常情况也做了释放锁的处理。
每个对象都存在一个与之关联的monitor,线程对monitor持有的方式以及持有时机决定了synchronized的锁状态以及synchronized的状态升级方式。monitor是通过C++中ObjectMonitor实现,代码可以通过openjdk hotspot链接(hg.openjdk.java.net/jdk8u/jdk8u... )进行下载openjdk中hotspot版本的源码,具体文件路径在src\share\vm\runtime\objectMonitor.hpp,具体源码为:
c++
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
• _header = NULL;
• _count = 0;
• _waiters = 0,
• _recursions = 0;
• _object = NULL;
• _owner = NULL;
• **_WaitSet** = NULL;
• _WaitSetLock = 0 ;
• _Responsible = NULL ;
• _succ = NULL ;
• _cxq = NULL ;
• FreeNext = NULL ;
• **_EntryList** = NULL ;
• _SpinFreq = 0 ;
• _SpinClock = 0 ;
• OwnerIsThread = 0 ;
• _previous_owner_tid = 0;
}
- 从ObjectMonitor的结构中可以看出主要维护WaitSet以及EntryList两个队列来保存ObjectWaiter 对象
- 当每个阻塞等待获取锁的线程都会被封装成ObjectWaiter对象来进行入队,与此同时如果获取到锁资源的话就会出队操作。
- 另外_owner则指向当前持有ObjectMonitor对象的线程。等待获取锁以及获取锁出队的示意图如下图所示:
- 当多个线程进行获取锁的时候,首先都会进行
_EntryList
队列,其中一个线程获取到对象的monitor后,对monitor而言就会将_owner
变量设置为当前线程,并且monitor维护的计数器就会加1。 - 如果当前线程执行完逻辑并退出后,monitor中
_owner
变量就会清空并且计数器减1,这样就能让其他线程能够竞争到monitor。 - 另外,如果调用了wait()方法后,当前线程就会进入到_WaitSet中等待被唤醒,如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到monitor。
从线程状态变化的角度来看,如果要想进入到同步块或者执行同步方法,都需要先获取到对象的monitor,如果获取不到则会变更为BLOCKED状态,具体过程如下图所示:
从上图可以看出任意线程对Object的访问,首先要获得Object的monitor,如果获取失败,该线程就会进入到同步队列中,线程状态变为BLOCKED。当monitor持有者释放后,在同步队列中的线程才会有机会重新获取monitor,才能继续执行。
Java对象内存布局
- 对象头(Object Header):对象头存储的是对象在
运行时状态
的相关信息、指向该对象所属类的元数据的指针
,如果对象是数组对象那么还会额外存储对象的数组长度,内存大小相对固定
。 - 实例数据(Instance Data):实例数据存储的是对象的
真正有效数据
,也就是各个属性字段的值
,如果在拥有父类的情况下,还会包含父类的字段。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响。 - 对齐填充字节(Padding):在java对象中,需要对齐填充字节的原因是,64位的jvm中对象的大小被要求向8字节对齐,因此当对象的
长度不足8字节
的整数倍时,需要在对象中进行填充操作,如对象头➕实例数据的大小一共是30 个字节,那么 padding 就还要补 2 个字节,所以这个 Java 对象内存就占用 32 个字节 。
在计算机系统中,数据访问的基本单位通常是字节。但是,大多数机器的数据总线宽度都大于一个字节,一次能够读取或写入多个字节的数据。为了提高存取效率,系统往往在存取数据时都会尽可能地一次性存取尽可能多的数据。因此,系统在存取某类型的数据时,总是把它们存放在内存的某个地址处,而这个地址应该是该类型数据长度的整数倍,这就是对齐
对象头
- Mark Word :Mark Word在64位JVM中占用
8字节
,32 位则是 4 个字节,主要用来存储对象的运行时信息
,包括哈希码(HashCode
)、GC年龄信息、锁的状态
。 - 指向类的指针 :称为
klass pointer
,这个指针指向对象所属类的元数据
,JVM使用这个指针来确定此对象是哪个类的实例,占用的字节数取决于JVM是否开启了指针压缩
。如果开启了指针压缩,那么这个指针占用4字节,否则占用8字节。 - 数组长度:只有数组对象才有这个字段,用来存储数组的长度,占用4字节。
因此,对于
非数组对象
,如果开启了指针压缩
,那么对象头占用的总字节数为12字节
(8字节的Mark Word + 4字节的类指针)。如果没有开启指针压缩,那么对象头占用的总字节数为
16字节
(8字节的Mark Word + 8字节的类指针)。对于
数组对象
,如果开启了指针压缩
,那么对象头占用的总字节数为16字节
(8字节的Mark Word + 4字节的类指针 + 4字节的数组长度)。如果没有开启指针压缩,那么对象头占用的总字节数为
20字节
(8字节的Mark Word + 8字节的类指针 + 4字节的数组长度)。
举个例子
java
public class MyClass {
private int a;
private double b;
}
- 当我们创建这个类的一个实例,比如MyClass myObject = new MyClass();,JVM在内存中为myObject分配空间。这个空间包含了klass pointer,Mark Word,以及实例变量a和b。
- 其中,klass pointer指向了MyClass的类元数据,JVM通过这个指针知道myObject是MyClass的一个实例。Mark Word包含了锁的信息,GC的年龄等信息。实例变量a和b就是我们在类定义中声明的变量。
Mark Word
- 无锁状态 :哈希码(HashCode)占用31位,对象年龄(Age)占用4位,偏向锁标记占用1位,锁标记占用2位,所以
最后三位是 001 则是无锁状态
。 - 偏向锁状态 :线程ID占用54位,偏向时间戳占用2位,对象年龄(Age)占用4位,偏向锁标记占用1位,锁标记占用2位,所以
最后三位是 101 则是偏向锁状态
。 - 轻量级锁状态 :指向锁记录的指针占用62位,锁标记占用2位,所以
最后两位是 00 则是轻量级锁状态
。 - 重量级锁状态 :指向重量级监视器的指针占用62位,锁标记占用2位,所以
最后两位是 10 则是轻量级锁状态
。 - GC标记状态:空,锁标记占用2位。
Mark Word占用8字节的原因是为了满足64位JVM的内存对齐要求。
注:在无锁状态下,Mark Word的哈希码(HashCode)占用31位。然而,当对象进入偏向锁、轻量级锁或重量级锁状态时,哈希码就不再存储在Mark Word中了。这是因为在这些状态下,Mark Word需要用来存储其他信息,如锁的信息和线程ID。
需要注意的是,对象在创建后,其哈希码并没有立即更新到Mark Word中,只有在调用了hashCode方法后,哈希码才会被写入到Mark Word中。
如果在调用hashCode方法之后对象的状态发生了改变(例如,对象被锁定),那么哈希码将被移出Mark Word,但仍然可以通过hashCode方法获取
用户态内核态
在操作系统中,CPU有两种运行级别:用户态(User Mode)和内核态(Kernel Mode)。
- 用户态(User Mode) :当程序运行在用户态时,处理器处于特权级最低的(3级)用户代码中运行。用户态下的程序只能执行一部分机器指令,不能直接访问操作系统内核数据结构和程序,也不能直接执行I/O命令或者影响机器控制的命令。大部分用户直接面对的程序都是运行在用户态。比如记事本或者Word,然后开始在里面输入文字,这个过程就是在用户态下运行的
- 内核态(Kernel Mode) :当程序运行在内核态时,处理器处于特权级最高的(0级)内核代码中执行。内核态下的程序可以访问所有的硬件设备,也可以执行硬件上能够运行的各种指令。操作系统是运行在内核态的。当你点击"保存"按钮,准备把你刚才输入的文字保存到硬盘上时,这个过程就涉及到了内核态
用户态和内核态的转换主要有以下三种方式:
- 系统调用 :这是用户态进程
主动要求
切换到内核态的一种方式,用户态进程通过系统调用申请
使用操作系统提供的服务程序完成工作。 - 异常 :当CPU在执行运行在用户态下的程序时,
发生了
某些事先不可知的异常
,这时会触发由当前运行进程切换到处理此异常
的内核相关程序
中,也就转到了内核态。 - 外围设备的中断 :当外围设备完成用户请求的操作后,会向CPU发出相应的
中断信号
,这时CPU会暂停执行下一条
即将要执行的指令转而去执行与中断信号
对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。
为什么要区分用户态和内核态
- 安全性:用户态的程序只能访问有限的处理器指令,并且不能直接访问操作系统内核数据结构和程序。这样可以防止用户程序直接操作硬件设备或者随意修改系统数据,从而保护系统的安全。
- 稳定性:当用户态程序出现错误时,不会影响到内核,因此可以保证系统的稳定性。例如,一个用户态程序的崩溃不会导致整个系统的崩溃。
- 效率:用户态和内核态的切换存在一定的时间和资源开销。通过合理地划分用户态和内核态,可以尽可能地减少这种切换,从而提高系统的效率。
锁升级
锁升级是指在多线程环境中,为了保证数据的一致性和完整性,对数据进行
访问控制
的一种机制。在Java中,锁的状态有四种,从低到高依次为:无锁、偏向锁、轻量级锁、重量级锁。这四种锁状态会随着竞争的情况
逐渐升级
,而且是不可逆
的过程。
锁升级的过程:
- 无锁状态:对象新创建出来,在
没有线程竞争
的情况下,对象处于无锁状态
。 - 偏向锁:当一个线程访问同步块时,会在
对象头
和栈帧
中记录偏向的锁的线程ID
,之后这个线程再进入同步块时,只需要比较对象头的记录
的线程ID是否为当前线程,如果是,则直接进入同步块。 - 轻量级锁:当有
另一个线程
尝试访问同步块时,偏向锁就会升级为轻量级锁。此时,竞争的线程不会阻塞
,而是进行自旋(CAS)
,看持有锁的线程是否会快速释放锁。 - 重量级锁:当自旋
超过一定次数
,或者有多于一定数量(一般是 CPU 核数的一半)
的线程同时竞争同一个锁,轻量级锁就会膨胀为重量级锁。此时,未能获取锁
的线程会进入阻塞状态
,等待唤醒。
一般来说,锁的状态只能升级,不能降级,也不能跨级升级(但是听说有些复杂的情况会直接跨级,可以稍微去了解一下)。
但在HotSpot JVM中,重量级锁的降级是可能的,但这种降级发生在STW(Stop-The-World)阶段,降级对象为仅仅能被VMThread访问而没有其他JavaThread访问的对象。然而,这种降级机制的效率较低,如果频繁升降级的话对性能就会造成很大影响。
举例说明:
- 假设有一个公共厕所,门口有位大爷在看门,初始状态下,公共厕所的门是开放的,任何人都可以进入,这就是无锁状态。
- 后来,张三想去上厕所,跟大爷说了一声,大爷记住他了,下次张三过来就不用问大爷了,大爷认得他,他直接进去就是了,这就是偏向锁。
- 随后,又有其他人来上厕所了,因为不知道里面的人什么时候出来,所以就一直在这里等,等里面的人出来了,大爷随机挑一个人进去,这就是轻量级锁。
- 最后,后面人越来越多了,所有人都在这里等着也太浪费时间了,所以大爷拿出一个抽屉,叫他们把联系方式放进来,然后他们该干嘛干嘛去,里面的人出来了之后,大爷随机拿起一个联系方式一个电话过去叫他过来上厕所,这就是重量级锁。
为什么要有偏向锁 因为在大多数程序中并发量并不高
,大多数都只是同一个线程在执行
,但是共享数据又不得不锁
,如果每次都是同一个线程获取同一个数据,又要锁又要释放锁
,这是一个很大的开销,所以就一开始设置偏向锁,避免了在无竞争情况下的同步开销
为什么要有轻量级锁 当有竞争但是不是很激烈的情况下,当前线程可以选择等一下(自旋),因为线程切换
也是一个很大的开销,涉及到阻塞、唤醒、就绪、调度
等操作,有时等个 1毫秒就能到他了,结果一个线程切换就花了 100 毫秒,得不偿失。这也涉及到一个用户态转内核态
,偏向锁、轻量级锁都是属于用户态的锁
,如果直接使用了重量级锁,就要从用户态转内核态
了,这也是相当耗时的工作
为什么要有重量级锁 当等待的线程太多了,自旋花费时间太长了(轻量级锁使用的 CAS 可是会一直占用着 CPU 的
),相当于一直在厕所门口等,其他事情也不能做,所以先留个手机号码(线程 id)在排队
,自己先去玩,等锁释放了,大爷打电话过来到我了(CPU 重新调度分配了时间片),我就过来了。
Java 实现案例
java
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
System.out.println("=======================================================");
System.out.println(Integer.toHexString(t.hashCode()));
System.out.println("=======================================================");
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
- OFFSET:偏移地址,单位字节,如第一行从 0 开始;
- SIZE:占用的内存大小,单位为字节,如第一第二行,每行占4 个字节,共 8 个字节组成 mark work;
- TYPE DESCRIPTION:类型描述,其中object header为对象头;
- VALUE:对应内存中当前存储的值,其中位 16 进制、二进制、十进制;
先解释一下那个 value 值怎么看,根据上面 Mark word 的图,以及结合上面的分析
有个疑问:根据代码就能知道,对象是没有加锁的,
锁状态应该是无锁
(对象刚创建确实应该是无锁状态),并且一开始是没有调用 hascode 的,所以前25 位
+31 位
应该都没有数据
才对的,为什么前八位就已经有数据了呢 00000101?
大端序与小端序
这是因为计算机中的字节顺序问题,也被称为端序(Endianness)。有两种类型的端序:大端序(Big Endian)和小端序(Little Endian),
这里是使用小端序的方式展示
。
- 大端序:最高有效字节在前,最低有效字节在后。这是人类通常阅读数字和文字的方式,例如 00 00 00 05。
- 小端序:最低有效字节在前,最高有效字节在后。例如 05 00 00 00。
所以上述的结果换成正常的读法就是 00000005,二进制的也同理,
分析
- 所以 Mark word 的
第一个字节
则是第二行的最后一个字节
,所以最后三个字节都是 0,前 25 位处于无使用状态
- 一开始
没有调用 hascode
的方法,所以前56 位都是没有数据
的,所以在小端序中只有第一个字节是有数据的,这就对上了 - 分析第一个字节可以拆成
0 0000 1 01
,其中第一位无使用,然后 4 位是分代年龄,所以这对象的分代年龄位 0,下一位是偏向锁状态,为 1,加上最后两位锁状态是 01,所以最后三位是 101 表示该对象处于偏向锁
。偏向锁???? - 为什么前面都对了,按理说是处于
无锁状态
的呀,怎么会是偏向锁呢? - 再看一下第二次打印的第一个字节,00000001,怎么有
变成无锁了
?不是说一般情况下不会锁不会降级的吗? 其实这里的偏向锁并不是真正的偏向锁,回想一下偏向锁,我一定需要把线程 id 存在 Mark word中我才知道我要偏向谁呀,按前面的数据前 56 位都是空的,不可能存了 Id 的,所以这个 101 只是表示可偏向状态,并不是真的偏向锁,因为下面又回到了无锁状态就很好的说明了
- 再来看一下第二次打印,后三个字节还是无数据,从倒数第四个字节开始有数据了,因为在打印前调用了 hascode,所以会把 hascode 存在了 Mark word 中,按照大端序来排序的话则是 52 af 26 ee,这个 16 进制就表示 hascode,第一个字节就是表示无锁状态