柳州网站建设数公式大全,公证网站建设管理,网站开发 访问速度慢,网址导航主页哪个好在开始讲解线程安全之前我们先来回顾一下我们学了那些东西了#xff1a;
1. 线程和进程的认识
2. Thread 类的基本用法
3. 简单认识线程状态
4. 初见线程安全
上一章结束时看了一眼线程安全问题#xff0c;本章将针对这个重点讲解。
一个代码在单线程中能够安全执行
1. 线程和进程的认识
2. Thread 类的基本用法
3. 简单认识线程状态
4. 初见线程安全
上一章结束时看了一眼线程安全问题本章将针对这个重点讲解。
一个代码在单线程中能够安全执行但是在多线程中就容易出现错误其本质原因就是线程在系统中的调度是无序的 / 抢占式执行的。
再看一眼上一章末尾的题两个线程各执行 5w 次自增操作最后的结果为什么是一个小于 10w 的随机数。
上节课也画了图 线程不安全的原因
我们在这里讨论一下照成线程不安全的原因有哪些
多线程的抢占式执行罪魁祸首多个线程修改同一个变量 【如果是一个线程修改一个变量 安全】【多个线程读取一个变量 安全】【多个线程修改不同变量 安全】修改操作不是原子的 内存可见性引起的线程不安全指令重排序引起的线程不安全
那么我们就开始本章内容的讲解
对于 多线程的抢占式执行 和 多个线程修改同一个变量 这两点不是我们能够改变的我们就直接跳过直接看第三条
修改操作不是原子的
这里说到的原子性数据库中 事物的原子性 是一个概念 原子性意味着不可再分说明每个操作都是最小单位。
例如上述例题 每次自增操作都不算是最小操作我们还可以对其进行划分将一次 add 操作分为三个小操作load 、 add 、 save
任意某个操作对应单个 cpu 指令就是原子的 对应多个 cpu 操作就是非原子的。
正是应该这个操作不是原子的导致了俩个线程的指令排序存在更多的变数
既然我们发现了这个问题了我们该如何解决呢
保证操作的原子性
既然它不是原子的那么我们就可以通过加锁操作让它变成原子性的。
就比如
我们要上厕所为了让别人也进来所以需要锁门我们就给门 加了个锁那么上完厕所以后就解锁剩下的两个人就继续 抢占式 上厕所。
那么这个锁呢就可以保证 “原子性” 的效果
锁的核心操作就两个加锁和解锁。
对于上述的一个锁当谁抢到了其他线程就需要等待也就发生了 阻塞等待直到拿到锁的线程释放为止。
那么如何对线程进行加锁呢
加锁 和 解锁
Java提供了关键字synchronizedJava直接用 synchronized 这个关键字实现加锁过程。
还是上一章中最后一段的线程自增 5w 次的例子
代码如下
class Count {private int count 0;public void add() {synchronized (this) {count;}}public int get() {return count;}
}
public class demo11 {public static void main(String[] args) throws InterruptedException {Count count new Count();Thread t1 new Thread(() - {for (int i 0; i 50000; i) {count.add();}});Thread t2 new Thread(() - {for (int i 0; i 50000; i) {count.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count.get());}
}
唯一不同的点在于 我们加了关键字。 这里给它加了个代码块这个代码块有啥用呢
一旦进入 被 synchronized 修饰的代码块时就出发加锁机制 一旦离开了这个代码块就会触发解锁机制。
而且我们在 synchronized 后面加了一个this这里的 this 就是锁对象。
谁调用 this 就是谁就对谁进行加锁操作。
例如 如果两个线程针对同一个对象进行加锁就会造成锁竞争一个拿到锁另一个线程阻塞等待。如果两个对象针对不同的锁竞争就不会照成锁竞争。
现在重点来说一下锁括号里面的东西 里的锁对象可以是写作任意一个Object 对象但是不能是 内置类型内置类型就是基本数据类型。
这括号主要就是为为了告诉大家,多个线程针对同一个对象加锁就会出现锁竞争,如果针对不同的对象加锁,就不会出现锁竞争了,再也没有别的作用
加锁以后,操作就变成原子的了,原来的操作就变成为了 那么再次执行的时候就变成为了 由于 t1 已经率先lock 了t2 再次尝试 lock 就会出现阻塞等待的情况。
此时就可以保证 t2 的load 一定是在 t1 save 之后此时计算的结果就一定是安全的。
加锁的本质其实就是变成串行化。
那么对比 join 方法join也是实现串行化join 方法是让两个线程都是实现串行化而加锁只是让加锁的部分串行其他部分还是并发执行的。
无论如何加锁可能会造成阻塞代码阻塞对于程序的效率还是会有影响的。
内存可见性引起的线程不安全
我们先来写个 bug 在来说原因。
看代码
import java.util.Scanner;public class demo12 {public static boolean flag false;public static void main(String[] args) {Thread t1 new Thread(() - {while (!flag) {}});Thread t2 new Thread(() - {Scanner scanner new Scanner(System.in);flag scanner.nextBoolean();});t1.start();t2.start();}
}我们在来运行一遍 可以看到输入了true 之后代码还在跑同样可以在 jconsole 里看到线程还在执行为什么这一段代码还继续执行呢。
这里就涉及到内存可见性了。
我们在执行这段代码的时候进入到 while 循环 flag 为真 在这个过程中又发生了两个 原子性的操作 一个是 load 从内存读取数据到 cpu 寄存器一个是 cmp 在cpu中可以叫别的名字比较寄存器内的值是否为 false 。
这两个操作load 消耗的时间远远高于 cmp 。
读内存虽然比读硬盘 快个几千倍 读寄存器又要比 读内存快个几千倍
这样换算下来 每秒钟就要执行上亿次。
那么这样看下来编译器发现 load 的开销很大并且每次的结果都一样那么编译器就做了一个非常大胆的操作直接将 load 优化掉了去掉了只有第一次执行的 load 真正执行了后续只循环 cmp 不执行 load 。 所谓的内存可见性就是在多线程的环境下编译器对于代码优化产生了误判从而引起的 bug 从而导致我们代码的 bug 。
那么我们就可以通过 让编译器对这个场景暂停优化
这里就需要使用另一个关键字 volatile
该关键字的含义就是被它修饰的变量此时编译器就会停止上述的优化。能够保证每次都是从内存上重新读取数据。
volatile关键字的作用主要有如下两个
保证内存可见性基于屏障指令实现即当一个线程修改一个共享变量时另外一个线程能读到这个修改的值。保证有序性禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束运行时靠屏障指令组织指令顺序。
volatile不能保证原子性volatile 使用的场景是一个线程读一个线程写的情况而 synchronized 则适用于多线程写。
volatile 的这个效果称为 “保证内存可见性”。
而 synchronized 不确定是否也能保证内存可见性网上资料 众说纷纭 。
volatile 还有一个效果禁止指令重排序。
指令重排序
什么是指令重排序
这也是编译器优化手段的一种调整了代码的执行顺序但是前后的逻辑不改变效率更高。
如果是单线程的实现逻辑结果并不会改变但是在多线程中就会产生问题。
举例
有个学生对象 Student s
线程 t1 s new Student
线程 t2 if s null s.learn
大体可以分为三个步骤
1. 申请内存空间
2. 调用构造方法初始化内存的数据
3. 把对象的引用赋值给s 内存地址的赋值
如果是个单线程此处可以发生指令重排序 2 和 3 谁先谁后都可以。
t1执行1和3,即将执行2的时候,t2开始执行,t2拿到的就不是一个空的对象,是一个非空的,他就去调用cow的方法,但是实际上,t1还没有初始化,调用方法,会产生bug,所以我们可以在cow对象前加关键字volatile,保证执行顺序。
那么本章的 线程安全 就到这里下一章继续多线程内容。