一. 线程安全问题
概念
首先, 线程安全的意思就是在多线程各种随机调度的情况下, 代码不出现 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);
}
}
那我们再来看运行结果:
根据截图的结果我们可以看到, 每一次运行结果, count 的值都不一样, 反正不是10000.
那么为什么会出现这种问题呢?
首先我们要知道进行的 count++ 的操作, 底层 是三条指令在CPU上完成的.
- load -> 把内存的数据读取到 CPU 寄存器上
- add -> 把 CPU 中寄存器上的值进行 +1
- save -> 把寄存器中的值, 写回到内存中
因为当前是两个线程一起修改一个变量(修改共享数据), 每次的修改是三个步骤(不是原子的), 并且线程之间的调度顺序的不确定的.
什么是原子性?
我们设想一个场景, 大家在厕所方便的时候, 假设, A 这个人进去了之后还没出来, 而如果A没有锁门的话, 那么B是不是也可以进去呢. 这显然是不行的, (而这个锁门就是需要一定的机制来保证安全). 这里A就是不具备原子性的. 而 A 要是把门锁好, 那么B就进不去了, 这样就保证了原子性.
有的时候也把这个现象叫做同步互斥.
因此, 两个线程在真正执行这些操作的时候, 就有可能会有很多种执行的排列顺序.下面来看图:
按照时间轴的形式画图, 在内存中真实存在的情况不止这几种, 而在上图的排列方式中, 没有问题的只有下面两种情况:
表面上是并发执行, 其实差不多是串行执行了.
以下图为例, 这个时候多线程自增就会产生 "线程安全问题"!!!
假设两个线程在两个CPU核心上运行:
按照上图的时间轴在内存中自增的话, 就发生问题了:
开始时 t1 线程先进行 load 操作,
然后 t2 线程进行 load 操作, 再 add, 然后再内存中 count++
这个时候, t1线程也进行 add 和 save, 这个时候就出问题了, 看图:
类似于这种情况, 就会出现线程安全问题了. 因此再最开始我们进行自增操作的时候, 得到的值不是10000.
三. 线程不安全的原因
1. 抢占式执行
线程不安全的罪魁祸首
我们都知道多线程的调度执行过程是随机的, 这是内核实现的, 咱们无能为力, 我们能做到的就是, 在写多线程代码的时候, 需要考虑到的就是, 在任意的调度情况情况下, 咱们的代码都能运行出正确的结果.
2. 多个线程修改同一个变量
有时可以从这里入手, 来规避线程安全问题, 但是普适性不高
注意我加颜色的汉字, 一个线程修改一个变量没事, 多个线程修改同一个变量没事, 多个线程修改不同变量还是没事, 但只要多个线程修改同一个变量的话, 问题就出来了.
3. 修改操作不是原子的
解决线程安全问题, 最常见的办法, 就是从这入手
像上面 count++ 操作, 本质上是三条指令: load add save
CPU 执行指令, 都是以 "一个指令" 为单位进行执行的, 一个指令就相当于 CPU 上的最小单位, 不会发生指令执行到一半, 线程被调度走了的情况.
4. 内存可见性问题
也会产生线程不安全问题
这是 JVM 的代码优化引出的 BUG
编译器优化:
程序猿写代码写好之后在机器上运行, 因为程序猿水平参差不齐, 大佬们的代码正确又高效, 而我这样的菜鸟写代码经常出BUG效率还低, 这个时候, 写编译器的大佬, 就想办法, 让编译器有了一定的优化能力, 就是编译器把你代码里面的逻辑等价转换为另一种逻辑, 转换之后逻辑不变, 但是效率变高了.
举个栗子:
假设我老妈让我去超市里面买上图中的四样东西,中午做饭用, 如果我按照列表上的顺序1>2>3>4去买东西的话, 那么我就会东跑跑西跑跑, 而我要是从超市入口进去, 然后按着箭头的方向去买的话, 那么我就会少走一些路, 结果是一样的, 效率提升了, 这种过程就是编译器优化.
5. 指令重排序
也会引发线程不安全
假设我们这里有这样三行代码, 这是指令重排序前的情况:
在前面我就说有三种指令 LOAD ADD SAVE, 而发生指令重排序的话,
在这个过程中, 就可能发生线程不安全问题.
四. 解决线程不安全问题
还是继续来看开始的代码. 顺着三中的各种引发线程不安全的原因来看, 首先 抢占式执行 这个我们无能为力, 再看 多个线程修改同一个变量 也没有关系, 接下来就是原子性的问题了, 所以这里的解决办法就是用一些办法, 来使得 count++ 操作变成原子的.
加锁
而要使操作变得原子呢, 我们需要做的就是加锁.
举个例子, 大家回家了之后肯定要关门吧, 把门关上外人就进不来, 这就是加锁, 然后等你有事情要出门了, 你在把门打开出去, 这就是解锁. 在这个过程中, 外人进不去也就是互斥
解决上述线程不安全问题, 类似这样, 在 count++ 之前加锁, 等 count++ 完之后解锁. 在加锁和解锁这个时间里, 别的线程想要修改是不行的, 修改不了的, 只能阻塞等待, 这里阻塞等待的线程状态就是 BLOCKED.
synchronized
1.对方法加锁
在 Java 中, 进行加锁, 使用 synchronized 关键字
把 synchronized 加到increase()方法上, 这是最基本的使用, 使用 synchronized 来修饰一个普通方法, 当进入方法的时候会加锁, 方法执行完之后, 就解锁.
我们来看加上 synchronized 之后的代码执行效果:
这个时候我们的count结果就是10000了.
下面来看下具体这个锁是怎么执行的呢, 他的内部是怎么工作的呢?
加锁之后, 就是在线程前后多了两个操作, LOCK 和 UNCLOCK.
假设 t1 线程先加锁, 那么 t2 线程就会出现阻塞, 这个时候和串行执行没啥区别了.
本来线程调度是随机的过程, 容易出现线程安全问题, 现在使用锁, 就使得线程能串行执行了.
加锁前我们要想好锁哪段代码, 锁的范围不一样, 对于代码的执行效果影响差距很大, 锁的代码越多, 我们称之为 "锁的粒度越大", 锁的代码越少, 称之为 "锁的粒度越小".
下面我们试试一个线程加锁, 一个线程不加锁, 我们来看看是否线程安全?
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 线程不加锁.我们直接看运行结果:
可以看到结果不是10000, 出问题了.
这就说明, 只给一个线程加锁是没啥用的, 一个线程加锁的话, 不涉及锁竞争, 也就不会发生阻塞等待, 也就不会 并发修改->串行修改
就好比, A 这个人追到了女神, 但是出来一个 B , 不讲武德, 他挖墙脚, 但要是 A 官宣了, 那么就相当于加锁了, 就是安全的, B 就需要阻塞等待.
2. 修饰代码块
也就是把需要加锁的逻辑放到 synchronized 修饰的代码块中, 也可以起到加锁的作用.
其中 (this) 被我们称为锁对象, 就是针对当前对象加锁, 谁调用这个方法就对谁加锁.