多线程-线程安全问题及解决方法

一.线程安全的概念​:

可以这么认为:如果多线程环境下代码运行的结果是符合我们预期的,就跟在单线程环境应该的结果一样,则说这个程序是线程安全的。

 来看这么一串代码:

public class Demo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        //一个线程自增5w次 按理来说一共10w次
        System.out.println(count);
    }
}

正常来说两个线程都对着个count变量各自增5w次 最后输出的结果应该是10w次

但真正的结果↓

可以看到每一次运行的结果都是随机的都比10w小

这很明显就是因为线程安全问题引起的bug

二.线程不安全的原因:

1.线程调度是随机的

这个是线程安全造成的罪魁祸首,随机调度使一个程序在多线程环境下,执行顺序存在很多的变数,必须保证 在任意执行顺序下 , 代码都能正常工作.

2.修改共享数据

多个线程修改同一个变量

3.修改操作不是原子的

一条 java 语句不一定是原子的,也不一定只是一条指令​
比如刚才我们看到的 count++,其实是由三步操作机器指令组成的:​
1. 从内存把数据读到 CPU​
2. 进行数据更新
3. 把数据写回到 CPU​

以上count++涉及三步机器指令 而不是一条 所以不是原子的 原子指的是一条 不是原子的就会导致一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。 

这里列举两种情况

 情况二👇

 两个线程之间和操作系统 以及JVM的线程都要进行全排列 情况分产多 这里只是简化只有两个线程

 线程安全问题还包括内存可见性问题和指令重排序问题 这里就不先介绍 后面文章会说到 上述代码只涉及前三点

三.Java 内存模型 (JMM):

Java虚拟机规范中定义了Java内存模型. ​

线程之间的共享变量存在 主内存 (Main Memory). ​
每一个线程都有自己的 "工作内存" (Working Memory) . ​
当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据. ​
当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存. ​

问题:

1)为啥要整这么多内存? ​

实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法. ​
所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

2)为啥要这么麻烦的拷来拷去?​

 因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍). ​

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了.

那为啥寄存器都这么快了 还要内存干嘛?

一个字:贵 而且运行速度是相对的内存就已经很快了

 

 通过JMM模型来看上述的情况二数据变化

从图中可以看到执行了两次add 但最后的结果确是1 这就是因为线程安全的导致的bug 

 像这种bug情况由于随机排序 还有很多 

四.解决线程安全的问题

对于1线程的随机调度是操作系统在操控 我们无法修修改 

对于2同时修改一个变量   可以调整代码结构 规避一些不安全的代码 但是这种方案不够通用 有些情况下就是需要多个线程同时修改同一个变量

所以我们只能对3 原子性进行修改解决

 

如何解决?

我们就需要用到synchronized 关键字 - 监视器锁 monitor lock ​

synchronized 的特性​
1) 互斥​
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待. ​
• 进入 synchronized 修饰的代码块, 相当于 加锁​
• 退出 synchronized 修饰的代码块, 相当于 解锁​

synchronized用的锁是存在Java对象头里的。​ 

对于上述的count++操作我们就可以将其上锁(将三步指令操作)包装起来成为意义上的原子性操作(当一个线程获得锁时,其他线程再获取这个锁时就会阻塞等待 无法继续执行count++ 自然不会打扰到 第一个线程count++的操作)

改正后的代码👇

public class Demo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //这里的括号可以是任何对象引用
                //synchronized关键字{}中的代码块就是要在机器语言中锁住执行的机器指令(将多个指令变成打包成一个)具有原子性
                synchronized (locker) {//进入代码块,相当于加锁
                    count++;//执行一些要保护的逻辑
                }//出了代码块,就相当于解锁
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //两个线程针对同一个对象(locker)加锁才能产生互斥效果 线程安全得到保证
                synchronized (locker) {
                    count++;
                }
            }
        });
        /**
         * 如果t2是后获取锁的
         * t1就已经lock完成了
         * t2的lock就会阻塞t2线程的进行 等到t1执行完unlock之后 t2才会继续执行
         */
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("count: " + count);

    }
}

若让两个线程产生互斥(阻塞)的前提 锁对象必须一样 这里的锁对象可以时java中任意对象(类的实例) 注意事项可以看上述注释

加锁后解决线程安全问题的一种情况 如下

阻塞:

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁. ​
注意: ​
 1)上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就是操作系统线程调度的一部分工作. ​
2)假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则. ​

其他加锁的情况

五.引入锁后也随之带来了一些问题:死锁

1.第一种情况 看下面代码

class Counter {
    private int count = 0;

    public void add() {
        count++;
    }

