Java 多线程王国奇遇记

第一回 江湖救急

NPR:“欢迎来到多线程的国度,勇士!”

你:“你你你,你不是正则王国的 NPC 吗?怎么又跑到多线程王国来了?”

NPR:“呃,你认错人了,那是我的双胞胎弟弟,我是他的哥哥 NPR。”

你:“你可别唬我,我记得 NPC 是 Non-Player Character 的意思,非游戏角色都叫 NPC,你这 NPR 是个啥?”

NPR:“I’m Non-Player Rule,也是非游戏角色的一种哦。”

NPR 一脸天真无邪的微笑望着你,一时间你竟分不清这个所谓的 NPR 是真是假。

你:“行吧,先不管那么多了,我到你们王国来,实在是江湖救急,有事相求。”

NPR:“说来听听。”每天来多线程王国求助的人络绎不绝,NPR 早已见怪不怪。

你:“我写了一段读写程序,但读写后的结果总是不对,可我怎么看逻辑都是正确的,您能帮我看看吗?”

说着,你亮出了自己写的代码:

public class Client {
    private int number = 0;

    private void read() {
        System.out.println("number = "+ number);
    }

    private void write(int change) {
        number += change;
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程加 10000 次
        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                write(1);
            }
            System.out.println("增加 10000 次已完成");
        }).start();

        // 开启一个线程减 10000 次
        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                write(-1);
            }
            System.out.println("减少 10000 次已完成");
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
        // 读取结果
        read();
    }
}

你:“这段代码简单得不能再简单了,这个类里只有一个 number 变量,我开启了一个新线程将它加了 10000 次,又开了一个线程让它减 10000 次,按理说结果肯定是 0,但我执行时,每次结果都不一样,就没有一次是 0。”

增加 10000 次已完成
减少 10000 次已完成
number = -981
  
增加 10000 次已完成
减少 10000 次已完成
number = 92

第二回 宇宙射线?

“我已经反反复复看了好多遍了”,你重复道,“可我怎么看都没有错,我想要么是我电脑的硬件问题,要么是 Java 基础库出了问题,或者是由于太阳黑子最近比较活跃,干扰了宇宙射线导致的。”

NPR 睁大了眼睛:“宇宙射线?哈,真是个毛头小子,程序可不是物理世界。看来你还不知道 Java 编程第一法则。”

说着,NPR 身前亮出几个鎏金大字:

Java 编程第一法则:程序出问题时,从自己的代码找原因,永远不要怀疑 Java 基础库。🐶

NPR:“在我们多线程王国的人看来,你的这段代码错得很明显。当多个线程同时进行读写操作时,要保证结果正确,必须保证每一步操作都是原子操作。”

你:“原子操作?刚才你还说程序不是物理世界,现在怎么扯到原子了。”

NPR:“原子是元素能存在的最小单位,也就是说原子是不可分割的。这里借鉴了原子的概念,原子操作是指不能被中断的一个或一系列操作。”

你:“我还是不太明白,您可以先给我解释一下我的程序为什么出错吗?”

NPR:“没问题。你可知道,在你的 write 方法执行时,实际上会执行三条语句。”

ILOAD
IADD
ISTORE

NPR:“这三条语句的意思是:程序先从主内存中拷贝一个 number 的副本到本地线程中,增加后再回写到主内存。所以两个线程同时执行 write(1) 和 write(-1) 时可能出现这样一种情况:

  • 第一个线程拷贝了主内存中的 number,假设此时主内存中 number 的值为 0
  • 第一个线程被操作系统暂停
  • 操作系统调度了第二个线程,第二个线程拷贝了主内存中的 number,值为 0
  • 第二个线程将本地副本中的 number 减少 1,第二个线程的本地副本中的 number 变为 -1
  • 第二个线程中的值回写到主内存,主内存中的 number 变成 -1
  • 第一个线程被系统继续调度,本地副本中的 number 增加 1,第一个线程的本地副本中的 number 变为 1
  • 第一个线程中的值回写到主内存,number 变成 1

