一. 线程安全问题

概念

首先, 线程安全的意思就是在多线程各种随机调度的情况下, 代码不出现 bug 的情况. 如果在多线程调度的情况下, 出现 bug, 那么就是线程不安全.

二. 观察线程不安全的情况

下面我们用多线程来累加一个数, 观察线程不安全的情况:

用两个线程, 每个线程对 counter 进行5000次自增.预期结果10000.

Class Counter {
    public int count = 0;
    public void increase() {
        count++;
    }
}

public class Demo {
    public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });

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

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

        System.out.println("count : " + counter.count);
    }
}

那我们再来看运行结果:

android 线程安全的map 安卓线程安全问题_学习

android 线程安全的map 安卓线程安全问题_学习_02

android 线程安全的map 安卓线程安全问题_android 线程安全的map_03

 

android 线程安全的map 安卓线程安全问题_android 线程安全的map_04

 根据截图的结果我们可以看到, 每一次运行结果, count 的值都不一样, 反正不是10000.

那么为什么会出现这种问题呢?

首先我们要知道进行的 count++ 的操作, 底层 是三条指令在CPU上完成的.

  1. load -> 把内存的数据读取到 CPU 寄存器上
  2. add -> 把 CPU 中寄存器上的值进行 +1
  3. save -> 把寄存器中的值, 写回到内存中

因为当前是两个线程一起修改一个变量(修改共享数据), 每次的修改是三个步骤(不是原子的), 并且线程之间的调度顺序的不确定的.

什么是原子性?

我们设想一个场景, 大家在厕所方便的时候, 假设, A 这个人进去了之后还没出来, 而如果A没有锁门的话, 那么B是不是也可以进去呢. 这显然是不行的, (而这个锁门就是需要一定的机制来保证安全). 这里A就是不具备原子性的. 而 A 要是把门锁好, 那么B就进不去了, 这样就保证了原子性.

有的时候也把这个现象叫做同步互斥.

因此, 两个线程在真正执行这些操作的时候, 就有可能会有很多种执行的排列顺序.下面来看图:

android 线程安全的map 安卓线程安全问题_android 线程安全的map_05

 按照时间轴的形式画图, 在内存中真实存在的情况不止这几种, 而在上图的排列方式中, 没有问题的只有下面两种情况:

android 线程安全的map 安卓线程安全问题_jvm_06

 表面上是并发执行, 其实差不多是串行执行了.

以下图为例, 这个时候多线程自增就会产生 "线程安全问题"!!!

android 线程安全的map 安卓线程安全问题_android 线程安全的map_07

 假设两个线程在两个CPU核心上运行:

按照上图的时间轴在内存中自增的话, 就发生问题了:

开始时 t1 线程先进行 load 操作, 

android 线程安全的map 安卓线程安全问题_android 线程安全的map_08

 然后 t2 线程进行 load 操作, 再 add, 然后再内存中 count++

android 线程安全的map 安卓线程安全问题_开发语言_09

 这个时候, t1线程也进行 add 和 save, 这个时候就出问题了, 看图:

android 线程安全的map 安卓线程安全问题_java_10

 类似于这种情况, 就会出现线程安全问题了. 因此再最开始我们进行自增操作的时候, 得到的值不是10000.

三. 线程不安全的原因

1. 抢占式执行

线程不安全的罪魁祸首

我们都知道多线程的调度执行过程是随机的, 这是内核实现的, 咱们无能为力, 我们能做到的就是, 在写多线程代码的时候, 需要考虑到的就是, 在任意的调度情况情况下, 咱们的代码都能运行出正确的结果.

2. 多个线程修改同一个变量

有时可以从这里入手, 来规避线程安全问题, 但是普适性不高

注意我加颜色的汉字,  一个线程修改一个变量没事, 多个线程修改同一个变量没事, 多个线程修改不同变量还是没事, 但只要多个线程修改同一个变量的话, 问题就出来了. 

3. 修改操作不是原子的

解决线程安全问题, 最常见的办法, 就是从这入手

像上面 count++ 操作, 本质上是三条指令: load add save 

CPU 执行指令, 都是以 "一个指令" 为单位进行执行的, 一个指令就相当于 CPU 上的最小单位, 不会发生指令执行到一半, 线程被调度走了的情况. 

4. 内存可见性问题

也会产生线程不安全问题

这是 JVM 的代码优化引出的 BUG

编译器优化:

程序猿写代码写好之后在机器上运行, 因为程序猿水平参差不齐, 大佬们的代码正确又高效, 而我这样的菜鸟写代码经常出BUG效率还低, 这个时候, 写编译器的大佬, 就想办法, 让编译器有了一定的优化能力, 就是编译器把你代码里面的逻辑等价转换为另一种逻辑, 转换之后逻辑不变, 但是效率变高了.

