synchronized和java对象关系详解和锁的膨胀流程

synchronized到底是怎么控制对象的,锁到是是怎么加的,怎么膨胀的(看明白就知道锁和Java对象到底是怎么回事了)

 

一,首先我们先看下java对象有哪些组成,java对象是怎么和锁联系起来的

1 Java对象有哪些组成的,java对象到底是个什么鬼?  

同步的原理


JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

Java对象头

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

 

长度

内容

说明

32/64bit

Mark Word

存储对象的hashCode或锁信息等。

32/64bit

Class Metadata Address  (指向类的指针)

存储到对象类型数据的指针

32/64bit

Array length (数组长度)

数组的长度(如果当前对象是数组)

 

 

 

 

 

 

 

对象头之Mark Word


Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。

Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否偏向锁

锁标志位

无锁

对象的HashCode

分代年龄

0

01

偏向锁

线程ID

Epoch(加锁的时间戳)

分代年龄

1

01

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向重量级锁的指针

10

GC标记

11

 

 

 

 

 

 

 

 

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

 

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位

(1) 32位JVM的Mark Word的默认存储结构如下:

 

25 bit

4bit

1bit

是否是偏向锁

2bit

锁标志位

无锁状态

对象的hashCode

对象分代年龄

0

01

 

(2) 在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下: 

锁状态

25bit

31bit

1bit

4bit

1bit

2bit

  

cms_free

分代年龄

偏向锁

锁标志位

无锁

unused

hashCode

 

 

0

01

 

偏向锁

ThreadID(54bit) Epoch(2bit)

 

 

1

01

 

      在64位虚拟机下markword的结构,定义在markOop.hpp文件:

      

 32 bits:
  --------
  hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
  JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
  size:32 ------------------------------------------>| (CMS free block)
  PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
 
  64 bits:
  --------
  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
  size:64 ----------------------------------------------------->| (CMS free block)
 
  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
 [ptr             | 00]  locked             ptr points to real header on stack
 [header      | 0 | 01]  unlocked           regular object header
 [ptr             | 10]  monitor            inflated lock (header is wapped out)
 [ptr             | 11]  marked             used by markSweep to mark an object

结构进行详细说明:

偏向锁标识位锁标识位锁状态存储内容
001未锁定hash code(31),年龄(4)
101偏向锁线程ID(54),时间戳(2),年龄(4)
00轻量级锁栈中锁记录的指针(64)
10重量级锁monitor的指针(64)
11GC标记空,不需要记录信息

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

锁标志为00,是轻量级锁。

锁标志位为10,是重量级锁,在这个状态下,未抢到锁的线程都会被阻塞。

 

锁的升级


Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。
 

偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。
 

轻量级锁

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

sychronized加锁和锁膨胀的流程:

1:由于java6之后偏向锁是开启的,也就是说当对一个代码块或者方法加上sychronized的时候先进入的是偏向锁获取状态。这时候依据4.1的步骤,通过CAS操作设置Mark-word格式如下

偏向锁

线程ID

Epoch

对象分代年龄

1

01

可见,偏向锁获取的阶段,只是指向了获取偏向锁的线程id,并没有用到Monitor对象。

2.当获取到某一个线程获取偏向锁的时候发现已经有线程获取了该对象的锁,当达到全局安全点(safepoint),获得偏向锁的线程被挂起,撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态;这取决于持有偏向锁的线程是否还处于活动状态,如果不处于活动状态,则转为无锁状态,如仍然活着这升级为轻量级锁,将Mark-word中的锁记录指向当前栈中的锁记录。

3.在轻量级锁获取阶段,Mark-word的格式如下,这时候Mark-word指向的对象也不是Monitor对象,而是线程栈帧中开辟的一块用于存储锁记录的空间,存储的内容为Mark-word中的内容(个人认为是无锁状态下对象的hashcode,分代年龄等信息),称为Displaced Mark-word。

轻量级锁

指向栈中锁记录的指针

00

4.在轻量级锁获取阶段,如果同时又其他线程竞争锁,这线程通过自旋来获取锁(循环CAS来修改Mark-word指向自己的锁记录地址),当自旋超时或者超过一定次数后,获取锁失败,导致锁膨胀为重量级锁,就是将Mark-word中的锁标志置为10,并指向Monitor对象,然后阻塞当前线程,当锁膨胀完成并返回对应的monitor时,并不表示该线程竞争到了锁。当持有轻量级锁的线程再次尝试进行CAS时,这时候已经被竞争线程修改,所以失败,释放锁,唤醒阻塞的线程,进入重量级锁阶段。

