[多线程]中线程安全问题及其解决策略

        上一篇小玉为大家讲解了关于多线程的初阶知识,那么我们算是已经小小的入门了多线程,这节课我们就需要掌握一些多线程引起的线程安全问题,这可以说是我们 [多线程] 章节中的重难点了,小玉会尽力为大家讲解清楚的,希望对玉粉们有帮助!

[多线程]中线程安全问题及其解决策略_第1张图片


目录

1.线程不安全的原因 

2.如何解决线程不安全问题(逐条解决)

2.1使用synchronized加锁

*Java中如何实现加锁操作的?

**join()和synchronized的区别 

2.2 使用volatile保证内存可见性 

2.3 使用volatile也可以禁止指令重排序 


 

1.线程不安全的原因 

  1. 抢占式执行 
  2. 多个线程修改同一个变量
  3. 修改操作非原子性
  4. 指令重排序
  5. 内存可见性

那么针对这五个因素,我们先简单逐条分析:

1.抢占式执行.->(线程安全根本原因) 首先明确线程是抢占式执行的,也就是说,CPU 调度线程的时间是不确定的。这个我们程序员无法控制,也无法预料.所以我们把这个叫万恶之源.....

2.多个线程修改同一个变量.->大家抓住这句话的重点:多个线程/修改/同一个变量.  那这以下的都是线程安全的了:单线程.....or多个线程读....or多个线程修改不同变量,这些都不会引起线程安全问题.

3.修改操作非原子.->比方我们上一篇讲的时间轴:load,add,save...这一个count++就是代表三步骤,正是这三步骤让正在执行中的线程有随时被打断/被调度的风险....

4.指令重排序.->这属于编译器的优化策略,编译器是佬中佬创作的牛逼东西,他会优化一些没有必要的东西比如:锁粒度的粗化/取消锁/指令重排序.....等等等等,(我们后序会讲到),所以我们需要在多线程的情况下,手动关闭优化策略来保证我们程序执行的正确性.

5.内存可见性.->由于我们线程之间共享进程的资源:内存and文件描述符表.所以我们内存发生变化的时候,一定要在多线程的前提下,让不同线程感知到这种变化,否则就会产生类似于无效自增的不安全问题......

2.如何解决线程不安全问题(逐条解决)

2.1使用synchronized加锁

        我们以上篇中count自增10w次为例:我们希望通过保证原子性的方法来让结果可靠,那么使用synchronized锁住laod,add,save即可.如下代码:

class Counter{
    public static int count;
    public void add(){
        synchronized (this){
            count++;
        }
    }
    public int getCount(){
        return count;
    }
}
public class ThreadDemo8 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

只需要给add()方法中count++操作加上synchronized的方法后,我们观察结果:这里注意synchronized的使用方法:

synchronized(锁对象){

}

[多线程]中线程安全问题及其解决策略_第2张图片

就算执行很多遍结果都很准确!!! 

这种加锁方式等价于:

[多线程]中线程安全问题及其解决策略_第3张图片

这个相当于直接给方法加锁.上一个是给当前调用这个方法的对象加锁,也就是counter:

[多线程]中线程安全问题及其解决策略_第4张图片

那么一旦线程1加了锁,其他线程要想对同一对象加锁,就不能直接加,需要阻塞等待,直到线程1 释放锁之后,等待的线程们再次抢,谁抢到就谁加锁,这算不算有点不公平,但是大家要牢记一句话那就是:线程调度是抢占式执行的! 


*Java中如何实现加锁操作的?

       1.为什么使用synchronized(锁对象){}这样的方式实现加锁解锁?

         有些玉粉可能学过其他编程语言,其中会有一些加锁解锁对应的方法:lock()/unlock(),但是Java中是用synchronized后面的大括号{}来标志加锁and解锁的,进入{  加锁, 出}  解锁.这样的优点就是 万一我们在逻辑实现的时候,不小心return了,或者抛异常了,使用lock()/unlock()这样的方法,就可能来不及解锁了.... 

        2.为什么要有锁对象?

        加锁解锁其实就想我们学校的洗浴中心一样,同学A还没洗完澡,这个坑就一直被锁,知道她洗完澡之后,锁才会释放,那么这个锁对象就是这个房间1号对应的锁,其他想用1号房的同学就需要阻塞等待,那么如果同学B不想等了,她选择2号房,如果此时2号房没有人用,她就可以使用,我们在加锁/解锁的时候必须指定锁对象,这代表着一种"标记",而这个锁对象可以是任何对象(除了基本类型),你随便不能写一个都行,仅仅是代表一"标记"..... 

       

