一:线程的状态

(1)线程状态分类

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


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

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


③TERMINATED:线程执行完毕


④BLOCKED:因为锁产生阻塞


⑤WAITING无超时时间的阻塞

(例如:wait方法/join无超时时间参数的方法)


TIMED_WAITING:有超时时间的阻塞

(例如:sleep方法/join有超时时间参数的方法)

(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关键字相当于给对象加锁

⭐注意事项:

1.任何一个对象都可以是锁对象,例如Object类/Integer等等包装类

2.不能是int等等这种内置类型

3.必须对同一个对象使用锁


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


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

进入方法,就会加锁

退出方法,就会解锁


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

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

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


⧊synchronized是可重入锁
在可重⼊锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息
①如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可继续获取到锁, 并让计数器自增
②解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
(可重入锁的简单理解:同一个线程可以多次获取同一把锁,而不会发生死锁;每次获取锁后,必须释放相同次数的锁才能完全释放)

例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)修饰方法

省略写锁对象(不用写对象,但本质上还是对this加锁)

⟁如下例子给increase方法加锁



易错:锁的其实是对象,而不是方法!



(3)synchronized(this)和synchronized方法的对比

(4)修饰静态代码块

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

▲当锁住的是类,即静态代码块或者静态方法时,其他静态方法就会被阻塞


synchronized (类.class) {
    //代码
}

//等价于
synchronized public static 返回类型 方法名(){
    //代码    
}
 public class Demo14{

        public static void test() {
            //锁的是类对象,类对象只有一个
            synchronized(Demo14.class) {
            
            }
            //等价于 
            synchronized public static 返回类型 方法名(){
            
            }
        }
  }
 (5)举例子详细理解synchronized的使用

③加锁要明确对象

(1)同一个对象

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

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

(什么对象不重要,重要的是否是同一个对象)


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


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


⯅举例:

当你去上厕所的时候,你把厕所锁起来了,此时别人要想和你上同一间厕所,他就得阻塞等待,等待你把锁释放了,你出去了他才能够进去,这把锁就必须是同一把锁,但是这把锁是什么类型的不用管,什么铁锁、密码锁、指纹锁等等,关键就在于这把锁你和别人用的是不是一样的

(2)不同对象

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

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


⯅举例:

当你去上厕所的时候,厕所里有三间room1,room2,room3,当每个厕所都有各自的锁的时候,那么就会出现你去room1上厕所,你锁的自然只能是room1,丝毫不影响其他人去room2,room3上厕所

(3)规则

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

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


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

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

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


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


除了对同一个对象加锁之外的其他黄金规则:

④思考

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


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

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

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

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

①内存可见性

(1)概念

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


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

(2)原因

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

⟁②内存模型

⟁③多线程


⭐理解编译器的优化:

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

②优化就可以想象成你的班级大扫除,老师要求把地板扫干净和吊顶的灰擦干净,对于你来说,你可能做的是先扫地板再擦吊顶,这就会造成扫完地板你擦吊顶的话吊顶的灰会落到地板上,这时候就要再扫一次地板;而对于编译器来说,它会帮你优化顺序,即先擦吊顶再扫地板

