本片文章尝试从另一个层面来了解我们常见的同步(synchronized)和锁(lock)机制。如果读者想深入了解并发方面的知识推荐一本书《java并发编程实战》,非常经典的一本书,英语水平好的同学也可以读一读《Concurrent programming in Java - design principles and patterns》由Doug Lea亲自操刀,Doug Lea是并发方面的大神,jdk的并发包就是由他完成的。
我们都知道在java中被synchronized修饰的代码被称为同步代码块,同步代码块意味着同一时刻只有一个线程执行,其他线程都被排斥在该同步块之外,并且访问也是按照某种顺序执行的。实际上synchronized是基于监视器实现的,每一个实例和类都拥有一个监视器,通常我们说的“锁”的动作就是获取该监视器。因此通常我们讲synchronized是基于JVM层面的,使用的是对象内置的锁。静态方法锁住的是该class的监视器,实例方法锁住的是对应实例的监视器。同步是使用monitorenter和monitorexit指令实现的,monitorenter尝试获取对象的锁,如果该对象没被锁定或者当前线程已经获取了锁,则把锁的计数器+1,同样monitorexit把锁的计数器-1。因此synchronized对于同一个线程是可重入的。
监视器支持两种线程:互斥(sync)和协作。java通过对象的锁实现对临界区的互斥访问,使用Object的wait(),notify(),notifyAll()方法来实现。
乐观锁和悲观锁
这两个名字很多地方都出现过,所谓的乐观锁就是当去做某个修改或其他操作的时候它认为不会有其他线程来做同样的操作(竞争),这是一种乐观的态度,通常是基于CAS原子指令来实现的。关于CAS可以参见这篇文章java并发包的CAS操作,CAS通常不会将线程挂起,因此有时性能会好一些。(线程的切换是挺耗性能的一个操作)。
悲观锁,根据乐观锁的定义很容易理解悲观锁是认为肯定有其他线程来争夺资源,因此不管到底会不会发生争夺,悲观锁总是会先去锁住资源。
以前的synchronized都是会阻塞线程的,就是说会发生上下文切换,从用户态切换到内核态,由于这种方式有时候太耗费资源,因此后来又出现了自旋锁,所谓自旋其实就是如果锁已经被其他线程占有,当前线程并不会挂起,而是做空操作,自旋其实从某种程度来说是乐观锁,因为它总是认为下次会得到锁的。因此自旋锁适合在竞争不激烈的情况下使用,据了解目前的jvm针对synchronized已经有了这方面的优化。
自旋的使用也是分场景的,有可能线程自旋很久也没获取到锁,那么CPU就白白被浪费了,还不如挂起线程,因此有出现了自适应的自旋锁,它会更具历史的自旋是否获取到锁的记录来判断自旋的时间或者是否需要自旋。
轻量级锁
轻量级锁的概念是相对需要互斥操作的重量级锁而言,轻量级锁的目的是减少多线程的互斥几率,并不是要代替互斥。要想了解轻量级锁和后面讲到的偏向锁必须先了解下对象头的内存布局。下面这张图就是Object Header的内存布局:
初始都是01表示无锁,00表示轻量级锁,10表示重量级锁等等。在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后虚拟机尝试利用CAS操作将对象的轻量级指针指向栈的lock record,如果更新成功当前线程获取到锁,并且标记为00轻量级锁。如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
偏向锁
偏向锁就是偏心的意思,当锁被某个线程第一次获取到得时候,会在对象头记录获取到该锁的线程id,以后每次该线程进入同步块的时候都不需要加锁,如果一旦有其他线程获取到该锁,则偏向锁模式宣告失败,锁撤销回未锁定或轻量级锁状态。偏向锁的作用就是完全消除锁,连CAS操作都不做。
下面来看一下线程在进入同步块和出同步块的状态转换。
当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
新请求的线程会被放置到ContentionList中,当某个Owner释放锁的时候,如果EntryList是空则Owner会从ContentionList中移动线程到EntryList。显然,ContentionList结构其实是个Lock-Free的队列,因为只有Owner才会从ContentionList取节点。
EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在Hotspot中把OnDeck的选择行为称之为“竞争切换”。
可重入锁
可重入锁的最大好处是可以避免思索,因为对于已经获取到锁的线程,不需要再一次去获取锁了,只需要将计数器+1即可,实际上synchronized也是可重入锁的一种。但是本节我们要讲的是并发包中的ReentrantLock及其实现。synchronized是JVM层面提供的锁,而在java的语言层面jdk也为我们提供了非常优秀的锁,这些锁都在java.util.concurren包中。
先来看一下JVM提供的锁和并发包中的锁有哪些区别:
1.synchronized的加锁和释放都是由JVM提供,不需要我们关注,而lock的加锁和释放全部由我们去控制,通常释放锁的动作要在finally中实现。
2.synchronized只有一个状态条件,也就是每个对象只有一个监视器,如果需要多个Condition的组合那么synchronized是无法满足的,而lock则提供了多条件的互斥,非常灵活。
3.ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 锁投票,定时锁等候和中断锁等候。
在讲解ReentrantLock之前,先来看下不AtomicInteger源代码大体了解下它的实现原理。
/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
//该方法类似同步版本的i++,先将当前值+1,然后返回,
//可以看到是一个for循环,只有当compareAndSet成功才会返回
//那么什么时候成功呢?
public final int incrementAndGet() {
for (;;) {
int current = get();//volatile类型的变量,因此每次获取都是最新值
int next = current + 1;//加1操作
if (compareAndSet(current, next))//关键的是if中的方法
//如果compareAndSet成功,则整个加操作成功,如果失败,则说明有其他线程已经修改了value
//那么会进行下一轮的加1操作,直到成功
return next;
}
}
/**
* Gets the current value.
*
* @return the current value
*/
//get方法很简单,返回value,这个value是类的成员变量,并且是volatile的
public final int get() {
return value;
}
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return true if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
//继续跟踪unsafe的方法,发现并没提供,实际上该方法是个基于本地类库的原子方法,使用一个指令即可完成操作。
//如果内存中的值和预期的值相同,也就是没有其他线程修改过该值,则更新该值为预期的值,返回成功,否则返回失败
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
可以预见的是如果竞争非常激烈,则失败的概率会大大增加,性能也会受到影响。实际上并发包中的锁大多是基于CAS操作完成的,本节打算讲解可重入锁,但是需要了解的东西还非常多,只好重新写一篇来介绍ReentrantLock了。