**join()和synchronized的区别 

        这是非常经典的面试题,也是大家初学时候非常易混淆的点!!!   
        join()是让两个线程完整的串行化执行,什么是完整的?[多线程]中线程安全问题及其解决策略_第5张图片

如上图:t1中先有个for循环,然后再调用add()方法,其中就有许多步骤:创建变量i,判定i,调用add(),count++,add()返回,i++.......这一轮算是完整的一次任务,而我们的join() 就是让每个线程都这样等待对放执行一遍,而synchronized加锁操作就是只在count++的时候上锁(仅仅针对我写的代码:对add()方法加锁),其他时候并发执行,这样效率是不是快很多.....

[多线程]中线程安全问题及其解决策略_第6张图片

加锁算是一个折中的办法:虽然时间效率比不加锁慢点,但是比不加锁算的准....


大家在学习锁这块的时候牢记一句话:多个线程尝试对同一锁对象加锁的时候才会出现"锁竞争" 

2.2 使用volatile保证内存可见性 

        观察以下代码:

public class ThreadDemo10 {
    public 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(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个非0整数");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

这段代码含义是,t1线程有个while循环空转,不断地看flag==0,t2线程让外界输入一个非0的整数强行改变flag的值,由于线程共享内存空间,也就是,这里内存中的flag是共有的,这样会使t1结束while循环,打印出语句后结束线程任务,那我们的预期结果是在控制台上:"请输入..."   "1"   "...t1结束"  后面main线程也结束.,我们来看实际结果:

[多线程]中线程安全问题及其解决策略_第7张图片

大家应该能发现输入1之后没反应的事实了吧,该打印的不打印,main线程也不结束....这是为什么?

        这就要回到上篇小玉提到的一个点"读寄存器要比读内存快几个数量级",我们在单线程的情况下这样读寄存器是没有问题的,但是多线程的时候编译器优化了频繁读内存的操作,它就开始频繁读寄存器了,也就是我们在第一次load的时候吧flag从内存督导寄存器了,然后后序while循环的时候,其实一直在读寄存器,即使我们在t2线程中更改了flag的值,寄存器不更新,t1就以为flag还是0,就不会退出while循环......... 如何手动关闭编译器的优化?使用volatile关键字!!!!

[多线程]中线程安全问题及其解决策略_第8张图片

只需要给成员变量flag前面加上volatile再观察结果我们发现,该打印的打印了,main也顺利结束了.结果就符合预期了. 

有点玉粉可能发现了一个问题:上篇我们在讲变量捕获的时候有个isQuit的例子:

public class ThreadDemo6 {
    public static boolean isQuit = false;

    public static void main(String[] args) {
//        boolean isQuit = false;

        Thread t = new Thread(()->{
           while (!isQuit){
               System.out.println("hello t ");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
            System.out.println("线程中断");
        });

        t.start();

        try {
            Thread.sleep(4000);//主线程休眠4s,然后修改成员变量isQuit的值
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isQuit = true;

    }
}

 有人会疑惑:这不也是外部修改isQuit的值了吗?为什么其他线程可以感知到isQuit的变化呢?

原因就在线程t的while循环中sleep了好长时间,这样"长时间"的休眠,就会导致空转中"判定"的时间,变得非常"渺小"了,这样的话,编译器就会自动取消优化,这时候我们读的就是内存而不是寄存器了.上一个空转的例子中,while里面什么都没有,仅仅是空转,这样的话,每次循环都在做load,compare,编译器就自动load了寄存器.

大家要注意,volatile只适用于一个线程频繁读,一个线程写的情况! 

2.3 使用volatile也可以禁止指令重排序 

        什么是指令重排序?  这也是编译器优化的手段之一.它会在保证结果不变的情况下,更换指令的顺序,以提高效率。

        比如:单线程的情况下,我们创建一个对象:大约有三步:
        1.申请内存空间. 
        2.初始化(调用构造方法)
        3.给对象赋值内存地址.

         此时执行123还是132结果都一样,都能使对象完整.但是多线程的情况下,假如t1在执行132,3刚执行完到2了,突然被调度了,另一个线程想访问对象的一些属性或者方法,就会出错.

使用volatile也可以通过禁止指令重排序来保证线程安全.这个指令重排序问题不能通过代码来演示,毕竟是随机的,所以大家了解原理即可.......


        好了,小玉先讲这么多,接下来会有更深度的关于多线程的线程安全等其它问题,小玉会持续更新,希望大家多多支持,快过年了,小玉提前祝大家新年快乐,码到成功!!!小玉的新年愿望就是写够100篇优质博客!大家一起努力吧~~~

[多线程]中线程安全问题及其解决策略_第9张图片

你可能感兴趣的:(java,开发语言)