1.Java对象内存布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据、对其数据。如下图所示:
长度 | 内容 | 说明 |
32/64 bit | Mark Word | 存储对象的HashCode或者锁信息等 |
32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数据的长度(如果当前对象是数组) |
对象的存储布局
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求,对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
- 对象头:Java对象头一般占2个机器码(在32位机器中,1个字节码等于4个字节,也就是32bit,在64位虚拟机中,1个字节码是8个字节,也就是64bit)。但是如果对象是数组对象,还需要三个字节码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数据的大小,所以用一块来记录数组的长度。
2.对象头
synchronized用的锁就是存在Java对象头里的,那么什么是Java对象头?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中ClassPointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。Java对象头具体结构描述如下:
长度 | 内容 | 说明 |
32/64 bit | Mark Word | 存储对象的HashCode或者锁信息等 |
32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数据的长度(如果当前对象是数组) |
Java对象头结构组成
Mark Word用于存储对象自身的运行时数据,如哈系码(HashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等。比如锁膨胀就是借助Mark Word的偏向的线程ID,参考 Java锁的膨胀过程和优化。下图是Java对象头无锁状态下Mark Word 部份的存储结构(32位虚拟机)。
| 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
无锁状态 | 对象的HashCOde | 对象分代年龄 | 0 | 01 |
Mark Word存储结构
对象头信息是对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在绩效的空间内存存储尽量多的数据,他会根据对象状态复用自己的存储空间,也就是说,MarkWord会随着程序的运行发生变化,可能变化为以下4中数据:
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否偏向 | 锁标志位 | ||
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象粉黛年来 | 1 | 01 |
在64位虚拟机下,Mark Word是64bit大小,其存储结构如下:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
|
|
| cms_free | 分代年龄 | 偏向锁 | 锁标志位 |
无锁 | unuserd | hashcode |
|
| 0 | 01 |
偏向锁 | ThreadID(54bit)、Epoch(2bit) |
|
| 0 | 01 |
对象头的最后两位存储了锁的标志位,01是初始状态。未加锁,其对象头里存储的是对象本身的哈系码,随着锁级别的不通,对象头会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级锁存储指向线程栈中锁记录的指针。从这里我们可以看到。“锁”这个东西,可能是个锁记录+对象头里引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址进行比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程ID和对象头里存储的现层ID进行比较)。
存储内容 | 标志位 | 状态 |
对象哈系码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 碰撞(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
3.对象头中Mark Word 与线程中Lock Record
在线程进入同步代码块的时候,如果此同步对象没有被所动,即他的锁标识位是01,则虚拟机 首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)” 的空间,用于存储对象的Mark Word的拷贝,官方把这个拷贝成为Displaced Mark Word。整个Mark Word及其拷贝至关重要。---置换标记字
Lock Record是线程私有的数据结构,每一个线程都有一个可用的Lock Record列表,同时还有一个全局可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord 中的Lock Word指向Lock Record的起始地址 ),同时Lock Record中有一个Owner字段存放该锁的线程唯一标识(或者object Mark Word),表示该锁被这个线程占用。Lock Record内部结构如下图表。
Lock Record | 描述 |
Owner | 初始化时为null 表示当前没有任何线程拥有该 monitor Record,当线程陈工拥有该锁后保存线程唯一标识,当锁释放时又设置为null |
EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有试图锁住的monitor record失败的线程 |
RcThis | 表示blocked或者waiting在该monitor record上的所有线程的个数 |
Nest | 用来实现重入锁的计数 |
HashCode | 保存从对象拷贝过来的hashcode值(可能还包含GC age)。 |
Candidate | 用来避免不必要的阻塞或等待线程唤醒,因为每次只有一个线程能够成功拥有锁,如果每次前一个释放的线程唤醒所有正在阻塞或者等待的线程,会引起不必要的上线为切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。 |
4.监视器(monitor)
任何一个对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。synchronized在JVM里的实现都是基于进入和退出monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的monitorentr 和monitorexit指令来实现。
- monitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象monitor的所有权,即尝试获得该对象的锁。
- monitorExit指令:插入在方法结束处和异常处,JVM保证每个monitorEnter必须有对应的MonitorExit
那什么是monitor?可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说synchronized的对象锁,Mark Word锁标示位为10 ,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是有ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.php文件中,C++实现的):
代码块
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 ;
}
ObjectMonitor中有两个队列,_WaitSet 和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段代码时:
- 首先会进入_EntryList集合,当线程获取到对象的monitor后,进入_owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
- 若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
同时,monitor对象存储在于每个Java对象的对象头Mark Word中(存储的指针的指向),synchronized锁便是通过这种方式获取锁,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
什么时候需要协助?比如:
一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视器区域的代码。如果在入口区中有其他等待的线程,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门重新获取监视器再执行。
注意:
当一个线程释放监视器时,在入口区和等待去的等待线程都会区竞争监视器。如果入口区的线程赢了,从从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待去,在等待区中的线程只有通过4号门才能退出等待去,也就是一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获取监视器才能退出等待状态。