举个栗子:

android 线程安全的map 安卓线程安全问题_java_11

假设我老妈让我去超市里面买上图中的四样东西,中午做饭用,  如果我按照列表上的顺序1>2>3>4去买东西的话, 那么我就会东跑跑西跑跑, 而我要是从超市入口进去, 然后按着箭头的方向去买的话, 那么我就会少走一些路, 结果是一样的, 效率提升了, 这种过程就是编译器优化.

 

android 线程安全的map 安卓线程安全问题_学习_12

5. 指令重排序

也会引发线程不安全

假设我们这里有这样三行代码,  这是指令重排序前的情况:

android 线程安全的map 安卓线程安全问题_jvm_13

在前面我就说有三种指令 LOAD ADD SAVE, 而发生指令重排序的话, 

android 线程安全的map 安卓线程安全问题_android 线程安全的map_14

 在这个过程中, 就可能发生线程不安全问题.

四. 解决线程不安全问题

还是继续来看开始的代码. 顺着三中的各种引发线程不安全的原因来看, 首先 抢占式执行 这个我们无能为力, 再看 多个线程修改同一个变量 也没有关系, 接下来就是原子性的问题了, 所以这里的解决办法就是用一些办法, 来使得 count++ 操作变成原子的.

加锁

而要使操作变得原子呢, 我们需要做的就是加锁.

举个例子, 大家回家了之后肯定要关门吧, 把门关上外人就进不来, 这就是加锁, 然后等你有事情要出门了, 你在把门打开出去, 这就是解锁. 在这个过程中, 外人进不去也就是互斥

解决上述线程不安全问题, 类似这样, 在 count++ 之前加锁, 等 count++ 完之后解锁. 在加锁和解锁这个时间里, 别的线程想要修改是不行的, 修改不了的, 只能阻塞等待, 这里阻塞等待的线程状态就是 BLOCKED.

synchronized

1.对方法加锁

在 Java 中, 进行加锁, 使用 synchronized 关键字

android 线程安全的map 安卓线程安全问题_开发语言_15

把 synchronized 加到increase()方法上,  这是最基本的使用, 使用 synchronized 来修饰一个普通方法, 当进入方法的时候会加锁, 方法执行完之后, 就解锁.

我们来看加上 synchronized 之后的代码执行效果:

android 线程安全的map 安卓线程安全问题_学习_16

这个时候我们的count结果就是10000了.

下面来看下具体这个锁是怎么执行的呢, 他的内部是怎么工作的呢?

android 线程安全的map 安卓线程安全问题_jvm_17

加锁之后, 就是在线程前后多了两个操作, LOCK 和 UNCLOCK. 

假设 t1 线程先加锁, 那么 t2 线程就会出现阻塞, 这个时候和串行执行没啥区别了.

android 线程安全的map 安卓线程安全问题_java_18

本来线程调度是随机的过程, 容易出现线程安全问题, 现在使用锁, 就使得线程能串行执行了.

加锁前我们要想好锁哪段代码, 锁的范围不一样, 对于代码的执行效果影响差距很大, 锁的代码越多, 我们称之为 "锁的粒度越大", 锁的代码越少, 称之为 "锁的粒度越小". 

下面我们试试一个线程加锁, 一个线程不加锁, 我们来看看是否线程安全?

class Counter {
    public int count = 0;
    public synchronized void increase() {
        count++;
    }
    
    public void increase2() {
        count++;
    }
}

public class Demo3 {
    public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase(); 
            }
        });

        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase2();
            }
        });

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

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

        System.out.println("count : " + counter.count);
    }
}

我们的代码变成这样, 让 t1 线程加锁, t2 线程不加锁.我们直接看运行结果:

android 线程安全的map 安卓线程安全问题_学习_19

可以看到结果不是10000, 出问题了.

这就说明, 只给一个线程加锁是没啥用的, 一个线程加锁的话, 不涉及锁竞争, 也就不会发生阻塞等待, 也就不会 并发修改->串行修改

就好比, A 这个人追到了女神, 但是出来一个 B , 不讲武德, 他挖墙脚, 但要是 A 官宣了, 那么就相当于加锁了, 就是安全的, B 就需要阻塞等待.

2. 修饰代码块

也就是把需要加锁的逻辑放到 synchronized 修饰的代码块中, 也可以起到加锁的作用.

android 线程安全的map 安卓线程安全问题_jvm_20

 其中 (this) 被我们称为锁对象, 就是针对当前对象加锁, 谁调用这个方法就对谁加锁.