各位读者好, 我是小陈, 这是我的个人主页
小陈还在持续努力学习编程, 努力通过博客输出所学知识
如果本篇对你有帮助, 烦请点赞关注支持一波, 感激不尽
希望我的专栏能够帮助到你:
JavaSE基础: 从数据类型 到 类和对象, 封装继承多态, 接口, 综合小练习图书管理系统等
Java数据结构: 顺序表, 链表, 二叉树, 堆, 哈希表等 (正在持续更新)
JavaEE初阶: 多线程, 网络编程, html, css, js, severlet, http协议, linux等(正在持续更新)
上篇[多线程基础2]主要介绍了 : Thread类 的构造方法, 常用成员属性, 常用成员方法以及多线程的状态, 状态转换
本篇继续介绍多线程相关的基础内容, 内容较多, 分为若干篇持续分享
提示:是正在努力进步的小菜鸟一只,如有大佬发现文章欠佳之处欢迎批评指点~ 废话不多说,直接上干货!
如何定义线程是否安全
?
如果多线程环境的代码执行结果, 和单线程环境的代码执行结果一致, 则认为线程是安全的, 否则认为线程不安全
下面介绍线程不安全的几个原因
这是导致多线程环境下 线程不安全 的最根本原因
由于多个线程是 “抢占式执行的” , 所以造成了多线程调度的随机性, 无序性
多线程在 CPU 上并发执行, 而 CPU 只能看懂二进制的指令, 所以多线程调度时的随机性, 无序性, 就有可能造成这些指令的混乱
所以多个线程互相影响起来, 也是无迹可寻的
原子性是指 : 不可分割的最小单位, CPU 执行的一条指令, 就是满足原子性的
然而一行 Java 代码(即便很简单易懂), 也不一定满足原子性, 因为这一行代码可能分为很多条指令
如果不满足原子性, 在多线程环境下, CPU 正在执行线程 A 的代码对应的指令, 此时另一个线程过来插了一脚, CPU 去执行线程 B 的代码对应的指令, 整个程序就有可能发生错误
可见性指 : 一个线程对共用变量的修改, 能够及时地被其他线程看到
如果不满足内存可见性, 在多线程环境下, 线程 A 修改了某个共用变量的值, 线程 B 看不到这这共用变量被修改了, 还在使用修改前的值, 程序就有可能发生错误
哎? 为啥线程 A 修改了共用变量, 线程 B 不能及时看到呢? 这就要谈谈 Java 内存模型 了
线程之间的共用变量存在 主内存 (Main Memory)
每一个线程都有自己的 “工作内存” (Working Memory)
当线程要读取一个共用变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据
当线程要修改一个共用变量的时候, 也会先修改工作内存中的副本, 再同步回主内存
这里的主内存才是平常说的内存, 工作内存 其实是 寄存器 和 高速缓存
不满足内存可见性的情况 :
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程A 的工作内存中的值, 线程B 的工作内存不一定会及时变化
为什么Java官方要把 寄存器和高速缓存, 定义成一个新的术语"工作内存"?
因为早期的CPU中是没有高速缓存的, 并且由于Java的可移植性, 为了应付不同电脑上的硬件软件差异, 保证文档的规范性, 适用性, 定义了"工作内存"
例如 : 如下有四条指令
1, 我从宿舍出发
2, 要去食堂吃饭
3, 要去快递站拿快递
4, 要帮舍友带饭
按照1 --> 2 --> 3 --> 4 的顺序执行, 我的路线是这样的 :
把 3 , 4 互换位置后, 按照1 --> 2 --> 4 --> 3 的顺序执行, 我的路线是这样的 :
在 保证逻辑不变 的前提下, 更改多条指令的顺序, 从而提高程序执行效率, 这就是指令重排序
有些指令重排序能够提高执行效率 有时不能
这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价, 这是指令重排序可能造成的弊端
并且重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论, 了解即可
首先定义一个 Counter类
class Counter {private int number = 0;public void add(){number++;}public int getCount(){return number;}
}
我们创建两个线程,两个线程都调用 add方法
5k 次,两个线程结束后主线程中获取 number 的值
public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread thread1 = new Thread( () -> {for (int i = 0; i < 5000; i++) {counter.add();}});Thread thread2 = new Thread( () -> {for (int i = 0; i < 5000; i++) {counter.add();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(counter.getCount());}
预期结果 : 最终number的值是1w,来看运行结果
实际结果 : 并不是 1w, 而是小于1w, 并且多次运行的执行结果都不一样
原因就在于 add方法 调用后的 number++ 这个操作并不是原子的
这一行代码看似简单易懂,实际上是三条 CPU 指令 :
1, load 从内存中读取 number 的值, 到寄存器中
2, add 修改寄存器中 number 的值, 把 number + 1
3, save 把寄存器中的值写回到内存中
其实就是上述的 Java内存模型 的机制
两个线程并发执行时, 如果是 理想情况 :
这三条指令没有相互交错, 就不会对最后的值产生影响, 不妨就称为 理性情况
对应的, 如果 thread1线程
在 CPU 上执行到 load 指令时, 读到 number 的值为 1 , 本该继续执行后两条指令, 但是突然被 thread2线程
抢占执行了, CPU 开始执行thread2线程
的三条指令, 就是 非理想情况 :
只要是 number++
时, 两个线程发生了 “抢占式” 执行, 导致了有一次修改无效(被覆盖), 不妨就称为 非理性情况
正是因为 number++
这个操作不是原子的, 所以才会在 “抢占式” 执行时产生问题, 可是, 只要是多线程环境, 就无法改变 “抢占式” 执行这一机制, 那有没有一种可能, 我们把 number++
变成原子性的呢?
当然可以, 就是通过 “加锁” 来实现, 即 : 使用 snychronized 关键字
只需要在 Counter类 中的 add方法上, 加一个 snychronized 关键字
synchronized public void add(){number++;}
来看运行结果 :
符合上面示例的预期结果
snychronized 关键字 最主要的特性就是 : 互斥
例如 : 线程 A 执行到对象 Counter 的 synchronized 修饰的代码块
中时, 线程 B 如果也同时执行到对象 Counter 的 synchronized 修饰的代码块
, 线程 B 就会阻塞等待
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
其实很好理解 :
张三去上厕所, 进去之后锁门 (加锁)
, 此时李四也想上厕所, 他就得在门口憋着, 等张三出来 (解锁)
之后, 才能进去厕所, 锁门 (加锁)
可如果在张三还没出来的时候, 李四和王五都想上厕所, 那么等张三出来 (解锁)
之后, 李四和王五就要抢这个厕所(锁竞争
), 谁抢到谁就进去厕所, 锁门(加锁)
,
李四和王五 抢 的过程就是多线程的抢占式执行
厕所, 其实就是一个锁对象
“锁” 这个话题很丰富, 以后还会详细介绍
定于一个成员属性 n 初始化为 0 , 再创建两个线程
在第一个线程中, 如果 n 为 0 , 一直循环, 没有循环体, 在第二个线程中, 从控制台输入整数, 赋值给 n
public static int n = 0;public static void main(String[] args) {Thread thread1 = new Thread( () -> {while (n == 0) {}System.out.println(" n 不是 0 了, 循环结束");});Thread thread2 = new Thread( () -> {Scanner scanner = new Scanner(System.in);n = scanner.nextInt();});System.out.print("请输入一个整数 : ");thread1.start();thread2.start();
预期结果 : 如果输入的 n 不是 0 , thread1 就会退出循环, 执行打印语句
实际结果 : 并没有退出循环, 但 n 的值确实被修改了
既然 n 的值确实被修改了, 那么 thread1线程 中的循环没有退出的原因只能是, thread1线程 没有读取到修改过的 n 的值, 而是一直读取原本的 n 的值
因为在 while 循环中, 没有循环体, 在整个循环中只有两条指令
1, load 从内存中读取 n 的值到寄存器
2, 从寄存器中读取 n , 比较 n 的值和 0 是否相同
由于从寄存器中读取数据, 比从内存中读取数据快了 3 ~ 4 个数量级( 1k ~ 1w 倍)
, 所以对于这一整个循环来说, 执行 1次 指令1, 就可以执行 1k ~ 1w次 指令2
那么从内存中读取 n 的值就成了 “负担” , 所以编译器就进行了优化, 直接省去了指令1, 那么 thread1线程 中, n 的值就永远为 0
这就导致了上述实例中, 对于 n 这个共用变量, 在 thread1线程 和 thread2线程 中不满足 内存可见性, 如何解决这个问题呢? 使用 volatile关键字
只需要在共用变量 n 之前加上 volatile关键字 即可
volatile public static int n = 0;
volatile 修饰的变量, 能够保证 内存可见性, 能够 强制执行内存和寄存器之间的读写指令, 虽然导致速度慢了, 但是数据变的更准确了
volatile 还可以禁止指令重排序
但是 volatile 不保证原子性, 如果把 示例1 中的 synchronized 关键字 改成 volatile 关键字, 最终执行结果仍然不符合预期
所以, volatile 关键字 适合于一个线程读, 一个线程写的情况
以上就是本篇的全部内容, 主要介绍了
如果本篇对你有帮助,请点赞收藏支持一下,小手一抖就是对作者莫大的鼓励啦😋😋😋~
上山总比下山辛苦
下篇文章见