(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(比较,并跳转,在什么情况跳转到代码什么地方)

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


1.当在这样一个场景下,编译器就发现,t1线程这个while循环要反复且快速的读取同一个内存的值,并且这个内存的值每次读取到寄存器还是一样的,更要命的是读一次内存的操作还慢,上述说读一次内存可以执行一万次寄存器操作

2.此时,编译器就做出一个大胆的优化,直接把load读取内存的操作优化掉,只是第一次执行load,后续都不再执行load,直接拿寄存器中的数据或者缓存L1/L2/L3中的数据来进行比较

3.但是,当我们在另一个线程t2中修改了flag的值,t1线程就读取不到了,尽管t2线程这里把内存的flag给改了,但是t1线程并没有重复读取flag的值,因此t1线程也就无法感知到t2的修改,就引发了内存可见性问题!

(4)了解JMM

①JMM:Java Memory Model

(Java内存模型)


②JMM存储空间:

1.首先对于一个Java进程,它会有一个"主内存"的存储空间

(这里的主内存其实是真正的内存)

2.其次每个Java线程,它会有一个"工作内存"的存储空间

(这里的工作内存不是内存,本质上其实是CPU的寄存器和CPU的缓存构成的)


③理解上述代码:

上述代码中有两个线程,分别为t1和t2,t1会进行对flag变量的判定,但它会先把flag的值从主内存读取到工作内存中来,用工作内存中的值来进行判定;同时t2对flag的修改,修改的则是主内存的值,主内存的变更不会影响到t1的工作内存

(虽然听着很复杂,但是这实际上和上述分析flag==0这行代码是一样的意思)

(5)了解缓存

⭐缓存其实是介于内存和寄存器之间的


①缓存的来源:

由于寄存器和内存之间的速度和空间差异较大,寄存器是空间小速度快,内存是空间大速度慢,两者难以协调工作;而CPU要进行一系列运算,在运算过程中,要反复用到一组内存中的数据,但这些数据又要频繁使用,因而又要频繁访问内存,同时数据有比较多,寄存器放不下,此时就可以放到缓存中


②缓存的分类:

L1:空间小,速度快

L2:空间变大,速度变慢

L3:空间更大,速度更慢


③内存/缓存/寄存器图:


④CPU的缓存对于CPU的执行性能影响是非常大的

例如:游戏一般都是比较吃CPU的,尤其是一些PUBG、CSGO2等等,CPU越好,游戏帧数才高,游戏才流畅,所以AMD公司就搞了x3d系列的CPU,比如7800x3d这个CPU的其他参数频率和核心数很一般,但是有个超级大的L3缓存,大概96MB,而我的游戏本L3缓存才18MB

②volatile关键字

(1)从何解决

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

内存可见性指令重排序


volatile只保证:

1. 可见性:一个线程修改后,其他线程立即能看到最新值

2. 有序性:禁止指令重排序

(▲不保证原子性!)

(2)特性

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

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


(3)修改上述代码

五:wait()方法

①wait需要做的三件事

1.解锁

2.阻塞等待

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


wait的详细执行流程:

1.首先,当条件不满足的时候,线程进入wait,此时解锁,不占用锁资源

2.然后,进入阻塞等待的状态

3.直到被唤醒了(notify),被唤醒后先执行wait()后面的语句,然后立即回到while条件检查,条件满足退出循环,不满足可能继续wait


💗核心作用是实现线程间的协调通信

▲主要就是用于让当前线程释放锁并进入等待状态,直到其他线程调用相同对象上的notify()notifyAll()唤醒它


场景举例:有两个线程t1和t2,这时候希望t1先执行完某个逻辑之后再让t2去执行,此时就可以先让t2去wait主动进行阻塞等待,注意此时t2是不占用锁资源的,而是释放锁让t1去获取到锁,此时t1就可以去完成它的任务,等到t1完成任务后使用notify方法唤醒t2,那么t2被唤醒了才继续往下执行它的任务

②wait怎么用

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

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


(2)wait 要搭配 synchronized 来使用

(在synchronized代码块里调用wait方法)

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


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

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


(4)wait要抛InterruptedException异常

(凡是Java标准库中涉及阻塞的方法都可能会抛InterruptedException异常;比如sleep)


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

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

(wait和notify都是要搭配synchronized来使用)


(6)检查条件用while而非if :防止虚假唤醒(spurious wakeup)

虚假唤醒(Spurious Wakeup):线程可能在没有被notify的情况下自己醒来,这是操作系统层面的特性。所以必须用while循环再次检查条件,因为if不像while重新去判断条件,如果用if的话就算不满足条件它也继续往下执行了

③wait的核心总结

④wait的细节和代码理解




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 参数的版本, 来指定等待时间)


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

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

⑥wait和sleep的对比

相同:

①interrupt唤醒

1.wait可以被interrupt唤醒

   sleep也能被interrupt提前唤醒

▶(两者都会被抛出InterruptedException,并且自动清除中断标志位(标志位设为false))


②超时时间

2.wait可以设置超时时间

   sleep也可以设置超时时间

▶(虽然都能设置超时时间,但是wait的设计主要还是为了提前唤醒,设置超时时间是后手选择;而sleep的设计是为了到时间才唤醒,设置超时时间是更必要的)

区别:

①使用方式

1.wait 需要搭配 synchronized 使用

   sleep 不需要搭配 synchronized 使用


②方法

2.wait 是 Object 的方法

   sleep 是 Thread 的静态方法


③锁的释放

3.wait在等待时会释放锁

   sleep在等待时不会释放锁

(wait是不占茅坑不拉/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可以搭配synchronized凭空调用,就算没有线程在wait,也能执行且不会抛异常

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

例2:假设我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();
    }
}

③wait/notify的使用场景一:生产者消费者

④wait/notify的使用场景二:火爆餐厅

七:notifyAll()方法

①作用

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

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

②与notify的区别

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