Basic Of Concurrency(三:竞态条件和临界区)

950 阅读3分钟

多个线程执行相同的程序本身不会有安全问题,问题在于访问了相同的资源。资源可以是内存区域(变量,数组,对象),系统(数据库,web 服务)或文件等。实际上多个线程读取不会变化的资源也不会有问题,问题在于一到多个线程对资源进行写操作。

以下给出累加器并发访问实例,多个线程对同一个累加器进行累加操作。分别打印出各个线程运行中数值前后变化,以及在主线程暂停2s后,给出最终的结果以及预期结果。

线程安全问题实例

done like this:

public class ThreadSecurityProblem {
    // 累加器
    public static class Counter {
        private int count = 0;
		
        // 该方法将会产生竞态条件(临界区代码)
        public void add(int val) {
            int result = this.count + val;
            System.out.println(Thread.currentThread().getName() + "-" + "before: " + this.count);
            this.count = result;
            System.out.println(Thread.currentThread().getName() + "-" + "after: " + this.count);
        }

        public int getCount() {
            return this.count;
        }
    }

    // 线程调度代码
    public static class MyRunnable implements Runnable {
        private Counter counter;
        private int val;

        MyRunnable(Counter counter, int val) {
            this.counter = counter;
            this.val = val;
        }

        @Override
        public void run() {
            counter.add(val);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        // 开启5个线程,调用同一个累加器
        IntStream.range(1, 6)
                .forEach(i -> {
                    final MyRunnable myRunnable = new MyRunnable(counter, i);
                    new Thread(myRunnable, "thread-" + i)
                            .start();
                });
        Thread.sleep(2000L);
        System.out.println(Thread.currentThread().getName() + "-final: " + counter.getCount());
		
        // 预期值
        int normalResult = IntStream.range(1, 6)
                .sum();
        System.out.println(Thread.currentThread().getName() + "-expected: " + normalResult);
    }
}

运行结果:

thread-1-before: 0
thread-3-before: 0
thread-2-before: 0
thread-2-after: 2
thread-3-after: 3
thread-1-after: 1
thread-4-before: 2
thread-4-after: 6
thread-5-before: 6
thread-5-after: 11
main-final: 11
main-expected: 15

如结果所示,线程1/2/3先后取得this.count的初始值0,同时进行累加操作(顺序无法预估)。线程1/2/3中的最后一次累加赋值后this.count变为2,随后第4个线程开始累加赋值this.count变为6,最后第5个线程累加赋值this.count变为11.所以5个线程执行完毕后的结果为11,并非预期的15.

实例片段

线程1/2/3在执行Counter对象的add()方法时,在没有任何同步机制的情况下,无法预估操作系统与JVM何时会切换线程运行。此时代码的运行轨迹类似下面的顺序:

从主存加载this.count的值放到各自的工作内存中
各自将工作内存中的值累加val
将各自工作内存中的值写回主存

线程1/2/3交替情况模拟:

this.count = 0;
线程1: 读取this.count到工作内存中,此时this.count为0
线程2: 读取this.count到工作内存中,此时this.count为0
线程3: 读取this.count到工作内存中,此时this.count为0
线程3: cpu将工作内存的值更新为3
线程2: cpu将工作内存的值更新为2
线程1: cpu将工作内存的值更新为1
线程3: 回写工作内存中的值到主存,此时主存中this.count为3
线程1: 回写工作内存中的值到主存,此时主存中this.count为1
线程2: 回写工作内存中的值到主存,此时主存中this.count为2
最终主存中的this.count被更新为2

三个线程执行完毕后,this.count最后写回内存的值为最后一个线程的累加值(实例中为线程2,最后回写到内存的值为2)。

总结

多线程访问顺序敏感的区域称为临界区,该区域代码会形成竞态条件。如实例ThreadSecurityProblem中的Counter对象的add()方法。对于临界区的代码不加上适当的同步措施将会形成竞态条件,其运行结果完全无法预估。

该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial

上一篇: 创建和启动线程
下一篇: 线程安全