锁是什么?

       锁是一种提供了排他性可见性的并发访问控制工具,它保证了在同一时刻,只能有一个访问者可以访问到锁保护的资源,这个资源可以是一段方法逻辑或是某个存储。锁是我们常用的一种并发访问控制工具,而排他性是它功能性最直接的体现,以至于它成了排他性的代名词,但其所隐含的可见性以及资源状态往往被我们忽视。

       可见性表达的是一个访问者对数据(比如:内存中的某个值)做了修改,其他的访问者能够立即发现数据的变化,从而能够做出相应的行为。这点看起来挺简单,甚至感觉有些理所应当,但如果带入到现代计算机体系结构下,就觉得没这么容易了。

       CPU和内存之间是有缓存的,这个缓存一般封装在CPU上,每个CPU核心同缓存进行沟通,当缓存中没有数据时,才会将内存中的数据载入到缓存中,然后进行处理。缓存封装在CPU内,其目的是离CPU核心更近,CPU对缓存的访问时间一般在1纳秒左右,而对内存的访问时间则在100纳秒左右。这个过程如下图所示:

       可以看到,与内存访问相比,缓存更加迅捷,但问题却随之而来,数据会同时出现在缓存和内存中,这使得该架构天生就存在可见性问题。比如:一个值在内存中是A,由于程序是多线程执行,导致在某个CPU缓存中的值是旧值B,这时就出现可见性问题了。

       如果要解决这个问题,就需要程序能够在访问数据之前先作废掉CPU上的缓存,从内存中加载最新的数据使用,同样也需要在数据变更后,将数据显式的刷回内存。锁就具有这个特性,锁除了能够完成排他性工作,它还能隐性的解决可见性问题。当使用锁的时候,程序会执行系统指令,将CPU中的缓存作废,然后载入内存中的最新数据,现在通过一个示例来看一下,示例代码如下所示:

public class NoVisibilityTest {
    // 准备状态
    private static boolean ready;
    // 数量
    private static int number;

    private static class ReaderThread extends Thread {

        public void run() {
            while (!ready) {
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) throws Exception {
        // 获取Java线程管理MXBean
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();

        IntStream.range(0, 15)
                .forEach( i -> {
                    ReaderThread readerThread = new ReaderThread();
                    readerThread.setName("Reader:" + i);
                    readerThread.start();
                });

        number = 42;
        ready = true;


        for (int i = 0; i < 20; i++) {
            System.err.println("=============" + (i + 1) + "===============");
            // 不需要获取同步的monitor和synchronizer信息,仅仅获取线程和线程堆栈信息
            ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
            // 遍历线程信息,仅打印线程ID和线程名称信息
            for (ThreadInfo threadInfo : threadInfos) {
                System.out.println("[" + threadInfo.getThreadId() + "] "
                        + threadInfo.getThreadName() + ", status:" + threadInfo.getThreadState());
            }

            Thread.sleep(1000);
        }
    }
}

       上述示例定义了两个静态变量,分别是状态ready和值number,然后启动15个线程进行执行操作,操作很简单,如果发现readytrue,则退出循环,打印并结束。

       如果不存在可见性问题,ReaderThread线程应该可以发现ready值被修改,然后跳出死循环,打印并退出。实际情况会是这样吗?运行程序后,主线程会每隔1秒打印一次线程信息,重复20次。这里截取最后几个批次,如下图:

       从第20次打印的线程信息可以看出,之前创建的ReaderThread大部分都存活着,难道它们都对ready值的变化视而不见吗?其实它们不是看不见,而是它们只盯着缓存中的ready值去看,对内存中的ready值变化不清楚罢了。线程运行在CPU核心上,在线程执行时,将值从内存载入到缓存中,依照缓存中的值来运行。

       那怎样才能让执行的线程从内存中获取最新的数据呢?有一种简单的做法是使用volatile关键字来修饰ready变量。当然更简单的就是什么都不做,等执行的线程被操作系统交换出去后,然后当线程下一次被调度执行时,会从内存中获取数据并恢复缓存,这时该线程有几率能够恢复过来。除此之外,还有别的方式吗?有的,使用锁。因为锁的资源状态在内存中,所以需要保证访问锁的线程能够正确看到锁背后的资源,而这个保证就是对可见性的承诺。基于这个特性,我们使用一个无意义的锁来获得可见性,这里所谓的无意义,是指没有发挥出锁的排他性能力。

       只需要对原有示例做出一些修改,如下所示:

private static class ReaderThread extends Thread {

    public void run() {
        while (!ready) {
            Lock lock = new ReentrantLock();
            lock.lock();
            try {

            } finally {
                lock.unlock();
            }
        }
        System.out.println(number);
    }
}

       可以看到,上述修改只是在ReaderThread的死循环中,创建了一个ReentrantLock,并在这个锁上调用lock方法。如果从功能角度上看这个修改,是一点作用也没有的,但重新运行修改后的程序,会看到结果,如下图所示:

       ReaderThread线程读到了内存中ready的新值,它们安全的退出了。这就是锁可见性的体现,它保证在锁保护的代码块中,能够看到最新的值,不论是锁的资源状态,还是程序中的数据变量。在使用锁时,会将CPU上的缓存作废,以期望获取到内存中的值,而作废的数据不止是资源状态,因此变相的使得线程获取到了最新的ready值。

       可以看到,锁是依靠可见性的保障来看清楚锁的资源状态,并在此基础上封装出能够提供排他性语义的并发控制装置。在使用锁的功能时,也会间接的享受到它对可见性的保证。

results matching ""

    No results matching ""