专栏首页海纳周报用Atomic实现锁

用Atomic实现锁

一直想写ReentrantLock,就得先介绍AbstractQueueSynchronizer,可是我觉得这样写,不过瘾,我把代码贴一遍,懂的人自己就能找到这些代码看,不懂的人还是不懂。直到昨天灵机一动,不如自己从简到难重新写一遍,带着读者跳几个坑,可能就好了。

java.util.concurrent.lock下的几个锁以及synchronized锁其实背后都要使用atomic操作,那我们不妨就使用atomic操作把锁实现一遍。

咱们先从最简单的开始。

互斥

并发模型中,最简单的问题,就是互斥。一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源,这个公共资源,我们通常会称之为关键区。如何保护这个关键区就是互斥的问题。

这个其实比较简单,我只需要用一个atomic变量,让它为 0,不管有多少线程过来,谁先抢到这个变量把它置为1,谁就相当于拿到了关键区的使用权,而其他没抢到的就不能进入关键区。来看这样一个例子:

public class Mutex {
    public static void main(String[] args) {
        TestMutex test = new TestMutex();
        int THREAD_NUM = 10;
        Thread[] threads = new Thread[THREAD_NUM];

        // 创建10个线程,让它们不断地去增加test.sum的值
        for (int i = 0; i < THREAD_NUM; i++) {
            Thread t= new Thread() {
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        test.add();
                    }
                }
            };

            t.start();
            threads[i] = t;
        }

        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println(test.sum);
    }
}

class TestMutex {
    public int sum = 0;

    public void add() {
        if (sum < 30_000) {
            try {
                // 这里sleep一下,增加线程切换的概率
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sum += 50;
        }
    }
}

运行这个程序,可以看到结果是随机的,大概率会是落在30300到30450这个区间。这个是典型的因为并发引起的。那么想改正它,我们就可以把add用一个atomic变量保护起来。一个线程只有获得了这个许可,才能继续执行 add 操作。如果没有许可,就直接放弃,修改过后的Mutex变成这样:

class TestMutex {
    public int sum = 0;
    AtomicInteger mutex = new AtomicInteger(0);

    public void add() {
        if (!mutex.compareAndSet(0, 1))
            return;

        if (sum < 30_000) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sum += 50;
        }

        mutex.set(0);
    }
}

经过这样的修改,运行的结果就一定会是30000了。

同步

在互斥的例子里,如果一个线程拿不到许可(即mutex变量),那就直接放弃什么都不做了。但是如果我们希望它继续完成加法操作呢?那么线程之间就必须有一定的手段相互可以知道有没有线程在关键区里,如果有,那么我就不再进入关键区了,如果没有,我就尝试进去。

举个例子,有一条仅容一人通过的小巷子,有两个人相对而行,那么当左端的人进入了这个巷子以后,右端的人就不能再进去了,他得先等待。必须等到左端的人到达右端,然后告诉右端等待的人可以进了,右端的人才可以进去。这个小巷子就不光是互斥的关键区了,还得有两个人相互通知的机制。

还是拿之前的课程里的例子来说吧:

class LockTest {
    public int total = 0;

    public void testTwoThreads() throws InterruptedException {
        Thread t1 = new Thread() {
            public void run() {
                for (int i = 0; i < 5_000; i++) {
                    incTotal();
                }
            }
        };

        Thread t2 = new Thread() {
            public void run() {
                for (int i = 0; i < 5_000; i++) {
                    incTotal();
                }
            }
        };

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

        t1.join();
        t2.join();

        System.out.println(total);
    }

    public void incTotal() {
        total += 1;
    }
}

这个例子,启动了两个线程,每个线程都执行5000次加法,但如果真正运行的话,大概率不会是10000。这就又是并发的问题了,具体的原因,咱们之前的课程里分析过,这里就不再重复了。如果我们使用Atomic保护关键区的思路来改写,应该怎么做呢?

自旋锁

