Java 并发“锁“的本质(一步步实现锁)

前言

在上篇分析了CAS、线程挂起/唤醒相关知识后,常规的做法本篇就需要分析Synchronized与AQS的源码了。不过此次并不打算这样做,这么做就会陷入源码的枯燥讲解中,不了解前因后果,转折过于生硬。因此本篇先从实际需求一步步推导为什么需要"锁"?如何自己实现"锁"等步骤,最后才自然过渡到系统提供了哪些"锁"及其原理与应用。
通过本篇文章,你将了解到:

1、互斥访问变量
2、CAS访问变量
3、互斥访问临界区
4、线程挂起/唤醒策略
5、线程同步策略
6、总结

1、互斥访问变量

先看一段代码:

    static int a = 0;
    private static void inc() {
        a++;
    }

如上线程1、线程2同时访问这段代码,对共享变量a进行自增操作,我们知道a的结果是不可控的。
image.png
要想结果可控,那么要求线程不能同时对a进行操作,也就是需要对a进行互斥访问。

2、CAS访问变量

通过上篇文章分析可知,CAS可以实现互斥地访问共享变量,于是代码改为如下:

    static AtomicInteger a = new AtomicInteger(0);
    private static void inc() {
        a.incrementAndGet();
    }

AtomicInteger 里面包装了变量a,其底层通过CAS互斥访问变量a,因此实现了多线程互斥访问变量。
CAS 细节请移步:Java Unsafe/CAS/LockSupport 应用与原理

3、互斥访问临界区

以上分析的是多线程访问单个变量的场景,考虑另一种情况:当需要互斥访问的变量不仅是a、还有b、c等变量,你可能会这么做:

    static AtomicInteger a = new AtomicInteger(0);
    static AtomicInteger b = new AtomicInteger(0);
    static AtomicInteger c = new AtomicInteger(0);
    private static void inc() {
        a.incrementAndGet();
        b.incrementAndGet();
        c.incrementAndGet();
    }

有多少共享变量就需要多少个AtomicInteger 封装。现在还只是有3个共享变量而已,若是有更多的呢?这么做显然无法满足更进一步的需求。
既然a、b、c都需要互斥访问,那么能否在入口处统一做互斥处理就好了呢?
image.png

进入临界区

对多个共享变量的操作放入到临界区,那么问题来了如何实现临界区的互斥访问呢?我们依旧想到了CAS。

设置共享变量x,初始值为0,每个想要进入临界区的线程都先要访问x,将x修改为1,若是成功,则能够进入临界区,否则一直死循环不断尝试修改。

用代码来实现如下:

    static AtomicInteger x = new AtomicInteger(0);
    static int a = 0;
    static int b = 0;
    static int c = 0;
    private static void inc() {
        //尝试将x从0修改为1,若是失败则一直重试
        while(!x.compareAndSet(0, 1));
        //走到这里说明已经修改成功
        {
            //临界区
            a++;
            b++;
            c++;   
        }
    }

当多个线程同时进入inc()方法后,先尝试修改x的值,若是成功则退出循环,否则一致尝试。当其中一个线程成功将x从0修改为1后,其它线程继续尝试此操作将会失败。而只有成功修改了x值的线程才能进入临界区,因此对于临界区的互斥访问已经实现了。

退出临界区

进入临界区的线程总有退出来的时候,退出时需要将x修改回来,以便其它线程能够进入临界区,因此再增加释放x的操作:

    static AtomicInteger x = new AtomicInteger(0);

    static int a = 0;
    static int b = 0;
    static int c = 0;
    private static void inc() {
        //尝试将x从0修改为1,若是失败则一直重试
        while(!x.compareAndSet(0, 1));
        //走到这里说明已经修改成功
        {
            //临界区
            a++;
            b++;
            c++;
        }
        //此处不需要一直尝试,因为同一时刻始终只有一个线程访问
        x.compareAndSet(1, 0);
    }

