**锁在并发编程中运用无处不在。在当今的面试,乃是工作中,难免需要用的锁来解决同步问题和互斥问题。
如今有大量的jdk实现锁功能,那面试中被问到你会如何自己实现分布式锁功能呢?**
为啥要用锁和锁的应用场景呢?
在谈锁的应用场景前,先分析一下客户下订单的代码:
public class OrderOperation implements Runnable {
//用于表示库存
private static volatile int count = 1; private static int userNum = 10; //用于模拟10个用户并发量
/**
* 进行执行下单减库存操作,以下操作会到只线程安全问题,进行优化
*/
public void run(){ //如果库存大于1,进行下单,减库存
if (count > 0) {
countDownLatch.countDown();
try {
countDownLatch.await();
if (count > 0) {
//下单的一些耗时操作
Thread.sleep(5);
count--;
System.out.println("库存:" + count);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 模拟多用户同下单的场景
*/
public static void main(String args){
//开启10个线程,同时触发减库存操作
for (int i = 0; i < userNum; i++) {
OrderOperation orderOperation = new OrderOperation();
Thread thread = new Thread(orderOperation);
thread.start();
}
}
对于上诉代码,执行结果会发现,count的个数会出现负数的情况,商品超卖的问题。为啥出现负数情况呢?
这涉及java的内存模型,不懂的同学可以参考友情链接:https://juejin.cn/post/6844904139022041101
那对于上述代码如何保证商品在并非操作下,解决超卖的问题(线程安全的问题)呢?
答案:通过锁操作,让对于减库存的同步代码块上加锁操作,保证只有一个线程进入同步代码块,
从而实现库存的正常减少。
现在对上述代码进行优化处理,如下:
/**
* 采用双层锁校验的方式进行保证线程安全
*/
public void run() {
//如果库存大于1,进行下单,减库存
if (count > 0) {
countDownLatch.countDown();
try {
countDownLatch.await();
synchronized (OrderOperation.class) {
if (count > 0) {
Thread.sleep(5);
count--;
System.out.println("库存:" + count);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}}
上述代码块,通过采用synchronized关键字,为减库存操作加上了锁操作。每个线程必须先获取到OrderOperation类的锁对象,才能执行count>0校验,并进行count--操作。
分布锁的应用
在上述的代码优化中,采用synchronized关键字,解决线程安全的问题。 但是在当今的分布式架构下,同一应用部署到多台服务器节点上,对于同步代码块不在同一服务器节点上,那如何协调多服务器节点执行同步代码块保证数据的同步问题呢?
马德~~这不就是分布式锁吗?又在装逼了,谁不知道分布式锁啊!
没错,采用分布式锁操作,可以实现对多节点上的服务器保证同步问题。如今,市面上有很多分布式锁工具如:Ression、Curator都能实现分布式锁。
那我问各位你们都如何实现分布式锁吗?分布式锁的实现有多少种呢?但各自有缺点,你知道吗?
其实分布式锁的实现都是通过将锁状态维持到一个资源存储服务器上,如Redis、ZK、mysql上,通过访问同步代码块之前,对上述服务器节点添加数据节点或数据值,如果在操作前已经存在值类型就报错,表示程序获取锁失败(如ZK数据节点、MYSQL唯一索引),直到持锁程序执行删除ZK、MYSQL、REDIS数据后,后面阻塞等待的程序才进行获取锁操作.
自定义实现ZK分布式锁
哎,感觉已经偏题了,本文章是介绍和如何实现ZK实现分布锁~~~真的是,对技术一讲就滔滔不绝、如同长江黄河、稀里哗啦、喋喋不休的
Zookeeper(ZK)是个啥玩意啊? zk是个服务发现治理、文件系统、分布式配置服务,其有一下应用场景:
- 服务注册与订阅(共用节点)
- 分布式通知(监听znode)
- 服务命名(znode特性)
- 数据订阅、发布(watcher)
- 分布式锁(临时节点)
ZK的文件树结构有什么特点?
ZK文件节点有两个维度的特点:持久/临时、有序/无效。
因此,被面试官问到ZK的文件节点有多少种?各个节点特性是怎样的? 你就可以自信且藐视的眼神看着他说:4种分别是:=持久性有序结点、持久性无序结点、临时性有序结点、临时性无序结点。 玩归玩,闹归闹,但别把面试开完笑~~~~~哈哈
那ZK是怎么实现分布式锁功能的呢?
没错,上面已经说到了,ZK是个文件路径树的结构,对于无序节点路径上,节点只能被创建一次,如果重复创建就会报错,从而导致获取锁失败。
原理就这么简单,但实现细节上还是有挺多讲究的,我们还是得一步一步来,一个脚印一个脚印的去走技术,方可认识自身不足与完善自己呢~~~respect~ 我们开始我们的比一个版本的ZK实现代码逻辑:
public class ZookeeperLock implements Lock{
private static String IP_HOST = "127.0.0.1:2181";
private static String BASE_ROOT = "/LOCK";
private static ZkClient zkClient = new ZkClient(IP_HOST);
private CountDownLatch countDownLatch = new CountDownLatch(1);
/**
* 尝试获取锁操作
* 其是判断创建一个路径节点,判断节点是否存在,如果存在获取锁成功,如果节点还存在则获取锁失败。
*
* @return
*/
@Override
public boolean tryLock() {
try {
zkClient.createPersistent(BASE_ROOT);
return true;
} catch (ZkNodeExistsException zkNodeExistsException) {
return false;
}
}
/**
* 获取锁操作
*/
@Override
public void lock() {
if (tryLock()) {
return;
}
//尝试获取锁失败后,等待监听ZK节点的释放
waitLock();
//监听到锁释放后,再次进行竞争锁操作
lock();
}
/**
* 等待获取锁操作,即尝试获取锁失败后,采用等待获取锁操作
* 该等待锁操作,采用CountDownLounch计数操作,用ZK的watch监听节点的状态,如果节点释放了则唤醒线程,进行执行操作
*/
public void waitLock() {
System.out.println("获取锁失败,准备等待获取锁操作");
IZkDataListener iZkDataListener = new IZkDataListener() {
@Override
public void handleDataChange(String s, Object o) throws Exception {
}
@Override
public void handleDataDeleted(String s) throws Exception {
System.out.println("ZK节点被删除,准备唤起线程");
countDownLatch.countDown();
}
};
//添加监听器,监听BASE_ROOT路径的节点的变化
zkClient.subscribeDataChanges(BASE_ROOT, iZkDataListener);
if (zkClient.exists(BASE_ROOT)) {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//释放监听
zkClient.unsubscribeDataChanges(BASE_ROOT, iZkDataListener);
}
/**
* 释放锁操作
*/
public void unLock() {
zkClient.delete(BASE_ROOT);
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public void unlock() {
}
@Override
public Condition newCondition() {
return null;
}
}
对上述代码进行必要的讲解和原理上的说明:
\
- 二话不说,先继承Lock接口,提供相应的实现哈,继承JDK爸爸准没错。。
- 实现lock方法的思路和其他Redisson、ReetrantLock、数据库实现上都是调用tryLock进行尝试获取锁操作,获取失败后再调用阻塞等待获取锁操纵。
- 先说说tryLock方法,其实就是直接操作ZK执行(create \LOCK)指令。如果执行成功,就获取锁成功,如果失败直接返回失败。
- 对于幸的小线程,就只能走等待获取锁的过程。等待获取锁,直接采用ZK提供的发布订阅功能,监听/LOCK路径结点的删除情况,如果被删除,其会发布消息到各个被阻式等待的线程,进行countDown减1的操作。让他重新获取锁操作。
- 可怜的小家伙,不断进行循环调用自身,直到获取锁为止(人生如此吧)
有同学开始问:我要运用代码。我丢~~,都到这里还不会用?
对于上述代码是否存在问题呢?在现实上真的可以应用吗?
其实上述代码是存在问题的,这问题我暂时想到了两个问题:
- 假如,应用线程获取到zk锁后。突然之间,wocao~,服务器宕机了,但ZK节点还没有被释放,完了,服务器程序不就出现死锁了吗,丢。
- 问题二,这个不会导致大量的服务进程同时监控着一个节点啊(就如同,好几百个,乃至好几亿个程序猿,同时追求求一个漂亮的姐姐一样,这竞争是多枚的大呀,而且只有一个人成功,这有多难受啊~~~),导致同时唤起多个线程,导致竞争激烈,导致大量线程同时唤起。
那怎么进行优化修改啊?? 咳咳咳~~,让开点各位,开始装装逼了~~~~~
如何进行优化呢?
- 上面说了,ZK的节点的特性,由于持久节点但出现服务器意外宕机了,导致节点不会自动删除,导致程序进入死锁。
那我们就不用持久结点了,我们用临时节点,任服务器宕机了。yyds,没错为保证在意外情况,出现锁得不释放上,所有的分布式锁实现上都是有一个兜底的处理方案的,如:Ression的值默认超时策略、watchDog的续命处理。
- 那么如何解决竞争激烈导致大量线程被唤起呢?
ZK不是有有序节点吗,对于同一个节点的创建,ZK会在子目录下创建一个数字顺序的节点。那我们可以让每个创建的有序节点都区监听其上一个节点的释放,不就不会出现同时唤醒线程的情况了吗? 好聪明啊~~~但代码怎么写,我不会~~。我们是空手套白狼,胡说八道的吗?上代码,盘它啊~~
public class ZookeeperLock3 implements Lock {
private CountDownLatch cdl = new CountDownLatch(1);
private static final String IP_PORT = "hsz.com:2181";
private static final String Z_NODE = "/LOCK";
private volatile String beforePath;
private volatile String path;
private ZkClient zkClient = new ZkClient(IP_PORT);
public ZookeeperLock3() {
/**
* 判断Z_NODE是否存在,如果不存在则进行初始化节点的操作,这操作需要在zk上手动创建一个临时节点避免
*/
synchronized (ZookeeperLock3.class) {
if (!zkClient.exists(Z_NODE)) {
zkClient.createPersistent(Z_NODE);
}
}
}
@Override
public void lock() {
if (tryLock()) {
return;
}else{
/**
* 未获取到锁,等待获取锁
*/
waitLock();
/**
* 递归重新尝试获取锁操作,
*/
lock();
}
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
/**
* 等待获取锁操作
*/
public void waitLock() {
System.out.println("获取锁失败,进行等待获取锁操作");
//先判断获取到的上一个节点路径的是否已经被删除,如果被删除进行唤醒线程
IZkDataListener iZkDataListener = new IZkDataListener() {
@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
}
@Override
public void handleDataDeleted(String dataPath) throws Exception {
System.out.println("锁已经被是否,前一个节点路径被删除,唤醒线程:" + Thread.currentThread().getId());
cdl.countDown();
}
};
zkClient.subscribeDataChanges(beforePath, iZkDataListener);
if (zkClient.exists(beforePath)) {
//如果前一个节点还存在,则进行等待前一节点的释放
try {
System.out.println("线程:"+Thread.currentThread().getId()+";开始等待上一个节点的释放");
cdl.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
zkClient.unsubscribeDataChanges(beforePath, iZkDataListener);
}
/**
* 尝试获取锁操作,采用ZK的临时有序节点实现
* @return
*/
@Override
public synchronized boolean tryLock() {
if (StringUtil.isEmpty(path)) {
path = zkClient.createEphemeralSequential(Z_NODE + "/", "lock");
}
/**
* 获取到节点下的所有子节点路径,并进行排序
*/
List<String> children = zkClient.getChildren(Z_NODE);
Collections.sort(children);
/**
* 进行判断其创建的节点的是否为头节点,如果是获取锁成功,如果不是获取锁失败
*/
if ((Z_NODE + "/" + children.get(0)).equals(path)) {
System.out.println("获取锁成功");
return true;
}else{
int i = Collections.binarySearch(children, path.substring(Z_NODE.length() + 1)) - 1;
beforePath = Z_NODE + "/" + children.get(i);
return false;
}
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public void unlock() {
zkClient.delete(path);
}
@Override
public Condition newCondition() {
return null;
}
}
在这里就不进行详细的代码分析了,就是在获取锁时,判断自身是否是有序节点的头节点,如果是则获取锁成功,如果不是则获取锁失败,进入等待获取。等待获取就是监听自身上一个节点,释放则zk唤起线程。
总结
个人通过观察与学习相应的锁操作,都会有些东西都是类似,都有相同实现技巧。在对比Redssion的时候,其处理思路又有相同境界的逻辑~~只不过是一些细节不同而已。
最后,我个人发现或许学有所成,学以分享,才是程序员道路上的成长与进步。还有很多东西可以分享的,希望以后保持每周一道两篇的技术总结与分享。
后面我们探究一下Redssion的实现呗!
\
我也向往湛蓝,愿成为你的湛蓝,你知道吗?有一天会成功的~~谢谢