Java 对象头、Mark Word、monitor与synchronized关联关系以及synchronized锁优化

发布于:2024-11-27 ⋅ 阅读:(15) ⋅ 点赞:(0)

1. 对象在内存中的布局分为三块区域:

(1)对象头(Mark Word、元数据指针和数组长度)

对象头:在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit,Java对象头一般占有2个机器码,即64bit,但是 如果对象是数组类型,则需要3个机器码,即96bit,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Mark Word(32bit)存储代表该对象运行时的一些信息,哈希码、GC分代年龄、锁标志位、偏向线程ID、偏向时间戳等信息。 这些信息都是与对象自身定义无关的数据,所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。

解读:

(1)前面30bit位可能表示的意思不一样,但是最后2个bit表示的都是锁模式。

(2)当偏向锁标志是0锁标志位是01,也就是最后3位是001的时候,表示无锁模式Mark Word记录的数据就是对象的hashcode 和 GC的年龄。当有第一个线程请求加锁的时候会升级为偏向锁;

(3) 当偏向锁标志是1锁标志是01,也就是最后三位是101的时候,处于偏向锁模式Mark Word这个时候记录的数据就是获取偏向锁的线程IDEpoch对象GC年龄:当有第二个线程请求加锁的时候会升级为轻量级锁;

(4)当锁标志位是00的时候,表示处于轻量级锁模式。会把锁记录放在加锁的线程的虚拟机栈空间中,所以这种情况下锁记录在哪个线程虚拟机栈中,就表示所在线程就获取到了锁Mark Word记录的数据就是就指向那个锁记录地址,这个锁记录地址在哪个线程中,就表示哪个线程获取到了轻量级锁

(5)当锁标志位是10的时候,表示处于重量级锁模式,这个时候就说明竞争激烈了,处于重量级锁模式了,此时使用重量级加锁不是Mark Word的职责范围了是monitor的职责Mark Word 记录的数据就是monitor的地址有加锁的需求直接根据这个地址找到monitor,找monitor加锁。

元数据指针(Klass Point):它主要指向类的数据,也就是指向方法区中的位置,通过这个指针,我们就可以知道该实例属于哪个类,长度通常为32bit。

数组长度(Array Length): 如果是数组,对象头中还有一块用于存放数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。只有数组对象才有,在32位或者64位JVM中,长度都是32bit。

(2)实例数据

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

(3)对齐填充(非必须)

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

2. 通过以上理解可以清楚对象头、Mark Word 和 monitor之间的关系

当Mark Word中最后两位的锁标志位是10的时候,Mark Word的前面是monitor监视器的地址,我现在就给你画出来对象头、Mark Word 和 monitor之间的关系图:

3. synchronized是如何通过monitor加锁的?

3.1 monitor概念

monitor叫做对象监视器、也叫作监视器锁,JVM规定了每一个java对象都有一个monitor对象与之对应,这monitor是JVM帮我们创建的,在底层使用C++实现的

3.2 monitor属性

其实monitor在底层也是某个类的对象,那个类就是ObjectMonitor,它拥有的属性字段如下:

ObjectMonitor() {
        _header = NULL;
        _count = 0;  
// 非常重要,表示锁计数器,_count = 0表示还没人加锁,_count > 0 表示已经加锁,加锁的次数,可重入锁的原理在此,再次执行monitorenter进入后就加1,释放时候执行monitorexit指令前就减1。
        _waiters = 0,
        _recursions = 0;
        _object = NULL;
        _owner = NULL;
 // 非常重要,指向加锁成功的线程,_owner = null 时候表示没人加锁,比如线程A获取锁成功了,则 _owner = 线程A。
        _WaitSet = NULL; // wait线程的集合,在synchorized代码块中调用wait()方法的线程会释放锁,被加入到此集合中沉睡,然后线程就会被挂起,等待别人调用notify叫醒它。
        _WaitSetLock = 0 ;
        _Responsible = NULL ;
        _succ = NULL ;
        //多线程竞争锁进入时的单向链表
        _cxq = NULL ;
        FreeNext = NULL ;
        //_owner从该双向循环链表中唤醒线程节点,_EntryList是第一个节点
        _EntryList = NULL ;
 // 非常重要,等待队列,加锁失败的线程会block住,被加入到这个等待队列中,等待再次争抢锁
        _SpinFreq = 0 ; // 获取锁之前的自旋的次数,

JDK1.6之后对synchronized进行优化;原先JDK1.6以前,只要线程获取锁失败,线程立马被挂起,线程醒来的时候再去竞争锁,这样会导致频繁的上下文切换,性能太差了。

JDK1.6后优化了这个问题,就是线程获取锁失败之后,不会被立马挂起,而是每个一段时间都会重试去争抢一次,这个 _spinFreq就是最大的重试次数,也就是自旋的次数,如果超过了这个次数抢不到,那线程只能沉睡了

        _SpinClock = 0 ; // 获取之前每次锁自旋的时间,上面说获取锁失败每隔一段时间都会重试一次,这个属性就是自旋间隔的时间周期,比如50ms,那么就是每隔50ms就尝试一次获取锁。
        OwnerIsThread = 0 ;
        _previous_owner_tid = 0;
}