由以上可知,进入临界区前先用CAS修改x的值,修改成功后进入临界区。当退出临界区后再用CAS修改x变回原来的值,这就实现了互斥访问临界区的过程。
想要对任何临界区进行访问,都可以使用这种方法,想一想这是不是相当于进临界区前先拿到"锁",其它没拿到"锁"的线程一直尝试拿"锁",当拥有锁的线程退出临界区后释放"锁",其它就可以拿到"锁"了。
image.png
将"锁"的代码抽象出来,访问临界区如下:

    static int a = 0;
    static int b = 0;
    static int c = 0;
    //构造Lock对象
    static Lock lock = new MyLock();
    private static void inc() {
        //获取锁
        lock.lock();
        {
            //临界区
            a++;
            b++;
            c++;
        }
        //释放锁
        lock.unlock();
    }
    
    //抽象锁结构
    interface Lock {
        void lock();
        void unlock();
    }
    
    static class MyLock implements Lock{
        AtomicInteger x = new AtomicInteger(0);
        
        @Override
        public void lock() {
            while(!x.compareAndSet(0, 1));
        }
        
        @Override
        public void unlock() {
            x.compareAndSet(1, 0);
        }
        
    }

因此互斥访问临界区的步骤:

1、获取锁
2、进入临界区
3、退出临界区
4、释放锁

4、线程挂起/唤醒策略

上面以两个线程访问临界区为例,当线程1成功获取锁进入临界区后,线程2拿不到锁但是会一直尝试。试想一下:

  • 不只是两个线程竞争锁,而是很多线程同时竞争锁。
  • 临界区执行时间很长,锁很难被释放出来。

那么没获取到锁的线程一直无限循环去尝试获取,如此一来很浪费CPU,能不能让没获取到锁的线程先挂起,当释放锁的时候再把它唤醒呢?

线程挂起

竞争锁失败的线程先将自己挂起,而其它释放锁的线程如何找到之前被挂起的线程呢?将挂起的线程放入队列里,当另一个线程释放锁后从队列里取出当初被挂起的线程并唤醒它,被唤醒的线程继续去竞争锁。
image.png
流程清晰了,看看如何用代码实现:

    static class MyLock implements Lock{
        AtomicInteger x = new AtomicInteger(0);
        
        //阻塞队列
        LinkedBlockingQueue<Thread> blockList = new LinkedBlockingQueue<>();

        @Override
        public void lock() {
            //while 循环是为了线程被唤醒后继续竞争锁
            while (true) {
                if (x.get() > 0) {
                    //说明已经有线程正在持有锁
                    //此处暂时不考虑重入
                } else {
                    //无锁状态
                    if (x.compareAndSet(0, 1)) {
                        //成功获取锁
                        return;
                    } else {
                        //获取锁失败
                    }
                }
                //走到此,说明没获取到锁
                //加入队列
                blockList.offer(Thread.currentThread());
                //挂起线程
                LockSupport.park();   
            }
        }
    }

上面是加锁的流程:

1、先判断当前锁是否可用,若是可用则获取锁。
2、若获取成功则退出,否则加入阻塞队列,并将自己挂起。

线程唤醒

再来看看,如何唤醒一个被挂起的线程:

        @Override
        public void unlock() {
            if (x.get() > 0) {
                //说明当前持有锁
                //释放锁
                x.compareAndSet(1, 0);
                //从阻塞队列里取出线程唤醒
                //此处取队头元素
                Thread thread = blockList.poll();
                if (thread != null) {
                    LockSupport.unpark(thread);
                }
            }
        }

上面是解锁流程:

1、先判断当前是否持有锁,若是则释放锁。
2、释放锁后,从阻塞队列里取出线程唤醒。

持有锁的线程从阻塞队列唤醒阻塞的线程后,被唤醒的线程继续尝试竞争锁。

LockSupport 细节请移步:Java Unsafe/CAS/LockSupport 应用与原理

5、线程同步策略

上述分析的是多个线程互斥访问临界区,这些线程对临界区的操作仅仅是互斥,并没有其它依赖关系,想象一种场景:

1、线程1对变量a进行自增操作,当a增加到10的时候暂停自增。
2、线程2对变量b进行自减操作,当a减到0的时候暂停自减。

结合Java 线程基础,我们知道线程1和线程2具有同步关系,也知道线程同步需要加锁对条件变量进行互斥访问。因此,有如下关系:
image.png

从图上可以看出:

