Java线程②-线程安全问题

一:线程的状态

(1)线程状态分类

①NEW:Thread对象已经创建好,但是还没有执行start()方法


②RUNNABLE:称为就绪状态,就绪状态分两类,两类都属于就绪状态

(第一类是线程正在CPU上运行 / 第二类是线程在排队,随时准备去CPU调度)


③TERMINATED:线程执行完毕


④BLOCKED:因为锁产生阻塞


⑤WAITING因为调用wait产生阻塞


TIMED_WAITING:因为调用sleep产生阻塞

(2)通过画图了解分布

二:线程安全问题

(1)概念

一个代码,在单线程下执行没有问题,在双线程下执行也没有问题,就称为线程安全!反之,则为不安全!

 (2)线程安全问题的原因

①根本原因(罪魁祸首)多线程的抢占式执行带来的随机性

⯭理由和单线程不同,多线程下代码的执行顺序会产生更多变化;如果是单线程,我们只需要考虑代码在一个固定的顺序下执行即可;如果是多线程,我们需要保证N中执行顺序下,代码的执行结果都得正确


多个线程同时修改同一个变量

▲一个线程修改一个变量,没问题

▲多个线程读取同一个变量,没问题

▲多个线程修改多个不同的变量,没问题


修改操作不是原子的

⯭原子不可拆分;也就是说,这个操作得是不可分步骤的,要么一步到位,要么不改!

例:下面一会提到的count++ 是非原子操作,可以拆分成load  add  save三个指令,就会导致线程出现安全问题,我们一会在下文详细解读分析

两种典型的不是 "原子性" 的代码:

1.check and set (if 判定然后设定值)

2.read and update (i++)  (读取然后变量++,就是下面一会提到count++例子)


④内存可见性

编译器优化带来的“好心办坏事”

详情见下文的volatile关键字具体说明


⑤指令重排序

编译器优化带来的“好心办坏事”

在保证原有逻辑执行不变的前提下,对代码执行顺序进行调整,使调整之后执行效率提高

但是对于多线程来说,调整执行顺序可能会导致线程安全问题

详情见Java线程③

(3)通过代码理解线程安全问题

1.代码
class Counter{
    public int count = 0;       //创建一个count属性(变量)

    public void increase(){    //创建一个方法increase
        count++;               //每调动一次increase则count就++一次
    }
}

public class Demo13 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        //创建线程t1调动5万次increase方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        //创建线程t2调动5万次increase方法
        Thread t2= new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        }); 

        //启动两个线程
        t1.start();
        t2.start();
        
        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        
        // 打印最终的结果,看看t1、t2各调动了5万次之后,count是否等于10万???
        System.out.println(counter.count);
    }
}
2.结果

预期结果:10000


实际结果:

3.分析原因

①t1、t2多线程中发生了线程安全问题

问题:为什么这个多线程会发生线程安全问题?

原因:原子性;与count++有关!!!!!


在 count++ 操作中, ++ 操作本质上要分为3步:

1.先把内存中的值,读取到CPU的寄存器上(该步骤称为load)

2.CPU寄存器中的值进行 +1操作 (该步骤称为add)

3.将得到的结果写回到内存中 (该步骤称为save)

这三个操作,就是CPU上执行的指令

4.画图理解

⧊前提:我们需要知道,t1、t2这两个线程的load、add、save这几个操作的顺序都是多种多样的,调度顺序也是不确定的,因此,会产生无数种排列方式

①正确结果的count++操作
(1)第一种正确操作

 (2)第二种正确操作

 

结论: 当t1、t2是串行式执行的时候,代码会执行正确!

②错误结果的count++操作
(1)注意

◬除了上述的两种情况是正确的之外,其余的排列顺序都是错的,因为有无数种排列方式,这里就先随机画上几种,方便理解!

(2)图示



 三:解决线程安全问题-synchronized

①synchronized关键字

(1)从何解决

synchronized关键字就是从原子性入手来解决线程安全问题

(2)特性

synchronized关键字相当于给方法加锁


加锁的本质是把并发执行变成了串行执行


⧊synchronized主要是配合代码块(方法)使用

进入方法,就会加锁

退出方法,就会解锁


⧊如果两个线程同时尝试加锁

此时一个获取锁成功,另一个获取锁失败阻塞等待

只有当前面的线程释放锁之后,才可以获取到锁.


例1比如t1、t2两个线程,当他们都需要调用同一个方法increase时,我们可以给方法加锁

也就说t1、t2同时执行,当t1去调用方法时,t1去访问count变量,此时t2就阻塞了,也就推迟了t2的count++操作,必须得等到t1的count++完,t2才能进行count++;t1和t2两个线程是同时执行的,但是执行到synchronized修饰的increase方法时,t1加锁t2就阻塞了,必须等t1解锁t2才能继续访问变量

