一、概述
1.1 背景
概念:
锁是多线程编程中的机制,用于控制对共享资源的访问
。可以防止多个线程同时修改或读取
共享资源,从而保证线程安全。
作用:
锁用于实现线程间的互斥和协调
,确保在多线程环境下对共享资源的访问顺序
和正确性
。
1.2 锁分类
【按锁性质
划分】:
乐观锁
:认为一个线程获取共享数据的时候,不会存在其他线程修改该共享数据的情况,所以不会上锁
。例如:CAS机制、版本号机制等悲观锁
:认为一个线程获取共享数据时,一定会存在其他线程修改该共享数据的情况,因此,获取共享数据时都会进行加锁
。例如:Synchronized锁、ReentrantLock锁。
【按锁被持有数量
划分】:
独占锁
:当前锁只有被一个线程
持有。例如:ReentrantLock锁;共享锁
:当前锁可以被多个线程
持有。例如:Semaphore等。
【按公平性
划分】:
公平锁
:多个线程竞争锁时,需要进行排队,按照先来后到顺序
获取锁。例如:ReentrantLock公平锁。非公平锁
:多个线程竞争锁时,先进行插队
,插入失败再排队。例如:Synchronized锁、ReentrantLock锁
【按可重入性
划分】:
可重入锁
:允许一个线程多次加锁
。例如:Synchronized锁、ReentrantLock锁
。不可重入锁
:允许一个线程仅加锁一次
。
【按锁范围
划分】:
单体锁
:仅能锁住当前JVM进程
中的共享资源,对其他JVM进程中的共享资源不起作用。例如: Synchronized锁和ReentrantLock锁;分布式锁
:借助中间件
,对多个JVM进程
中的同一共享资源都能锁住。例如:Redis分布式锁。
二、单JVM进程锁
2.1 独占锁
2.1.1 synchronized锁
详情见:深入解析Synchronized锁底层原理
局限性:
- 是否释放锁,开发者无法自己控制,导致其他线程只能一直阻塞;
- 若获取锁的线程进入休眠或阻塞,除了线程出现异常,否则其他线程将会一直阻塞等待。
因此,在JDK1.5后,加入了Doug Lea大神贡献的java.util.concurrent包,包内提供了Lock类,提供了更加灵活控制锁的功能,弥补了Synchronized的缺陷。
2.1.2 ReentrantLock锁
Lock完全是由Java编写,提供了锁获取和释放的控制权、可中断的获取锁以及超时获取锁等多种高级特性。Lock只是一个接口,常见的实现类有:
1. 重入锁:ReentrantLock;
2. 读锁:ReadLock
3. 写锁:WriteLock
但底层都是通过聚合
了一个java 同步器(AbstractQueueSynchronizer, AQS)
来完成线程的访问控制的。因此,需要提前了解AQS的底层原理。详情见:深入解析AQS队列同步器的底层原理
ReentrantLock实现了Lock接口,同时底层通过聚合AQS完成并发的功能【注意:此时state只能为0或1】。主要有以下特点:
1. 支持重进入的锁,表示该锁能够支持一个线程对资源的重复加载,同时还支持获取锁的公平和非公平性。
2. 构造方法会接收一个可选的公平参数(默认是非公平锁)。设置为true时,表示公平锁;否则为非公平锁。
3. 可重入性的体现:任意线程获取锁之后,再次获取该锁时,不会被锁所阻塞。因为是可重入的,有一个计数器记录重入次数n, 当n = 0时表明锁完全被释放。
ReentrantLock实现的公平锁和非公平锁的区别:
1. 获取锁的时候是否按照FIFO的顺序来的。公平锁不仅会对state状态进行判断,还会判断当前同步队列中是否有元素,如果存在元素,则插入到同步队列的尾部,真正的先来后到;
2. 非公平锁性能高于公平锁性能。非公平锁可以减少CPU唤醒线程的开销,整体的吞吐率会高点,CPU也不会唤醒所有的线程,减少唤醒线程的数量。具体原因为:
【公平锁获取锁:】会将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序。在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
】非公平锁获取锁:】当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。
3. 非公平锁会存在线程饥饿的情况。但出现线程饥饿的机率非常低可以忽略不记。这就是默认非公平锁的原因。
2.1.3 局限性
synchronized和ReentrantLock锁,一次仅允许一个线程访问资源
,即属于独占性
。对于多个线程同时访问共享资源的场景,是无能为力的。不过Java也提供了对应的解决方案:java Semaphore信号量、CountDownLatch以及CyclicBarrier共享锁
。
2.2 共享锁
java Semaphore信号量、CountDownLatch以及CyclicBarrier共享锁
底层的原理是相通的,都是基于AQS队列同步器
来实现的。相比于独占锁,主要区别在于state值设置可由开发者进行控制
,这样就可以实现多个线程同时访问共享资源。AQS底层原理见:深入解析AQS队列同步器的底层原理。
2.2.1 Semaphore
Semaphore(信号量)为多线程协作提供了更为强大的控制方法,默认是非公平
的。
常用场景
:限流,尤其是公共资源有限的应用场景,例如数据库连接,停车场车位数等。
2.2.2 CountDownLatch
CountDownLatch称之为闭锁,它可以使一个或一批线程在闭锁上等待,等到其他线程执行完相应操作后,闭锁打开,这些等待的线程才可以继续执行。确切的说,闭锁在内部维护了一个倒计数器
。通过该计数器的值来决定闭锁的状态,从而决定是否允许等待的线程继续执行,是批量Join
的实现方案。
2.2.3 CyclicBarrier
CyclicBarrier通常称为循环屏障
。它和CountDownLatch很相似,都可以使线程先等待然后再执行。不过CountDownLatch是使一批线程等待另一批线程执行完后再执行;而CyclicBarrier只是使等待的线程达到一定数目后再让它们继续执行。故而CyclicBarrier内部也有一个计数器,计数器的初始值在创建对象时通过构造参数指定。
场景:
可循环使用的屏障。即等待一组线程到达一个屏障时被阻塞,直到最后一个线程到达,才会执行。例:五个人一组玩游戏,先到的进行等待,直到凑齐五个人才开始执行任务。
2.2.4 CyclicBarrier与CountDownLatch的区别:
- CyclicBarrier的计数器可以重置而CountDownLatch不行,这意味着CyclicBarrier实例可以被重复使用而CountDownLatch只能被使用一次;
- CyclicBarrier还有getNumberWaiting()方法获取阻塞线程数量;isBroken()方法用来了解阻塞的线程是否被中断。
- CountDownLatch指的是
每个线程的主业务逻辑执行完成后,再统一释放锁
;而CyclicBarrier指的是等指定数量线程准备
好后,再执行主业务逻辑。
2.3 总结
不管是独占锁还是共享锁,解决的是共享资源的访问控制问题,无法解决线程见的通信问题。对应的解决方案有:
1. Synchronized锁配合Object的wait和notify等方法来实现线程通信;
2. ReentrantLock锁配合Condition实现多个条件下的线程通信。
Condition的底层实现原理
见:深入解析Condition的底层实现原理。
上述相关的锁的实现,底层都离不开CAS机制和Volatile。因此,有必要了解CAS底层的实现原理
,详情见:深入解析CAS的原理机制。
三、分布式锁
解决的问题:
保证一个方法在同一时间内只能被同一个线程执行,在单体应用下,单体锁只能锁住一个JVM进程,其他进程不受影响,显然是无法满足我们的要求的。要考虑非阻塞式分布式锁和阻塞式分布式锁,要根据业务来进行考虑。
分布式锁的要求:
- 保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行;
- 是一把
可重入锁
(防止死锁); - 是
阻塞锁
(根据业务考虑阻塞或非阻塞); 高可用
的获取锁和释放锁功能;- 获取锁和释放锁的
性能要好
。
3.1 数据库分布式锁
多个进程多个线程访问共同组件数据库,专门建立一个数据库一张表存放用户自定义锁。
3.1.1 基于数据库表
当想要锁住一个方法或资源时,直接将方法或资源信息
插入到表中, 同时在数据库层面对方法或资源信息添加唯一性约束,这样当插入成功时,就表示获取到锁;释放锁的时候直接删除信息即可。例如:
锁信息表:
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENG
加锁:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
解锁:
delete from methodLock where method_name ='method_name'
存在的问题:
可用性:
强依赖数据库的可用性,一旦数据库宕机,会导致业务系统不可用;自动释放:
由于无法设置失效时间,一旦解锁失败,那么其他线程将无法获取到锁;阻塞性:
插入数据失败的线程会直接报错,返回报错信息,不会等待,因此对某些业务来说是不可接受的;可重入性:
该锁是非重入锁,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了
问题解决方法:
- 问题1:数据库集群部署,但为了使用分布式锁,多部署一个集群,性价比低,同时高并发情况下,数据库会宕机;
- 问题2:后台启动一个定时任务, 定期清理数据表无用的数据。某一时间点占用了大量的数据库连接;
- 问题3:设置一个while循环,直到insert成功。性能非常差,产生大量无效insert行为;
- 问题4:在数据库表中加个字段,记录当前获得锁的
机器的主机信息和线程信息
,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
3.1.2 基于数据库排他锁
具体SQL语句:
select ... for update
注意:
1:要配合事务使用,才会有效使用事务将要加锁的地方包裹住,等执行完后,再进行提交。
因为如果select .. for update后就提交事务
2:Innodb只针对根据索引查询来添加行锁,否则添加表级锁
加锁:
select ... for update
解锁:
应用层面:自己实现事务的提交
public void unlock(){
connection.commit();
}
该中方式解决了基于数据库表的阻塞和无法释放锁的问题:
阻塞性:
当select … for update时,会被数据库阻塞住,直到查询数据才会返回;自动释放锁:
若数据库宕机,会自动释放锁
存在的问题:
单点故障以及可重入
问题;是否走索引不确定
,导致使用的是表锁:MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁;数据库性能:
一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
注意: 将作为锁的数据库与业务数据库分开。
3.2 基于缓存实现分布式锁
3.2.1 Redis分布式锁
使用Redis的setnx实现分布式锁。命令如下:
set resource_name my_random_value NX PX 30000
resource_name: 资源名称,可根据不同业务区分不同的锁;
my_random_value: 随机值,每个线程的随机值都不同,用户释放锁时的校验。一般采用UUID
NX:key不存在时设置成功,key存在时则设置不成功
PX:自动失效时间,出现异常情况,锁可以过期失效
实现原理:
- 利用NX的原子性,多个线程并发时,只有一个线程可以设置成功;
- 设置成功即可获得锁,可以执行后续的业务处理;
- 如果出现异常,过了锁的有效期,锁自动释放;
- 释放锁采用delete命令;
- 释放锁时校验之前设置的随机值,相同才释放;
- 释放锁的LUA脚本【先校验后释放】。
原因:
有A和B两个线程, 若A先获取锁,由于某些原因,A超时了,导致A的锁被释放,此时B获取到了锁,然后执行A释放锁的操作,此时会释放掉B持有的锁。【产生并发问题,所以释放和校验要使用LUA脚本来实现】
优点:
- 可自动释放锁:设置过期时间;
- 可靠性高:集群部署
缺点:
- 无法缓存层面实现阻塞:只能应用层面实现
- 无法实现可重入性
3.2.2 基于Redisson实现分布式锁
在Redis基础上,利用Java对Redis客户端进行封装,并对单体应用下的JDK并发包和JDK集合类等进行扩展,提供分布式下的解决方案。
RLock lock = redisson.getLock();
3.3 基于Zookeeper分布式锁
3.3.1 基于Zookeeper的瞬时节点实现分布式锁
3.3.1.1 前言
取决于Zookeeper内部的命名空间模型结构。该命名空间模型类似于Linux文件结构,采用树状结构,各个节点被称为znode。每个节点可以存储路径以及与之相关的元数据,还有子节点列表。
3.3.1.2 节点类型
3.3.1.3 基于临时顺序节点的分布式锁
核心思想:
临时顺序节点 + Watch(观察器)机制
实现原理:
- 多线程并发创建多个瞬时节点,得到有序的瞬时节点列表;
- 选用
序号最小
的线程获取锁; - 其他线程则利用
Watch机制
监听自己序号的前一个序号; - 前一个线程执行完成,删除自己序号的节点,利用
线程的wait和notify
来阻塞和唤醒对象的线程获取锁。
优点:
锁自动释放
:一旦zookeeper宕机或session断开,瞬时节点就会被删除,因此锁就被释放了;可阻塞:
用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。底层使用的是wait和notify机制,因此是阻塞的
;
3.可重入:
客户端在创建节点的时候,把当前客户端的主机信息和线程信息
直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队;单点被解决:
zookeeper通常是集群部署的。
3.3.2 基于Zookeeper的Curator客户端实现分布式锁
使用Java对Zookeeper客户端进行进一步封装,并提供许多简单便利的功能,比如分布式锁java InterProcessMutex
。
缺点: 频繁的创建和删除瞬时节点
,ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上,性能有影响。
3.4 分布式锁对比
易于理解程度(从低到高):数据库 > 缓存 > zookeeper;
实现复杂度(从低到高):zookeeper > 缓存 > 数据库
性能(从低到高): 缓存 > zookeeper > 数据库
可靠性(从低到高):数据库 < 缓存 < zookeeper
不推荐使用自己编写的分布式锁,推荐使用Redisson和Curator实现的分布式锁