本人在看《实战java高并发程序设计》关于java虚拟机对锁优化时,感觉介绍的不是很清晰。本人在结合之前自己看的
《深度理解java虚拟机》一书,然后整理出本人对java虚拟机对锁的优化的理解,在这里本人着重介绍CAS,锁偏向、轻量级锁、
自旋锁以及它们之间的工作联系。在这里声明,这里可能有本人介绍不妥的地方,希望得到广大网友指正!
“锁”是最长用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序下降。这是因为基于互斥量的传统锁(sysnchronized,
ReentrantLock)实现的同步都是互斥同步,也称为阻塞同步。大家都知道互斥同步是一种悲观的并发策略,就是无论共享数据是否真的会
出现竞争,它都会进行加锁(这里说的是概念模型,在java虚拟机及时编译时会有锁优化进行锁消除),这样就会造成线程频繁的阻塞和
等待唤醒——线程切换,就会引起用户态核心态来回转换。这样对于多线程来说,系统除了处理功能需求外,还需要额外的维护多线程的
特有信息,如用户态和心态转换、维护锁计数器、线程的调度、检查是否有被阻塞的线程需要唤醒等操作,因此造成程序的性能下降。
java作为这么优秀的语言,这里就算不说大家一定能猜到java语言肯定做了相应的处理。在这里主要介绍两类优化措施:
乐观并发措施(CAS)和java虚拟机对锁的优化。在这里声明,这两类和传统的锁都不是互斥的关系。CAS和传统锁的使用场景不一样,
在并发量不是很大的时候,CAS性能更好,但并发量大的时候,反之。java虚拟机对锁的优化的本意是在多线程竞争不是很激烈的前提下,
减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
(一)乐观并发措施——CAS
与锁相比,使用比较交换(CAS)会使程序看起来更加复杂一些,但由于其非阻塞性,它对死锁免疫。更重要的是,使用无锁的方式
完
全没有锁竞争带来的系统开销,也没有线程频繁调度带来的开销,因此,它是比基于锁的优势的方式拥有更优越的性能。
CAS算法的过程是这样:它包含三个参数CAS(V,E,N)。V表示要更新的值,E表示预期值,N表示新值。仅当V等于E时,才会将V的值
设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作一个变量
时,只有一个会胜出,并更新成功,其余均会失败。失败的线程不会被挂起,仅是被告知失败 ,并且允许再次尝试,当然也允许失败的线程
放
弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰恰当的处理。
说到这里大家是不是感觉CAS确实很好,但我不得不说这是得利与硬件指令集的发展,让很多操作变成原子性操作,比如我们正在说
的
CAS,它是一个比较并且交
换指令,你看上去感觉是不是觉得这是两个步骤,其实这在硬件指令集中就是一个步骤,也就是说所的原子性。
如
果你想使用这些CAS等CPU指令,JDK并发包中有一个atomic包,里面实现了一些直接使用CAS操作的线程安全的类型。在这里我举一个我们
最常用的AtomicInteger 你可以把它看做一个整数。但是与Integer不同,它是可变的,并且线程安全的。对其进行任何操作,都是用CAS指
令
进行的。AtomicInteger的使用非常简单,这里给一个示例,借此梳理一下AtomicInteger的工作流程:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerDemo {
static AtomicInteger i = new AtomicInteger();
public static class AddThread implements Runnable{
public void run(){
for (int k = 0; k<10000;k++){
i.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] ts = new Thread[10];
for(int k=0;k<10;k++){
ts[k] = new Thread(new AddThread());
}
for(int k=0;k<10;k++){ts[k].start();}
for(int k=0;k<10;k++){ts[k].join();}
System.out.println(i);
}
}
}
代码的第7行AtomicInteger.incrementAndGet()方法会使用CAS操作将自己加1,同时也会返回当前值。如果你执行这段代码,你会看到结果
是10000。这说明程序正常执行
没有出现错误。如果不是线程安全,i的值应该会小于10000才对。
接下来我们看一下incrementAndGet()这个
方法的实现:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
可能你会感觉到奇怪,为什么设置一个值那么简单的操作都需要一个死循环呢?原因是:CAS操作未必是成功的,因此对于不成的情况,我们
需要不断尝试。第3行的get()
取得当前值,接着加1操作得到新的next写入成功的条件实在写入的时刻,当前的值应该要等于刚刚取得的current
如果不是这样,就
说明
AtomicInteger的值在第3行到
第5行
代
码之间,又被其他线程修改过了。当前线程看到这个状态就是一个过期状态。因此
compareAndSet返回失败,需要进行下一次重试,只到成功。
接下来我们在看看compareAndSet()这个方法的实现:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
在这里,我们看到一个特殊的变量unsafe,它是sun.misc.Unsafe类型。从名字看,这个类应该是封装了一些不安全的操作。那什么操作是不
安
全的呢?学习过C或者C++
的话,大家应该都知道,指针是不安全的,Unsafe就是java中指针,Unsafe中的一些方法就是使用CAS原子指令
来完
成的。
但是很遗憾的是,JDK的开发人员并不希望我们
使用
这个类。获得Unsafe实例的方法是调用其工厂方法getUnsafe().但是,它的
实现确实
这样的:
public static Unsafe getUnsafe(){
Class cc = Reflection.getCallerClass();
if(cc.getClassLoader() != null)
throw new SecurityException("Unsafe");
return theUnsafe;
}
注意加粗部分的代码,它会检查调用getUnsafe()函数的类,如果这个类的ClassLoader不为null,就直接抛出异常。根据java类加载器的工作
原理,应用程序的类由App Loader加载。而系统核心类,如rt.jar中的类由Bootstrap类加载器加载。Bootstrap不是用java语言实现的,大部
分使用C写的,所以会返回null。
所以,当一个类的类加载器
不为null
时,说明它是有Bootstrap加载的,而这个类也极有可能是rt.jar中的类。
(二)JAVA虚拟机对锁的优化
在这里我主要说比较难以理解的三个锁优化技术:自旋锁与自适应自旋、轻量级锁、偏向所以及它们之间的工作联系。
(1)自旋锁与自适应自旋
前面我们以及讨论过互斥同步带来的性能影响,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转
入内
核态中完成,这些操作给系统
带来很大的影响。如果共享数据的锁定状态只会持续很短的一段时间,那么挂起和恢复线程并不值得。如果
一
个物理机有一个以上的处理器,能让两个或以上的线程同时并行
执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器
的执行时间,看看持有所得线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行
一个自旋,这项技术就是所谓自旋锁。
自旋等待本身虽然避免了线程切换的开销,但它是占用处理器时间的,因此,如果所被占用的时间很短,自旋的效果就会非常好,
反之,效果非常差,
自旋线程就会白白浪费处理器资
源。因此,自旋等待的时间要有一定限度,如果自旋超过了限定的次数仍然没有成功获
得锁,
就用传统
的方式
挂起线程。自旋次数默认是10次。
在JDK1.6中引用了自适应的自旋锁,自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及所得拥有者的状态
来决定。这个不难理解,就是在一
个锁上,上一次自旋成功了,那么这次自旋应该也会成功,进而允许它获得更长的时间。如果对于一个锁
很少自旋成功,那么以后获得这个锁就可以省掉自旋等待。这样自旋锁
就会变得越来越聪明了。
(2)轻量级锁
轻量级锁是JDK1.6之中加入的新型锁机制,它的名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的
锁机制就被称为“重量级锁”。
首先需要强调的一点是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少
传统的冲量级锁带来的性能消耗。
要理解轻量级锁,必须从Hotspot虚拟机的对象头部分的内存布局开始介绍。Hotspot虚拟机的对象头分为两
部分,第一部分用于存储对象自身的运行数据,如哈希码,
GC分代年龄等。这个部分的长度在32位和64位的虚拟机中分别为32bit和64bit,官
方称它为"Mark Word"。另外一部分用于存储指向方法去对象类型数据的指针。
接下来介绍32位的Mark Word的存储情况,其中25bit用于存储
对
象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。
我们把话题返回到轻量级锁的执行上来。在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位位“01”状态),虚拟机
首先
将在当前栈帧中建立一个锁记录(Lock Record)的空间,用于存储当前对象目前的Mark Word的拷贝。然后,虚拟机将使用CAS操作尝试
将对象
头的Mark Word 更新为指向Lock Record的指针(这里Mark Word之前的信息已经复制到Lock Record中了),并且对象Mark Word
(也就复制在
Lock Record中的Mark Word)的锁标志位将转变位“00”表示轻量级锁定。如果这个更新操作失败,虚拟机首先检查对象的的
Mark Word是否
指向当前线程的栈帧,如果当前线程已经拥有了这个对象的锁,那就直接就如同步快执行,否则说明这个锁对象已经被其他
线程强占了。如果
有两个以上的线程争用一个同一个锁,那么轻量级锁就不再有效,要膨胀为重量级锁,(
这里为什么是两条以上的线程
下来我自己理解了下
,后面说明
)。接下来解锁也是通过CAS操作来进行的,就是用CAS操作把对象当前的Mark Word和线程中复制
Lock Record
中的Mark Word替换
回来。
(3)偏向锁
理解了轻量级锁,那么接下来理解偏向锁就不难。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向
所就是
在无竞争的情况下把整个
同步都消除掉,连CAS操作都不做了。接下来说明偏向锁过程,当锁对象第一次被线程获取的时候,虚拟机将
会
把对象头中的标志位设为“01”,即偏向模式。同时使用CAS
操作把获取到这个所得线程的ID记录在对象的Mark Word之中,如果CAS操作
成功,
持有偏向锁的线程以后每次进入这个锁相关的同步快时,虚拟机都可以不再进行任何同步
操作。当有另外一个线程去尝试这个锁时,
偏向模式就
宣告解锁。
它们之间的工作联系:
当一个线程进入同步块进行执行时,如果同步对象没有被锁定,那么该线程使用轻量级锁来锁定该对象,同时使用偏向锁进行加锁,
那么锁就进入偏向模式。当这个线程
再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高程序新能。
对于几乎没有锁竞争的场合,这个优化效果是非常好的。接下来可能有另
一个
线程尝试获取这个锁时,偏向模式就宣告结束。根据锁对象是否
处于被锁定状况(开始使用轻量级锁来锁定该队形),撤销偏向后恢复到未锁定(标志位“01”)或轻量
级
锁定(标志位“00”)。
如果恢复到轻量级锁定,那么此时进来的线程该怎么办呢。这就使用到了自旋锁,那么此时的线程就自选等待。一旦等待成功便可执行。
如何等待
的过程
又进来一个线程怎么办。这时轻量级锁就不再有效,要膨胀为重量级锁。