1、当需要等待的时候,走红色线条部分。将线程放入到等待队列里、释放锁,并唤醒阻塞队列里的线程。
2、当需要通知的时候,走绿色线条部分,将线程从等待队列里移除,并将之加入到阻塞队列里。
3、线程同步的过程比线程互斥过程新增了等待队列,该队列存储着因某种条件而挂起等待的线程。当另一个线程发现条件满足后,通知等待队列里的线程,让它继续做事。

在线程互斥代码的基础上,增加同步相关的代码:
首先
先抽象出等待-通知接口:

    //抽象同步的等待、通知机制
    interface Condition {
        void waitCondition();

        void notifyCondition();
    }

其次
实现该接口:

    static class MyLock implements Lock {
        AtomicInteger x = new AtomicInteger(0);

        //阻塞队列
        LinkedBlockingQueue<Thread> blockList = new LinkedBlockingQueue<>();

        public Condition newCondition() {
            return new MyCondition();
        }

        @Override
        public void lock() {
            //while 循环是为了线程被唤醒后继续竞争锁
            while (true) {
                if (x.get() > 0) {
                    //说明已经有线程正在持有锁
                    //此处暂时不考虑重入
                } else {
                    //无锁状态
                    if (x.compareAndSet(0, 1)) {
                        //成功获取锁
                        return;
                    } else {
                        //获取锁失败
                    }
                }
                //走到此,说明没获取到锁
                //加入队列
                blockList.offer(Thread.currentThread());
                //挂起线程
                LockSupport.park();
            }
        }

        @Override
        public void unlock() {
            if (x.get() > 0) {
                //说明当前持有锁
                //释放锁
                x.compareAndSet(1, 0);
                //从阻塞队列里取出线程唤醒
                //此处取队头元素
                Thread thread = blockList.poll();
                if (thread != null) {
                    LockSupport.unpark(thread);
                }
            }
        }

        class MyCondition implements Condition {
            LinkedBlockingQueue<Thread> waitList = new LinkedBlockingQueue<>();

            //等待-通知
            @Override
            public void waitCondition() {
                //加入等待队列
                waitList.add(Thread.currentThread());

                //释放锁,让其它线程有机会获得锁
                x.compareAndSet(1, 0);
                Thread thread = blockList.poll();
                if (thread != null) {
                    LockSupport.unpark(thread);
                    //挂起当前线程
                    LockSupport.park();
                }
            }

            @Override
            public void notifyCondition() {
                //收到通知后,说明等待的条件满足了
                //将线程从等待队列里移除
                Thread thread = waitList.poll();
                //并将该线程加入到阻塞队列里
                if (thread != null)
                    blockList.offer(thread);
            }
        }
    }

并提供获取该接口的方法:newCondition()。

最后
来看看如何使用它:

    public static void main(String args[]) {
        try {
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    inc();
                }
            }, "t1");

            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    sub();
                }
            }, "t2");

            t1.start();
            t2.start();

        } catch (Exception e) {

        }
    }
    static int a = 0;
    //构造Lock对象
    static MyLock lock = new MyLock();
    static Condition conditionInc = lock.newCondition();
    static Condition conditionSub = lock.newCondition();


    private static void inc() {
        try {
            while (true) {
                //获取锁
                lock.lock();
                {
                    System.out.println("lock suc in " + Thread.currentThread().getName());
                    if (a < 10) {
                        a++;
                        conditionSub.notifyCondition();
                        System.out.println("notify: a:" + a + " in " + Thread.currentThread().getName());
                    } else {
                        //阻塞等待,并释放锁
                        System.out.println("wait: a:" + a + " in " + Thread.currentThread().getName());
                        conditionInc.waitCondition();
                    }
                }
                //释放锁
                lock.unlock();
            }
        } catch (Exception e) {

        }
    }

    private static void sub() {
        try {
            while (true) {
                lock.lock();
                System.out.println("lock suc in " + Thread.currentThread().getName());
                if (a == 0) {
                    System.out.println("wait: a:" + a + " in " + Thread.currentThread().getName());
                    conditionSub.waitCondition();
                } else {
                    a--;
                    conditionInc.notifyCondition();
                    System.out.println("notify: a:" + a + " in " + Thread.currentThread().getName());
                }
                lock.unlock();
            }
        } catch (Exception e) {

        }
    }

该Demo实现功能:

