Synchronized关键字理解
1 synchronized的三种应用方式
- 修饰实例方法: 作用于当前对象实例this加锁,进入同步代码前要获得当前对象实例的锁。
- 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例。如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。简单来说,就是获取了类锁的线程和获取了对象锁的线程是不冲突的!
- 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
2 synchronized底层实现原理
理解synchronized底层实现原理之前,首先要理解一下Java对象头于Monitor监视器锁对象。
关于Java对象头和Monitor监视器对象的介绍见底部补充内容
有了上面的基础,这里主要从三个方面展开对synchronized底层实现原理进行分析
- Java字节码层面
- JVM层面,随着多线程的竞争情况,会进行锁升级的过程
- 在更底层的汇编实现,使用了
lock comxchg
实现。
2.1 从Java字节码出发
synchronized关键字根据不同的使用方法,会有不同的字节码实现,但是最终得原理都是一样的,都是获取Monitor对象实现同步。
-
synchronized 对同步代码块进行加锁分析
public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println("synchronized 代码块"); } } }
imgjavac
编译成字节码文件之后
可以看到synchronized 同步语句块的实现使用的是monitorenter
和monitorexit
指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。 -
synchronized 对方法进行同步加锁分析
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
编译后的字节码文件为:
img 可以看到修饰同步方法的时候,使用到了ACC_SYNCHRONIZED
标识,区别与同步代码块,JVM 通过该ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。底层都是获取Monitor监视器对象。
2.2 JVM实现锁升级的过程
JDK1.6
对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
锁升级的详细过程如下:
3 Java虚拟机对synchronized的优化
synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord
来实现的。JDK1.6
之前synchronized使用的是重量级锁,JDK1.6
之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
[图片上传失败...(image-784773-1591625443207)]
首先大致了解一下每种锁存在的意义、
-
无锁:
MarkWord
标志位01,没有线程执行同步方法/代码块时的状态。 -
偏向锁:在锁不存在竞争的时候,大部分情况下,两次获取到锁的线程都是同一个线程,那么这种情况下实际上实现完整的加锁过程实际上是特别消耗资源的,这个时候用的就是偏向锁,可以简单理解它只是一个标记,没有任何功能。
偏向锁是通过在
bitfields
中通过CAS设置当前正在执行的ThreadID
来实现的。假设线程A获取偏向锁执行代码块(即对象头设置了ThreadA_ID
),线程A同步块未执行结束时,线程B通过CAS尝试设置ThreadB_ID
会失败,因为存在锁竞争情况,这时候就需要升级为轻量级锁。(通过CAS操作设置线程ID) -
轻量级锁:当锁存在线程之间竞争的时候,则会升级为轻量级锁,轻量级锁的采用的是自旋锁,它默认在同步代码块消耗CPU时间不长的情况下,不用去申请底层的Monitor锁,在锁被栈用的时候,线程处于自选的状态
轻量级锁是采用自旋锁的方式来实现的,自旋锁分为固定次数自旋锁和自适应自旋锁。轻量级锁是针对竞争锁对象线程不多且线程持有锁时间不长的场景。
-
重量级锁:重量级锁就是
JDK1.6
之前获取Monitor的过程
4 synchronized对锁的可重入性理解
- 可重入性针对的是线程对于锁对象的获取过程,简单理解为一个线程在拿到锁对象的时候能否再次拿到这个锁对象,可重入就表示可以再次拿到这个锁对象,不可重入则表示不可以拿到这个锁对象,这样就会发生死锁的情况。
- 看下面的例子,正是由于java的内置锁是可重入的,所以下面这段代码不会发生死锁:
public class Child extends Father{
public static void main(String[] args) {
new Child().doSomething();
}
public synchronized void doSomething(){
System.out.println("child");
super.doSomething();
}
}
class Father{
public synchronized void doSomething(){
System.out.println("Father");
}
}
首先执行main方法,会执行Child类中doSomething()
方法,该方法会拿到child对象的锁。
然后执行super.doSomething()
方法,又会重新获取一下实例对象的锁,注意,这里在两个类中的方法锁对象均为(new Child()),所以这里就发生了重入锁,不会造成死锁的情况。
- 重入锁的一种实现方法是为每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而如果同一个线程再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
5 释放锁的时机
- 当方法(代码块)执行完毕后会自动释放锁,不需要做任何的操作。
- 当一个线程执行的代码出现异常时,其所持有的锁会自动释放。
-------------------以下是补充内容--------------------------
6 Java对象的内存布局
参考 —— Java对象头详解
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
img-
对象头:对象头分为
Mark Word
和Class Metadata Addresss
两个部分,Mark Word
存储对象的hashCode、锁信息或者分代年龄GC等标志等信息。Class Metadata Addresss
存放指向类元数据的指针,JVM通过这个指针确定该对象是那个类的实列。 - 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐
- 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
1. 对象头形式
前面已经简单说了对象头中存储的内容,这里详细展开一下;JVM中对象头的方式有以下两种(以32位JVM为例):
- 普通对象
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
- 数组对象
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
2 对象头详解
Mark Word:
这部分主要用来存储对象自身的运行时数据,如hashCode、GC分代年龄等。mark word
的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word
为32位,64位JVM为64位。为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
这里以32位JVM为例,64位的情况下是一样的,32位JVM一个字是32为,64位JVM一个字是64为,JVM均用一个字的大小记录当前Mark Word中的信息。
-
Normal状态下:正常无锁的状态
-
identity_hashcode
: 25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()
计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。也就是说当对象加锁之后,前25位将不再是对象的hashCode。 -
age:
4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold
选项最大值为15的原因。 -
biased_lock:
对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。 -
lock:
2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。biased_lock lock· 状态 0 01 无锁 1 01 偏向锁 0 00 轻量级锁 0 10 重量级锁 0 11 GC标记
-
-
Biased偏向锁的状态:
-
thread:
23位表示持有偏向锁的线程ID。 -
epoch:
2位,偏向时间戳,达到一定数量之后就会升级为轻量级锁。 -
age
、biased_loc
、lock
跟无锁的状态一致。
-
-
Lightweight Locked轻量级锁状态:
-
ptr_to_lock_record:
30位指向栈中锁记录的指针。 - 剩下的两位用于标志当前的锁状态
-
-
Heavyweight Locked重量级锁状态:
-
ptr_to_heavyweight_monitor:
30位指向管程Monitor的指针。 -
剩下的两位用于标志当前的锁状态
-
Class_Metadata_Addresss:
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
注意: 这里在64位的JVM情况,可以出现指针压缩,将类型指针压缩成32位
array length:
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops
选项,该区域长度也将由64位压缩至32位。
7 Monitor监视器锁
在上面对象头分析中,可以看到当对象头锁状态位重量级锁的时候,有一位ptr_to_heavyweight_monitor
空间,它存放的就是monitor对象(也称为管理或监视器锁)的起始地址。
每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor
实现的,其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
这里我们主要关系几个量:
-
_EntryList:
当多个线程同时访问一个同步代码块的时候,线程首先会进入EntryList
集合中,表示等待获取锁资源。 -
_WaitSet:
若线程调用 wait() 方法,将释放当前持有的monitor, 然后线程会进入WaitSet
,表示等待集合。 -
_owner:
指向持有ObjectMonitor
的线程 -
_count:
count变量是实现锁的可重入性的关键所在,当已经拥有此ObjectMonitor
对象的线程再次请求获取此对象的时候,锁会判断当前_owner
,如果还是当前的线程,则会在count位上加1,实现锁的可重入性。
简单总结一下:
- monitor对象在每个Java对象的对象头上面都有指向他的地址,synchronized就是通过这种方式实现锁。
8 synchronized 关键字的具体使用——手写双重检验锁方式实现单例模式的原理
- 双重校验锁实现对象单例(线程安全)
public class Singleton {
// 这里必须要加volatile修饰,在多线程的情况下可能会发生指令重排,如果不加此修饰,可能会出现创 建对象的可能
private volatile static Singleton singleton;
// 私有化构造方法,只有他自己可以调用
private Singleton(){
}
// 创建静态方法返回当前的对象实列
public static Singleton getUniqueInstance(){
if(singleton == null){
// 如果检查到当前的singleton对象为空,则上锁创建新对象,其他线程不得入内
synchronized (Singleton.class){
// 双重判断,避免另一个线程在创建完对象之后获取了锁进入当前的同步代码块
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
- 第一次判空是在同步代码块之外, 如果当前的singleton已经常见出来了,不为null,那么根本不会进入同步代码块。
- 第二部判空是在同步代码块内部,假设线程A抢到了锁进入了同步方法,还没等到创建对象结束,线程B就执行了同步代码块之外的判空指令,那么那就会等待线程A执行完毕,获取锁之后再继续执行。但是此时的线程A会进行new对象的操作,等到线程B拿到锁进入同步代码块的时候,singleton对象已经被new出来了,这里的二次判空操作使得线程B不会再去执行new对象的操作。
- 需要注意 singleton实列采用 volatile 关键字修饰也是很有必要。因为在new一个对象的时候,代码其实是分为三步执行:
- 为singleton分配内存空间
- 初始化singleton
- 将singleton指向分配的内存空间
- 如果不加volatile 关键字修饰,在多线程的情况下,很可能因为指令重排,导致执行顺序变成了1-3-2,在这种情况下,线程获取了还没初始化的实列,所以会产生问题,而volatile 关键字的最大功能之一就是防止指令重排序。
参考: