线程安全 – 找出不安全的数据

什么才是线程安全?

《Java Concurrency in Practice》 有一个比较恰当的界说 :“当多个线程访问一个方针时,假设不必考虑这些线程在运转时环境下的调度和替换履行,也不需求进行额外的同步或许在调用办法进行任何其他的协调操作,调用这个方针的行为都能够获得正确的成果,那这个方针是线程安全的。”

经典 Counter 计数比方🌰

/**
 * MultiErrorDemo 多线程环境下的常见的计数过错
 *
 * @author suremotoo
 * @date 2022/11/07 12:24
 */
public class MultiErrorDemoCounter implements Runnable {
    static int index = 0;
    static MultiErrorDemoCounter errorDemo = new MultiErrorDemoCounter();
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            index++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(errorDemo);
        Thread t2 = new Thread(errorDemo);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(index);
    }
}

没错,上述代码,咱们启动了 2 个线程:t1、t2 ,别离对 index 进行 10 万次的计数,也便是别离履行 index++

成果,index 最终咱们 预期值为:200000,为实践运转成果,往往都是 小于 200000

这便是典型的线程不安全问题操作,由于 两个线程履行同一个 index 方针的时分,总有可能是针对同一个数值核算,这样重复核算才导致真是数字往往小于预期!

小处理

当然,咱们给 index++ 运用一个 synchronized 同步锁即可处理,run 办法调整后代码如下:

public void run() {
    for (int i = 0; i < 10000; i++) {
      // 需求获得 errorDemo 方针的锁才干进行 index++, 然后保证精确核算
        synchronized(errorDemo) {
            index++;
        }
    }
}

当然,更多具体进程能够参考文档 synchronized 剖析文章(我还没出呢😁)

测验找出 Counter 比方中重复核算的数字!

鉴于上面的问题,大部分想法都是去处理这个问题,已然针对某些值进行了重复核算,那么能不能测验着能不能找出到底是哪些值呢?本着研究学习的心态,去试一试!💪💪

第一步:记载

已然是重复核算,那么咱们就每次核算的都记载一下,判别一下是不是现已核算过了,核算过了就打印出来。

public class FindErrorNumsCounter implements Runnable {
    static FindErrorNumsCounter instance = new FindErrorNumsCounter();
    int index = 0;
    /**
     * 真实运转的次数,该值正好和 index 理论上核算的值是共同
     */
    static AtomicInteger realCount = new AtomicInteger();
    /**
     * 过错的次数
     */
    static AtomicInteger errorCount = new AtomicInteger();
    /**
     * 记载符号核算的数字,容量比理论核算的数值大一些,以便都能装进去
     */
    static boolean[] marked = new boolean[1000000];
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            index++;
            realCount.incrementAndGet();
            // 判别 index 是否现已核算过
            if (marked[index]) {
                System.out.println("犯错了: " + index);
                errorCount.incrementAndGet();
            }
            marked[index] = true;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("真实核算的次数: " + realCount.get());
        System.out.println("过错核算的次数: " + errorCount.get());
        System.out.println("实践成果: " + instance.index);
        System.out.println("----------------");
    }
}

为了便利,咱们先界说了 3 个变量:

boolean[] marked:用于记载符号现已核算过的数值; AtomicInteger realCount:用于记载理论预期的正确值; AtomicInteger errorCount: 记载重复核算的次数;

要点就剖析一下 run() 办法

@Override
public void run() {
    for (int i = 0; i < 100000; i++) {
        index++;
        // 核算次数加 1,核算真实核算的次数,也便是理论上 index 的值
        realCount.incrementAndGet();
        // 判别 index 是否现已核算过,假设现已核算过,阐明重复核算,则打印出来该值
        if (marked[index]) {
            System.out.println("犯错了: " + index);
            // 一起重复核算的次数加 1 
            errorCount.incrementAndGet();
        }
        // 没有重复核算则添加到数组中,符号现已核算过
        marked[index] = true;
    }
}

运转一下:

真实核算的次数: 200000
过错核算的次数: 255
实践成果: 199604

会发现数字很离谱,理论上 实践成果 + 过错核算的次数 = 真实核算的次数 才对!

这儿咱们犯了一个过错,便是咱们核算重复核算的逻辑,也是线程不安全的!便是这儿:

// 判别 index 是否现已核算过,假设现已核算过,阐明重复核算,则打印出来该值
if (marked[index]) {
    System.out.println("犯错了: " + index);
    // 一起重复核算的次数加 1 
    errorCount.incrementAndGet();
}
// 没有重复核算则添加到数组中,符号现已核算过
marked[index] = true;

过错剖析

过错剖析

前提条件: 假设两个线程发生了重复核算,index 从 0 开端,都履行完 index++index 的值都为 1;

  1. 第 1 个线程判别 if (marked[index]) 不符合,那么要符号该 index 现已核算过,也便是要履行 marked[index] = true;
  2. 成果第 1 线程还没履行 marked[index] = true;,偏偏 CPU 调度切换履行第 2 个线程;
  3. 第 2 个线程判别 if (marked[index]) 也不符合,然后第 2 个线程就履行了 marked[index] = true;
  4. 第 2 个线程履行完成后,CPU 调度又切回第 1 个线程去履行 marked[index] = true;

那么最终两个线程对同一个 index 进行了符号!就跟 index++ 重复核算相同,这样便是两个线程冲突,却没有核算到犯错的数字。

示例图

能够配合该动图,理解上面的话(动图稍大,耐心等候)

find-counter-error-marked-unsafe.gif

find-counter-error-marked-unsafe

没处理问题反而还新造出了新问题🤦🤦

第二步:记载调整 – 添加 synchronized

上面记载重复次数的代码不安全,那么咱们用 synchronized 来同步这段代码试试呀!😏😏

@Override
public void run() {
    for (int i = 0; i < 100000; i++) {
        index++;
        realCount.incrementAndGet();
        // 运用 synchronized 保护
        synchronized (instance) {
            if (marked[index]) {
                System.out.println("犯错了: " + index);
                errorCount.incrementAndGet();
            }
            marked[index] = true;
        }
    }
}

这样咱们再看看~

... ... 
犯错了: 183544
犯错了: 190630
真实核算的次数: 200000
过错核算的次数: 1781
实践成果: 199999

多运转几回,实践成果 199999 都现已迫临 真实核算的次数 200000 了,但是这个 过错核算的次数 竟然高的离谱!应该是 1 的呀!😦😦


过错剖析

过错剖析 前提条件: 假设两个线程没有发生冲突,正常核算,index 从 0 开端,第 1 个线程履行完 index++index 的值为 1

提示 2 : 这个又要提到一个概念,便是 synchronized 拥有一个特性:线程可见

  1. 第 1 个线程正常履行 index++ ,index 值变为 1,紧接着进入 synchronized 中,第 2 个线程是无法进入 synchronized 代码块的。
  2. 第 1 个线程此刻即即将履行 if (marked[index]) 代码,却又没履行的时分, CPU 调度又回去让第 2 个线程持续履行;
  3. 这时分第 2 个线程又履行 index++index 变为 2,履行完后 CPU 调度又回去让第 1 个线程履行,这时分第 1 个线程要履行: if (marked[index]) 代码

counter-error-img-1

counter-error-img-1

  1. 由于 synchronized 的线程可见性, 1 个线程能够看到之前的线程干了什么事情,这样第 1 个线程原本要 if (marked[index]) 判其他是 marked[1] ,由于第 2 个线程的成果导致变成了 marked[2]
  2. 如此的话,第 1 个线程将 index 2 就被符号为 true,然后退出 synchronzied 代码块。
  3. 轮到第 2 个线程持续履行的时分,第 2 个线程履行 if (marked[index]) 判其他也是 marked[2] ,由于第 1 个线程现已符号过了,所以会满意 if (marked[index]) 的条件, 然后打印出 犯错了

可实践上两个线程并没有冲突,1 个线程将 0+1=1,另 1 个线程将 1+1=2。

这样下来,原本正确的核算,却打印出了 1 次 犯错了,就会导致上述 过错核算的次数 核算过多的问题了。

示例图

能够配合该动图,理解上面的话(动图稍大,耐心等候)

find-counter-error-marked-synchronized

find-counter-error-marked-synchronized

第三步:记载调整 – 坚持每两个线程一组

上面剖析添加 synchronized 还不可,由于 CPU 调度,可能会让前面的线程一、线程二中的某一个线程步骤加快,进入下一次 index++ 的核算,那么咱们就控制一下,每次 index++ 前保证是 2 个线程一起来!

这时分咱们引入一个新的工具类:CyclicBarrier,先不必理解它到底是什么,只需求知道它到底有什么作用即可。

CyclicBarrier 其实就个栅门,假设你有个草场,养了一窝的阿拉斯加,它们也总是兴致勃勃,为了不让他们乱跑,你为了个栅门把他们圈起来~,哪天你想放出来遛遛它们,打开栅门,那叫一个: 斯如涌泉 🤪,一下子全冲出来了~

