❣️关注专栏:: JavaEE
这篇文章将为大家描述线程安全问题的原因和解决方案。线程安全是多线程编程中最难的地方,也是重要的地方,还是一个最容易出错的地方,也是面试中容易考的要点,同样也是我们以后工作中经常爱出错的地方,所以线程安全问题大家一定要注意!!!
线程安全
- 🌴线程安全的概念
- 🌴典型的线程安全问题
- 🌴线程不安全案例
- 🌴案列不安全的原因
- 🌴线程不安全的原因
- 🌴抢占式执行
- 🌴代码结构
- 🌴原子性
- 🌴可见性
- 🌴代码顺序性
- 🌴解决线程不安全问题----加锁
- 🌴synchronized的使用方法
🌴线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该得到的结果,则说这个程序是线程安全的。
🌴典型的线程安全问题
🌴线程不安全案例
观察以下代码,了解什么是线程的不安全状态,线程的不安全问题会带来什么结果呢?
class Counter { // 实现 count 累加的类
public int count = 0;
public void add() { // 实现 count 累加的方法
count++;
}
}
public class ThreadDemo13 {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞两个线程。2个线程分别对 counter 来调用 5w 次的 add 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
// 启动线程
t1.start();
t2.start();
// 等待两个线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印最终的 count 值
System.out.println("count = " + counter.count);
}
}
按照我们所想,对 count 进行了两次 5w次的累加,最终的结果应该是10w才对,那么我们运行一下结果却不一样(结果太多就不截图举例了),并且每次运行后的结果都不一样,为什么呢?
造成这样的结果,罪魁祸首就是多线程的抢占式执行所带来的随机性。
如果没有多线程,此时程序的执行顺序只有一条,也就是固定的顺序,那么程序的执行结果就是固定的。如果有了多线程,此时抢占式执行下,代码的顺序会有很多种的变化,代码的执行顺序会有无数种,所以代码的执行结果就会不正确,就视为有 bug ,此时线程就是不安全的。
🌴案列不安全的原因
这个案例它之所以不安全,主要与 count++ 有关,因为++操作本质上是要分为3步完成的。
- 先把内存中的值,读取到 CPU 的寄存器中。(load)
- 把 CPU 寄存器里的数值进行 +1 运算。(add)
- 把得到的结果写回到内存中保存起来。(save)
这3个操作就是 CPU 上执行的三个指令,为机器语言。
如果两个线程并发执行的 count++,此时就相当于是两组 load、add、save 进行执行,那么不同的线程调度顺序就可能会产生一些结果上的差异。
接下来以图示展示可能出现的执行顺序。
在众多中的情况下,只有1和2这两个调度顺序才是安全的
因为1和2原理相同,只对1进行分析:可见线程是安全的。
下面对第3种情况,一个不安全的执行调度顺序进行分析:由此可见,得到的结果不是我们想要的结果。预期结果是2,实际结果确实1,所以出现了 bug。
其实这里的安全问题和事务中的读未提交(read uncommitted)的原理差不多。相当于 t1 读到了 t2 还没来得及提交的脏数据,就成了“脏读”。多线程安全问题和并发事务本质上都是“并发编程”的问题。
一个线程要是要执行,就需要先编译成许多的 CPU 指令,我们所写的任何一个代码,都是要编译成很多 CPU 指令的。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都可能被调度走,这时 CPU 会让其他的线程来执行。
🌴线程不安全的原因
🌴抢占式执行
抢占式执行是线程不安全的根本原因,导致随机调度。
🌴代码结构
- 多个线程同时修改同一个变量,是不安全的。
上述的线程不安全是由于多个线程同时修改共享数据。涉及到多个线程针对 counter.count 变量进行修改,此时这个 counter.count 是一个多个线程都能访问到的 “共享数据”,counter.count 这个变量就是在堆上,因此可以被多个线程共享访问。 - 一个线程,修改一个变量,是没事的,安全的。
- 多个线程,读取同一个变量,是没事的,安全的。
- 多个线程,修改不同的变量,是没事的,安全的。
因此我们可以通过调整代码的结构来规避这个问题,但是这种调整不一定都能使用,所以这不是一个普适性的方案。
🌴原子性
如果修改的操作是原子(不可拆分的单位)的,那问题不大。如果不是,那么出现的问题概率就非常高。
但是原子性并不会保证线程一定就是安全的。如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大。
针对线程的安全问题,最主要的手段就是把这个非原子的操作编程原子的,对它进行加锁操作。
🌴可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到
🌴代码顺序性
这里说的是“指令重排序”。重排序指的是单个线程里,编译器在保证逻辑不变的情况下将代码进行调整,代码顺序发生改变从而加快程序的执行效率,线程123可能调整为321。
🌴解决线程不安全问题----加锁
解决不安全的额问题最主要的就是加锁,加锁的关键字是:synchronized,给 add() 方法前边加入关键字synchronized,就可以实现了加锁。
加了 synchronized 之后,进入方法就会加速,出了方法就会解锁。
synchronized用的锁是存在Java对象头里的。
其他线程的排队等待就是“阻塞等待”。
就用刚才的案例解释“加锁”,对一个线程加锁,其实是让3个操作在执行过程中不进行调度,而是让其他线程在阻塞等待。
加锁的本质就是把 并发 变成了 串行。
🌴synchronized的使用方法
- 修饰方法
进入方法就是加锁,离开方法就是解锁。
(1)修饰普通方法
(2)修饰静态方法 - 修饰代码块
但是这两种方法,加锁的“对象”不同。
修饰普通方法,锁对象就是 this
修饰静态方法,锁对象就是类对象
修饰代码块,显示/手动指定锁对象
加锁时一定要明确对哪个对象加锁。
如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争/锁冲突);如果两个线程对不同的对象进行加锁,不会阻塞等待,不会锁竞争/锁冲突。
简单地理解就是:不管加锁的对象是谁,只要锁对象相同,就会产生锁竞争(产生阻塞等待),锁对象不同就不会产生锁竞争(产生阻塞等待)。