高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(LockElimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(BiasedLocking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程的执行效率
JDK 6之前,Synchornized关键字无论什么情况都会去使用ObjectMonitor对象来实现多线程。
JDK 6之后,Synchornized具有无锁 -> 偏向锁 -> 轻量级锁(采用自适应自旋) -> 重量级锁的优化过程
我个人认为:除了重量级锁,其它的状态基本都是只通过锁对象的Mark Word以及CAS来帮助实现的,提升了效率。而重量级锁会利用ObjectMoniter对象去与OS层面的mutex信号量做映射,线程切换的时候也会造成更多的消耗(详细见后文)。
了解锁优化之前,必须先了解HotSpot虚拟机的对象头的内存布局,它有助于我们去了解其中的原理
在不同的锁状态下,Mark word会存储不同的信息,这也是为了节约内存常用的设计
32位的MarkWord
64位的MarkWord
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。默认开启
偏向锁的定义:这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁工作原理
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,等待全局安全点时(不执行字节码指令的时候),开始撤销偏向锁,根据锁对象目前是否处于被锁定的状态发生以下两种情况:
后续的同步操作就按照马上介绍的轻量级锁那样去执行。
关于hash:在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变,否则很多依赖对象哈希码的API都可能存在出错风险。绝大多数对象哈希码来源的**Object::hashCode()**方法,返回的是对象的一致性哈希码。
如何保持hash码一致不变?
它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。
因此,由于偏向锁的产生需要利用存储hash码的位置,所以当一个对象计算过hash码后会造成下面两种情况:
当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了
当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的 调用,如果重写了对象的hashCode()方法,计算哈希码时并不会产生这里所说的请求
在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。
偏向锁可以提高带有同步但无竞争的程序性能。但如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。
自旋锁:如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
JDK 6中默认开启,轻量级锁状态下会使用自适应自旋锁去尝试获取锁,所以我们先介绍自旋锁与自适应自旋的概念。
自旋锁的缺点:
解决方案:在 JDK 6中对自旋锁的优化,引入了自适应的自旋。
自适应自旋:自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
如果对于同一个锁对象,自旋等待刚刚成功获得过锁。那么虚拟机会将自旋次数设置为更大。如果通过自旋很少获得锁,那么会减少自旋次数甚至不开启自旋以避免浪费处理器资源。
有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,性能就会越来越好。
重量级锁:在传统的锁机制中,每次发生线程切换时,需要从用户态切换到核心态,然后发出中断处理并作出保护现场、恢复现场的一些操作,代价及其昂贵,因此传统的使用操作系统互斥量来实现的锁机制被称为”重量级锁“
轻量级锁的工作流程:
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)
然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
解锁过程同样通过CAS来实现:如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。
可以先看看moniter的实现原理,有助于帮助理解
[Java面试常见问题:Monitor对象是什么? - 知乎 (zhihu.com)]
使用了monitor的对象的就是重量级锁,因为monitor的实现依赖于底层操作系统的mutex互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。
对于monitor的引用是也是存放在对象的Mark Word中的
下面两种方式就是重量级锁:
同步方法的时候
Jvm采用的是 ACC_synchoronized 标记符来实现的同步,这个是因为jvm在调用方法时会验证方法是不是有ACC_synchoronized 的标记符。如果设置了该标志,执行线程会线获取到monitor对象,然后执行方法,在该方法的运行期间,其他线程是无法获取到monitor对象的,只有当拥有monitor对象的线程执行完任务了才能获取,释放了monitor对象才能进入到代码块。
同步代码块的时候
对于同步代码块,是由 monitorenter和monitorexit指令来实现的同步。monitorenter是获取monitor的所有权,mointorexit是释放monitor的所有权。
monitorenter的执行原理
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和非同步块只有纳秒级别的差距 | 如果存在线程竞争,回带来额外的锁撤销消耗 | 使用于一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高来线程的响应速度 | 如果始终得不到锁竞争的线程,是使用自旋消耗CPU | 追求响应 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
参考资料:《Java并发编程的艺术》《深入理解JAVA虚拟机》