你看,执行了一次 +1,执行了一次 -1,因为不是原子操作,第一个线程被系统中断了一次,导致两次运算的最终结果不是 0,而是 1。”

NPR:“同样的,还存在另一种情况,如果第二个线程拷贝了副本后,第一个线程先回写到主内存,number 变成 1 ,然后第二个线程中的 -1 回写主内存,就会导致结果变成 -1,所以说你执行多次,有时候大于 0 ,有时候小于 0。就是因为这个原因。”

你:“原来如此!”

第三回 独一无二的钥匙

你:“可是为什么操作系统不把我一个线程执行完后,再去执行另一个线程呢?这样就不会有这个问题了。”

NPR:“那是因为操作系统需要提高电脑运行效率。线程的调度完全是由操作系统决定的,程序不能自己决定什么时候执行,以及执行多长时间。

你:“那就没有什么其他办法了吗?多个线程同时读写是一个很常见的需求啊,不能自己控制岂不是漏洞百出?”

NPR:“办法当然有,试想一下,如果我们制造一把钥匙,这把钥匙独一无二。在一个线程执行 write 前,先检查这个线程有没有拿到钥匙,有钥匙的话我们才让它执行,执行完再把钥匙交出来。没有拿到钥匙的线程就先等待钥匙。这样是不是就能保证一次只有一个线程能执行 write 了。”

你:“有点意思,你说的这个钥匙像是一个通行证,因为通行证只有一个,所以每次只有一个线程能拿到通行证,确实能保证一次只有一个线程执行。”

NPR:“你可以尝试用伪代码实现它吗?”

你:“没问题,代码应该是类似这样的。”

private final Object 钥匙 = new 钥匙;
private void write(int change) {
    if(拿到了钥匙) {
        number += change;
        执行完毕,交出钥匙;
    }
}

NPR:“没错,Java 里的 sychronized 关键字就是用来实现这个功能的,实际代码和这个差不多。”

private final Object key = new Object();
private void write(int change) {
    synchronized (key) {
        number += change;
    }
}

你:“为什么没有看到交出钥匙的代码呢?”

NPR:“因为交出钥匙是每次都会执行的操作,所以被封装到 synchronized 中了,当程序执行到 synchronized 的 } 时,就会交出钥匙。另外,在我们多线程王国,一般不把它称之为钥匙,而是按照它的功能,将其称之为。”

private final Object lock = new Object();
private void write(int change) {
    synchronized (lock) {
        number += change;
    }
}

你:“我把我的程序中的 write 方法换成这样,果然每次执行都是 0 了。真是太感谢您了,NPR 先生!”

NPR:“别客气,先别高兴地太早,现在我们给 write 方法加上锁后,写入时没有问题了,读取时还是有问题的。”

你:“我想一想,现在读取时不需要获取钥匙,所以读取时可以直接将主内存中的 number 值拷贝到自己的工作内存。而此时有可能存在线程正在写入值,这就会导致读取线程无法读到写入后的最新值!”

