本篇文章参考以下博文
前言
上一节中我们介绍了,Java中锁的基本概念,以及 reentrantLock 实现的基础,有兴趣的同学可以点击这里跳转–Java中的锁–概念篇
本节我们继续上一节的内容,学习 synchronized 。
一、sync 锁原理
1. synchronized 属于 Java 关键字,对于 jvm 来说有特殊意义,关键字不能用作变量名,方法名,类名,包名或者当做参数。[例如: public void new 等]
2. Java 中每一个对象都有一个对象监视器( monitor ) 用来控制对象同步, monitor 底层是由 ObjectMonitor 实现( C++ )
3. 在 java 语言中存在两种内置 sync 语法
synchronized 语句: sync 语句再被 Java 编译成原语( byteCode )时,会在同步块的入口位置和退出位置插入字节码指令 minitorenter 与 minitorexit 线程执行到同步方法时会向 jvm 发送 minitorenter 指令,尝试去获取对象的 minitor ,当 minitor 被拥有之后就会被锁住,在访问完成之后执行 minitorexit 。
synchronized 方法:在 Class 文件的方法中将 sync 方法的 access flags 字段中的 acc_synchronized 标志位置为 1 ,表示该方法是同步方法并使用调用方法的对象或该方法所属的 Class 在 jvm 内部对象 Klass 中作为锁对象。
①当多个线程同时访问该方法,那么这些线程会先被放进 _EntryList 队列,此时线程处于 blocking 状态。
②当一个线程获取到了实例对象的监视器 ( monitor )锁,那么就可以进入 running 状态,执行方法,此时, ObjectMonitor 对象的 _owner 指向当前线程, _count + 1 表示当前对象锁被一个线程获取。
③当 running 状态的线程调用 wait() 方法,那么当前线程释放 monitor 对象,进入 waiting 状态, ObjectMonitor 对象的 _owner 变为 null , _count - 1 ,同时线程进入 _WaitSet 队列,知道有线程调用 notify() 方法唤醒该线程,则该线程重新获取 monitor 对象计入 _Owner 区
④如果当前线程执行完毕,那么也释放 monitor 对象,进入 waiting 状态, ObjectMonitor 对象的 _owner 变为 null , _count - 1 。
二、共享锁与排它锁与 reentrantWriteReadLock
2.1 定义 :共享锁又称毒锁, S 锁,排它锁又称 写锁,独占锁, X 锁,同属于悲观锁范畴。读写锁是对常规锁的粒化,用于优化应用程序,有效的控制资源的读写。读写锁只是一种资源访问的机制,并不是实际对资源加上了锁,使得加“读”锁的代码无法执行“写”资源操作。
2.2 特性 :
读读共享:当一个线程获取读锁后,后续线程依旧可以获取读锁,不会阻塞。类似概念还有“写写互斥”,“读写互斥”。
支持公平模式与非公平模式选择:默认非公平模式,连续竞争的非公平锁可能无限期的推迟一个或多个 reader 或者 writer 线程,吞吐量一般高于公平锁,内置写优先;公平模式利用一个近似到达的策略来争夺进入,当释放当前保存的线程时,可以为等待线程时间最长的 writer 线程分配写入锁,如果有一组等待时间大于所有正在等待的 writer 线程的 reader ,则分配读者锁。
reentrantWriteReadLock 读写锁支持可重入,支持写锁单向降级到读锁[获取写锁—>获取读锁—>释放写锁],避免大事务,高耗时任务,阻塞高响应查询任务的推进。
2.3 经典同步问题–读者写者问题(读者优先,写者优先,公平竞争)
2.3.1 问题描述:有读者和写者两组并发进程,共享一个文件,当两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程和写进程)同时访问共享数据时,则可能导致数据不一致的错误。
2.3.2 因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者写者全部退出。
2.3.3 同时还提供三种策略:1.读者优先:总是给读者优先权,只要没有写操作,读者就能获取到访问权,常用语阅读量远高于修改量的系统;2.写者优先:将读者延迟到所有等待的写者和活动的写者完成之后,常用语修改量巨大的系统,票务系统;公平竞争:基本保持先到先服务的原则。
2.3.4 总述:无论三种中的哪一种,在没有程序占用临界区市,读者与写者之前的竞争都是公平的,所谓的不公平是在读者优先或者写者优先模式中,优先方只要占用了临界区的主导权,除非有没优先方提出要求,否则始终是优先方的程序占有临界区,反观非优先方即使某一次占用了临界区,那么释放后,回到了没有程序占用临界区的情况,非优先方又要重新和优先方公平竞争,所谓的优先可以理解为占有临界区后,便可以对临界区进行“垄断”。在读者优先中,当读者在里面占用文件的时候,外部的读者可以直接读取文件。在写者优先中,每一个写者操作文件之后总是优先唤醒等待的写者。
三、进程锁与分布式锁
3.1 概念
进程锁与分布式锁的功能基本一致,都是用来保证资源操作的同步性,解决多操作系统之间部分数据的不可见性。只是作用范围大小的不同。
作用范围:分布式锁 > 进程锁 > 线程锁。
实现分布式锁要依靠第三方存储介质来存储锁的元数据等信息。
3.2 分布式锁应该具备哪些条件:
1.在分布式系统环境下,一个方法在同一时间内只能被一个机器的一个线程执行。
2. 高可用的获取锁与释放锁
3. 高性能的获取锁与释放锁
4. 具备可重入性
5. 具备锁失效机制,防止死锁
6. 具备非阻塞锁特性
3.3 分布式锁分类:
3.3.1 基于数据库实现分布式锁
利用数据库唯一索引特性 :
CREATE TABLE "public"."tbl_lock" (
"id" int4 NOT NULL,
"method_name" carchar(32) COLLATE "default" NOT NULL,
"desc" carchar(255) COLLATE "default",
"ops_date" date,
CONSTRAINT "tbl_lock_pkey" PRIMARY KEY ("id")
)
WITH (OIDS=FALSE)
;
ALTER TABLE "public"."tbl_lock" OWNER TO "postgres";
COMMENT ON COLUMN "public"."tbl_lock"."id" IS '主键';
COMMENT ON COLUMN "public"."tbl_lock"."method_name" IS '锁定方法名';
COMMENT ON COLUMN "public"."tbl_lock"."desc" IS '备注';
COMMENT ON COLUMN "public"."tbl_lock"."ops_date" IS '操作时间';
CREATE UNIQUE INDEX "_method_name" ON "public"."tbl_lock" USING btree ("method_name");
获取锁( insert )解锁( delete )
insert INFO tbl_lock (method_name, decs) VALUES ('methodName', 'message');
delete * from tbl_lock where method_name = 'methodName'
method_name 作为唯一约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作成功,其余返回 sql 报错。
利用数据库 select update :
表结构:
CREATE TABLE "public"."tbl_lock" (
"id" int4 NOT NULL,
"method_name" carchar(32) COLLATE "default" NOT NULL,
"desc" carchar(255) COLLATE "default",
"ops_date" date,
CONSTRAINT "tbl_lock_pkey" PRIMARY KEY ("id")
)
WITH (OIDS=FALSE)
;
ALTER TABLE "public"."tbl_lock" OWNER TO "postgres";
COMMENT ON COLUMN "public"."tbl_lock"."id" IS '主键';
COMMENT ON COLUMN "public"."tbl_lock"."method_name" IS '锁定方法名';
COMMENT ON COLUMN "public"."tbl_lock"."desc" IS '备注';
COMMENT ON COLUMN "public"."tbl_lock"."ops_date" IS '操作时间';
CREATE UNIQUE INDEX "_method_name" ON "public"."tbl_lock" USING btree ("method_name");
获取锁与释放锁(select update)
select id method_name,state from tbl_lock where state = '0' and method_name = 'methodName';
update tbl_lock set state = 0 where method_Name = 'methodName' and state = '0'
利用数据库更新的特性,如果没有更新到一行数据,返回结果为 0,代表资源已被其他系统占用。
隐患与对策 :
1.这把锁强依赖数据库的可靠性,数据库若是单点,一旦数据挂掉,会导致业务系统不可用。
2.利用数据库加锁没有失效时间,一旦解锁操作失败,会导致致锁记录一直在数据库中,其它线程无法再次获得锁。
3.这把锁只能是非阻塞的,因为数据的操作特性,一旦操作失败,或者数据没有更新数据库直接返回。没有获取锁的线程并不会排队,想要再次尝试获取锁就需要再次出发操作。
4.这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
******* 分割线 *******
1.解决单点问题,那么增加数据库的主从配置。加锁做到双向同步,保证主从的快速切换。
2.解决没有超时失效机制,那么增加定时任务,记录加速时间,每隔一定是的时间清理超时数据。
3.解决非阻塞问题,可以设定重试机制,例如自旋, while(true){} 等,直到操作成功。
4.解决非重入问题,可以在表中增加字段记录获取锁的主机信息和线程信息,那么下次再获取锁之前先进行数据比对,如果比对一致,那么就可以再次获取多锁。
3.3.2 基于缓存实现分布式锁(redis)
实现基础 :
1. redis 为单进程单线程模式,采用队列模式将并发访问控制变成串行访问,且多客户端对 redis 的连接并不存在竞争关系。
2. redis 的 SETNX 命令可方便的实现 分布式锁, SETNX(set if Not exist) 语法: SETNX key value; 设置成功返回 1,失败返回 0,且不作任何改动。
3.通过 delete key 可以快速的完成锁释放,由于 redis 中 key 可以设置过期时间,在业务发生不可知错误的情况下,会自然过期。
4.以 key-value 键值对存储数据的 redis 可以辅助存储包括主机信息在内的锁相关持有信息。
5. redis 在 2.6 版本之后推出脚本功能,允许开发者使用 lua 语言编写脚本传到 redis 中执行。
lua 脚本优点有:
-
减少网络开销——脚本可以一次性完成,减少代码调用的多次 redis 网络请求。
-
原子操作—— redis 会将整个脚本作为一个整体执行,中间不会被其他命令打断插入,在 java 业务代码中除非刻意控制,不然很难做到原子性,尤其是分布式系统中。
-
复用性高——客户端发送的脚本会永久的存在 redis 中,意味着其他客户端可以使用这一脚本而不需要使用代码来完成同样的逻辑。(注意 redis 中的 lua 脚本不可以执行耗时操作,一旦执行耗时操作,就会阻塞 redis 的主线程,影响其他客户端, redis 为 lua 脚本配置了默认超时机制,一般为5s ,超出后将被中止执行)
redisTemplate + lua实现分布式 :
lock.lua
-- Set a lock
-- 如果获取锁成功,则返回 1
local key = KEYS[1]
local content = ARGV[1]
local ttl = tonumber(ARGC[2])
local lockSet = redis.call('setnx', key, content) --setnx操作,lua 中call函数代表 redis 执行
if lockSet == 1 then
redis.call('PEXPIRE', key, ttl) -- 执行成功并返回 1 ,并设置过期时间
else
--如果 value 相同,则认为是同一个线程请求,认为是同步锁
local value = redis.call('get', key)
if(value == content) then --使用 content 记录加锁主机
lockSet = 1;
redis.call('PEXPIRE', key, ttl)
end
end
return lockSet
unlock.lua
-- unlock key
local key = KEYS[1]
local content = ARGV[1]
local value = redis.call('get', key)
if value == content then
return redis.call('del', key) --将 key 进行 delete 操作释放锁
else
return 0
end
redisson 上实现高性能分布式锁 :
1. Redisson 是架设在 redis 基础上的一个 Java 驻内存数据网络,建立在 netty 基础上,充分利用 redis 键值对数据库提供的一系列优势,为使用者提供了一系列具有分布式特性的常用工具类,使得原本作为协调单机多线程的并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。
2. Redisson 众多特性,其中提供了分布式锁的工具类。
3. Redisson 支持哨兵模式( Sentinel 模式)来实现高可用
- ①监控: Sentinel 会不断的检查主从服务器是否运转正常
- ②提醒:当被监控的某个 redis 服务器出现问题是, Sentinel 可以通过 API 向管理员或者应用程序发出通知。
- ③自动故障转移:当一个主服务器不能工作时, Sentinel 会开始一次自动故障迁移操作,它会将失效的主服务器的其中一个从服务器升级为新的主服务器,并将失效的主服务器的其他从服务器改为复制新的主服务器,当客户端视图连接失效的服务器时,集群也会向客户端返回新主服务器的地址,使得集群可以使用新的主服务器代替失效的服务器。
4. Redisson 为分布式锁提供看门狗( watch dog )延时机制
- ① redis 的 key 存在超时时间是一种有效的防资源锁死策略,但是存在以下场景A,B 在执行业务,A 加了分布式锁并设置了 key 超时时间,如果 A 的锁越过超时时间,但是 A 业务任然在运行,那么 B 业务也就可以正常拿到锁了,此时加锁失效。
- ②因场景1, redisson 为分布式锁 tryLock 方法提供了看门狗机制,默认情况下, redisson 的锁持有时间为 30s ,看门狗检测到执行程序执行到 30 - 30/3 = 20s 依旧没有释放锁,那么会主动为加锁时间续期 30s 。
5. Redisson 内封装了多种锁( RLock)用于满足不同的业务场景
- ① 可重入锁( Renentrant Lock ): redisson.getLock(key) 是最常用的方法,支持过期解锁功能,在不 expire 的情况下,默认锁持有 10s 。之后自动解锁; redisson.getLock(key).tryLock() 可以配置看门狗过期续时长。
- ② 公平锁( Fair Lock ): redisson.getFairLock(key) 在提供了自动过期解锁功能的同时,保证了当多个 Redisson 客户端线程同时请求加锁时,满足先到服务原则。
- ③ 联锁( MultiLock ): redisson 为满足同一时间多线程多资源分别占用的业务场景而提供的一种加锁机制,将多个 Rlock 关联为一个联锁,所有的锁在加锁成功后才算作成功。
Rlock lock1 = redisson1.getLock("lock1");
Rlock lock2 = redisson1.getLock("lock2");
Rlock lock3 = redisson2.getLock("lock3");
RedissonMultiLock Mlock = new Redisson MultiLock(lock1, lock2, lock3);
- ④ 读写锁( ReadWriteLock ):满足 java 线程中的可重入读写锁( ReentrantWriteReadLock )功能相似功能,保证执行系统可以在获取多个读锁,但是最多同时只有一个写锁。
ReadWriteLock rwlock = redisson.getLock("RWLock");
rwlock.readLock().lock(); rwlock.writeLock().lock();
- ⑤ 闭锁( countDownLatch ):闭锁并不是实际意义上的锁,它是一种同步工具,可以延迟线程直到其达到最终状态。存在如下场景:当服务器执行一些操作时,可能需要调用多个接口,等待返回结果,各个接口相互独立,为例提高效率,一般设计成为异步获取结果,当所有异步线程都完成后,通知主线程进行下一步操作。闭锁就能完成上述场景,让多个线程执行完后通知主线程,若未执行完成,主线程会阻塞,等待其执行完毕后再进行下一步操作。
concurrent 包中提供的 countDownLatch 是一种灵活的闭锁实现,基本实现原理为初始化计数器为 nThread [实际操作线程数] 每执行完一个操作使用 countDown() 方法对计数器减 1,在计数器未减到 0 之前(包括主线程),使用 await 方法使主线程阻塞等待,等到计数器为 0 时放行主线程。在 redisson 中提供 RCountDownLatch 对象来实现与 countDownLatch 一致的功能用于分布式异步业务。
RCountDownLatch latch = redisson.getCountDownLatch("ops");
latch.trySetCount(1);
latch.await();
// 在其他线程中或者jvm中执行
RCountDownLatch latch = redisson.getCountDownLatch("ops");
latch.countDown
- ⑥ 红锁( RedLock ): redLock 是 redis 官方提供的关于使用 redis 实现分布式锁的一种算法那,旨在为分布式锁提供更好的可靠性, Redisson 基于 redlock 算法推出了 RedissonRedLock 工具包。
高效分布式锁应该具有的特性:
1. 一致性:不管什么时候,同时只能有一个客户持有锁。
2. 区分容忍性:不会死锁,最终一定会得到锁,就算持有锁的客户端宕机或者发生网络错误。
3. 可用性:只要 redis 的大多数节点工作正常,客户端就应该都能加锁 / 解锁。
大多数基于 redis 的分布式锁现状:
用 redis 实现分布式锁最简单的方式就是创建一个键值并设定过期时间,每个锁最终都会被释放,释放锁时删除 key 即可,表面上这个模式没有任何问题,但是存在隐患。
场景一:在集群中 Redis 的 mater 节点单点故障时业务受到影响,所以一般会增加 slave 节点,但是通过 master - save 模式来解决容灾,却让分布式锁失去了一致性,因为 redis 是通过异步复制来完成主从复制的,在 master 收到写命令后,先在内部写入数据,然后再异步发送给 slave node 如下图:
场景二:因为网络延时,客户端 1 长时间阻塞,设定的 T 内 A锁过期,这时客户端 2 获取到了 A 锁,此时客户端1的网络恢复正常,导致两个客户端同时操作资源。
如何正确实现单例方案?(非集群 redis 环境)
lua 脚本的加锁核心语句为 setnx key value;pexpire time;setnx 保证只有在没有 key 的情况下才能设置这个 key 值,超时时间设置为 time , key 的值设置为 value 需要做到 value 在所有获取锁的客户端中是唯一的,利用 value 就可以在单机环境下保证安全的释放锁,再删除 key 时当且仅当这个 key 存在,且 value 为解锁时传入的预期值才可以删除成功,如果不这么做会存在以下场景:
C1 获取到锁 A,执行无异常但是耗时,锁自动过期,C2 就可以再次获取到锁,此时 C1 已经完成了业务,调用解锁方法,在未做任何保护的情况下 [ value 校验],C1 使得 C2加上的锁过期,C1,C2 的执行结果都未可知。
在 redis 集群中,针对上一节中 redis 分布式锁现状暴露的问题, redis 官方提出了 redlock 加锁模型,假设我们有 N 个 Redis master 节点,这些节点都是完全独立的,我们不用任何肤质算法或者隐含的分布式协调算法,按照上方的单例方案,我们在这个集群中的每一个节点都采用如上方案加锁与解锁,假定 N 为 5 ,我们保证在服务器上运行 5 个 master 节点来保证大多数情况下都不会出现宕机的情况,当有一个客户端 C1 想要加锁时,需要如下几个操作:
① 获取当前的时间 t1 (毫秒)。
② 轮流用相同的 key A 在 n 个节点上请求锁,因为加锁操作本身是耗时操作(5-50ms)在请求每一个节点上锁完成时,总有一个节点(大概率是第一个写入 key 的节点)的预期释放锁时间要比最终释放锁的时间要小得多,加锁过程有了一段程序执行时间差,遇到其中某一个 master 宕机时,直接跳过继续执行。
③ 客户端计算完成第二部的花费时间差( t3 - t1 ),只有当客户端在大多数( n / 2 + 1 ) master 节点上获取锁,而且总消耗的时间不会超过 T 那么可以认定为加锁成功,加锁成功时其他客户端来竞争锁,加锁成功的节点最多也只能达到 ( n % 2 - 1 ) 个会被判定为加锁失败。
④ 如果判定获取锁成功了,那现在锁自动释放时间就是 T - (t3 - t1) 。
⑤ 如果锁获取失败了,不管是因为获取成功的锁不超过一半还是因为 T - (t3 - t1) < 0 ,客户端都会去每一个 master 上释放锁,建立在单例方案上,释放锁并不会影响到下一个加持相同锁的客户端。
redlock 是否完美?
redlock 有效解决了因为主从关系异步复制导致锁失效的问题,但是依旧不够完美,存在以下场景:
① 存在 5 个 master 节点(A-E)客户端,客户端 C1 成功在 A, B,C上加锁,DE没有锁住,
② 节点 C 发生崩溃重启,但是 C 节点未做持久化保护,(redis 持久化数据周期为 1s 一次,极有可能丢失 1s 内的数据),重启后未锁住 C
③ 客户端 2 此时锁住了 C,D,E,判定为获锁成功,最终导致两把红锁通过。
redlock 依然对于客户端长时间阻断然后恢复业务的场景并没有做到很好的资源保护。
redlock 算法的第二步存在关键问题,那就是实际上减少了客户端真正持有资源的时间,对于最终剩余的时间多少算长或者短是无法规定的。
redlock 算法对于各个节点之间的时间依赖性非常的强,若 N 个节点中某个 / 某些节点发生时间跳跃,就会存在部分节点先行释放锁,导致其他锁竞争大于 n / 2 + 1 的局面。
关于红锁的争论当然也一直存在,有兴趣的同学可以访问官网了解更多。