1、线程1对共享变量a进行自增,当a<10时一直自增,并通知线程2对a自减。当a>=10时,不再自增,在原地等待a的值变小。
2、线程2对共享变量进行自减,当a>0时一直自减,并通知线程1对a自增。
当a=0时,不再自减,在原地等待a的值变大。
3、通过waitCondition/notifyCondition 实现了两个线程之间的有序协作。实际上就是个典型的生产者-消费者模型。

需要注意的时:

当调用waitCondition 时候释放了锁,并唤醒了阻塞队列上的线程。
当调用notifyCondition 时候并没有释放锁,也没有唤醒阻塞队列上的线程。

6、总结

以上从线程互斥访问共享变量、CAS访问共享变量、线程互斥访问临界区、线程互斥锁、最后到线程同步,一步步阐述了"锁"的由来,以及如何实现"锁"。
当然这个"锁"只是一个最基本最简单的"锁",没有考虑到重入、中断取消、公平与非公平抢占、一些竞争锁的性能提升等方面的问题。但是却是囊括了"锁"的基本思想:

线程挂起/唤醒 + CAS + 阻塞/等待队列

了解了基本思想,再去看系统提供的锁实现–>AQS、Synchronized,相信就能够轻松看懂了。
下篇正式进入AQS、Synchronized 的分析。

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 我可以为你提供一些有关如何使用Java实现分布式的信息。首先,您可以使用Java中的定机制,例如java.util.concurrent.locks.Lock,来实现分布式。其次,您可以使用分布式系统中常见的分布式编程模式,例如Two-Phase-Commit(2PC),分布式(DLM)和Zookeeper等,来解决分布式问题。 ### 回答2: Java实现分布式主要可以使用如下几种方式: 1. 基于数据库:可以通过数据库的行级实现分布式。具体实现方式是,在数据库中创建一张表,其中每个对应于一个唯一的资源。当需要获取时,可以在表中插入对应的资源记录,并使用数据库的唯一索引来保证该资源的唯一性。其他线程或进程需要获取时,会尝试插入相同的资源记录,如果插入失败(唯一索引冲突),则说明已经被其他线程或进程获取。 2. 基于Redis:可以利用Redis的原子操作来实现分布式。具体实现方式是,通过使用Redis的SET命令设置一个key,其中key的值可以被设置为当前线程的唯一标识。其他线程或进程需要获取时,会尝试设置相同的key,如果设置成功,则说明获取到了。 3. 基于ZooKeeper:可以利用ZooKeeper的顺序节点来实现分布式。具体实现方式是,每个线程或进程尝试在指定路径下创建一个临时顺序节点,并获取所有的子节点,如果当前节点是最小的子节点,则说明获取到了,否则监听前一个节点的删除事件,等待被唤醒。 无论是哪种方式,需要注意的是,获取的过程应该是原子的,避免获取失败时出现竞态条件。此外,还需要考虑的超时机制,避免被长时间占用而导致死。 ### 回答3: Java 实现分布式可以借助于 Redis、Zookeeper 或数据库等工具实现。 在 Redis 中,可以使用 SETNX (SET if Not Exists) 命令来实现。当一个线程需要获取时,可以执行 `SETNX lockKey 1`,如果返回的结果是 1,表示获取成功;如果返回的结果是 0,表示已经被其他线程占用。在执行完业务逻辑后,需要释放,可以执行 `DEL lockKey` 命令。 在 Zookeeper 中,可以使用节点的特性来实现分布式。创建一个临时顺序节点,当一个线程需要获取时,可以在指定的路径下创建一个节点。然后通过获取子节点列表并判断自己是否为最小节点来判断是否获取到。如果自己不是最小节点,则监听并等待前一个节点被删除,然后再尝试获取。释放时,只需要删除自己创建的节点。 在数据库中,可以使用数据库事务和唯一索引来实现分布式。创建一张表,其中包含一个键的唯一索引列,当一个线程需要获取时,可以通过尝试插入一行数据来获得。如果插入成功,表示获取成功;如果插入失败,表示已经被其他线程占用。在执行完业务逻辑后,通过删除对应的行来释放。 无论使用哪种方式实现分布式,都需要注意的超时和宕机问题,以及避免死并发竞争的情况的发生。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值