1.JAVA对象内存布局


Java对象在内存中的布局分为三块区域:对象头实例数据对齐填充。如果对象是一个数组,那在对象头中还必须有一块数据用于记录数组长度。
image.png

1.1 实例变量

实例数据。存放类的属性数据信息包括父类的属性信息

  1. 如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。
  2. 根据字段类型的不同占不同的字节。例如boolean类型占1个字节,int类型占4个字节等等。这部分内存按4字节对齐。 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。 HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。 从分配策略中可以看出,相同宽度的字段总是被分配到一起。 在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。

    1.2 填充数据

    填充数据不是必须存在的,仅仅是为了字节对齐。 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。填充的最终目的是为了计算机高效寻址

    1.3 对象头

    对象头包含两部分: mark word, klass pointer

    Mark Word
    标记字段。用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。 Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

image.png

  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock:是否偏向锁,由于正常锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针.。

    Klass Pointer类型指针是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

image.png

  • 每个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,可以来回导航。
  • 这个模型里,java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。

2、Synchronized底层实现


2.1 重量级锁

在32位和64位机器上锁标识位都为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。 每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如:monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

monitor对象存在于每个Java对象的对象头中(存储的是指针),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。

监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态。这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。

image.png
一个monitor对象包括这么几个关键字段:cxq(上图中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程。

如果一个线程在同步块中调用了Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中,然后释放锁。当wait的线程被notify之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。

Monitor AbstractQueuedSynchronizer
同步状态 _count state
同步队列 _EntryList CLH同步队列
条件队列 _WaitSet Contition条件队列

monitor对象内部持有_count字段,_count等于0表示管程未被持有,_count大于0表示管程已被持有,每次持有线程重入时_count都会加1,每次持有线程退出时_count都会减1,这就是内置锁重入性的实现原理。

2.2 同步代码块

image.png
从字节码中可知同步语句块的实现使用的是monitorentermonitorexi指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。

一条指令Monitorenter可以对应到多条monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁

2.3 同步方法

image.png
使用synchronized 标记方法时,并没有monitorenter指令和monitorexit指令,从字节码中,我们可以看到方法的访问标记包括ACC_SYNCHRONIZED了。该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。在进入该方法时,Java 虚拟机需要进行 monitorenter操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行monitorexit操作

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。
对于实例方法来说,这两个操作对应的锁对象是 this
对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

3.偏向锁


偏向锁是最乐观的一种情况:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。 因此为了减少同一线程获取锁的代价而引入偏向锁。

偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,直接可以获取锁。这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程

  1. 当该对象第一次被线程获得锁的时候,发现是无锁(匿名偏向状态),则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
  2. 当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word为空的锁记录(Lock Record)中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。
  3. 当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活:
    • 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

解锁过程
当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

4.轻量级锁


轻量级锁是一种比较乐观的情况:多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。
标记字段(mark word)的最后两位被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁。

线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个锁记录Lock Record,用来存储锁对象Mark Word的拷贝,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。
image.png
加锁过程

  1. 在代码进入同步块时,如果此同步对象没有被锁定,虚拟机会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录Lock Record,并且将锁对象的标记字段(Mark word) 复制到该锁记录中(可以理解为保存之前锁对象的标记字段。如果是同一个线程这个值会是0:后面的锁记录清零就是这个意思)。
  2. 然后,Java 虚拟机会尝试用 CAS操作替换锁对象的标记字段。
    假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01(锁标志位01表示偏向锁)。

如果是X…X01则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00(锁标志位00表示轻量级锁)。此时,该线程已成功获得这把锁,可以继续执行了。
如果不是 X…X01,那么有两种可能。

  • 第一,该线程重复获取同一把锁(此刻持有的是轻量级锁)。此时,Java 虚拟机会将锁记录清零,以代表该锁被重复获取。
  • 第二,其他线程持有该锁(此刻持有的是轻量级锁)。此时,Java 虚拟机会将这把锁膨胀为重量级锁,并且阻塞当前线程。

解锁过程

  1. 如果当前锁记录的值为 0,则代表重复进入同一把锁,直接返回即可。(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录
  2. 否则,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。

如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。

5.自旋锁


轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。

自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

6.锁消除、锁粗化


Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,或者粗化几把锁为同一把锁通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

链接:https://zhuanlan.zhihu.com/p/290991898
https://www.zhihu.com/question/50258991/answer/120450561
https://mp.weixin.qq.com/s?__biz=MzI4Njc5NjM1NQ==&mid=2247487298&idx=1&sn=b4ccd12d26329dbc5f1abc45ec83de0e&scene=21#wechat_redirect