当程序在高并发的情况下,对共享资源进行读写操作,如果不进行并发控制,就必然会带来数据不一致的线程安全性问题。
针对这种高并发的情况,就需要引入锁的机制来保证数据的安全性。
首先什么情况下需要用到锁:
1、多任务环境中
2、任务需要对同一共享资源进行读写操作
3、对资源的访问是互斥的
我举个经典栗子:
车站卖票,一共100张票(共享资源),4个窗口进行卖票(多任务),假设分别叫abcd窗口,
a窗口卖了座位号1的票后,就不能在有座位号1的票被卖了(互斥)。
这种情况下,若不使用锁进行并发控制,那么a卖的座位号1的票不能及时通知到abc,
就必然会产生一票多卖的问题,也就是我们所说的并发问题。
代码模拟这种买票行为:
然后我们开始卖票:
看下运行结果:
从程序的运行结果,我们不难看出,出现了一票多卖的情况,第95张票被卖了2次。
针对这种情况最简单的方式就是使用synchronized,
除此除此之外jdk还为我们提供了一把很好用的锁:Lock,ReentrantLock实现了lock接口,我们可以直接拿来用:
看上去好像比synchronized关键字用起来复杂了,不但要加锁,还要解锁,如果忘记解锁还会出现死锁的情况,所以,通常在finally中进行锁的释放。Synchronized虽然使用简单,只需要对自己的方法或者关注的同步对象或类使用synchronized关键字即可,但是对于锁的粒度控制比较粗,同时对于实现一些锁的状态的转移比较困难,而lock更加灵活。
看下lock接口的方法:
Lock:阻塞式加锁,(不加成功就一直加,知道加锁成功为止)
lockInterruptibly:可被中断的阻塞式加锁
trylock:尝试加一次锁
tryLock(long time, TimeUnit unit):在指定的时长中尝试加锁
unlock:解锁
newCondition:根据一些条件进行加锁
总结下lock和synchronized的区别:
1.lock是jdk 1.5后新增的
2.synchronized是修饰整个方法,整个代码块。lock可以在任何地方调用lock方法,再在想要结束的地方调用unlock()方法
3.synchronized是java的底层关键字,是在JVM层面上实现,在代码执行异常时,jvm可以自动释放锁定。lock是java类,是通过代码实现来处理异常,所以在finanlly里面一定要调用unlock释放锁。
4.使用synchronized关键字,如果一个线程不释放锁,另一个会一致等待下去。使用lock,如果一个线程不释放锁,在等待很长时间后,可以中断等待去做其他事情。
局限性:
以上synchronized,和lock方法也有其局限性:这2种锁机制只能在进程中的多线程间有效,无法解决分布式环境的多进程间的资源问题。
分布式锁:
为了解决以上问题,在高并发的多进程的情况下我们就需要引入分布式锁。目前主流的分布式锁的实现方式主要有3种:
一、利用数据库自身提供的锁机制来实现分布式锁
实现思路:现在的数据库基本都支持行级锁,基于行级锁,我们可以在数据库中建一个锁的表,只有一个id字段,设置主键,那么当需要加锁时,我们只需要向这张表里插入一条id=1的数据即可,其他进程在想加锁,也就是插入id=1是加不了的,这是加锁,解锁只要delete这条id=1的数据即可。利用数据库自身提供的锁机制来实现分布式锁
分析一下数据库实现分布式锁的优缺点:优点很明显:实现简单,稳定可靠,应为是用数据库的行级锁来变相的帮助我们实现分布式锁的,可就是说只要数据库的行级锁没问题,我们的分布式锁就没问题。缺点:1性能差,由于依靠数据库实现,那么其性能也受限于数据库的读写能力,在高并发场景下显然不适用2易出现死锁,一旦加锁成功后没有进行解锁操作或者这个应用挂了,那么在数据库中永远都有一条id=1的记录,其他所有进程都无法进行加锁操作。
二、利用redis实现分布式锁方案
实现思路:
redis的特性:
- 由于redis本身就是单进程单线程的,所以即使在高并发场景下也不会存在竞争关系
- redis中的数据设置生存时间后,当key过期时会被自动删除
- setnx key value,当key不存在时将key的值设置为value
基于以上redis特性,当我们需要加锁:使用setnx向特定的key写入一个随机值,并同时设置失效时间(避免死锁),写值成功即加锁成功,其他进程或线程在进行加锁操作显然就会失败
以上加锁操作有3点我们需要关注:
- 写入随机值时要设置失效时间,这样可以有效避免死锁问题,到期自动解锁;
- 加锁时每个产生一个随机字符串,当解锁时为了避免锁误删,需要对这个随机值和写入redis的值进行比对,如果一致就认为是原加锁线程来进行了解锁,可以执行解锁操作,如果不一致,就拒绝解锁;
- 写入随机值与设置失效时间必须是同时的(保证加锁是原子的)
解锁:解锁需要执行3步:获取数据,判断一致,删除数据,这里使用lua脚本进行这3步操作,如不使用lua脚本执行解锁操作,无法保证操作的原子性,使用lua脚本实现解锁(保证操作原子性,下面3个步骤要么全部执行,要么全部不执行,不存在执行部分的情况):
If redis.call(“get”,keys[1])==argv[1] then
Return redis.call(“del”,keys[1])
Else
Return 0
End
同样,分析一下优缺点:优点也很明细:基于redis的实现方式,同样继承了redis的高性能的优点;缺点:1相对比数据库实现,redis的实现相对复杂2key有效期的设置需要基于个人经验,有出现死锁的可能性3无法优雅的实现阻塞
三、利用zookeeper实现分布式锁方案
实现思路:
Zookeeper特性:
1、Zookeeper会在内存中维护一个具有层次结构的数据结构,类似于文件系统
2、数据结构中的每个节点都可以存数据,还有各种属性信息;数据节点类型有:持久节点、持久顺序节点、临时节点、临时顺序节点,临时顺序节点是实现分布式锁的基础
3、事件监听器watcher:客户端可以在节点注册watcher,当节点发生特定的变化,服务器会将事件通知到客户端
利用以上zk特性,我们先在zk上维护一个lock的持久节点,任何业务需要加锁就在zk的lock节点下维护一个临时顺序节点,临时顺序节点是有序的,并且一旦链接断开临时节点就会自动删除,(这样就保证了不会死锁),当创建完成临时顺序节点后我们获取当前节点的序号值L,然后获取lock节点的所有子节点,比较当前节点的L值是否是所有节点中最小的,如果是最小的就获得锁,如果不是最小的,就向当前节点的前一个节点添加监听器,那么一旦前一个节点被删除,就会重新走一下上面的判断是否最小节点流程流程,直到获取锁。
同样分析一下优缺点:优点:zookeeper和redis一样,数据保存在内存中,这就意味着它同redis一样有高吞吐量和低延迟的性能优势;能避免出现死锁(基于临时节点的特性,一旦客户端挂掉,临时节点即删除,也就是解锁了)能优雅的实现阻塞式锁(基于zk的watcher机制,前2种方法都无法实现阻塞式锁);缺点:缺点很明显,实现方式较复杂
以上介绍了分布式锁的3种实现方式的实现思路,具体的实现代码太占篇幅就不贴上来了