3.3 monitor如何通过这些属性加锁

(1)首先呢,没有线程对monitor进行加锁的时候是这样的:_count = 0 表示加锁次数是0,也就是没线程加锁; _owner 指向null,也就是没线程加锁。

(2)此时线程A、线程B来竞争加锁了,都请求将_count 修改为1,此修改具有原子性,同一时间只有一个线程可以修改成功,此时线程A竞争到锁,将 _count 修改为1,表示加锁次数为1,将_owner = 线程A,也就是指向自己,表示线程A获取到了锁。同理可得:释放锁的时候将_count 设置为 0 , 将 _owner 设置为 null 。

(3)在线程A持有锁的时候,monitor里面记录的 _spinFreq 、spinclock 信息告诉线程B,你可以每隔50ms来尝试加锁一次,总共可以尝试10次。

(4)如果线程B10次尝试加锁期间,获取锁成功了,那线程B将 _count 设置为 1, _owner 指向自己表示自己获取锁成功了。

(5)如果10次尝试获取锁此时都用完了,那线程B只能放到等待队列_EntryList里面先睡觉去了,也就是线程B被挂起了。

3.4 线程获取锁失败后的自旋操作好处

这个其实跟jvm获取monitor锁的优化有关。

(1)首先线程挂起之后唤醒的代价很大,底层涉及到上下文切换,用户态和内核态的切换打个比方可能最少耗时3000ms这样,这只是打个比方。

(2)线程A获取了锁,这个时候线程B获取失败。按照上面自旋的数据 _spinclock = 50ms(每次自旋50ms), _spinFreq = 10(最多10次自旋)。

(3)假如线程A使用的时间很短,比如只使用150ms的时间;那么线程B自旋3次后就能获取到锁了,也就花费了150ms左右的时间,相比于挂起之后唤醒最少花费3000ms的时间,大大减少了等待时间,这也就提高了性能了。

(4)如果不设置自旋的次数限制,而是让它一直自旋。假如线程A这哥们耗时特别的久,比如它可能在里面搞一下磁盘IO或者网络的操作,花了5000ms!!。

线程B可不能在那一直自旋着等着它吧,毕竟自旋可是一直使用CPU不释放CPU资源的,CPU这时也在等着不能干别的事,这可是浪费资源啊,所以啊自旋次数也是要有限制的,不能一直等着,否则CPU的利用率大大被降低了。

所以在10次自旋之后,也就是500ms之后,还获取失败,那就把自己挂起,释放CPU资源咯

3.5 monitor的wait和notify

说起monitor里面的waitset,上面讲的就是一个集合

当线程获取锁之后,才能调用wait()方法,然后此时释放锁,将_count恢复为0,将_owner指向 null,然后将自己加入到waitset集合中等待别人调用notify或者notifyAll将其中waitset的线程唤醒

3.6 notify和notifyAll区别?

简单说就是notify就是从waitset随机挑一个线程来唤醒,只唤醒一个。notifyAll这方法就是将waitset中所有等着的线程全部唤醒了。

3.7 wait() 和 Thread.sleep()的区别

wait()会释放锁,而Thread.sleep()不释放锁

4. synchronized锁升级优化

4.1 偏向锁

  引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

4.1.1 偏向锁获取过程

当线程A第一次进入synchronized的同步代码块之内,发现Mark Word的最后三位是001,表示当前无锁状态,于是选择代价最小的方式加了个偏向锁只在第一次获取偏向锁的时候执行CAS操作将自己的线程Id通过CAS操作设置到Mark Word中),同时将偏向锁标志位改为1后面如果自己再获取锁的时候,每次检查一下发现自己之前加了偏向锁,就直接执行代码,就不需要再次加锁了。加了偏向锁的线程是个自私的线程,这家伙用完了锁之后,自己加锁时候修改过的Mark Word信息都不会再改回来了也就是它不会主动释放锁

