多线程——线程安全
我们学习多线程编程的目的是为了能够实现“并发编程”,从而来提高我们代码的执行效率,在学习使用多线程时,一定避免不了“线程安全”这样的话题,这可以称的上我们多线程编程中最重要的部分,因为他会关系到我们所写的代码是否能够正确的运行,同时,线程安全也是学习多线程编程中最困难的部分,本篇文章将会对“线程安全”这一话题进行讲解。
目录
·前言
我们学习多线程编程的目的是为了能够实现“并发编程”,从而来提高我们代码的执行效率,在学习使用多线程时,一定避免不了“线程安全”这样的话题,这可以称的上我们多线程编程中最重要的部分,因为他会关系到我们所写的代码是否能够正确的运行,同时,线程安全也是学习多线程编程中最困难的部分,本篇文章将会对“线程安全”这一话题进行讲解。
一、观察线程不安全
这里我通过一个代码示例来展现一下线程不安全是什么样的,下面代码示例做的主要工作是使用两个线程,分别对变量 count 进行自增操作,从而快速达到自增 10w 次的效果(一个线程对变量 count 进行 5w 次的自增),代码及运行结果如下:
// 线程不安全演示代码
public class ThreadDemo13 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
// 启动线程 t1 和 t2
t1.start();
t2.start();
// 等待线程 t1 和 t2 工作完成
t1.join();
t2.join();
// 打印 count 的值
System.out.println("count = " + count);
}
}
我们代码想让变量 count 自增 10w 次,最终得到的结果应该是 count = 100000,但是由上面的四次执行结果可以看出,这四次的结果都不一样,并且都没有得到正确的结果,所以上面这个循环自增的代码就是存在线程安全问题的代码。
二、线程安全概念
观察完线程不安全的示例后,我来介绍一下线程安全,我们可以认为,某个代码,无论是在单个线程下执行,还是多个线程下执行,都不会产生 bug ,这个情况就可以成为“线程安全”,但是如果这个代码,在单线程下运行正确,多线程下可能产生 bug ,这个情况就称为“线程不安全”,也就是“存在线程安全”问题。
三、产生线程安全问题的原因
1.分析示例代码
在介绍产生线程安全问题的原因之前,先解释一下上述示例代码为什么会产生线程安全问题,代码中出现问题的就是 count++ 这一操作,在我们编写代码时,count++ 看起来就只是一句话,但是这个 count++ 操作其实是由三条 CPU 指令构成的:
- load:从内存中读取数据到 CPU 的寄存器中;
- add :把寄存器中的值进行 + 1操作;
- save:把寄存器的值写回到内存中。
下面我将上面示例代码中两个线程在进行 count++ 操作时可能出现的部分情况画出来,如下图所示: 上面我列出来了四种情况,每种情况,都是两个线程在调度中可能产生的执行顺序,但其实,这里的情况可以有无数种,这是由于线程的随机调度所产生的,下面就针对这四种情况来模拟演示一下他们各自在 CPU 上的执行指令的过程,在 CPU 上执行指令需要经过读指令,解析指令,执行指令这几个步骤,下面的图中省略读指令与解析指令的过程,就单看执行指令的过程,这里我们假设线程 t1 与 t2 分别在 CPU 的两个核心上并发执行,如下图所示:
由上图我们可以观察到,只有情况1与情况2我们得到了正确的两次自增结果,情况3与情况4虽然两个线程都执行了 count++ 的操作,但是由于线程的随机调度,导致他们执行的结果好像只是进行了一次 count++ 操作,这就导致我们上面示例代码运行时没有得到正确的结果。
经过这几个情况的过程分析,我们可以发现关于这个示例代码中,最关键的问题就在于,我们需要确保第一个线程在执行完 save 指令后,第二个线程再执行 load 指令,这时候第二个线程所加载到的 count 的值才是第一个线程所进行完 count++ 后的结果,否则第二个线程加载到的 count 的值,就是第一个线程执行 count++ 前的结果了,这时候虽然两个线程都执行了 count++ 操作,但其实就只执行了一次。
这里对示例代码出现线程安全问题的原因做一个总结:
- 根本原因:由于操作系统上的线程是“抢占式执行”“随机调度”,导致线程之间的执行顺序产生了诸多变数;
- 代码结构:在示例代码中,涉及到多个线程修改同一个变量;
- 直接原因:上述多线程执行 count++ 操作不属于“原子性”操作,这就导致在执行 count++ 中,多个 CPU 指令在执行到一半的时候被其他线程调度走,从而给其他线程“可乘之机”。
2.线程随机调度
线程的随机调度可以说是产生线程安全问题的“罪魁祸首”,正是因为线程的随机调度,才会给我们在进行多线程编程引入诸多的变数,随机调度使我们的程序在多线程环境下执行顺序存在随机性,我们需要让我们的代码保证在任意执行顺序下都能正常工作才能保证线程安全。
3.修改共享数据
在我们上面的代码示例中,两个线程都涉及到对同一个变量 count 进行自增操作,这就是修改了共享的数据,这时由于线程的随机调度就可能产生问题。
4.原子性
一段代码具有原子性,就可以认为在执行这段代码时,要么这段代码都执行完,要么这段代码就都不执行,上述 count++ 操作产生问题就是因为这个操作不具有原子性,所以在线程的随机调度下,产生了问题。
5.可见性
可见性指,一个线程对共享变量值的修改,能够及时被其他线程看见。
6.指令重排序
假设目前我们执行的一段代码顺序是这样的:
- 去宿舍楼下取外卖;
- 回宿舍写作业;
- 去宿舍楼下卖水。
如果上述逻辑是在单线程的情况下,我们的 JVM 会对上述流程进行一个优化,比如按 1->3->2 的顺序执行也是没有问题的,并且可以少下一次楼,这就叫做指令重排序,我们编译器在对于指令重排序的前提是“保持原有的逻辑不发生变化”,这一点在单线程环境下比较容易判断,但是在我们多线程环境下就没那么容易判断了,所以多线程中 JVM 对我们的代码进行指令重排序时就可能出现优化后的逻辑与之前不等价的情况。
四、解决示例代码的问题
知道了代码中出现的问题,就可以“对症下药”了,根本原因我们无法做出任何改变,因为这是系统内部已经实现的“抢占式执行”“随机调度”,我们干预不了,针对原因2,代码结构,这个有时候可以进行调整,有时候也调整不了,需要看情况,我们这里针对直接原因,count++ 不是原子性来进行入手解决。
虽然 count++ 看起来生成的三个指令我们无法干预,但其实我们还是有办法的,我们可以通过特殊的手段,把这三个指令打包到一起,成为一个“整体”,这就涉及到“加锁”的操作了,在 Java 中,加锁的方式有好几种,但是最主要使用方式还是用 synchronized 关键字,这里我们先进行运用,后面文章再进行进一步的讲解,修改之后的示例代码及运行结果如下所示:
// 修改后,线程安全的代码
public class ThreadDemo13 {
public static int count = 0;
// 创建锁对象 locker
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
// 启动线程 t1 和 t2
t1.start();
t2.start();
// 等待线程 t1 和 t2 工作完成
t1.join();
t2.join();
// 打印 count 的值
System.out.println("count = " + count);
}
}
此时,修改之后代码执行的结果就是正确的结果了。
·结尾
本篇文章到此也就要结束了,文章主要对于线程安全进行了展开介绍,在文章末尾,我们提到解决线程安全问题的一种方式使用 synchronized 关键字,这也是我们学习多线程编程中的一个重点,在下一篇文章里,我会对 synchronized 关键字再进行进一步的讲解,那么关于线程安全这一话题的分享到这里就结束了,我们下一篇文章再见。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)