NPR:“没错,就是这个问题,我们写个测试类来验证一下。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();

    private void read() {
        System.out.println("number = " + number);
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("写入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                write(1);
            }
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

运行结果如下:

...省略
写入 43
写入 44
写入 45
number = 36
写入 46
写入 47
写入 48
写入 49
写入 50
写入 51
写入 52
写入 53
number = 46
写入 54
number = 54
写入 55
写入 56
...省略

你:“果然如此,所以我要给 read 中的代码也加上判断,它也要拿到钥匙后才能读取,这样就能保证读取时不会有写操作,写的时候也没有读取操作了。”

private void read() {
    synchronized (lock) {
        System.out.println("number = " + number);
    }
}

运行结果如下:

写入 1
写入 2
写入 3
...
写入 13
number = 13
number = 13
number = 13
number = 13
写入 14
写入 15
...
写入 80
number = 80
number = 80
...
number = 80
写入 81
写入 82
...
写入 99
写入 100
number = 100
number = 100
...
number = 100

NPR:“很好,运行结果没有错,说明我们确实解决了多线程竞争的问题。但这样的流程往往并不是我们想要的。更常见的需求是写入全部完成后,再去读取值。”

第四回 死局

你:“没错,不过这难不倒我,我可以增加一个标志位来实现这个功能。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();
    // 标志是否写入完成
    private boolean writeComplete = false;

    private void read() {
        synchronized (lock) {
            // 如果还没有写入完成,循环等待直到写入完成
            while (!writeComplete) {
                System.out.println("等待写入完成...");
            }
            System.out.println("number = " + number);
        }
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("写入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

你:”我增加了一个标志位 writeComplete,在写入完成后,将它置为 true,读取时循环等待,直到它变成 true 时才读取结果。“

NPR 不置可否,说道:“你运行一下试试。”

运行结果:

写入 1
写入 2
写入 3
写入 4
写入 5
写入 6
写入 7
写入 8
写入 9
写入 10
写入 11
写入 12
等待写入完成...
等待写入完成...
等待写入完成...
等待写入完成...
... 省略 20 多万次 "等待写入完成..."

你:“???为什么写入一会之后,就一直卡在等待写入完成?”

NPR 擡头看了看天,若有所思地说:“据说太阳黑子每 11 年活跃一次,上一次活跃还是 2009 年,最近也差不多该发出新一轮的宇宙射线了。”

你翻了个白眼,感叹道:“你和你的双胞胎弟弟还真是如出一辙,都喜欢阴阳怪气地取笑人。快给我讲讲我的程序是哪里出了问题吧!”

NPR:“哈哈,看来你终于领悟了 Java 编程第一法则。这次的问题在于现在读写方法都需要获取锁,一旦进入 read 函数,write 函数就必须等待,直到 read 函数释放锁。这会导致什么问题?”

你:“ write 函数在等待 read 函数,write 函数中的循环无法执行完,那么 writeComplete 就无法被置为 true,所以 read 函数就会无限循环。啊,这样就陷入死局了!这可怎么办?”

第五回 破局

NPR 微微一笑,身前再次亮起几个鎏金大字:

Java 编程第二法则:当你无法解决问题的时候,往往说明了现有知识量储备不足。🐶

NPR:“单用 synchronized 是无法实现这个功能的,但在解决问题之前,我们最好先搞清楚我们想要的是什么。”

你:“我想要的效果是:写入操作不受限制;如果写入还没有完成,read 方法先进入等待状态。write 方法写入完成后,通知 read 开始读取。”

NPR:“很好,像上次一样,先尝试写一下伪代码吧!”

你:“好的,我想 read 方法中应该有一个等待方法,并且这个等待方法不能阻塞写入过程。”

private void read() {
    synchronized (lock) {
        // 如果还没有写入完成,循环等待直到写入完成
        while (!writeComplete) {
            等待,并且不要阻塞写入
        }
        System.out.println("number = " + number);
    }
}

你:“在写线程写完后,唤醒读取线程继续读取。”

 // 开启一个线程写入 100 次 number
 new Thread(() -> {
     writeComplete = false;
     for (int i = 0; i < 100; i++) {
         write(1);
     }
     writeComplete = true;
     写入完成,唤醒读取线程
 }).start();

NPR:“没错,我的勇士,你在多线程编程上真是有天赋!你写出的正是等待/唤醒机制设计者的设计思路。这个等待方法叫做 wait(),唤醒方法叫做 notify()。唯一需要注意的一点是,等待与唤醒操作必须在锁的范围内执行,也就是说,调用 wait() 或 notify() 时,都必须用 synchronized 锁住被 wait/notify 的对象。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();
    // 标志是否写入完成
    private boolean writeComplete = false;

    private void read() {
        synchronized (lock) {
            // 如果还没有写入完成,循环等待直到写入完成
            while (!writeComplete) {
                // 等待,并且不要阻塞写入
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("number = " + number);
        }
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("写入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 写入完成,唤醒读取线程,wait/notify 操作必须在 synchronized 中执行。
            synchronized (lock) {
                lock.notify();
            }
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

你:“现在运行果然没有问题了,达到了我预期的效果。”

写入 1
写入 2
写入 3
...
写入 100
number = 100
number = 100
...
number = 100

NPR:“不错,由于本例中只有一个线程在等待,所以我们只需要使用 notify() 函数,如果有多个线程需要唤醒,我们应该用 notifyAll() 函数。实际上,工作中往往都是使用 notifyAll() 函数。”

你:“是为了防止某些线程由于没有被唤醒一直等待吗?”

NPR:“没错。很好,你已经掌握了我们多线程王国的 synchronized 关键字和 wait/notify 机制,但我想告诉你,synchronized 并不是一个很好的加锁方案。”

你:“啊?我觉得 synchronized 已经很好用了啊,简直是一个相当伟大的发明,它还有什么缺点吗?”

NPR:“年轻人啊,真是健忘。你刚才已经遇到了一次 synchronized 的缺点,由于使用了 synchronized,你刚才的程序陷入了死循环中。”

你:“的确,但那是我代码逻辑没有考虑清楚导致的,不能让 synchronized 背锅吧!”

“‘如果不曾见过太阳,我本可以忍受黑暗’”。NPR 突然吟诵起狄金森的诗句,“这样的死循环完全是可以避免的,如果你使用过更加优秀的加锁工具,可能就不会再觉得 synchronized 有多好了。”

第六回 并发大师 Doug Lea

NPR 凝望着天空,思绪仿佛回到了多年以前,你惊异的发现 NPR 眼中竟闪烁出崇拜的光芒。

只听 NPR 娓娓道来:“很早以前,我们王国只有 synchronized 关键字可以使用,每个 Java 程序员必须小心翼翼,生怕线上的程序陷入无尽等待。而 synchronized 又是一个很重的操作,为了优化 synchronized 的效率,一代又一代的程序员们做了非常多的努力,但并发始终是一个艰难又让人头疼的问题。直到后来,并发大师 Doug Lea 的出现,这个鼻梁挂着眼镜,留着德王威廉二世的胡子,脸上永远挂着谦逊腼腆笑容的老大爷,亲自操刀设计了 Java 并发工具包 java.util.concurrent。这套工具在 Java 5 中被引入,从此以后,Java 并发变得相当容易。”

作为一名年轻的 Java 工程师,你实在很难代入 NPR 的情绪中,只是简单地说道:“哦,那他很棒棒哦!他的这个工具包要怎么用呢?”

NPR:“有了它,我们可以用 ReentrantLock 类替代 synchronized 关键字。”

使用 synchronized 的代码:

public class Client {
    private int number = 0;
    private final Object lock = new Object();

    private void write(int change) {
        synchronized (lock) {
            number += change;
        }
    }
}

使用 ReentrantLock 的代码:

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();

    private void write(int change) {
        lock.lock();
        number += change;
        lock.unlock();
    }
}

刚听 NPR 吹了半天,以为这个工具包会有多牛的你,盯着这段代码端详了半天,却根本没看出有多大区别,忍不住吐槽道:“恕我直言,看起来完全没有那么神奇,只是把 synchronized 关键字换成了 ReentrantLock 类而已。”

NPR:“当然,这只是替换,ReentrantLock 的优势在于,它可以设置尝试获取锁的等待时间,超过等待时间便不再尝试获取锁,这在实际开发中非常有用。”

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();

    private void write(int change) throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            number += change;
            lock.unlock();
        } else {
            System.out.println("1 秒内没有获取到锁,不再等待。");
        }
    }
}

你:“原来如此,看来 ReentrantLock 比 synchronized 更安全,可以完全避免无限等待。小 Doug 还是有一点实力的。”

NPR:“再来看看 ReentrantLock 是怎么实现 wait/notify 功能的,感受一下它的第二个优势。”

第七回 睡觉记得定闹钟

NPR:“在使用 ReentrantLock 时,我们通过一个叫做 Condition 的类实现 wait/notify,与之对应的方法为 await/signal,我仍然会从最简单的开始,先将之前使用 wait/notify 的代码替换为用 Condition 实现。”

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    // 标志是否写入完成
    private boolean writeComplete = false;

    private void read() {
        lock.lock();
        // 如果还没有写入完成,循环等待直到写入完成
        while (!writeComplete) {
            // 等待,并且不要阻塞写入
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("number = " + number);
        lock.unlock();
    }

    private void write(int change) {
        lock.lock();
        number += change;
        System.out.println("写入 " + number);
        lock.unlock();
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 写入完成,唤醒读取线程,await/signal 操作必须在 lock 时执行。
            lock.lock();
            condition.signal();
            lock.unlock();
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

你:“看起来并不难,通过 ReentrantLock 的 newCondition 方法创建出 Condition 类,wait 方法替换成了 Condition 的 await 方法,notify 方法替换成了 Condition 的 signal 方法。我试着运行了一下,运行结果和之前一模一样。”

写入 1
写入 2
写入 3
...
写入 100
number = 100
number = 100
...
number = 100

NPR:“另外,Condition 中对应 notifyAll 的方法是 signalAll 。”

你:“这么看来,ReentrantLock 和 Condition 结合,确实可以完全替代 synchronized 和 wait/notify。并且 ReentrantLock 相比 sychronized 还有一个独到的优点,那就是可以设置尝试获取锁的等待时间。”

NPR:“独到的优点~我喜欢这个词!很好地描述出 ReentrantLock 拓展了 synchronized 没有的功能。其实,Condition 也有两个独到的优点~你不妨猜猜看是什么。”

你:“让我想想,嗯…ReentrantLock 可以设置等待时间…莫非 Condition 可以设置醒来的时间?”

NPR:“你真令我骄傲,勇士!完全正确,Condition 的 await(long l, TimeUnit timeUnit) 方法就是用来实现这个功能的。”

if (condition.await(1, TimeUnit.SECOND)) {
    // 1 秒内被 signal 唤醒
} else {
    // 1 秒内没有被唤醒,自己醒来
}

你:“哈哈,这就像线程睡觉前,先给自己定了一个闹钟,如果没人唤醒自己,就自己醒过来,真是太有趣了!”

NPR:“一个定时唤醒自己的闹钟,非常棒的理解!”

你:“您刚才说 Condition 有两个独到的优点,那另一个是什么呢?”

NPR:“Condition,译为情境、状况。当我们在不同的情境下,通过使用多个不同的 Condition,再调用不同 Condition 的 signal 方法,就可以唤醒自己想要唤醒的某个或某一部分线程。”

你:“妙啊,这样的同步操作真是太灵活了!我逐渐感受到 Doug Lea 的大师魅力了!”

这时,NPR 眼中再次泛起崇拜的光芒:“伟大的 Doug Lea,我们多线程王国的一颗巨星,浑身散发着睿智的光芒,亿万 Java 程序员心中的梦。”

你听得起了一身的鸡皮疙瘩,忽而眉头一皱,发现事情并不是这么简单。思索片刻后,你对 NPR 说道:“我隐隐觉得现在的流程还不够完美,ReentrantLock 好像还得再优化一下。”

第八回 更进一步

NPR 饶有兴趣的看着你,问道:“何出此言?”

你:“打个比方,将读操作比作浏览一个网页,写操作比作修改这个网页的内容。当多个用户浏览一个网页时,由于读操作被加了锁,大家必须排队依次浏览,这会严重影响效率。”

NPR 再次竖起大拇指:“很不错,我的勇士!这正是可以优化的地方。我们回到之前讨论过的问题:读操作真的必须锁吗?”

你:“必须锁啊,我们刚才做了实验,如果读操作不锁的话,会导致无法及时读取到最新值。”

NPR:“梳理一下我们的需求,其实我们想要的是这样的效果。”

  • 当有写操作时,其他线程不能读取也不能写入。
  • 当没有写操作时,允许多个线程同时读取,以提高并发效率。

你:“对对对,这就是我想要的优化。可是要怎么做呢?我没有想到一个好的实现思路。”

NPR:“Doug Lea 早已考虑到这一点,并且为我们提供了一个使用非常方便的工具类,名字叫 ReadWriteLock。”

public class Client {
    private int number = 0;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();
    private final Condition condition = writeLock.newCondition();
    // 标志是否写入完成
    private boolean writeComplete = false;

    private void read() {
        readLock.lock();
        // 如果还没有写入完成,循环等待直到写入完成
        while (!writeComplete) {
            // 等待,并且不要阻塞写入
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("number = " + number);
        readLock.unlock();
    }

    private void write(int change) {
        writeLock.lock();
        number += change;
        System.out.println("写入 " + number);
        writeLock.unlock();
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 写入完成,唤醒读取线程,await/signal 操作必须在 lock 时执行。
            writeLock.lock();
            condition.signal();
            writeLock.unlock();
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

NPR:“只需定义一个 ReentrantReadWriteLock 变量,读取时使用 readLock,写入时使用 writeLock,这个工具就会帮我们完成剩下的所有工作,达到的就是我们之前讨论的效果:单独写、一起读。”

你:“666,ReadWriteLock,啊!这就是大名鼎鼎的读写锁!”

NPR:“没错,ReadWriteLock 为我们优化了读与读之间互斥的问题。

这时,你露出了羞赧的神色,挠着脑袋说道:“其实我以前也听过读写锁,可总觉得它是一个很难的东西,所以自己一直没有掌握。”

NPR:“嗯,我见过很多来我这里的多线程新手,他们畏难情绪太重,总是被我们王国的各种专业名词吓到,实际上这些可爱的工具类们都不难。毕竟工具是用来服务大众的,API 设计之初就要考虑到使用时称不称手。”

第九回 做人最重要的就是开心

你:“ReadWriteLock 就是最完美的锁了吗?”

NPR:“不是的,我们还可以更进一步优化。”

你:“竟然还可以优化?我实在是想不到哪里还能优化了。”

NPR:“同样以你刚才的例子打比方,使用 ReadWriteLock 会出现一个问题,当多个用户一起浏览网页时,如果有网页修改操作,必须等待所有人浏览完成后,才能修改。”

你:“使用 ReadWriteLock 会导致写线程必须等待读线程完成后才能写?这是个坑啊。”

NPR:“没错,读的过程中不允许写,我们称这样的锁为悲观锁。”

你:“程序又没有感情,怎么还悲观起来了。”

NPR:“悲观锁是和乐观锁相对的,如果不是乐观锁的出现,人们也不会发觉现在的锁是悲观的。”

你:“乐观锁又是什么?”

NPR:“乐观锁的特点是,读的过程中也允许写。”

你:“啊?这不是会出问题吗?就像我们刚才测试的那样,万一读的过程中写线程拿到写锁后将值修改了,读的数据就错了啊。”

NPR:“你说得没错,但只要我们乐观地估计读的过程中不会有写入,就不会出问题了。几乎所有的数据库,读操作比写操作都要多得多,所以乐观锁可以进一步提高效率。”

你:“编程哪能靠乐观地估计,万一出问题了,造成多大的损失啊。果然人不能太乐观,古人都说生于忧患,死于安乐。”

NPR 嬉皮笑脸地说:“害,那都是几千年前的思想了,现在提倡做人最重要的就是开心。”

你:“可别得意忘了形,我想是个正常的公司都会使用悲观锁吧。性能和稳定二选一的话,只能舍弃性能选择稳定。”

NPR:“呵,小孩子才做选择。”

你:“你是说我们可以全都要?”

NPR:“没错,只要我们乐观地读取数据后做一个检查,判断读的过程中是否有写入发生。如果没有写入,说明我们乐观地获取到的数据是正确的,乐观为我们提高了效率。如果检查发现读的过程中有写入,说明读到的数据有误,这时我们再使用悲观锁将正确的数据读出来。这样就可以做到性能、稳定兼顾了。”

你:“听起来还不错,不过要怎么判断读的过程中是否有写入发生呢?”

NPR:“比如我们可以在读取前,给数据设置一个版本号,写入后修改此版本号,读取完成后通过判断这个版本号是否被修改,就可以做到这一点了。”

你:“这个版本号也不好实现啊,Doug Lea 大师有给我们提供什么工具类吗?”

NPR:“当然有,这个类叫做 StampedLock,Stamp 译为戳,戳就是我给你提到的版本号。”

public class Client {
    private int number = 0;
    private final StampedLock lock = new StampedLock();

    private void read() {
        // 尝试乐观读取
        long stamp = lock.tryOptimisticRead();
        int readNumber = number;
        System.out.println("乐观读取到的 number = " + readNumber);
        // 检查乐观读取到的数据是否有误
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            System.out.println("乐观读取到的 number " + readNumber + " 有误,换用悲观锁重新读取:number = " + number);
            lock.unlockRead(stamp);
        }
    }

    private void write(int change) {
        long stamp = lock.writeLock();
        number += change;
        System.out.println("写入 " + number);
        lock.unlockWrite(stamp);
    }

    @Test
    public void test() throws InterruptedException {
        // 开启一个线程写入 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                write(1);
            }
        }).start();

        // 开启一个线程读取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保证线程执行完成
        Thread.sleep(1000);
    }
}

运行程序,输出如下:

写入 1
写入 2
写入 3
写入 4
写入 5
写入 6
...
乐观读取到的 number = 86
写入 87
乐观读取到的 number 86 有误,换用悲观锁重新读取:number = 87
乐观读取到的 number = 87
写入 88
乐观读取到的 number 87 有误,换用悲观锁重新读取:number = 88
乐观读取到的 number = 88
写入 89
写入 90
写入 91
写入 92
乐观读取到的 number 88 有误,换用悲观锁重新读取:number = 92
写入 93
写入 94
写入 95
写入 96
写入 97
写入 98
写入 99
写入 100
乐观读取到的 number = 92
乐观读取到的 number 92 有误,换用悲观锁重新读取:number = 100
乐观读取到的 number = 100
乐观读取到的 number = 100
乐观读取到的 number = 100
乐观读取到的 number = 100
...

NPR:“就是这么简单,先用 tryOptimisticRead 尝试乐观读取,再使用 lock.validate(stamp) 验证版本号是否被修改。如果被修改了,说明读取有误,则换用悲观锁重新读取即可。”

你:“之前我看到 lock 和 unlock 都是成对出现的,这段代码里如果 lock.validate(stamp) 验证结果为 true,乐观锁就执行不到 unlock 方法了啊!会不会导致没有解锁?”

NPR:“你多虑了,tryOptimisticRead 只是返回一个版本号,不是锁,根本没有锁,所以不需要解锁。这也是乐观锁提升效率的秘诀所在。”

你:“原来如此,这么说来,当需要频繁地读取时,使用乐观锁可以大大的提升效率。”

NPR:“没错,如果读取频繁,写入较少时,使用乐观锁可以减少加锁、解锁的次数;但如果写入频繁,使用乐观锁会增加重试次数,反而降低了程序的吞吐量。所以总的来说,读取频繁使用乐观锁,写入频繁使用悲观锁。”

你:“大师果然是大师,针对各种场景的优化都替我们考虑到了,我已经路转粉了!伟大的 Doug Lea!”

NPR 不禁感叹道:“是啊,伟大的 Doug Lea!世界上 2% 的顶级程序员写出了 98% 的优秀程序,我们平常不过是使用他们造好的轮子而已。”

第十回 最终考验

你:“要用好轮子也需要懂得轮子从创造到发展的过程。谢谢你教我这么多,NPR 先生。”

NPR:“哈哈,先别谢我,其实我给你展示的代码是有一点问题的,为了给你讲解,我简化了一句代码,你能看出是什么吗?”

你:“啊?你个坑货!让我想想,嗯…”

NPR:“提示你一下,问题在于异常处理。”

你:“我知道了,Java 代码需要考虑异常,使用 java.util.concurrent 时,获取锁之后的代码都需要放在 try 代码块中,并且需要将 unlock() 函数写在 finally 语句中,才能保证一定能够解锁。”

NPR:“就是这样!再会,我的勇士!”

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章