锁的状态和锁的升级过程

在synchronized最初的实现方式是“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这种方式就是synchronized实现同步最初的方式。

在JDK1.6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

锁的四种状态

目前锁状态一共有四种,从级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁,锁状态只能升级,不能降级。

无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程修改成功。

无锁的特点是修改操作会在循环内进行,线程会不断地尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其它修改失败的线程会不断重试直到修改成功。

偏向锁

偏向锁也是JDK6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

设置当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking),当锁对象第一次被线程所获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块,虚拟机都可以不再进行任何同步操作(加锁、解锁以及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作按照轻量级锁去执行。

当对象进入偏向状态的时候,Mark Word大部分的空间都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象的哈希码的位置,那么原来对象的哈希码怎么办呢?

在Java中一个对象如果计算过哈希码,就应该保持不变,否则很多依赖哈希码的API都可能存在出错的风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码,这个值是能强制保证不变的,它通过对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态下的Mark Word,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说她并非总是对程序运行有利的。如果程序中大多数的锁都总是被多个不同线程访问,那么偏向模式是多余的。在具体问题具体分析的前提下,有时候用参数:-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提提升性能。

轻量级锁

轻量级锁中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它的设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

在HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机栈分别占用32个或64个比特,官方称为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有额外的部分用于存储数组长度。

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存存储尽量多的信息。它会根据对象的状态复用自己的存储空间。

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(Displaced Mark Word)。

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变为“00”,表示此对象处于轻量级锁定状态。

如果这个跟新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态变为“10”此时Mark Word中存储就是指向重量级锁(互斥量)的指针,后面等待锁的线程也就必须进入阻塞状态。

轻量级锁的解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能成功替换,那整个同步过程就顺利完成;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:

  • 当关闭偏向锁功能时;
  • 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自选锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当前某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不断地循环判断是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间忙等,换取线程在用户态和内核态之间切换的开销。

为什么要引入轻量级锁?

重量级锁

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

简而言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

Java头对象

以Hotspot虚拟机为例,Hotspot对象头主要包括两部分:Mark Word(标记字段)和Klass Pointer(类型指针)。

Mark Word

默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

锁状态 25位 31位 1位 4bit 1bit偏向锁位 2bit锁标志位
无锁 unused hashCode unused 分代年龄 0 01
锁状态 54位 2位 1位 4bit 1bit偏向锁位 2bit锁标志位
偏向锁 当前线程指针 Epoch unused 分代年龄 0 01
锁状态 62位 2bit锁标志位
轻量级锁 指向线程栈中Lock Record 00
重量级锁 指向互斥量的指针 10
GC标记信息 CMS过程用到的标记信息 11

Klass Point

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

synchronized用的锁是存在Java对象头里的,具体存在锁对象的对象头的Mark Word中。

Monitor

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

在Java中,一个对象对应了一个monitor对象,而synchronized关键字也需要关联一个对象,这个对象需要天生就支持monitor,所以在Java中,可以就是Java中的java.lang.Object类,便是满足这个要求的对象,任何一个Java对象都可以作为monitor机制的monitor object。这也就是wait()、notify()、notifyAll()在Object类中的原因。

Monitor是线程私有的数据结构,每一个线程都有一个可用的monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized是通过对象内部的一个叫做监视器(monitor)来实现的,监视器本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为重量级锁。

锁粗化

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

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

1
2
3
4
5
6
7
public String concatString(String s1, String s2, String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

上述的append()方法就属于这类情况。如果虚拟机探测到有这这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部,就是扩展到第一个append()操作之前直到最后一个append()操作之后,这样只需要加锁一次就可以了。

Donate comment here