今天介绍一种自旋锁的思想。举个实际的例子,去公司的卫生间蹲坑,一直没位置,这时候由于没有任何的通知机制,所以我只能每隔一会去看看有没有空位,有空位就赶紧抢,然后把门锁上,如果没有,就只能一直在门口等。

这就是最典型的自旋锁。它不需要任何的通知机制,一个线程去抢许可变量,抢到了就进关键区,抢不到就死循环一直抢。好,我们来实现一个自旋锁:

public class SpinLock implements Lock{
    AtomicInteger state = new AtomicInteger(0);

    public void lock() {
        for(;;) {
            if (state.get() == 1)
                continue;
            else if (state.compareAndSet(0, 1)) {
                break;
            }
        }
    }

    public void unlock() {
        state.set(0);
    }
}

原理很简单,就是一直CAS抢锁,如果抢不到,就一直死循环,直到抢到了才退出这个循环。然后,我们使用这个工具改写一下incTotal:

    private Lock lock = new SpinLock();
    //.....
    public void incTotal() {
        lock.lock();
        total += 1;
        lock.unlock();
    }

这次再运行,结果就是10_000了。我们使用一个Atomic变量把整个关键区保护起来了。

自旋锁实现起来非常简单,如果关键区的执行时间很短,往往自旋等待会是一种比较高效的做法,它可以避免线程的频繁切换和调度。但如果关键区的执行时间很长,那这种做法就会大量地浪费CPU资源。

针对关键区执行时间长的情况,该怎么办呢?下篇文章再说吧。

本文分享自微信公众号 - HinusWeekly(gh_4b8b4eda4e40),作者:海纳

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-01-18

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 多线程内幕

    本文是HinusWeekly第三期的第二篇文章,第三期的主题就是多线程编程。本文试图从单核CPU的角度讨论并发编程的困难。 函数调用的过程,就是不断地创建栈帧,...

    海纳
  • synchronized关键字的语义

    上一篇文章,我们讲到,如果发生了多个线程共同访问一个全局变量的时候,就会发生各种意料之外的情况。其实现实生活中有很多这样的例子。我举一个例子。 一群人都要过河,...

    海纳
  • Java线程模型

    大家新年好。2018年的第一期来得晚了一些。因为年底有很多工作要做,加班多了一些,公众号停更了两周。 今天借着知乎上一个关于线程模型的问题,我正好可以讲一下Ja...

    海纳
  • 设计模式之命令模式

    命令模式在书本上是这样说的,将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,队请求排队或者记录请求日志,可以提供命令的撤销或者恢复功能。

    用户5166556
  • ​[JUnit] 基于JUnit从零开始认识单元测试

    如果你听说过测试驱动开发(TDD:Test-Driven Development),单元测试就不陌生。单元测试是用来对一个模块、一个函数或者一个类来进行正确性检...

    架构探险之道
  • 如何为自己的网站规划内容图谱

    Content Mapping,翻译为内容映射也好,或者理解为内容图谱,实际上是一种帮助我们组织和理解网站所包含内容的数据可视化技术。这项简单的技术在我们的网站...

    大江小浪
  • 策略模式(分离算法,选择实现)

    如果您是第一次阅读我的设计模式系列文章,建议先阅读设计模式开篇,希望能得到您宝贵的建议。

    幺鹿
  • Java并发入门指南

    关于Java并发 从创建起,Java已经支持核心的并发概念,如线程和锁。本指南帮助Java开发人员使用多线程程序来了解核心并发概念以及如何应用它们。本指南涵盖...

    用户1263954
  • 深入理解Java多线程(multiThread)多线程的基本概念线程同步wait,notify,notifyAll线程的生命周期

    一个java程序启动后,默认只有一个主线程(Main Thread)。如果我们要使用主线程同时执行某一件事,那么该怎么操作呢? 例如,在一个窗口中,同时画两排...

    desperate633
  • Jetpack:Lifecycle

    通常我们都会用MVP模式把业务逻辑和数据处理分开,但是这样做有个小问题:如果在某个生命周期还有别的类要监听 Activity 生命周期变化,那也需要添加许多生命...

    提莫队长

扫码关注云+社区

领取腾讯云代金券