哈喽,大家好~我是保护小周ღ,本期为大家带来的是 Java 多线程状态下的线程安全问题,多线程修改共享数据,内存可见性,指令重排序造成的线程安全,以及详细的讲述了 解决方案,使用 synchronized 关键字对程序进行加锁 和 volatile 关键字 保证内存可见性相关知识,确定不来看看嘛~
更多精彩敬请期待:保护小周ღ *★,°*:.☆( ̄▽ ̄)/$:*.°★* ‘
多线程带来的程序 bug 主要原因是线程之间的调度执行是无序的,意思就是可能线程 1对某件事务的处理还没完成,CPU 就去执行 线程2 了,线程1 只能将当前执行部分指令定位交给进程的上下文存档,等待下一次CPU 的调度执行时从文档中读取指令定位信息继续执行线程1,此时如果 线程2 也对 该事务进行处理,等到执行线程1 的时候,线程1:我执行一半了,怎么事务变了样?这个时候就容易引发线程安全的相关问题。
讲解一下: 进程的上下文
进程在并发处理的状态下,处理器会循环在各进程之间进行切换处理,上下文就是描述当前进程执行到哪里的(执行到了那条指令)“存档记录”,进程在“暂时" 离开CPU 的时候就会将运行时的中间结果存档,等 CPU 下次再执行该进程的时候,根据“存档记录”恢复到上次执行的状态,然后继续对该进程往后执行。
其中的处理过程会涉及到 CPU 其中的寄存器,CPU 的寄存器会动态的维护操作系统为进程分配的空间包括“存档记录”,当进程离开CPU 的时候,就需要把这些寄存器的值保存在PCB 的上下的字段中,当CPU 下次继续执行该进程的时候,CPU 的寄存器会重新维护我们的进程(把PCB 中的值给恢复到上下文的字段中),所谓的上下文具体指的就是进程运行过程中,CPU 内部的一系列存储器维护的值。
概念:
进程是系统分配资源的基本单位
线程是系统调度执行的基本单位
一个进程至少有一个线程执行,这就是主线程的来由: Java 代码是从main 方法开始执行的,main 就是主线程。
同一进程下的多线程并发执行,且共享进程资源,这里包括进程的 PCB 的上下文文档。
例题:我们设计两个线程 t1 、t2 对同一个变量 count = 0 进行+1 操作,每个线程执行一万次增加。
按照我们单线程的执行逻辑,将一个count 循环增加一万次,那么 count 的值就有10000,此时有两个线程进行count 增加操作,各循环一万次,那么 count 的值应该有两万。
public class Demo1 {
static class CountNumber { //计数类
public int count = 0;
public void increase() { //调用该方法 count变量自增
count ++;
}
}
public static void main(String[] args) throws InterruptedException {
final CountNumber countNumber = new CountNumber();
Thread t1 = new Thread(new Runnable() { //使用 Runnable 接口对象来创建线程
@Override
public void run() {
for (int i = 0; i < 10000; i++) { //增加1000
countNumber.increase();
}
}
});
Thread t2 = new Thread(() -> { // 使用 lambda 表达式创建线程对象
for (int i = 0; i < 10000; i++) { //增加1000
countNumber.increase();
}
});
//启动线程
t1.start();
t2.start();
// 主线程 等待 t1线程和 t2 线程执行完毕后再执行
t1.join();
t2.join();
System.out.println(CountNumber.count); //打印 count 值
}
}
再运行一次:
根据执行结果显示count 变量的值并非是 20000,这就是多线程并发执行时造成的线程安全问题,多线程对同一内存空间进行修改。
解释多线程状态下为什么会出现这种情况:
count++ 操作,看似只是一条语句, count = count + 1;对于 CPU 来说 count ++ 处理的就是三条指令
1. load , 把内存中的数据读取到 cpu 的寄存器中
2. add , 将寄存器中的值进行 + 1 运算
3. save , 把寄存器中的值写回到内存中
其中一次自增的结果被另一个线程自增的结果覆盖掉了,在想解决办法之前先了解一个概念。
举个例子:
以上事例:张三先李四一步抢到厕所,并将厕所上锁,李四只能在外面等着或者是去寻找别的厕所,此时李四无法对张三造成任何影响,张三上厕所就具有“原子性", 关键在于张三进厕所后反手上了锁,不然难以保证李四会不会“并发执行”~
总结:原子性是指一个操作(事务-张三上厕所)是不可中断的,要么全部执行成功要么全部不执行,在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
一条Java 语句不一定只有一条指令的,CPU 是按照指令执行的。
比如举例的 count ++ 操作, 如果这条java语句背后对应一条指令,那么多线程状态执行 ++ 是没有任何影响的,但是我们通过分析 ++ 操作其实是三个指令:
1. load , 把内存中的数据读取到 cpu 的寄存器中
2. add , 将寄存器中的值进行 + 1 运算
3. save , 把寄存器中的值写回到内存中
那么在cpu 是以指令为基本处理单位,线程之间的调度执行又是无序的,那么各线程的 ++ 操作指令的排序组合就有许多种可能,很容易造成一个线程自增的结果被另一个线程自增的结果覆盖掉了。
对某些程序代码块添加 synchronized 关键字后,程序代码块具有原子性。
某个事物添加 synchronized 关键字后,那么该事务就具有"原子性", 当cpu 调度线程执行该事务的时候其他线程无法对该事务进行处理,如果线程并发的要对该事务进行处理的时候,其他线程就会陷入阻塞等待。
张三上厕所反手上锁,李四在外面干等着,同理张三上完厕所后,轮到李四,李四上厕所也可以反手上锁,以防其他人进入,锁对象很明显是 "厕所"。
进入 synchronized 修饰的厕所(提供锁),就相当于加锁
退出 synchronized 修饰的厕所(提供锁),就相当于解锁
解锁之后他人(其他线程获取锁)才能进入厕所(处理事务)。
一个线程上了锁,其他线程只能等待这个线程释放锁。
到了现在我们得思考一个问题:锁从何处来?
每个实例化的对象在内存中存储的时候,都自带了一块内存表示当前的“锁定”状态。
拿厕所举例:
厕所里面自带了一把锁,张三着急上厕所,首先他判断的锁是否提示 ”有人“,”无人”
有人表示该厕所已被上锁,无法执行,张三就只能阻塞等待,如果显示无人,厕所没有上锁,张三就可以进入上厕所了,然后张三加锁,厕所就进入有人状态了,其他后来人就无法进入厕所。
synchronized 的工作过程:
当程序执行 synchronized修饰的代码块的时候会从对象中获取到锁,然后对代码块加锁,其他线程如果想竞争锁,就只能阻塞等待,一直要等到拿到锁的线程释放了锁。
将主内存的数据拷贝到工作的内存
执行代码(指令)
将更新后的刷新写回主内存
释放锁
这个刷新操作很关键,保证了内存的可见性
可见性:保证同一内存数据的修改能够及时可见,在下一个线程在操作该内存数据之前,必须把此变量同步回主内存中。
synchronized 是要搭配一个具体的对象来使用了,毕竟要获取对象中的锁嘛。
public class Demo2 {
//直接修饰普通方法
public synchronized void run() {
System.out.println("普通方法加锁");
/*放在方法上面就锁住整个方法里面所有的语句!
1线程进来了,2线程就进不来,除非 1 被wait,或者强制结束 wait() 主动释放锁*/
}
//直接修饰代码块
public synchronized void pieceRun() {
System.out.println("代码块加锁"); //允许被并发执行
synchronized (this) {
System.out.println("代码块加锁");
/* 如果锁放在这里,只能说,这个代码块里面的语句被锁
1线程进来在代码块里面执行,线程2就进不来,会在这个代码块外面候着,除非1被 wait()
站在pieceRun方法的角度,所有线程都可以进来,只是被锁修饰的代码块只能被加锁的线程执行*/
}
}
//以下两种方式实质是等价的,锁住的类对象
//锁类对象
public void ClassObject() {
synchronized (Demo2.class) {
System.out.println("类对象加锁");
}
}
//直接修饰静态方法
public static synchronized void staRun() {
System.out.println("静态方法加锁");
}
}
synchronized 修饰普通方法时,锁的是当前对象的方法,等价于 synchronized (this)
synchronized 修饰静态方法时,锁的是所有对象的方法,等价于 synchronized (类名.class)
类对象:
我们的java 文件最终会被编译成类名. class 文件(二进制字节码文件),JVM 就可以执行 .class 文件了, JVM 执行 . class文件之前就需要把文件内容读取到内存中(类加载),类对象就可以来表示这个 .class 文件的内容,描述了类的详细信息,类的名字,类的属性,类继承子那个类,类实现了那些接口等
类对象就相当于对象的图纸,反射 api 就是通过类对象获取类的相关信息。
我们还可以手动的指定一个锁对象,第三方对象,例如:
//手动指定锁对象
private Object locker = new Object();
public void run() {
synchronized (locker) {
System.out.println("获取 locker 中的锁");
}
}
这就要利用到我们上文所学的加锁操作 synchronized,
public class Demo1 {
static class CountNumber { //计数类
public static int count = 0;
public void increase() { //调用该方法 count变量自增
synchronized (this) { //获取 CountNumber 类中的锁
count ++;
}
}
}
public static void main(String[] args) throws InterruptedException {
final CountNumber countNumber = new CountNumber();
Thread t1 = new Thread(new Runnable() { //使用 Runnable 接口对象来创建线程
@Override
public void run() {
for (int i = 0; i < 10000; i++) { //增加1000
countNumber.increase();
}
}
});
Object locker = new Object(); //使用第三方锁
Thread t2 = new Thread(() -> { // 使用 lambda 表达式创建对象
for (int i = 0; i < 10000; i++) { //增加1000
countNumber.increase();
}
});
//启动线程
t1.start();
t2.start();
// 主线程 等待 t1线程和 t2 线程执行完毕后再执行
t1.join();
t2.join();
System.out.println(CountNumber.count);
}
}
CountNumber 类中的 count++ 操作进行了加锁,这意味着除了 ++ 操作以外的程序都是可以多线程并发执行的。
总结:
我们的加锁操作 synchronized 锁住的是它修饰的代码块,将其中的指令操作看作是一个整体,要么我执行的时候(未释放锁)其他线程不得参与,要么阻塞等待不执行。
多个线程竞争同一把锁才会造成阻塞等待, 例如: t1 线程, t2 线程想同时执行 count ++ 操作,但是cpu 调度是无序的,谁先抢到 count ++ 设置的锁,谁先执行,另一个阻塞等待,一个线程++ 操作执行完毕释放锁后,线程之间才允许继续竞争锁吖。
如果两个线程分别尝试获取不同的锁, 就不会产生竞争关系,也就不会阻塞等待。
举个例子:先写个 bug 出来。
public class Test2 {
//同一进程下,多线程共享线程资源
public static int flag = 0; // 作为 t1 线程循环的运行条件
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (flag == 0) {
}
System.out.println("t1 线程结束");
}
});
Thread t2 = new Thread(() -> {
Scanner in = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = in.nextInt(); // 我们 t2 线程修改 flag 的值
System.out.println("flag 的值:" + flag);
System.out.println("t2 线程结束");
});
// 启动线程
t1.start();
t2.start();
}
}
我们设计t1 和 t2 两个线程,设计全局变量 flag = 0; t1 循环的结束条件是 flag == 0,
所以当 flag != 0 的时候循环就结束了,所以我们用 t2 线程 输入一个非0 整数来修改 flag 变量的值,但是结果发现 flag 的值已经被修改了,打印显示也是非0, t1 线程却没有结束,光标闪烁,也没有提示,意味着 t1 线程的循环还没有结束,对于 t1 线程来讲,flag 的值已经被修改,但是他不知道!!!
解释:
内存可见性引起的bug,就是在多线程的环境下,编译器对于代码的优化,导致线程之间对数据实时性、准确性的的误判。
站在单线程的情况下这种编译器优化带来的效果是很好的,但是多线程的情况下还是需要注意的,最关键的是要让编译器刷新内存呐,synchronized 也有刷新内存的功能,但是针对内存可见性的情况还是不敢保证在所有场景下使用,关于如何解决内存可见性问题下午一并解决。
这个bug 说到底还是因为编译器的优化效果,举个例子:
volatile 关键字修饰的变量,能够保证”内存可见“,意思就是避免编译器优化,偷懒,让他每次从内存读取数据,就可以保证不会出现多线程的情况下,一个线程修改了变量的值,另一个线程不知道该变量的值已经被修改的情况。
volatile 修饰的变量的时候
改变线程工作内存中volatile变量拷贝的值,将改变后的拷贝的值从工作内存(CPU 处理数据的空间)新到主内存。
读取 volatile 修饰的变量的时候
从主内存中读取volatile变量的最新值到线程的工作内存中,从工作内存中读取volatile变量的拷贝值。
变量在被 volatile 修饰的时候,要求在执行的时候强制读写内存,使用该变量就从内存中读取,修改后就直接写回内存,不存在什么编译器优化,可以有效保证数据在多线程的情况下的有效性。
volatile 关键字也可以保证指令重排序问题,被修饰的变量禁止指令重排序。
解决:1.6 内存可见性引起的多线程安全问题 (BUG)
注意:vlatile 和 synchronized 有着本质上的区别, synchronized 能够保证事务的原子性,vlatile 保证的是内存的可见性。
总结:
1. volatile 是java 中的关键字,是一个变量修饰符,常常被用来修饰需要被不同线程访问和修改的变量,
2. 被 volatile 修饰的变量具有可见性,当一个线程修改了该变量的值时,其他线程如果对该变量进行操作的时候会从内存中重新读取该变量的数据,可以保障了数据的有效性,避免内存可见性造成的线程安全问题(bug)。
3. volatile 只能保证单次读/写操作的原子性(针对一条指令),不能保证多步操作的原子性。例如:修改一个变量的值:第一步将数据从内村中读取到寄存器中,cpu 从寄存器中读取数据,对数据进行处理后重新写入到内存,我们可以把三步操作看作是修改变量的一个事件,使用 volatile 不能保证该事件的原子性,可能执行第一步的时候cpu 就执行了别的线程,如果别的线程也对同一变量进行修改,就会造成bug , 针对这个问题我们需要使用 synchrnized 对事件进行加锁,即可保证事件的原子性,此时其他线程如果也需要对同一变量进行操作,只能阻塞等待当前线程将事件处理完毕。
4.在 Java 内存模型中,允许编译器和处理器对指令进行重排序(最优的处理效率),重排序过程不会影响到单线程程序的执行结果,但是可能会影响到多线程并发执行的正确性。volatile 修饰的变量的读写指令不能和其前后的任何指令重排序,其前后的指令可能会被重排序, volatile 关键字可以禁止指令重排序。
vlatile 修饰的变量不能保证原子性。 原子性——对变量的处理,要么全部执行,要么阻塞等待。vlatile 尽管保证每次的处理都是从内存中读取数据,同时也能保证指令的排序问题,但是在多线程的情况下就可能会出现,线程调度上所造成的指令组合问题,详解见:1.2 事务的原子性。
至此,Java 线程的安全性问题,博主已经分享完了,希望对大家有所帮助,如有不妥之处欢迎批评指正。
本期收录于博主的专栏——JavaEE,适用于编程初学者,感兴趣的朋友们可以订阅,查看其它“JavaEE基础知识”。
下期预告:watl 和 notify 控制线程的执行顺序
感谢每一个观看本篇文章的朋友,更多精彩敬请期待:保护小周ღ *★,°*:.☆( ̄▽ ̄)/$:*.°★* ‘