原创

Java锁体系之sychorized

引言:synchronized 是一个通用且重量级的锁机制,我在写 Java 内存模型三大特性的时候就介绍过,他是唯一一个可以支持多线程并发三个特性的关键字。

一、synchronized 关键字使用及原理

1.锁住对象

synchronized (this) {
    System.out.println("synchronized 代码块");
}

使用 javap -verbose TestClass 查看使用的字节码指令为:monitorenter、monitorexit

2.锁住方法

public synchronized void method1() {
    System.out.println("synchronized 方法");
}

使用 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

二、java 对象结构

谈到 synchronized 关键字,绕不开的就是对象头,因为 synchronized 锁住的就是对象,锁状态就存在对象头,所以了解对象的存储结构会更容易理解 synchronized。

1.java 对象存储结构

Java 对象存储在堆(Heap)内存。那么一个 Java 对象到底包含什么呢?概括起来分为对象头、对象体和对齐字节。如下图所示: java对象存储结构

对象的几个部分的作用:

  1. 对象头中的 Mark Word(标记字): HotSpot 虚拟机的对象头(Object Header)用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(暂不考虑开启压缩指针的场景)中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”。 对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机 中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25Bits 用于存储对象哈希码(HashCode),4Bits 用于存储对象分代年龄,2Bits 用于存储锁标志 位,1Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表所示。
Mark Word 的存储格式
Mark Word 的存储格式
  1. Klass Word 是一个指向方法区中 Class 信息的指针,意味着该对象可随时知道自己是哪个 Class 的实例;
  2. 数组长度也是占用 64 位(8 字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
  3. 对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
  4. 对齐字:在 64 位的虚拟机中,要求对象占用内存的大小应该是 8bit 的倍数,这个信息是用来补齐 8bit 的,无其他作用)。

三、JDK1.6 之后的锁优化

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。 JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

1 锁的状态

摘自周志明《深入理解 JAVA 虚拟机第二版》 如上图(Mark Word 的存储格式)所示,锁的状态共有四种:无锁态、偏向锁、轻量级锁和重量级锁,其中偏向锁和轻量级锁是 JDK1.6 开始为了减少获得锁和释放锁带来的性能消耗而引入的。 四种锁的状态会随着竞争情况逐渐升级,锁可以升级但是不能降级,意味着偏向锁可以升级为轻量级锁但是轻量级锁不能降级为偏向锁,目的是为了提高获得锁和释放锁的效率。用一张图表示这种关系:

锁升级
锁升级

2.锁优化

2.1 偏向锁

引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

2.2 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6 之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了 CAS 操作。

轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生 CAS 操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!

2.3 自旋锁和自适应自旋

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

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。

JDK1.6 及 1.6 之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。 如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是 10 次,用户可以修改--XX:PreBlockSpin来更改。 另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。

2.4 锁消除

锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

2.5 锁粗化

原则上,我们再编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

四、总结

synchronized 是一个通用且重量级的锁机制,在 JDK1.6 以前,性能还非常差,在经过 jvm 设计团队的不断优化,新加了 NIO、优化 synchronized 等,让 JDK 成为一个稳定经典的版本

  1. synchronized 可以锁对象、方法、类,他们的实现得益于 JVM 的指令:monitorentermonitorexitACC_SYNCHRONIZED 标识。
  2. 需要注意的是,synchronized 真正锁住的东西是 java 对象,锁状态就存在对象头,所以了解对象存储结构也是很需要的。
  3. JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
正文到此结束