• 线程安全问题


    目录

    线程安全

     线程安全问题的原因及解决办法

    synchronized关键字

    volatile关键字

    死锁



    线程安全

    在单线程的情况下,程序代码执行顺序都是固定的,程序的运行结果就是固定的.而有了多线程,代码抢占式执行,代码的执行顺序,会出现多种情况,代码的执行顺序就从一种可能性变成了无数种情况,所以需要保证无数线程调度的顺序下,代码的执行结果都是正确的.只要有一种情况下,代码运行不正确,就会出现线程安全问题.

    下面通过代码说明线程安全问题

    1. class Counter {
    2. public int count = 0;
    3. public void add() {
    4. count++;
    5. }
    6. }
    7. public class ThreadDemo7 {
    8. public static void main(String[] args) {
    9. Counter counter = new Counter();
    10. Thread t1 = new Thread(() -> {
    11. for (int i = 0; i < 50000; i++) {
    12. counter.add();
    13. }
    14. });
    15. Thread t2 = new Thread(() -> {
    16. for (int i = 0; i < 50000; i++) {
    17. counter.add();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. try {
    23. t1.join();
    24. t2.join();
    25. } catch (InterruptedException e) {
    26. e.printStackTrace();
    27. }
    28. System.out.println("count = " + counter.count);
    29. }
    30. }

    运行结果

    在上面代码中我们让线程t1和线程t2都循环50000次执行add()方法,预期的执行结果是count = 1000_00,而结果却不是,那是 因为上面的++  操作分为三步,

    1  load 先把内存中的值读取到CPU寄存器上

    2  add 把CPU寄存器里的数值进行 +1运算

    3  save 把得到的结果写到内存中

    如果两个线程并发执行count++,此时就相当于两组 load,add,save进行执行,此时现成的调度顺序不一样就可能产生结果上的差异  

    画图演示线程的调度顺序

    当执行正确的调度的时候,t1调度完成把值西写到内存中后t2才开始调度此时就是安全的线程,在第二种情况下,t1先load,t2 load的值是t1修改之前的值,导致t2后续保存数据的时候和t1保存的是同一份数据,就会出现线程不安全问题

     线程安全问题的原因及解决办法

    1 根本原因: 线程之间抢占式执行,随机调度

    2 代码结构: 多个线程同时修改同一个变量,当多个线程读取到同一个变量的时候是安全的

    3. 原子性;如果修改操作时原子的,是安全的的,修改非原子的就是不全的

    所谓原子性指的是 不可拆分的基本单位

    解决办法

    通过 synchronized关键字进行加锁

    synchronized关键字

    synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到 同一个对象 synchronized 就会 阻塞等待 .
    进入 synchronized 修饰的代码块 , 相当于 加锁
    退出 synchronized 修饰的代码块 , 相当于 解锁
    理解 " 阻塞等待 ".
    1.针对每一把锁, 操作系统内部都维护了一个等待队列 . 当这个锁被某个线程占有的时候 , 其他线程尝 试进行加锁, 就加不上了 , 就会阻塞等待 , 一直等到之前的线程解锁之后 , 由操作系统唤醒一个新的 线程, 再来获取到这个锁.
    2.如果两个对象针对同一个对象进行加锁,就会出现锁竞争/锁等待.一个线程能够获取到锁,另外一个线程阻塞等待,等到上一个线程解锁,他才能获取到锁,否则就不能加锁
    2. 当两个线程对不同对象加锁,此时不会产生竞争/锁冲突,这两个线程都能获取到各自的锁,不会产生阻塞等待
    3.当两个线程,一个线程加锁,一个线程不加锁,这个时候不会产生锁竞争/锁冲突
    注意 :
    上一个线程解锁之后 , 下一个线程并不是立即就能获取到锁 . 而是要靠操作系统来 " 唤醒 ". 这也就是操作系统线程调度的一部分工作.
    假设有 A B C 三个线程 , 线程 A 先获取到锁 , 然后 B 尝试获取锁 , 然后 C 再尝试获取锁 , 此时 B 和 C 都在阻塞队列中排队等待 . 但是当 A 释放锁之后 , 虽然 B C 先来的 , 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争 , 并不遵守先来后到的规则
    synchronized的使用方法
    1. 修饰方法:
     修饰普通方法:锁对象就是当前对象
    此时synchronized修饰的是add方法,当t1执行add()的时候,就针对counter对象加锁,当t2执行add的时候,也尝试对counter对象加锁,但由于counter已经被t1占用,此时t2在对counter对象加锁,就会产生阻塞.
    修饰静态方法: 锁对象就是类对象
    2. 修饰代码块
      
    手动指定锁对象

    当前使用方法就是对代码块进行加锁,进入代码块就加锁,出了代码块就解锁,在这里可以指定任何想指定的对象,不一定是this.

    3.可重入

    一个线程针对同一个对象,连续加锁两次,是否会有问题,如果没有问题,就是可重入的,否则就是不可重入的.

    针对上面代码,锁对象就是this,只要有线程调用add(),进入add()方法的时候,就会现加锁,紧接着执行到代码块再次尝试加锁,站在this的角度,他认为自己已经加锁了,已经被其他线程调用了,而此时相当于两个线程是同一个线程,如果允许上述操作,就是可重入的,如果不允许就是不可重入的,此时会导致死锁.

    在java中,为了避免不小心死锁,java就把synchronized设定成了可重入的.

    4. 内存的可见性问题

    volatile关键字

    上面代码t1线程要循环快速读取,t2进行修改,预期结果是t2把flag改成非0 的值之后,t1随之循环结束,但结果是t1线程一直处于循环状态

     向上面出现的结果可以用汇编来理解,这里有两步操作

    1. load 把内存中flag的值读取到寄存器中,

    2. cmp 把寄存器中的值和0进行比较,根据比较结果决定下一步往哪个地方执行

    相比于tmp来说,load的执行速度太慢,在加上反复load的值都是一样的,jvm就不在真正重复load了,就只读取一次, 一个线程针对变量进行读取操作,另外一个线程针对这个变量进行修改操作,此时读取到的值不一定是修改后的值,向这样的问题就是内存可见性问题

    可以使用volatile关键字来解决内存可见性的问题

    使用volatile修饰的变量能够保证内存可见性.但volatile不保证原子性

    5.指令重排序,本质上是编译器优化出bug了)

    死锁

    在Java中,下面几种情况会出现死锁

    1.一个线程,一把锁,连续加两次,如果锁是不可重入锁,就会死锁.但在Java里,synchronized和ReentrankLock都是可重入锁

    2.两个线程.两把锁,t1和t2各自先针对锁A和锁B加锁,在尝试获取对方的锁.

    1. public static void main(String[] args) {
    2. Object lockerA = new Object();
    3. Object lockerB = new Object();
    4. Thread t1 = new Thread(() -> {
    5. synchronized (lockerA) {
    6. try {
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. synchronized (lockerB){
    12. System.out.println("t1获取到lockerA 和lockerB");
    13. }
    14. }
    15. });
    16. Thread t2 = new Thread(() -> {
    17. synchronized (lockerB) {
    18. try {
    19. Thread.sleep(1000);
    20. } catch (InterruptedException e) {
    21. e.printStackTrace();
    22. }
    23. synchronized (lockerA){
    24. System.out.println("t2获取到lockerA 和lockerB");
    25. }
    26. }
    27. });
    28. t1.start();
    29. t2.start();
    30. }

     执行结果

    上面代码没有任何日志,说明没有线程拿到两把锁,此时两个线程都在等待对方先释放锁,就一直处在阻塞状态.

    3. 多个线程多把锁

     在学习这个之前,先听一个哲学家就餐的故事

    一张圆桌上坐着5名哲学家,每两位哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考是,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根的拿起)。如果筷子已在他人手上,则需要等待。饥饿的哲学家只有同时拿起两根筷子才可以进餐,当进餐完毕后,放下筷子继续思考。

    死锁的四个必要条件

    1. 互斥使用.  线程1拿到了锁,线程2就要等待

    2. 不可抢占.  线程1拿到锁之后,必须是线程1主动释放锁,而不是线程2就把锁强行获取到

    3, 请求和保持. 线程1拿到锁A之后,在尝试获取锁B,线程1还是保持对A的加锁状态,不会因为锁Bj就把锁A释放

    4. 循环等待 线程1尝试获取到锁A和B,线程尝试获取到锁B和锁A,线程1 在获取锁B的时候等待线程2释放B,同样,线程2在获取锁A的时候等待线程1释放A.

    如何避免死锁

    我们只需要给锁编号,然后指定一个固定的顺序来进行加锁.来解决循环等待的问题,

    1. public static void main(String[] args) {
    2. Object lockerA = new Object();
    3. Object lockerB = new Object();
    4. Thread t1 = new Thread(() -> {
    5. synchronized (lockerA) {
    6. try {
    7. Thread.sleep(1000);
    8. } catch (InterruptedException e) {
    9. e.printStackTrace();
    10. }
    11. synchronized (lockerB){
    12. System.out.println("t1获取到lockerA 和lockerB");
    13. }
    14. }
    15. });
    16. Thread t2 = new Thread(() -> {
    17. synchronized (lockerA) {
    18. try {
    19. Thread.sleep(1000);
    20. } catch (InterruptedException e) {
    21. e.printStackTrace();
    22. }
    23. synchronized (lockerB){
    24. System.out.println("t2获取到lockerA 和lockerB");
    25. }
    26. }
    27. });
    28. t1.start();
    29. t2.start();
    30. }

    运行结果

     此时我们按照A,B,C的顺序来进行加锁,就解决了循环等待问题.

  • 相关阅读:
    【ASE入门学习】ASE入门系列二十三——顶点偏移
    中职计算机应用专业(云计算方向)建设实践
    2022.8.12-----leetcode.1282
    【leetcode刷题】练习Day1 1480.一维数组的动态和
    测试Python的poplib模块读取邮箱信息
    处理不平衡数据的十大 Python 库
    在 Android 中使用 Lambda 的原理
    SM3加密udf
    下拉选择框监听el-option的方式
    Java Web之JSP
  • 原文地址:https://blog.csdn.net/2301_76692760/article/details/134143780