CyclicBarrier 其实就跟这个差不多,咱们能够设置一个条件,比方有 2 个线程都等候安排妥当后,然后才允许放行!咱们看代码:

/**
 * FindErrorNumsCounter
 *
 * @author suremotoo
 * @date 2022/11/07 19:57
 */
public class FindErrorNumsCounter implements Runnable {
    static FindErrorNumsCounter instance = new FindErrorNumsCounter();
    int index = 0;
    /**
     * 真实运转的次数,该值正好和 index 理论上核算的值是共同
     */
    static AtomicInteger realCount = new AtomicInteger();
    /**
     * 过错的次数
     */
    static AtomicInteger errorCount = new AtomicInteger();
    /**
     * 记载符号核算的数字,容量比理论核算的数值大一些
     */
    static boolean[] marked = new boolean[1000000];
    static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            try {
                cyclicBarrier1.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
            index++;
            realCount.incrementAndGet();
            synchronized (instance) {
                if (marked[index]) {
                    System.out.println("犯错了: " + index);
                    errorCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("真实核算的次数: " + realCount.get());
        System.out.println("过错核算的次数: " + errorCount.get());
        System.out.println("实践成果: " + instance.index);
        System.out.println("----------------");
    }
}

要点看这儿:

counter-error-img-cyclicBarrier1

counter-error-img-cyclicBarrier1

咱们界说了一个变量 cyclicBarrier1 并且设置了栅门开启的线程数量是 2 个,这儿 cyclicBarrier1.await(); 便是 2 个线程安排妥当了才会履行下一行!

这下咱们再运转,看看成果:

真实核算的次数: 200000
过错核算的次数: 4
实践成果: 200000

🎵眼睛瞪得像铜铃🔔 ~~~~

我… … 怎样仍是有问题❓❓❓❓❓

过错剖析

过错剖析经过 cyclicBarrier1.await(); 代码后,咱们有 2 个线程过来。

前提条件(很重要): 咱们 假设 index 为 0, 第 2 个线程进来就一向没有履行,就卡在 index++;留意:是没履行 index++;

  1. 而第 1 个线程履行 index++ , i 变成 1,并且进入 synchronized 代码块,if (marked[index]) marks[1] 也不满意条件(不记载过错),当即将履行 marked[index]=true 的时分却被 CPU 调度走了,让第 2 个线程持续履行了~
  2. 第 2 个线程履行 index++; index 是从 1 开端核算的,由于第 1 个线程现已 index++ 了!
  3. 这时分 第 2 个线程 index++ 完 index 就变成了 2 ,刚履行完又被 CPU 调度回去,持续让第 1 个线程履行,而这时分第 1 个线程持续履行:marked[index]=true,可此刻 index 现已变成 2 了,原本是要履行 marked[1]=true,成果变成了 marked[2]=true
  4. 最终第 1 个线程履行完后退出 synchronized,第 2 个线程回来持续履行 进入 synchronized
  5. 这样第 2 个线程原本要 if (marked[index]) 判其他是 marked[1] ,成果也变成了 marked[2]

如此的话,第 2 个线程 if (marked[index]) 判其他也是 marked[2] ,这样 2 就现已重复了,就会打印出来 “犯错了:” ,可实践并没有重复呢!和之前剖析导致 过错核算的次数 核算过多的问题相同。

其实你会发现,这和 第二步 的场景是完全相同的嘛!😁

第四步:记载调整 – 坚持每两个线程一组 – 晋级!

第三步,咱们添加了 1 个 CyclicBarrier 栅门来处理 第二步两轮核算 index++ 的问题,发现仍是不可,依然存在一个在 synchronized 代码块里,由于线程切换履行 index++ 导致 index 值变的问题!

已然有可能线程半路才 index++,这样,那么我再加 1 个 CyclicBarrier 栅门,放在 index++ 后边 synchronized 前面,这样就避免了其中 1 个线程在 synchronized 里履行代码的时分突然让其他线程 index++

没问题,上代码:

/**
 * FindErrorNumsCounter
 *
 * @author suremotoo
 * @date 2022/11/07 19:57
 */
public class FindErrorNumsCounter implements Runnable {
    static FindErrorNumsCounter instance = new FindErrorNumsCounter();
    int index = 0;
    /**
     * 真实运转的次数,该值正好和 index 理论上核算的值是共同
     */
    static AtomicInteger realCount = new AtomicInteger();
    /**
     * 过错的次数
     */
    static AtomicInteger errorCount = new AtomicInteger();
    /**
     * 记载符号核算的数字,容量比理论核算的数值大一些
     */
    static boolean[] marked = new boolean[1000000];
    static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
    static volatile CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            try {
                cyclicBarrier2.reset();
                cyclicBarrier1.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
            index++;
            try {
                cyclicBarrier1.reset();
                cyclicBarrier2.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
            realCount.incrementAndGet();
            synchronized (instance) {
                if (marked[index]) {
                    System.out.println("犯错了: " + index);
                    errorCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("真实核算的次数: " + realCount.get());
        System.out.println("过错核算的次数: " + errorCount.get());
        System.out.println("实践成果: " + instance.index);
        System.out.println("----------------");
    }
}

咱们在 index++ 前后都添加了 CyclicBarrier 栅门!这样能够保证让两个线程都会履行 index++

ok,咱们运转一下代码。

犯错了: 199920
犯错了: 199922
犯错了: 199924
犯错了: 199926
犯错了: 199928
犯错了: 199930
犯错了: 199932
犯错了: 199934
犯错了: 199936
犯错了: 199938
犯错了: 199940
犯错了: 199942
犯错了: 199944
犯错了: 199946
犯错了: 199948
犯错了: 199950
犯错了: 199952
犯错了: 199954
犯错了: 199956
犯错了: 199958
犯错了: 199960
犯错了: 199962
犯错了: 199964
犯错了: 199966
犯错了: 199968
犯错了: 199970
犯错了: 199972
犯错了: 199974
犯错了: 199976
犯错了: 199978
犯错了: 199980
犯错了: 199982
犯错了: 199984
犯错了: 199986
犯错了: 199988
犯错了: 199990
犯错了: 199992
犯错了: 199994
犯错了: 199996
犯错了: 199998
真实核算的次数: 200000
过错核算的次数: 100000
实践成果: 199998

美丽马!怎样还越来越离谱了❓❓❓❓❓❓并且犯错的还都是偶数。

counter-error-img-cyclicBarrier2

counter-error-img-cyclicBarrier2

留意:

留意: 由于在 index++ 前后都加入了栅门,所以 2 个线程都会履行 index++ 的。

过错剖析

前提条件:index 为 0

  1. 假设现在 2 个线程是正常按照逻辑履行, 第 1 个线程 index++ index 变为 1
  2. 由于第 1 个线程的成果,第 2 个线程 index++ index 就变为 2
  3. 然后铺开栅门!两个都去履行 synchronized 代码,要进行锁竞赛,由于 synchronized 的线程可见特性, 1 个线程能够看到之前的线程干了什么事情所以不管哪个线程抢到锁去履行,它们用的 index 值都是 2
  4. 第 1 个线程 if (marked[index]) 判别 marked[2] 不满意条件,则履行 marked[2]=true;
  5. 第 2 个线程 if (marked[index]) 判别 marked[2] 满意条件,则打印 犯错了:

这样的状况对吗?必定不对啊,咱们的方针、主旨是要找出重复核算的,现在的 index 有重复核算吗?并没有! 1 个线程将 index 从 0 变为 1,另 1 个线程从 1 变为 2,没问题呀,但咱们现在这种代码就会多打印出来 犯错了:

🤨🤨你会发现,这仍是和之前 第三步、第四步 类似呀,都是正常的逻辑状况下,由于其中 1 个线程将 index 改变后,导致另 1 个线程运用改变后的 index ,然后导致重复打印的问题!

第五步:记载调整 – 调整重复核算的判别处理!

基于上面 第四步,咱们剖析了正常状况多核算了,现在想办法要去掉,那么,一起咱们也要会想一下过错的状况,应该是什么样的。

提示

提示:

由于 index 从 0 开端,index++ 对于 0 是不会漏算的,咱们就把设置 marked[0]=true;

正常的状况,0→1→2:

线程 index 值 marked 符号成果 补白
线程 1 1 false 并没有履行 marked[1] = true; 符号, 由于 线程 2 的将 index 改变为 2 了,所以 线程 1 实践履行的是 marked[2] = true; 所以 index 为 1 在 marked 里默许的值为 false
线程 2 2 true 实践履行的是 marked[2] = true;

或许

线程 index 值 marked 符号成果 补白
线程 2 1 false 并没有履行 marked[1] = true; 符号, 由于 线程 1 的将 index 改变为 2 了,所以 线程 2 实践履行的是 marked[2] = true; 所以 index 为 1 在 marked 里默许的值为 false
线程 1 2 true 实践履行的是 marked[2] = true;

⚠️过错的状况,0→1→1:

线程 index 值 marked 符号成果 补白
第 1 个线程 1 true 实践履行的是 marked[1] = true;
第 2 个线程 1 true 实践履行的是 marked[1] = true;

从上面的表格,咱们能够看到,由于 2 个线程去履行 index++ , 所以 正常的状况,总会像 0→1→2 这样的规则,并且 0→1→2 中间的那个 1 是会略过 sychronized 的代码处理的,2 是正确的,但咱们却多打印出来来了,不应该打印它,所以 marked [0] 总是 true,marked [1] 是 false,marked [2] 是 true以此类推,后边都是 true、false 替换的成果。

过错的状况总会像 0→1→1 这样的规则,所以 marked [0] 总是 true,marked [1] 也是 true,这样呢,我就能够得出一个规则,只要呈现了 marked [index] 和 marked [index-1] 都为 true 的状况,index 才是真实的重复核算,这种状况下才是需求将信息打印出来!

这样咱们就知道调整哪里的代码了,便是 synchronized 代码块中 if (marked[index]) 这句代码,咱们调整为 if (marked[index] && marked[index - 1])

run() 完整代码如下:

@Override
public void run() {
   // 0 的时分永久不会重复核算,手动符号为 true
    marked[0]=true;
    for (int i = 0; i < 100000; i++) {
        try {
            cyclicBarrier2.reset();
            cyclicBarrier1.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (BrokenBarrierException e) {
            throw new RuntimeException(e);
        }
        index++;
        try {
            cyclicBarrier1.reset();
            cyclicBarrier2.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (BrokenBarrierException e) {
            throw new RuntimeException(e);
        }
        realCount.incrementAndGet();
        synchronized (instance) {
          // 判别条件,假设上一个和当时这个都为 true,则阐明 index 漏算
            if (marked[index] && marked[index - 1]) {
                System.out.println("犯错了: " + index);
                errorCount.incrementAndGet();
            }
            marked[index] = true;
        }
    }
}

这样咱们再次运转

真实核算的次数: 200000
过错核算的次数: 0
实践成果: 200000

ok,没问题了!emm,运转好屡次,只是一向没有失利的,这是由于咱们调整屡次代码后,线程磕碰的概率变小了,不要紧!咱们微调一下再测验就能够!

第六步:调整 main 办法,运转代码打印!

只要呈现过错,errorCount 必定能核算到值,咱们就来个循环,直到遇到漏算的过错状况。

完整代码:

/**
 * FindErrorNumsCounter 找出并发状况下哪些计数被漏算
 *
 * @author suremotoo
 * @date 2022/11/07 15:57
 */
public class FindErrorNumsCounter implements Runnable {
    static FindErrorNumsCounter instance = new FindErrorNumsCounter();
    int index = 0;
    /**
     * 真实运转的次数,该值正好和 index 理论上核算的值是共同
     */
    static AtomicInteger realCount = new AtomicInteger();
    /**
     * 过错的次数
     */
    static AtomicInteger errorCount = new AtomicInteger();
    /**
     * 记载符号核算的数字,容量比理论核算的数值大一些
     */
    static boolean[] marked = new boolean[1000000];
    static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
    static volatile CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);
    @Override
    public void run() {
        marked[0] = true;
        for (int i = 0; i < 100000; i++) {
            try {
                cyclicBarrier2.reset();
                cyclicBarrier1.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
            index++;
            try {
                cyclicBarrier1.reset();
                cyclicBarrier2.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } catch (BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
            realCount.incrementAndGet();
            synchronized (instance) {
                if (marked[index] && marked[index - 1]) {
                    System.out.println("犯错了: " + index);
                    errorCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        while (errorCount.get() == 0) {
            realCount.set(0);
            errorCount.set(0);
            instance.index = 0;
            marked = new boolean[1000000];
            Thread t1 = new Thread(instance);
            Thread t2 = new Thread(instance);
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("真实核算的次数: " + realCount.get());
            System.out.println("过错核算的次数: " + errorCount.get());
            System.out.println("实践成果: " + instance.index);
            System.out.println("----------------");
        }
    }
}

成果:

真实核算的次数: 200000
过错核算的次数: 0
实践成果: 200000
----------------
真实核算的次数: 200000
过错核算的次数: 0
实践成果: 200000
----------------
犯错了: 77953
真实核算的次数: 200000
过错核算的次数: 1
实践成果: 199999
----------------

77953 重复核算了,过错 1 次,实践成果 199999,真实的核算为:200000,这次核算对咯~

能够屡次运转验证,发现没问题~🎉🎉