上节我们已经分析了对象是如何创建到内存中,这节我们要再仔细的看看每个对象的结构!同时看看JVM为何将对象作为锁,其中的原理又是什么呢?带着这个问题,我们进入本次漫游吧!
以HotSpot为例,JVM是基于C++ 实现的,在使用C++ 描述Java的实例时,工程师们专门设计了一个【OOP-Klass Model】的模型,在本文就不展开探讨了,有兴趣的童鞋,可以推荐大家阅读一位大佬的博客:
1 实例的结构
当我们新建一个对象或者一个数组时,JVM会在堆中开辟一块内存空间来存放这个【实例】,这个实例分别是【Java对象实例】和【Java数组实例】,而这些实例都会包含三部分:【对象头】、【实际的数据】和【对齐填充】:
- Java对象实例
- 对象头(head)
- 对象实际数据(instance data)
- 对齐填充(padding)
- Java数组对象
- 对象头(head)
- 数组实际数据(array data)
- 对齐填充(padding)
对齐填充:
因为JVM要求java的对象占的内存大小应该是8bit的倍数,
所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
本文的主角,就是这个对象头了!我们先来看看对象头中又包含了哪些内容:
- Mark Word
- 指向类的指针
- 数组长度(只有数组对象才有)
2 对象头
2.1 class pointer
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是【哪个类的实例】。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。可以使用选项+UseCompressedOops开启指针压缩。
Java对象的类元数据保存在方法区。
2.2 array length
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
2.3 Mark Word
在对象头中的三部分,最核心的部分就是Mark Word,他与锁和锁的升级有着密切的关系,也和对象在GC中年龄的记录有关;我们先来看看Mark Word的空间划分:
- 32位:
- 64位:
从上图可以看出,锁标志位是Mark Word中很重要的一个标识,不同的锁标志位表示不同的锁状态,存放的数据也很不一样,而锁状态在JVM中的转变,就是我们说的锁的升级,在展开讨论锁的升级之前,我们先来看看synchronized的原理。
3 synchronized的原理
synchronized是Java的关键字,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized常用的方式有以下三种:
- 修饰代码块:即同步代码块,作用范围就是被大括号包裹的代码,这部分代码在任意时刻只能由一个线程执行。
- 修饰方法:即同步方法,作用范围是整个方法,整个方法任意时刻只能由一个线程执行。
- 修饰类:被synchronized修饰的类,相当于给类中的所有方法都加上了synchronized。
3.1 JVM的实现原理
同步代码块
是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到【同步代码块的开始位置】,而monitorexit是插入到【方法结束处】和【方法异常处】。
//代码
public int i;
public void test(){
synchronized (this){
i++;
}
}
//反编译:javap -verbose XXXX.class
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //进入同步方法
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit //退出同步方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //退出同步方法(异常)
22: aload_2
23: athrow
24: return
同步方法
下面的方法是修饰了synchronized的方法,从反编译的结果来看,并没有【monitorenter】和【monitorexit】,而是在方法的flags中添加了【ACC_SYNCHRONIZED】,带上这个标识,JVM会自动在方法调用前插入monitorenter,在方法退出前插入monitorexit。
//代码
public int i;
public synchronized void test2(){
i++;
}
//反编译:javap -verbose XXXX.class
public synchronized void test2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED //标识整个方法都是同步的
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
经过对上面两段代码的反编译,我们可以看出一些端倪了,那就是需要实现同步的代码块(方法其实就是一个作用范围为整个方法的代码块)前后分别插入指令【monitorenter】和【monitorexit】!
那我们的问题就剩下:加上了这两个指令,JVM又会做什么处理呢?
3.2 对象监视器(Monitor)
为了解决线程安全的问题,Java提供了同步机制、互斥锁机制,这个机制保证了在同一时刻只有一个线程能访问共享资源。这个机制的保障来源于监视锁Monitor,每个对象都拥有自己的监视锁Monitor。
Monitor是由C语言实现的,下面我们看看对于Monitor的定义:
ObjectMonitor() {
_header = NULL;
_count = 0; //用来记录该线程获取锁的次数
_waiters = 0,
_recursions = 0; //锁的重入次数
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //存放处于wait状态的线程队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //存放处于等待锁block状态的线程队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
在源码中,我们重点关注:
- _owner:指向持有ObjectMonitor对象的线程,也就是【获得锁的线程】
- _WaitSet:存放处于wait状态的线程队列,又叫【等待池】
- _EntryList:存放处于等待锁block状态的线程队列,又叫【锁池】
-
我们说一个线程“获取锁”,对应到Monitor的模型的话,就是_owner指向这个线程,同一时刻 _owner只能指向一个线程。当一个线程被 _owner指向,这个线程就获得了锁,也就是进入了上图的【The Onwer】区域了。
-
【_EntryList】相当于一个“进入大厅”,这个大厅可以有很多线程,他们都是在等待进入上图的【Running】区域,也可以说这些线程在等待锁的释放。(①)
-
当线程执行wait方法时,Monitor会将这个线程放入【_WaitSet】,同时将 _owner的值清空(也就是释放锁),其他线程可以重新竞争进入。(③)
-
当notify或notifyAll方法执行后,之前因调用wait方法进入【_WaitSet】的线程就做好了准备 (④) 和在“等待大厅”等候获取锁的线程 (②),一同竞争,竞争成功的线程,会被 _owner指向,也就成功获得了锁。
-
当线程进入了【Running】区域后,享有了锁的独占权,直到释放锁(③)或者完成退出并释放锁 (⑤)。
以上,则是Monitor对象管理线程的方式了,回到上面说的monitorenter/monitorexit指令,其实就是对应上图的①和⑤,看看两个指令的名字,其实就是说进入和退出监视器嘛!
4 锁的升级
前面我们分析了JVM中的实例结构,也对锁的原理剖析了一番,最后,让我们看看在JDK1.6之后,对synchronized关键字的优化,也就是:锁的升级。
在JDK对synchronized优化之前,每次加锁都是一个重量级锁,但大多数时候其实是不存在锁的竞争,而是单个线程重复获取同一个锁,如果每次重复获取锁都要进行锁的控制,会产生很多额外的开销,因此锁在特定情况下才会升级成重量锁的控制,就能大大减少开销。
上文我们对Mark Word的数据进行了分析,我们发现有四种不同的锁状态,下图可以直观的表示四个锁状态对应的标志位及其转变方向。(图自博客)
4.1 无锁
没有被作为锁对象的对象,都是属于无锁的状态;被作为锁的对象中,在没有线程访问前,也是无锁的状态。此时Mark Word存放对象的hashCode以及分代年龄。
4.2 偏向锁
升级
当有了第一个线程T1尝试获取这个锁对象时,这个锁对象则会记录这个访问线程的线程ID,并且将是否偏向锁标志改为1。完成这步操作以后,锁就完成了一次从无锁升级到偏向锁的过程。
表现
成为偏向锁之后,这个锁对象就会偏向于记录的线程,只要还是这个线程去获取锁的话,都无需进行任何锁的处理。要注意的是,这个锁其实不会释放的,因为不存在竞争,也没有释放的必要了。
4.3 轻量级锁
升级
当另一个线程T2想要获取已经偏向T1的偏向锁对象时:
- 首先会去判断T1线程是否已经消亡了,如果T1已消亡,则会让对象取消对T1的偏向(将偏向锁降级为无锁),然后再重新偏向T2,成为偏向T2的偏向锁;(不升级)
- 如果T1未消亡,则会去查看原线程的栈帧信息,进一步确认是否有在使用此锁对象,如果已经不需要使用此锁对象,则和上面一样,该锁对象为成为偏向T2的偏向锁;(不升级)
- 如果T1未消亡,且还需要持有这个锁对象,表明T2目前无法获取到这个锁对象,此时,锁对象则会升级为轻量级锁。(升级)
- 从偏向锁升级为轻量级锁后,锁将不会重新降级为偏向锁了,有些信息已经无需记录了,如分代年龄,偏向锁的标记等。
表现
线程T1获得轻量级锁时,会复制锁对象的【对象头MarkWord】,把其存放到栈帧中的Lock Record区域中,这个区域的地址为【T1的锁记录地址】。
然后JVM使用CAS操作将锁对象的【对象头MarkWord】的内容修改为【T1的锁记录地址】。成功修改后,T1线程和这个锁对象,相当于互相指向对方,这也表示T1线程持有了这个锁。当T1线程释放锁后,个人理解:应该是会将锁对象中【对象头MarkWord】的内容清空,以此来表示释放。(如果有了解的老师求赐教,感谢感谢!)
CAS:Compare And Swap(比较并交换)-简单的理解:
“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”。
在这里的CAS应该是这样的(基于个人的理解):
我认为MarkWord中的记录(V)应该为空(A),如果是,则把我自己线程的地址(B)记录到MarkWord中的记录中。
当线程T2想要获取这个轻量级锁时,也会先复制锁对象的【对象头MarkWord】到自己栈帧中的Lock Record区域,同时JVM尝试使用CAS修改锁对象的【对象头MarkWord】的内容修改为【T2的锁记录地址】,如果修改失败,则会持续尝试修改,这个尝试修改的过程就是自旋了!
4.4 重量级锁
升级
当出现下面两种情况之一,轻量级锁将会升级为重量级锁:
- 线程通过自旋尝试获得锁的次数超过限制,一般为(10)。
- 当出现第三个线程想要获取这个锁时,表示竞争已经足够大了。
表现
此时的锁对象就和优化前的synchronized一样,是个悲观的互斥锁,只有持有锁才能执行,只有执行完毕或调用wait方法才能释放锁了。
4.5 参考资料
Java并发——Synchronized关键字和锁升级,详细分析偏向锁和轻量级锁的升级
java 偏向锁、轻量级锁及重量级锁synchronized原理
5 小结
- 1 实例的结构
- 2 对象头
- 2.1 class pointer
- 2.2 array length
- 2.3 Mark Word
- 3 synchronized的原理
- 3.1 JVM的实现原理
- 3.2 对象监视器(Monitor)
- 4 锁的升级
- 4.1 无锁
- 4.2 偏向锁
- 4.3 轻量级锁
- 4.4 重量级锁