这个哥们不释放锁,如果它用完了,别人这个时候需要进入synchronized代码块怎么办?此时涉及到偏向锁之重偏向。

4.1.2 偏向锁之重偏向

线程B去申请加锁,发现是线程A加了偏向锁;这时候回去判断一下线程A是否存活,如果线程A挂了就可以重新偏向了重偏向也就是将自己的线程ID设置到Mark Word中

如果线程A没挂,但是synchronized代码块执行完了,这个时候也可以重新偏向了,将偏向标识指向自己。

如果线程B在申请获取锁的时候,线程A这哥们还没执行完synchronized同步代码块怎么办?这就需要将锁升级一下了,都使用偏向锁不行吗?不升级有什么坏处?

4.1.2 偏向锁的释放

偏向锁的释放在上述提到偏向锁不会主动释放锁,只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的释放,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

4.2 轻量级锁

4.2.1 偏向锁为什么要升级为轻量级锁?

轻量级锁模式下,加锁之前会创建一个锁记录,然后将Mark Word中的数据备份到锁记录中(Mark Word存储hashcode、GC年龄等很重要数据,不能丢失了),以便后续恢复Mark Word使用。

这个锁记录放在加锁线程的虚拟机栈中,加锁的过程就是将Mark Word 前面的30位指向锁记录地址。所以mark word的这个地址指向哪个线程的虚拟机栈中,就说明哪个线程获取了轻量级锁

先看如下代码块:

// 代码块1
synchronized(this){
  // 业务代码1  
}
// 代码块2
synchronized(this){
  // 业务代码2
}
// 代码块3
synchronized(this){
  // 业务代码3
}
// 代码块4
synchronized(this){
  // 业务代码4
}

假如这个时候有线程A、B、C、D四个线程,线程A先加了偏向锁。之前讲过偏向锁只是在第一次获取锁的时候加锁,后面都是直接操作的不需要加锁

这个时候其它几个线程B、C、D想要加锁,如果线程A连续执行上面4个代码块,那么其他线程看到线程A都在执行synchronized同步代码块,没完没了了,想重偏向都不行!! ,这个时候就需要等线程A执行完4个synchronized代码块之后才能获取锁啊,哈哈,别的线程都只能看线程A一个人自己在那表演了,这样代码就变成串行执行了。所以偏向锁需要升级。

(1)首先线程A持有偏向锁,然后正在执行synchronized块中的代码。

(2)这个时候线程B来竞争锁,发现有人加了偏向锁并且正在执行synchronized块中的代码,为了避免上述说的线程A一直持有锁不释放的情况,需要对锁进行升级,升级为轻量级锁。

(3)先将线程A暂停为线程A创建一个锁记录Lock Record,将Mark Word的数据复制到锁记录中;然后将锁记录放入线程A的虚拟机栈中。

(4)然后将Mark Word中的前30位指向线程A中锁记录的地址,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,将线程A唤醒,线程A就知道自己持有了轻量级锁。

4.2.2 在轻量级锁模式下,多线程是怎么竞争锁和释放锁的?

(1)线程A线程B同时竞争锁,在轻量级锁模式下,都会创建Lock Record锁记录放入自己的栈帧中。

(2)同时执行CAS操作将Mark Word前30位设置为自己锁记录的地址谁设置成功了,谁就获取到锁。

4.2.3 轻量级锁模式下获取锁失败的线程应该会怎么样?

获取不到会自旋,回看3.4讲解的:线程获取锁失败后的自旋操作好处

4.2.4 轻量级锁的释放

就将自己的Lock Record中的Mark Word备份的数据恢复回去即可,恢复的时候执行的是CAS操作将Mark Word数据恢复成加锁前的样子。

(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。

(2)如果替换成功,整个同步过程就完成了。

(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

4.3 重量级锁、轻量级锁和偏向锁之间转换

4.4 其他优化

4.4.1 适应性自旋(Adaptive Spinning)

从轻量级锁获取的流程中我们知道当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

4.4.2 锁粗化(Lock Coarsening)

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。

4.4.3 锁消除(Lock Elimination)

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

4.4.4 总结

  本文重点介绍了JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。 同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。 同步块执行速度较长。