    public int get() {
        return count;
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread thread = new Thread(() -> {
           for (int i = 0; i < 50000; i++) {
               synchronized (counter) {
                   synchronized (counter) {
                       counter.add();
                   }
               }
           }
        });

        thread.start();
        thread.join();

        System.out.println("count= " + counter.get());
    }
}

上述代码中

一个线程没有释放锁, 然后又尝试再次加锁. ​
 第一次加锁, 加锁成功​
第二次加锁, 锁已经被占用, 阻塞等待. ​
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁. ​

虽然上述情况会出现死锁 但是java中引入了可重入的概念

当某个线程针对一个锁 加锁成功后,后续该线程再次针对这个锁进行加锁,不会触发阻塞,而是直接往下走 这个锁时当前线程持有,如果其他线程加锁时就会正常阻塞, 可重入锁只是针对 一个线程一把锁 连续加锁多次的情况

可重入锁的实现原理:

关键在于让锁对象内部保存,当前时哪个线程拥有这一把锁,后续有线程针对这个锁加锁的时候对比一下是不是锁持有者的线程是否和当前加锁的线程是同一个,同一个就不会阻塞,不同则会阻塞

对于如何知道在哪一个大括号的时候解锁的

JVM的Monitor先引入一个变量 计数器 每次触发{的时候计数器++ 触发}的时候 计数器-- 当计数器--为0的时候就是真正解锁的时候

那么还有一个问题{ }那么多 计数器怎么判断到底哪一个才是synchronized的{}

其实在JVM视角中看到的是编译后的字节码文件javac ,java代码中看到的是{ } 字节码看到的是不同的指令 { 涉及加锁指令 } 涉及解锁指令 而其他的{ }不会被编译成加锁解锁的指令

第二种情况(真正会构成死锁阻塞的情况)

public class Demo20 {
    public static void main1(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread thread1 = new Thread(() -> {
            //该线程拿到第一把锁 再拿第二把锁的时候,因为第二把锁也因同样的情况不能释放,所以就会产生死锁(不能释放第一把锁)
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread1拿到locker1
                    //不加sleep很可能thread1一口气就把locker1和locker2都拿到了 这时候t2还没动 自然无法构成死锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2) {
                    System.out.println("thread1 获取到两个锁");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread2拿到locker2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1) {
                    System.out.println("thread2 获取到两个锁");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

上述是线程一拿到锁1之后不释放锁1的前提下获取锁2 这时锁2被线程二拿到 线程1被阻塞线程二 也要获取锁1 也会被阻塞 进而导致两个线程都会被阻塞 这就是请求和保持

请求和保持概念:

主要时单个线程的资源请求和占有行为,线程因为新资源请求被阻塞,而自身持有的资源有阻碍了其他线程获取资源,强调的时单个线程对资源的不合理占有和请求

解决方法就是不要去嵌套锁

public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread thread1 = new Thread(() -> {
            //取消嵌套
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread1拿到locker1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (locker2) {
                System.out.println("thread1 获取到两个锁");
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread2拿到locker2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (locker1) {
                System.out.println("thread2 获取到两个锁");
            }
        });

        thread1.start();
        thread2.start();
    }

像这个中不嵌套 让一个线程拿到一个锁之后 就释放再获取另一个锁 这个就能解决请求和保持问题

但是不够通用 有些嵌套不可避免 但是大部分的请求和保持还能用循环等待的解决方法来解决

循环等待问题:

还是看到上面有线程安全问题的代码 多个线程,多把锁之间的等待过程构成了循环 比如:A等待B ,B也在等待A释放锁 或者A等待B , B等待 C, C等待A释放锁 这就构成了循环等待的问题

***解决方法就是约定好加锁的顺序

例如每个线程加锁的时候先获取序号小的锁再获取大的锁
public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread thread1 = new Thread(() -> {
            //都先获取小锁
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread1拿到locker1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2) {
                    System.out.println("thread1 获取到两个锁");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);//加上sleep确保thread2拿到locker2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2) {
                    System.out.println("thread2 获取到两个锁");
                }
            }
        });

        thread1.start();
        thread2.start();
    }

这样也能解决

请求和保持强调的是单个线程在资源获取上的不合理状态

循环等待强调的是多个线程之间的循环依赖的关系

六.死锁的总结

1.构成死锁的场景:

a)一个线程一把锁 -- 可重入

b)两个线程两把锁 

b)M个线程N把锁

2.构成死锁的必要条件

a)锁的互斥(无法改变)

b)锁的不可剥夺(无法改变)

c)请求和保持

d)循环等待

3.如何避免死锁

避免死锁只能打破c 和d

c是把嵌套的锁改为并列的锁

d是约定好加锁的顺序

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值