在并发编程中,经常会遇到多个线程访问同一个共享资源,这时就需要考虑维护数据的一致性。也就是要用到锁,Java中有两种加锁的方式。一种是synchronized关键字,另一种是用Lock接口的实现类。
形象的说,synchronized关键字可以满足日常需求,如果需要各种骚操作,就需要使用Lock接口的实现类。
synchronized是Java内置的关键字,Lock是一个接口,这个接口在代码层面上实现了锁的功能,
ReentrantLock、ReadLock、WriteLock 是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”和“写锁”,后面会讲它们的用途。
ReadWriteLock其实是一个工厂接口,ReentrantReadWriteLock是ReadWriteLock的实现类,它包含两个静态内部类,ReadLock和WriteLock,这两个类又分别实现了Lock接口。
Lock的使用
/**
*在finally块中释放锁,目的使保证获取到锁后,最终能够被释放。
* 不要将获取锁的过程写在try中,由于在获取锁(自定义锁的实现)时发生了
* 异常抛出的同时,也会导致锁无故释放。
*
*/
public static void main(String[] args) {
Lock lock=new ReentrantLock();
lock.lock();
try{
//处理业务逻辑
}finally {
lock.unlock();
}
}
悲观锁与乐观锁
锁的一种常见的分类是悲观锁与乐观锁,这两个概念并不是特指某个锁,而是在并发情况下的两种策略。
悲观锁,就是很悲观,每次去拿数据的时候都会认为别人会修改,所以在每次拿数据的时候都会给上锁,这样别人拿数据就会被挡住,直到悲观锁释放。
乐观锁,就是很乐观,每次去拿数据的时候都认为别人不会修改数据,所以不会上锁,但是如果想要更新数据,需要在更新前检查在读取到更新这段时间里有没有其他人修改过这个数据(可以使用版本号机制等)。如果修改过,那么重新获取,再次尝试更新,循环上述步骤直到更新成功。
悲观锁阻塞事务,乐观锁回滚重试。各有优缺点,乐观锁适合读多写少的情况,可以加大整个系统的吞吐量。但如果写多,冲突较多,上层应用就会不断重试,反倒是降低了性能,所以在写多的情况下,用悲观锁比较合适。
唯一的乐观锁——自旋锁
说到自旋锁,必须要提到一个概念,CAS。比较并替换。过程如下:
- 先读取值,假设读到的值为A;
- 尝试更新,更新之前,检查原值是否仍为A(是否被其他线程动过);
- 如果是,将值更新为B,结束。
- 如果不是,返回第一步。
CAS是实现自旋锁的基础,直白的讲就是while(true)无线循环,就像自己在来回旋转。
data = 123;
spin = true;
while (spin) { // 自旋
oldValue = data; // 读取数据
newValue = doSomething(oldValue); // 处理数据
/* 下面的部分为CAS原子操作,尝试更新data的值 */
if (data == oldValue) {
data = newValue;
spin = false;
} else {
// 啥也不干,继续循环
}
}
/* 很明显,这样的代码根本不是原子性的,
因为真正的CAS利用了CPU指令,
这里只是为了展示执行流程,本意是一样的 */
CAS利用CPU指令,从硬件层面保证了操作的原子性,以达到锁的效果。
JDK里并没有提供自旋锁(SpinLock)这个类,但是提供了CAS方法,有了这个方法自己手动实现自旋锁并不难。在java.util.concurrent.atomic包里面的原子类,其原子性的实现都是利用了自旋锁。自旋锁是唯一的乐观锁,换句话说,乐观锁就是自旋锁,自旋锁就是乐观锁。
实际上,乐观锁根本就不是“锁”,因为他根本就没与锁住对象,而是一个无限重试的算法而已。
CAS可能遇到的问题
synchronized锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
在JDK1.6之前,synchronized关键字是重量级锁,在1.6之后,对synchronized关键字进行了升级,一脚油门下去,先是从无锁升级为偏向锁,再升级为轻量级锁,最后再升级为重量级锁。
初次执行synchronized代码块,JVM会将锁对象修改为偏向锁(CAS修改对象头里的标识位),字面意思是“偏向于第一个获得它的进程”。执行完同步代码块之后,线程并不会主动释放锁,当第二次到达同步代码块的时候,线程会主动去判断持有锁的线程是不是原来的那个,如果是,那么就继续往下执行。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有任何额外开销,性能极高。
偏向锁有一个特征,持有锁的线程在执行完加锁代码块之后不会主动释放锁,那么当第二个线程执行到同步代码块的时候,是否一定发生锁竞争然后升级为轻量级锁呢?
线程A在第一次执行完同步代码快之后,当线程B尝试获取锁的时候,发现是偏向锁,会判断A线程是否还存在,如果线程A仍然存在,那么将线程B挂起,此时锁升级为轻量级锁,之后线程A继续执行,线程B自旋。但是如果A不存在了,那么线程B持有偏向锁,锁不升级。
轻量级锁
重量级、自旋锁和自适应自旋锁,他们都有一个特点,就是进入一个方法的时候,就会加上锁,退出一个方法的时候,也就释放对应的锁。
轻量级锁认为,当你在方法里面执行的时候,其实是很少刚好有人也来执行这个方法的,所以,当我们进入一个方法的时候根本就不用加锁,我们只需要做一个标记就可以了,也就是说,我们可以用一个变量来记录此时该方法是否有人在执行。也就是说,如果这个方法没人在执行,当我们进入这个方法的时候,采用CAS机制,把这个方法的状态标记为已经有人在执行,退出这个方法时,在把这个状态改为了没有人在执行了。
如果真的遇到了竞争,我们就会认为轻量级锁已经不适合了,我们就会把轻量级锁升级为重量级锁了。所以轻量级锁适合用在那种,很少出现多个线程竞争一个锁的情况,也就是说,适合那种多个线程总是错开时间来获取锁的情况。
一旦有第二个线程加入锁竞争,偏向锁就会升级为轻量级锁。这里明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都非常顺利,没有发生阻塞,那么就不存在锁竞争,只有当线程去尝试获取锁,但是发现锁已经被占用,只能等待其释放,这才发生了锁竞争。
当发生了锁竞争之后,轻量级锁其实就是自旋锁的忙等,只不过这个忙等是有限度的(有个计数器记录自旋的次数,默认允许10次,可以通过虚拟机参数修改)。如果多个线程争一个锁,但是没有发生锁竞争,或者发生了轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等。
这里的允许忙等是一种折中的想法,短时间的忙等,换取了用户态和内核之间切换的开销。当某个线程没有竞争到锁时,不直接阻塞自己,而是先自旋一会,可能在自旋的时间内,锁就被释放了。
但是如果锁竞争严重,自旋一段时间之后,就会升级为重量级锁,当线程尝试获取锁的时候,发现被占用的锁为重量级锁,那么该线程就会挂起(不是忙等),等待将来被唤醒,在JDK1.6之前,synchronized是直接加重量级锁,在之后有优化。
一个锁只能按照偏向锁 → 轻量级锁 → 重量级锁这样升级,不允许降级。
可重入锁
可重入锁的字面意思是可以重新进入的锁,即“允许同一个线程多次获得同一个锁”。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里以reentrant开头命名的锁都是可重入锁,而且只要是JDK提供的所有的现成的lock的实现类都是可重入的,我知道的不可重入锁只有自旋锁,99%的业务场景用可重入锁就可以了。
公平锁、非公平锁
如果多个线程申请同一把锁,当锁被释放时,先申请的先得到,叫公平锁,反之,如果后申请的可能能得到锁,则不公平。
对ReentrantLock类而言,通过构造函数传参可以指定该锁是否是公平锁,默认是非公平锁。一般情况下,非公平锁的吞吐量比公平锁大,如果没有特殊要求,优先使用非公平锁。
对于synchronized来说,它是一种非公平锁,并且不能变成公平锁。
可中断锁
可中断锁,字面意思是“可以响应中断的锁”。
这里的关键是理解什么是中断。Java并没有提供任何直接中断某线程的方法,只提供了中断机制。何谓“中断机制”?线程A向线程B发出“请你停止运行”的请求(线程B也可以自己给自己发送此请求),但线程B并不会立刻停止运行,而是自行选择合适的时机以自己的方式响应中断,也可以直接忽略此中断。也就是说,Java的中断不能直接终止线程,而是需要被中断的线程自己决定怎么处理。这好比是父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己。
回到锁的话题上来,如果线程A持有锁,线程B等待获取该锁。由于线程A持有锁的时间过长,线程B不想继续等待了,我们可以让线程B中断自己或者在别的线程里中断它,这种就是可中断锁。
在Java中,synchronized就是不可中断锁,而Lock的实现类都是可中断锁。
读写锁,共享锁,互斥锁
读写锁其实是一对锁,包括共享锁(读锁)、互斥锁(写锁)
读写锁其实和CAS做的事情是一样的,但是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思),那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示,那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。
虽然读写锁感觉与CAS有点像,但是CAS是乐观锁策略,而读写锁是悲观锁策略。因为读写锁并没有在更新前判断值有没有被修改过,而是在加锁前决定应该用读锁还是写锁。如果有疑惑可以再回到第一小节,看一下什么是“乐观锁”。
JDK提供的唯一一个ReadWriteLock接口实现类是ReentrantReadWriteLock。看名字就知道,它不仅提供了读写锁,而是都是可重入锁。 除了两个接口方法以外,ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法,这里就不一一展开。
回到乐观锁悲观锁
CAS自旋锁就是唯一的乐观锁,其他的全是悲观锁。synchronized从偏向锁、轻量级锁到重量级锁,全都是悲观锁。JDK提供的Lock实现类全是悲观锁。只要有“锁对象”出现,那么就一定是悲观锁。但java.util.concurrent.atomic包里面的原子类是利用乐观锁实现的。严格地说,乐观锁不是锁,而是一个无限循环直至CAS成功的算法。
那么为什么有些资料认为ReentrantLock、偏向锁、轻量级锁等是乐观锁呢?理由是它们底层用到了CAS算法,或者是把“乐观/悲观”与“轻量/重量”搞混了?其实,线程在抢占这些锁的时候,确实是用到了CAS,看起来好像是乐观锁。但问题的关键是,我们说一个锁是悲观锁还是乐观锁,总是应该站在应用层,看它们是如何锁住应用数据的,而不是站在底层看抢占锁的过程。如果一个线程尝试获取锁时,发现已经被占用,它是否继续读取数据,等后续要更新时再看要不要重试?对于Lock实现类以及synchronized三种锁来说,显然答案是否定的。无论是挂起还是自旋,对应用数据的读取操作都被“挡住”了。从这个角度看,它们确实是悲观锁。
退一步讲,也没有必要在这些术语上狠钻牛角尖,最重要的是理解它们的运行机制。想写得尽量简单一些,却发现洋洋洒洒近万字,只讲了个皮毛。深知自己水平有限,不敢保证完全正确,只能说路漫漫其修远兮,望指正。