浅谈线程安全问题的原因和解决方案
1. 观察线程不安全
class Counter {
public int count = 0;
public void increase() {
count++;
}
}
// 线程安全问题演示.
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
上述的代码中两个线程, 针对同一个变量, 进行循环自增. 各自自增5w次,预期最终应该是10w, 但实际上,并不是这样的结果. 每次运行的结果都不一样, 并且还都是错的.
在多线程下,发现由于多线程执行,导致的bug, 统称为"线程安全问题". 如果某个代码, 在单线程下执行没有问题, 多个线程下执行也没问题, 则称为"线程安全",反之就可以称为"线程不安全".
那么啥是bug呢? bug是一个非常广义的概念. bug 的中文名,可以翻译成"幺蛾子". 只要是实际运行效果和预期效果(需求中的效果)不一致,就可以称为是一个bug.
线程安全和线程不安全的区别也就是多线程代码是否有bug.
那么上述的代码为啥会出现bug呢?
如果上述操作, 在两个线程或者多个线程并发执行的情况下, 就可能会出现问题.
如果上述两个线程是这样串行执行的, 那么结果就会是对的. 但是真的能这样吗? 上述图片中虽然是只是自增两次,但是由于两个线程并发执行, 就可能在一定的执行顺序下,导致运算的中间结果就被覆盖了. 在这5w次的循环过程中, 有多少次这俩线程执行++是"串行的”?,有多少次会出现覆盖结果的? 这些都不确定. 因为线程的调度是随机的, 是抢占式执行的过程.
上述的过程就是结果被覆盖的例子. 此处这两个线程的调度是不确定的, 这两组对应的操作也会有差异. 而且上述代码得到的结果一定是小于100000的, 因为有结果被覆盖掉了.
2. 线程安全问题的原因
1) [根本原因]多个线程之间的调度顺序是"随机的", 操作系统使用"抢占式"执行的策略来调度线程.这就是罪魁祸首,万恶之源.
和单线程不同的是, 在多线程下, 代码的执行顺序,产生了更多的变化.
以往只需要考虑代码在一个固定的顺序下执行,执行正确即可. 现在则要考虑多线程下, N种执行顺序下,代码执行结果都得正确.
这件事情,木已成舟,咱们无力改变.当前主流的操作系统,都是这样的抢占式执行的.
2) 多个线程同时修改同一个变量就容易产生线程安全问题.
一个线程修改一个变量, 没事.
多个线程读取同一个变量, 没事.
多个线程修改多个变量, 没事.
3) 进行的修改, 不是"原子的".
如果修改操作,能够按照原子的方式来完成, 此时也不会有线程安全问题.
count++ 不是原子的~
= 直接赋值, 可以视为原子.
if = 先判定, 再赋值, 也不是原子的~~
所以解决线程安全, 最主要的切入手段就是"加锁".
"加锁"相当于是把一组操作, 给打包成一个"原子"的操作.
事务的那个原子操作, 主要是靠回滚. 此处这里的原子, 则是通过锁进行"互斥", 也就是这个线程进行工作的时候, 其他线程无法进行工作.
那根据上面的例子和代码, 我们就可以知道要给count++加锁, 使用synchronized关键字即可.
于是乎代码变动成了这样.
class Counter {
public int count = 0;
synchronized public void increase() {
count++;
}
}
// 线程安全问题演示.
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
那么就有一个问题了, 通过加锁操作之后, 把并发执行=>串行执行了. 此时, 多线程还有存在的意义嘛?
必然是有的. 代码中的线程并不是只做了count++这一件事, for循环并没有加锁, for循环中操作的变量i是栈上的一个局部变量. 两个线程, 是有两个独立的栈空间, 也就是完全不同的变量, 就不涉及到线程安全问题. 因此,这两个线程,有一部分代码是串行执行的, 有一部分是并发执行的, 就仍然要比纯粹的串行执行效率要高.
synchronized进行加锁解锁, 其实是以“对象"为维度进行展开的.
加锁目的是为了互斥使用资源.(互斥的修改变量)
synchronized每次加锁,也是针对某个特定的对象加锁!
如果两个线程针对同一个对象进行加锁
就会出现锁竞争/锁冲突(一个线程能加锁成功,另一个线程阻塞等待), 那么就可以解决线程安全问题.
具体是针对哪个对象加锁,不重要.
重要的是, 两个线程, 是不是针对同一个对象加锁.
就比如更改一下代码, 也一样可有算出正确答案.
class Counter {
public int count = 0;
private Object locker = new Object();
public void increase() {
synchronized (locker) {
count++;
}
}
public void increase2() {
synchronized (locker) {
count++;
}
}
}
// 线程安全问题演示.
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
4) 内存可见性,引起的线程安全问题.
5) 指令重排序,引起的线程安全问题.