并发编程系列(九)Synchronized的使用与实现原理(续)-锁升级与锁优化

通过 并发编程系列(四)Synchronized的使用与实现原理 我们已经介绍了Synchronized的加锁实现是通过monitor来实现的。但是我们知道monitor的实现是需要依赖操作系统完成。这极大地消耗了性能。因此在使用synchronized同步锁的时候需要进行用户态到内核态的切换。

内核态
CPU可以访问内存所有数据,包括外围设备,例如硬盘,网卡。CPU也可以将自己从一个程序切换到另一个程序

用户态
只能受限的访问内存,切不允许访问外围设备。占用CPU的能力被剥夺,CPU资源可以被其他程序获取。

之所以会有这样的区分是为了防止用户进程获取别的程序的内存数据,或者获取外围设备的数据。

在JDK1.6以前,使用synchronized就只有一种方式即重量级锁,而在JDK1.6以后,引入了偏向锁,轻量级锁,重量级锁,来减少竞争带来的上下文切换。这几种锁的升级也是通过记录在对象都的MarkWord来实现的,我们以32位虚拟机为例,如下图:
并发编程系列(九)Synchronized的使用与实现原理(续)-锁升级与锁优化

偏向锁

当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,以后该线程进入和退出同步代码块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为ThreadID即可。处理流程如下:
(1). 检测Mark Word 是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
(2). 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤5,否则执行步骤3;
(3). 如果测试线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行步骤4;
(4). 通过CAS竞争失败,证明当前存在多线程竞争情况,当达到安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续向下执行同步代码块;
(5). 执行同步代码块。

偏向锁存在只有一个线程访问不存在竞争下。

轻量级锁

轻量级锁的获取主要由两种情况:① 当关闭偏向锁功能时;② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态,虚拟机将首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后将对象头中的 Mark Word 复制到锁记录中。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

重量级锁

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

重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。

注意:锁的升级只能是由低到高,是不可逆的。
并发编程系列(九)Synchronized的使用与实现原理(续)-锁升级与锁优化

关于自旋

关于自旋,用代码解释就是:

do {
// do something
} while (自旋的规则,或者说自旋的次数)

引入自旋这一规则的原因其实也很简单,因为阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。并且在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,这部分操作的开销其实是得不偿失的。

所以,在物理机器有多个处理器的情况下,当两个或以上的线程同时并行执行时,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋。如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。

所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。

自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。