例2:比如你去上公共厕所,厕所都有锁,通过这个锁,你就可以限制别人进来,也就说,这个锁的目的在于同一时刻只有一个人能使用这个厕所,不可能多个人同时上同一个厕所

(3)修改代码,给increase方法加锁

(4)针对上面 count++ 结果不正确的操作,加锁后进行分析

 

(5)引发思考

问题:既然通过加锁操作之后把“并发执行”改成“串行执行”,那多线程的意义在哪?我干嘛不直接用单线程就行了?

回答:


对于上述代码,只有increase方法加锁了,也就是限制了变量count;但是我们的线程并不是只做了count++一件事,比如for循环就不加锁


⟁结论:两个线程,有一部分是并发执行,有一部分是串行执行,但无论如何,还是比纯粹的串行执行效率高不少!

②synchronized使用方法

(1)this修饰

synchronized每次加锁,也是指定某个特定的具体的对象进行加锁


synchronized(锁对象){         //锁对象通常用this

        //任意代码

}


锁对象是this,谁调用这个普通方法,锁的对象就是谁

例如:counter调用increase方法,锁的对象就是counter

(2)修饰方法

手动指定锁对象


(3)修饰静态代码块

作用域是整个方法,锁住的是当前类及该类的所有对象

synchronized (类.class) {
    //代码
}
public class Demo14 {
    public synchronized static void test() {
        
        }
    }

    //等价于
 public class Demo14{

        public static void test() {
            //锁的是类对象,类对象只有一个
            synchronized(Demo14.class) {
            }
        }
  }

 ③加锁要明确对象

(1)同一个对象

⯅如果两个线程对同一个对象加锁,就会产生阻塞等待,锁竞争/锁冲突

(⎊一个线程加锁,另一个线程阻塞等待)


💙①synchronized加到普通方法时,都是针对this加锁


💙②synchronized加到静态方法时,都是针对类对象(类.class)加锁

(2)不同对象

⯅如果两个线程对不同对象加锁,不会产生阻塞等待(不会锁冲突/锁竞争)

(⎊此时也就无法解决线程安全问题)

(3)规则

具体针对哪个对象加锁不重要

重要的是两个线程是否针对同一个对象加锁


(⎊两个线程必须都要加锁,且加锁的对象是同一个对象才能解决线程安全问题)

(⎊单方面加锁等于没加锁,必须多个线程都加锁才有意义)

(⎊对象不重要;重要的是否为同一个对象;只有针对同一个对象才能解决线程安全问题)


判断是否解决线程安全问题:看是否会出现锁竞争

④思考

问题:如果一个线程加锁了,一个线程没加锁,是否会存在线程安全问题?


回答:会存在线程安全问题!

我们上述的规则提到过两个线程是否针对同一个对象加锁

也就是说两个线程必须都要加锁,且加锁的对象是同一个对象,这样才能解决线程安全问题,但此时一个线程加锁一个线程不加锁,也就导致了无法出现线程竞争的现象,单方面的加锁相当于没加锁!

四:解决线程安全问题-volatile

①内存可见性

(1)概念

当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化


例如:如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值,反之,如果B无法读取A修改过的值,那么就会引发线程安全问题,也就是内存不可见了!

(2)原因

⟁①编译器/JVM的优化 (主要)

⟁②内存模型

⟁③多线程


所谓编译器的优化,本质上都是智能的对你写的代码进行分析判断且进行调整,这个调整的过程大部分都是ok的,都可以保证逻辑不变;但是!如果遇到多线程的情况,此时的优化就会使程序中原有的逻辑发生改变!

(3)通过代码理解

1.代码逻辑

t1始终在进行while循环


t2则是让用户通过控制台输入一个整数作为flag的值

当用户输的是0时,t1线程继续执行while循环

当用户输的是非0时,t1线程就应该循环结束

import java.util.Scanner;