只有在锁膨胀到重量级锁的时候对象头才会指向Monitor对象。

5.openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp源码如下:

1 ObjectMonitor() {
 2     _header       = NULL;//markOop对象头
 3     _count        = 0;
 4     _waiters      = 0,//等待线程数
 5     _recursions   = 0;//重入次数
 6     _object       = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
 7     _owner        = NULL;//指向获得ObjectMonitor对象的线程或基础锁
 8     _WaitSet      = NULL;//处于wait状态的线程,会被加入到wait set;
 9     _WaitSetLock  = 0 ;
10     _Responsible  = NULL ;
11     _succ         = NULL ;
12     _cxq          = NULL ;
13     FreeNext      = NULL ;
14     _EntryList    = NULL ;//处于等待锁block状态的线程,会被加入到entry set;
15     _SpinFreq     = 0 ;
16     _SpinClock    = 0 ;
17     OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock
18     _previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
19   }

Monitor对象的结构如下:

1,Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;

2,Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;

3,Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;

4,OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;

5,Owner:当前已经获取到所资源的线程被称为Owner;

6,!Owner:当前释放锁的线程。

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

综上:

JVM对Synchronized的优化,简单来说解决三种场景:

1)只有一个线程进入临界区,偏向锁

2)多个线程交替进入临界区,轻量级锁

3)多线程同时进入临界区,重量级锁

 

JVM一般是这样使用锁和Mark Word的:

1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
 

总结:

实现原理

synchronized 是基于 Java 对象头和 Monitor 机制来实现的。

Java 对象头

一个对象在内存中包含三部分:对象头,实例数据和对齐填充。其中 Java 对象头包含两部分:

Class Metadata Address (类型指针)。存储类的元数据的指针。虚拟机通过这个指针找到它是哪个类的实例。

Mark Word(标记字段)。存出一些对象自身运行时的数据。包括哈希码,GC 分代年龄,锁状态标志等。

Monitor

Mark Word 有一个字段指向 monitor 对象。monitor 中记录了锁的持有线程,等待的线程队列等信息。前面说的每个对象都有一个锁和一个等待队列,就是在这里实现的。 monitor 对象由 C++ 实现。其中有三个关键字段:

_owner 记录当前持有锁的线程

_EntryList 是一个队列,记录所有阻塞等待锁的线程

_WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程。

Monitor的操作机制如下:

多个线程竞争锁时,会先进入 EntryList 队列。竞争成功的线程被标记为 Owner。其他线程继续在此队列中阻塞等待。

如果 Owner 线程调用 wait() 方法,则其释放对象锁并进入 WaitSet 中等待被唤醒。Owner 被置空,EntryList 中的线程再次竞争锁。

如果 Owner 线程执行完了,便会释放锁,Owner 被置空,EntryList 中的线程再次竞争锁。

JVM 对 synchronized 的处理

上面了解了 monitor 的机制,那虚拟机是如何将 synchronized 和 monitor 关联起来的呢?分两种情况:

如果同步的是代码块,编译时会直接在同步代码块前加上 monitorenter 指令,代码块后加上 monitorexit 指令。这称为显示同步。

如果同步的是方法,虚拟机会为方法设置 ACC_SYNCHRONIZED 标志。调用的时候 JVM 根据这个标志判断是否是同步方法。

JVM 对 synchronized 的优化

synchronized 是重量级锁,由于消耗太大,虚拟机对其做了一些优化。

自旋锁与自适应自旋

在许多应用中,锁定状态只会持续很短的时间,为了这么一点时间去挂起恢复线程,不值得。我们可以让等待线程执行一定次数的循环,在循环中去获取锁。这项技术称为自旋锁,它可以节省系统切换线程的消耗,但仍然要占用处理器。在 JDK1.4.2 中,自选的次数可以通过参数来控制。 JDK 1.6又引入了自适应的自旋锁,不再通过次数来限制,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

锁消除

虚拟机在运行时,如果发现一段被锁住的代码中不可能存在共享数据,就会将这个锁清除。

锁粗化

当虚拟机检测到有一串零碎的操作都对同一个对象加锁时,会把锁扩展到整个操作序列外部。如 StringBuffer 的 append 操作。

轻量级锁

对绝大部分的锁来说,在整个同步周期内都不存在竞争。如果没有竞争,轻量级锁可以使用 CAS 操作避免使用互斥量的开销。

偏向锁

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值