在java中,我们经常碰到各种锁,比如公平锁&非公平锁、乐观锁&悲观锁、可重入锁&不可重入锁、互斥锁&共享锁,如果对这些锁不是很了解,很容易混淆概念。
本文将梳理一下这些的锁,介绍它们的不同点,以及如何使用。
文章目录
- 1、公平锁&非公平锁
- 2、可重入锁&不可重入锁
- 3、互斥锁&共享锁
- 4、乐观锁&悲观锁
- (1)使用版本号
- (2)使用时间戳
- (3)CAS实现
- 5、自旋锁
- 参考文章
1、公平锁&非公平锁
公平锁:按照申请锁的顺序分配锁,也就是先来先得,禁止插队。申请锁时,发现当前有其他线程在等待锁,那么当前线程自动进入等待队列。
- 优点:按照FIFO顺序获得锁,防止饥饿;
- 缺点:降低了吞吐量。
非公平锁:允许线程抢占锁,只有抢不到才会进入等待队列,进入等待队列后,每次只有队列头的线程才有机会抢占锁。
- 优点:减少了线程唤醒次数,也减少了线程切换次数,提升了吞吐量;
- 缺点:有可能出现饥饿。
ReentrantLock和ReentrantReadWriteLock为我们提供了公平锁与非公平锁的实现,默认创建的锁都是非公平锁。以ReentrantLock为例,它提供了一个入参为布尔类型的构造方法,当入参为true时,表示创建的是公平锁,false表示创建的是非公平锁。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
2、可重入锁&不可重入锁
可重入锁:当前线程已经获得了锁,其可以再次获得相同的锁。比如ReentrantLock就是一种可重入锁。可重入锁的意义在于防止死锁。
不可重入锁:当前线程已经获得了锁,如果再次获得相同的锁必须先释放之前的锁,否则阻塞线程。
3、互斥锁&共享锁
互斥锁:一个线程获得了互斥锁,其他线程无法再次获得,只能等到该线程释放锁。比如ReentrantLock、ReentrantReadWriteLock.WriteLock都是互斥锁。
共享锁:允许多个线程同时持有锁。比如ReentrantReadWriteLock.ReadLock。
共享锁和互斥锁之间还有排他性,线程获得互斥锁之后,其他线程获取共享锁会被阻塞,同样的,如果有线程已经获得了共享锁,那么会阻塞其他线程获取互斥锁。
4、乐观锁&悲观锁
乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候判断在此期间别人有没有更新过这个数据,可以使用版本号、时间戳等机制。乐观锁适用于数据争用不大、冲突较少的环境或者是读多写少的环境,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。CAS算法属于乐观锁。
- 优点:不用每次访问数据都加锁,省去了锁开销,减少了资源消耗,提升了系统吞吐量,防止出现死锁;
- 缺点:如果数据经常被修改,更新数据可能会经常出现失败,这将导致程序频繁重试,反而降低了系统性能;当使用时间戳判断数据是否更新过,如果时间戳精度不够,比如毫秒级,那么在高并发情况下,可能会凑巧出现数据在同一毫秒被更新多次,这种情况下乐观锁会失效,如果使用高精度时间戳,会增加系统负担;乐观锁一般在应用程序中锁定,如果在当前应用之外还有其他应用没有使用相同乐观锁更新数据,也会造成乐观锁失效,所以使用乐观锁必须指定一个统一的规则,大家都按照这个规则读取更新数据;使用版本号作为乐观锁时,如果事务A先将版本号为默认值的记录删除,然后增加一个新纪录,同时版本号还是默认值,而同时另一个事务先查询到了旧数据,那么此时后一个事务更新数据会造成乐观锁失效。
悲观锁:顾名思义,就是很悲观,每次拿(读取数据和更新数据)数据的时候都认为别人会修改,所以每次拿数据时都会上锁,这样别人想拿这个数据就会被阻塞。悲观锁主要应用于数据争用激烈的场景,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。java里面的ReentrantLock、synchronized属于悲观锁,数据库中的行锁、表锁等也属于悲观锁。悲观锁简单的说就是访问数据前先加锁再访问。悲观锁一般借助数据库实现,可以使用事务对更新的数据自动加锁或者使用select…for update手动加锁。
- 优点:确保数据可以按照规定的顺序访问更新,防止数据被并发修改;
- 缺点:每次访问数据都需要加锁,降低了系统吞吐量,有产生死锁的可能。
悲观锁在每次使用数据前加锁,使用完数据解锁,比较简单。下面介绍使用乐观锁的例子。
乐观锁在更新失败后,既可以不断重试,也可以抛出异常,由应用程序或用户决定如何处理。乐观锁中,判断数据是否被修改过,一般有两种方式:一种是使用版本号,另一种是使用时间戳。一般的,版本号和时间戳都记录在数据库中。除了上面两种实现乐观锁的方式外,有时间记录的状态也是一种乐观锁,比如订单状态,更新订单状态前需要先判断当前订单状态,如果符合才更新。
(1)使用版本号
使用版本号时,可以在数据初始化时指定一个版本号,更新数据前,先将数据和版本号同时查询出来,每次对数据更新时都对版本号执行+1操作,更新的同时,判断之前取到的版本号与现在数据库中的版本号是否一致。
--根据主键查询出商品库存
select (stock,version) from goods where id=#{id}
--对商品库存做一些处理
....
--修改商品库存,库存减1
update t_goods set stock=stock-1,version=version+1 where id=#{id} and version=#{version};
上面的SQL例子没有开启事务,如果最后更新失败,可以重试,或者回滚之前的操作。
(2)使用时间戳
使用时间戳时,更新数据前,先将数据和时间戳同时查询出来,每次对数据更新时先判断之前取到的时间戳与现在数据库中的时间戳是否一致,一致的话才更新数据,同时将时间戳更新为当前时间。
--根据主键查询出商品库存
select (stock,update_time) from goods where id=#{id}
--对商品库存做一些处理
....
--修改商品库存,库存减1
update t_goods set stock=stock-1,update_time=current time where id=#{id} and update_time=#{update_time};
(3)CAS实现
CAS也是一种乐观锁,下面以java类AbstractQueuedSynchronizer中的线程申请锁失败进入等待队列的操作为例子介绍。
//线程申请锁失败,需要进入等待队列的尾,入参node代表申请锁的线程
private Node enq(final Node node) {
for (;;) {//不断循环直到成功,自旋锁
Node t = tail;
if (t == null) { //如果等待队列是空的,则创建一个头结点
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
//将新插入的节点作为尾结点,更新属性tail
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
private final boolean compareAndSetTail(Node expect, Node update) {
//使用CAS将新插入的节点作为尾结点,更新属性tail,更新前先判断当前属性tail的值
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
5、自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,该线程进入循环,不断的重试,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting(忙等待)。
因为自旋锁会使线程一直处于运行状态,造成CPU空转,所以自旋锁适合于锁使用者保持锁时间比较短并且锁竞争不激烈的情况。
- 优点:自旋锁使线程一直处于运行态,一旦获得锁可以立马开始后续业务处理,减少了线程切换的开销;
- 缺点:如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU,使用不当会造成CPU使用率极高;不公平的自旋锁容易造成线程饥饿;如果有大量的线程持有自旋锁,容易造成系统“假死”。
在java源码中,自旋锁经常与CAS一起出现,比如上一小节的(3)CAS实现
中,就使用了自旋锁。
在下面参考文章中提供了可重入自旋锁、公平自旋锁等几个自旋锁的变种。