public class Demo15 {
    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(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag的值");
            flag=scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

2.运行结果

3.分析逻辑

问题: 当输入非0值的时候,已经修改了flag的值,此时flag也不为0,但是为什么t1线程仍然在继续执行while循环呢?这显然不符合预期效果!


解答:编译器的优化导致


注意flag==0这个代码,本质上是由两个指令构成的 

第一个指令是load(读内存)

第二个指令是jcmp(比较,并跳转,在什么情况跳转到代码什么地方)

读内存操作速度慢(相对于寄存器来说,读一次内存可以执行一万次寄存器操作)

比较是寄存器操作,速度极快


当在这样一个场景下,编译器就发现,t1线程这个while循环要反复且快速的读取同一个内存的值,并且这个内存的值每次读出来还是一样的;此时,编译器就做出一个大胆的优化,直接把load操作优化掉,只是第一次执行load,后续都不再执行load,直接拿寄存器中的数据进行比较;但是,当我们在另一个线程t2中修改了flag的值,t1线程就读取不到了,尽管t2线程这里把内存的flag给改了,但是t1线程并没有重复读取flag的值,因此t1线程也就无法感知到t2的修改,就引发了内存可见性问题!!!

②volatile关键字

(1)从何解决

volatile关键字是从两个方面解决线程安全性问题

▲注意:volatile 不保证原子性!!!


一个是解决内存可见性问题

一个是禁止指令重排序

(2)特性

当为变量加上volatile关键字时,告诉编译器,这个变量是“易变”’的

需要每次都重新读取这个变量的内存内容!

(3)修改上述代码

五:wait()方法

①wait需要做的三件事

1.解锁

2.阻塞等待

3.当被其他线程唤醒后,就会尝试重新加锁,加锁成功,wait执行完毕,继续往下执行逻辑

②wait怎么用

(1) wait是Object类提供的方法

💗(也就是说,随便找个对象都能使用wait)


(2)wait 要搭配 synchronized 来使用

在synchronized代码块里调用wait方法

(脱离 synchronized 使用 wait 会直接报错)


(3)调用wait的对象要和加锁的对象是同一个对象

(要不然wait做的第一件事解锁解的谁?)


(4)wait要抛异常


(5)wait一般是结合notify使用,目的在于安排执行顺序和避免“线程饥饿”

(线程饥饿:该线程一直轮不到去CPU执行/调度)


public class Demo16 {
    public static void main(String[] args) throws InterruptedException {

        Object o1 = new Object();

        //调用wait的对象要和加锁的对象是同一个对象
        synchronized (o1){      //给o1加锁
            o1.wait();          //也必须要用o1调用wait
        }
        System.out.println("wait结束");
    }
}

③wait 结束等待的条件

(1)其他线程调用该对象的 notify 方法,用notify唤醒wait等待的线程


(2)其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常


(3)wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间)

④wait和sleep的对比

联系:

wait可以被interrupt唤醒

💗(wait等到notify的唤醒是顺理成章的唤醒,唤醒之后该线程还需要继续工作,后续可能还会进入wait状态)

💔(wait被interrupt提前唤醒,是要被告知线程要结束了,接下来线程就要进入到收尾工作了)


sleep也能被interrupt提前唤醒

区别:

1.wait 需要搭配 synchronized 使用

sleep 不需要搭配 synchronized 使用


2.wait 是 Object 的方法

sleep 是 Thread 的静态方法


3.wait是一个死等,需要一直等到有其他线程调用notify

sleep是有一个明确时间,到达时间,自然就会被唤醒

六:notify()方法

①作用

唤醒等待的线程


如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程(没有 "先来后到")

②notify怎么用

(1) notify是Object类提供的方法

💗(也就是说,随便找个对象都能使用notify)


(2)notify 要搭配 synchronized 来使用

在synchronized代码块里调用notify方法

(虽然脱离 synchronized 使用 notify不会报错,但这就是Java的硬性规定)


(3)想让notify能够顺利唤醒wait,就要确保wait和notify都是同一个对象调用


(4)notify不用抛异常


(5)如果有两个线程,一个线程进行notify的时候,另一个线程没有处于wait状态,此时的notify相当于"空打一枪",没有任何作用或副作用

假设我7点去跑步,我和母亲商量好7点的时候来叫我起床;明天7点时,我已经醒了,那么母亲叫我也没有任何作用


(6)代码逻辑:t1先去进行wait,当t1在wait时,wait一秒后,t2进行notify,这个t2的notify就会唤醒t1的wait,让wait结束
public class Demo17 {
    //通过Locker对象来负责加锁
    private static Object Locker = new Object();

    public static void main(String[] args) {
        //想让notify能够顺利唤醒wait,就要确保wait和notify都是同一个对象Locker调用

        Thread t1 = new Thread(() -> {      //t1负责wait
           while (true){
               synchronized (Locker){
                   System.out.println("t1 wait 开始");
                   try {
                       Locker.wait();      //同一个对象Locker调用
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   System.out.println("t1 wait 结束");
               }
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {        //t2负责notify
            while (true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (Locker){
                    System.out.println("t2 notify 开始");
                    Locker.notify();         //同一个对象Locker调用
                    System.out.println("t2 notify 结束");
                }
            }
        });
        t2.start();
    }
}

七:notifyAll()方法

①作用

notify方法只是唤醒某一个等待线程

使用notifyAll方法可以一次唤醒所有的等待线程

②与notify的区别

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值