【JavaEE】多线程 -- 线程安全
目录
- 观察线程不安全
- 观察具体执行过程分析原因
- 其中一种执行过程(正确)
- 其中一种执行过程(错误)
- 线程不安全的原因
- 操作系统对线程调度是随机的(根本原因)
- 修改共享数据
- 修改操作不具有原子性
- 解决事务不是原子性导致线程不安全问题
- synchronized 关键字 - 加锁解决
- synchronized 关键字 - 监视器锁 monitor lock
- synchronized的特性
- 互斥
- synchronized的使用
- synchronized修饰实例方法
- synchronized修饰静态方法
- 总结
- 由内存可见性引起的线程安全问题
- volatile关键字解决
- sleep解决
- 加上volatile 关键字代码执行过程
- volatile 不保证原⼦性
- 补充
观察线程不安全
public class demo12 {private 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.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
- 第一次执行结果
- 第二次执行结果
- 可以看到我们每次的执行结果都是不一样的, 但是这个代码我们预期是加到100000, 两个循环各加5w次, 但是每次结果都不一样是怎么回事呢?
观察具体执行过程分析原因
- 我们在计算机如何工作的那篇文章中, 讲解了CPU的执行过程. 所以我们刚刚修改count变量的执行过程准确来说分为这三步
- 但是我们CPU调度线程执行是依靠调度器来调度的, 在前面的文章中我说过, 线程调度的顺序是随机的.
其中一种执行过程(正确)
注意:图上的CPU是两个CPU核心, 不是两块CPU, 这里我们是用多核心的场景
- t1线程的执行过程:
- 把count这个变量(内存中count初始值为0)加载到第一个核心寄存器中(寄存器是CPU里面的),
- 把寄存器中的值加1, 这个时候count的值就是1
- 然后把寄存器中的count, 写回到内存中. 这个时候内存中的count值是1
- t2线程的执行过程
- 把count这个变量(内存count值是1)加载到第二个核心寄存器中(寄存器是CPU里面的),
- 把寄存器中的值加1, 这个时候count的值就是2
- 然后把寄存器中的count, 写回到内存中. 这个时候内存中的count值是2
现在来看我们内存中的值是2, 现在CPU调度执行线程的顺序就是正确的, 但是我们说了, CPU调度线程执行的顺序是随机的, 这只是其中一种顺序恰好是正确的
其中一种执行过程(错误)
注意:图上的CPU是两个CPU核心, 不是两块CPU, 这里我们是用多核心的场景
-
这里看到我们一会调度t1线程执行, 一张调度t2线程执行, 又导致了修改count变量这个值的步骤, (不是原子性的.即修改操作3个步骤: load, add, save由于线程频繁切换调度分开了, 不是像上面一个例子一样一气呵成)
-
第一次调度t1的执行过程
- 把count这个变量(内存中count初始值为0)加载到第一个核心寄存器中(寄存器是CPU里面的),
- 这个时候CPU又把t2线程调度执行, t1线程重新排队
-
第一次调度t2的执行过程
- 把count这个变量(内存中count初始值为0)加载到第二个核心寄存器中(寄存器是CPU里面的),
- 把寄存器中的值加1, 这个时候count的值就是1
- 这个时候CPU又把t1线程调度执行, t2线程重新排队
-
第二次调度t1的执行过程
- 把寄存器中的值加1, 这个时候count的值就是1(第一次加载第一个核心寄存器中, 之后就被调度重新排队了)
- 然后把寄存器中的count, 写回到内存中. 这个时候内存中的count值是1
- 这个时候t1线程执行完了, CPU又把t2线程调度执行
-
第二次调度t2的执行过程
- 把第二个核心中寄存器的值写回到内存中, 此时寄存器中是1
-
最终内存中就是1
线程不安全的原因
操作系统对线程调度是随机的(根本原因)
- 可以从上面的例子中看出, 我们如果保证第线程的执行顺序, 答案就是正确的, 但是遗憾的是, 这个我们无法改变, 这个是操作系统内核.
修改共享数据
- 我们上面的例子就是t1和t2两个线程对同一个变量count进行修改. 那我们能不能不对同一个变量进行修改呢? 可以但是比较吃业务和逻辑, 有可能客户的业务就是需要对同一个变量进行修改, 也有可能对同一个变量修改的逻辑太复杂了. 这就导致风险有点高.
修改操作不具有原子性
- 可以看到上面的例子中, 由于我们线程的随机调度总是打断我们修改操作3个指令步骤, 这就让我们修改操作不具有原子性. 但是我们改变不了线程调度执行的顺序, 可是我们可以通过让修改操作具有原子性来保证修改操作一气呵成达到例子1的效果.
- 怎么做到呢, 就要用我们下面讲的东西来实现, 举个例子
- 可以看到我们张三抢到了厕所, 并且对厕所进行了上锁. 外面的李四不能直接进来打扰张三上厕所这个事务. 对应的我们多线程中, 也就是张三上厕所这个线程抢占到了厕所, 并且上了锁. 这个时候我们张三就在里面进行上厕所事务的执行. 这个时候李四如果也想进来上厕所, 就必须要等张三把上厕所这个事务执行完了.并且解锁. 我们李四才能抢占到锁, 没抢到锁之前, 李四不能干扰张三上厕所的执行(因为李四如果想抢占到锁必须要等张三上完厕所解锁)
总结:
这样我们张三上厕所这个事务的执行指令就不会因为CPU随机调度而被插队了(这里注意上锁并不能改变CPU调度线程执行顺序, 调度线程的顺序还是随机的, CPU可以调度张三线程也可以调度其他线程执行, 不是让CPU必须把张三这个线程先执行完) 我们只是通过对张三上厕所这个事务进行上锁, 如果李四也想要上厕所就必须等张三上厕所这个事务执行完后解锁, 并且抢占到这把锁才能也进行上厕所这个事务. 也就是张三和李四的任务都是上厕所, 所以执行指令也是一样的, 但是由于锁的缘故. 李四的上厕所执行指令并不能插队在张三前面CPU调度被执行(李四上厕所也要上锁, 所以要拿到这把锁就要等张三事务执行完后解锁, 没拿到锁之前李四处于阻塞状态).必须等张三执行完上厕所事务指令, 解锁后, 李四抢到这把锁才能执行上厕所的指令. 这就保证了我们事务的原子性
解决事务不是原子性导致线程不安全问题
- 进行加锁的时候,需要先准备好 “锁对象”
加锁 / 解锁操作,都是依托于这里的 “锁对象” 来展开的。
如果一个线程,针对一个对象加上锁之后,
其他线程,也尝试对同一个对象加锁,就会产生阻塞。(BLOCKED)
一直阻塞到前一个线程释放锁为止。 锁冲突 / 锁竞争
如果两个线程是分别针对不同的对象加锁,此时就不会有锁竞争,也就不会有阻塞
synchronized 关键字 - 加锁解决
- 执行过程大概是这样
- 如果我们对两个线程用不同的锁
- 如果一个线程加锁, 一个线程不加锁, 是否会存在线程安全问题?
总结一下:
从这个例子来看, 我们让两个线程事务不会插队执行就是让两个线程之间进行锁竞争. 如果没有对锁的竞争, 那么我们就可能会出现线程不安全的问题了
synchronized 关键字 - 监视器锁 monitor lock
synchronized的特性
互斥
- synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏ 到同⼀个对象 synchronized 就会阻塞等待
- 进⼊ synchronized 修饰的代码块{, 相当于 加锁
- 退出 synchronized 修饰的代码块}, 相当于 解锁(如果一直不解锁, 那么竞争这把锁的另一个线程就会一直阻塞, 就会导致这个线程一直不被调度, 那么就会出现bug(也去剧场经典的占着坑不拉shit))
synchronized的使用
synchronized修饰实例方法
也就是说, 他们两个对象有两把不同的锁, 不会产生锁竞争. 这个时候对同一个count进行add操作就会导致修改操作不具有原子性, 那么线程就不安全了.
-
等价写法
-
这里的this就代表当前对象
-
但是注意这个锁修饰的地方
-
那么如何改正确呢? 请看下面
synchronized修饰静态方法
这个时候t1线程和t2线程调用的就是同一个方法, 那么方法相同, 他们的方法使用的也就是同一把锁, 就会产生锁竞争, 修改操作就具有原子性, 线程就安全了.
- 也可以这样写
- 这里的class 是JVM处理完经过编译后的.class文件得到的类对象, 包含了类的各个属性和方法结构等等
总结
- synchronized 修饰普通方法时,锁的是当前对象的方法,等价于 synchronized (this)
- synchronized 修饰静态方法时,锁的是所有对象的方法,等价于 synchronized (类名.class)
- 无论synchronized对那个对象加锁, 都不重要, 重要的是对同一个对象加锁, 这样才会产生锁竞争
由内存可见性引起的线程安全问题
- 这个代码中, 预期通过 t2 线程输入整数, 只要输入的整数不为 0 , 就可以使 t1 线程结束:
package thread;
import java.util.Scanner;
public class ThreadDemo23 {private static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(()->{while(flag == 0) {}System.out.println("t1 线程结束");});Thread t2 = new Thread(()->{System.out.println("请输入flag的值:");Scanner scanner = new Scanner(System.in);flag = scanner.nextInt();System.out.println("t2 结束");});t1.start();t2.start();}
}
- 但是当我们执行这段代码的时候, 发现t1并没有结束, 这是为什么呢?
volatile关键字解决
package thread;
import java.util.Scanner;
public class demo14{private volatile static int flag = 0; //加上volatile关键字public static void main(String[] args) {Thread t1 = new Thread(()->{while(flag == 0) {}System.out.println("t1 线程结束");});Thread t2 = new Thread(()->{System.out.println("请输入flag的值:");Scanner scanner = new Scanner(System.in);flag = scanner.nextInt();System.out.println("t2 结束");});t1.start();t2.start();}
- 这个时候结果就是正确的了
- 加上这个关键字后, 给编译器说明这个变量是容易变的, 一定要从内存读. 那么久不回出现上面的编译器优化了
sleep解决
- 因为内存可见性的问题, 根本原因是编译器优化操作导致的. 编译器优化是因为上面代码执行逻辑非常简单, 可以优化. 但是如果说是代码逻辑复杂了, 那么编译器优化就可能失败了.
加上volatile 关键字代码执行过程
- 代码在写⼊ volatile 修饰的变量的时候,
1.改变线程⼯作内存中volatile变量副本的值
2.将改变后的副本的值从⼯作内存刷新到主内存
- 代码在读取 volatile 修饰的变量的时候,
1.从主内存中读取volatile变量的最新值到线程的⼯作内存中
2. 从⼯作内存中读取volatile变量的副本
- 从上面可以看出来, 我们读取volatile变量的值的时候, 会强制从主内存中读取, 然后把从主内存读取的值刷新到工作内存. 最终从工作内存中保存. (这里之所以不在主内存中读取而是在工作内存中读取, 其实主内存就是我们常说的内存, CPU直接读内存比较慢, 但是工作内存就是指我们说的寄存器, CPU读寄存器的速度就比较快. 所以放在工作内存读取)
- 虽然每次都要从内存中读速度确实变慢了, 但是解决了上面的内存可见性问题, 数据更准确了.
volatile 不保证原⼦性
- volatile 和 synchronized 有着本质的区别. synchronized 能够保证原⼦性, volatile 保证的是内存可⻅ 性.
- 也就是说, 加上volatile关键字并不能阻止, 让我们一个线程执行一个事务代码的时候, 另外一个线程也来执行这个事务代码. 这个时候因为CPU的随机调度, 很有可能t1线程刚刚执行了事务代码的1步, 就被CPU调走了, 紧接着CPU调度t2线程来执行相同的事务代码(比如修改同一个变量), 执行他的1,2步. 他的事务代码就插队到了t1事务代码的前面. 导致事务代码不具有原子性, 就出现线程安全问题了
- 这个时候就必须用synchronized加锁解决, 保障事务代码执行的原子性
补充
- 还一个关于指令重排序导致的线程安全问题, 后面再设计模式的单例模式讲解. 这里请看后面的文章即可