【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例

【Java 多线程】多线程带来的的风险-线程安全、多线程经典案例

日常开发中如果用到多线程编程,也一定会涉及到线程安全问题
线程安全这个问题就不太好理解
正因为如此,程序猿们才尝试发明出更多的编程模型来处理并发编程的任务
例如:多进程、多线程、actor、csp、async+await、定时器+回调

操作系统,调度线程的时候,是随机的 (抢占式执行)
正是因为这样的随机性,就可能导致程序的执行出现一些 bug
如果因为这样的调度随机性引入了 bug,就认为代码是线程不安全的,如果是因为这样的调度随机性,也没有带来 bug,就认为代码是线程安全的
这里的线程安全指的是有没有bug (平时谈到的 “安全”,主要指的是黑客是不是会入侵你的计算机,破坏你的系统)

什么时候会有安全问题? 多个执行流,访问同一个共享资源的时候

线程模型,天然就是资源共享的,多线程争抢同一个资源(同一个变量)非常容易触发的
进程模型,天然是资源隔离的,不容易触发。进行进程间通信的时候,多个进程访问同一个资源,可能会出问题

为什么chrome浏览器要使用多进程编程模型(每个标签页都是个进程),目的就是为了防止,一个页面挂了把别的页面带走.

当前多线程的编程模型,要比多进程这种模型更加广泛
工作中遇到多线程的情况也要比多进程多很多,尤其是Java圈子


一、多线程带来的的风险-线程安全

1、一个线程不安全的典型案例:

使用两个线程,对同一个整型变量进行自增操作,每个线程自增5w次,看最终的结果

package thread;

class Counter {
    // 这两个线程要自增的变量
    public int count;

    public void increase() {
        count++;
    }
}
public class demo1 {
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();

        // 必须要在 t1 和 t2 都执行完后,再打印,否则 main 和 t1 t2 是并发关系
        // 导致t1 t2 还没执行完,就执行了下面的打印
        t1.join(); // 两个join 顺序前后没关系
        t2.join();

        // 在 main 中打印两个线程自增完 得到的 count
        System.out.println(counter.count);
    }
}

两个 join 谁在前,谁在后,都没关系
由于线程调度是随机的,咱们也不知道 t1 先结束,还是 t2 先结束

t1.join();
t2.join();

假设 t1 先结束
先执行 t1.join,然后等待 t1 结束
t1 结束了,接下来调动 t2.join,等待 t2 结束,t2 结束了,t2.join 执行完毕

假设 t2 先结束
先执行 t1.join,等到 t1 结束,
t2 结束了,t1 还没结束,main 线程仍然阻塞在 t1.join 中,再过一会,t1 结束了,t1.join 返回。执行 t2.join ,此时由于t2已经结束了,t2.join 就会立即返回

执行结果:
76854,再运行:83041
这个代码中,是自增了10w次两个线程一人5w,预计输出10w
那么 count++ 到底做了什么?

站在 CPU 的角度来看待 count++,实际上是三个CPU指令

  1. 把内存中的 coun t 的值,加载到 CPU 寄存器中 —— load
  2. 把寄存器中的值+1 —— add
  3. 把寄存器的值写回到内存的 count中 —— save

这三个操作,,是CPU上执行的三个指令!! (视为是机器语言)
JVM上执行的二进制字节码和CPU的原生指令还不太一样,咱们此处讨论的指令是指CPU的指令,而不是字节码这里

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


2、抢占式执行

正因为前面说的 “抢占式执行”,这就导致两个线程同时执行这三个指令的时候t1 t2这三个指令之间的相对顺序充满随机性,各种情况都可能发生,并且哪种情况出现多少次,是否出现,都无法预测

可能的情况举例: (方框表示内存)

1、按照下面的执行步骤,执行的结果是对的,没有产生bug

在这里插入图片描述

2、两个线程 “抢占式执行” 过程中可能出现的一种先后排列情况:

t1 load 将 0 加载到 cpu 寄存器,t2 load 将 0 加载到 cpu 寄存器,t2 add 将 0 加成 1,t2 sava 将 t2 寄存器的 1 写回内存。t1 add 将 t1 寄存器的 0 加至 1,t1 save 将 t1 寄存器的 1 写回内存,最终内存中还是 1,而不是 2。

按照下面的执行过程,两个线程的并发相加,最终结果仍然是 1
相当于+了两次,但是只有一次生效了,后一次自增覆盖了前一次的结果,这个情况就是产生 bug 的根源,也就导致了线程不安全问题

在这里插入图片描述

这里累加的结果是 5w 到 10w 之间
这5w对并发相加中,有时候可能是串行的 (+2),有的时候是交错的 (+1),具体串行的有多少次,交错的有多少次,咱们不知道,都是随机的

3、还可能出现,t1自增一次,t2自增了两次,最终还是增1。或者t1自增一次,t2自增了三次,最终还是增1 (如果大部分是这种情况,就是小于 5w)

在这里插入图片描述

极端情况下:

  • 如果所有的操作都是串行的,此时结果就是 10w (可能出现的,但是小概率事件)
  • 如果所有的操作都是交错的,此时结果就是 5w (可能出现的,也是小概率事件)

在这里插入图片描述


3、加锁 synchronized

例如去 ATM 取钱,通过加一个锁,限制了一次只能有一个人进来取钱
通过这样的锁,就可以避免出现上述所描述的一些乱序排序执行的情况

在自增之前,先加锁 lock
在自增之后,再解锁 unlock

在这里插入图片描述
刚才 t1 已经把锁给占用了,此时 t2 尝试 lock 就会发生阻塞
lock 会一直阻塞,直到 t1 线程执行了 unlock
通过这里的阻塞,把乱序的并发,变成了一个串行操作,这个时候运算结果也就对了

变成串行了,确实就和单线程没啥区别了
并发性越高,速度越快, 但是同时可能就会出现一些问题,加了锁之后,并发程度就降低了,此时数据就更靠谱了,速度也就慢了

实际开发中,一个线程中要做的任务是很多的,例如,某个线程里要执行:步骤1、步骤2、步骤3
其中很可能只有步骤3,才涉及到线程安全问题,只针对步骤3,加锁即可,此时上面的1,2 都可以并发执行

java 中加锁的方式有很多种,最常使用的是 synchronized 这样的关键字

class Counter {
    public int count;
	// 此时运行 就是 100000
    synchronized public void increase() {
        count++;
    }
}

给方法直接加上 synchronized 关键字,此时进入方法就会自动加锁离开方法,就会自动解锁
当一个线程加锁成功的时候,其他线程尝试加锁,就会触发阻塞等待 (此时对应的线程,就处在BLOCKED 状态)
阻塞会一直持续到,占用锁的线程把锁释放为止


二、产生线程不安全的原因

什么样的代码会产生这种线程不安全问题呢?
不是所有的多线程代码都要加锁 (如果这样了,多线程的并发能力就形同虚设了)

1、抢占式执行 【根本原因】

线程是抢占式执行,线程间的调度充满随机性,这是线程不安全的万恶之源!

解决方法:无,虽然这是根本原因,但是咱们无可奈何


2、修改共享数据

多个线程对同一个变量进行修改操作

上面的线程不安全的代码中, 涉及到多个线程针对counter.count变量进行修改,此时这个counter.count是一个多个线程都能访问到的 “共享数据”,counter.count 这个变量就是在堆上. 因此可以被多个线程共享访问

如果是多个线程针对不同的变量进行修改,就没事;如果多个线程针对同一个变量,也没事!

解决方法可以通过调整代码结构,使不同线程操作不同变量

这种调整,不一定都能使用的。代码结构也是来源于需求的
调整代码结构是一个方案,但是不是一个普适性特别高的方案

一个线程,修改一个变量,没事
多个线程读取同一个变量,没事 (String是不可变对象,不可变对象,天然是线程安全的
像有些编程语言,比如erlang 语法里就没有"变量”这个概念,所有的"变量"都是"不可变的”,这样的语言就更适合并发编程 (出现线程安全问题的概率就大大降低了))
多个线程修改多个不同的变量,也没事


3、原子性

针对变量的操作不是原子的(讲数据库事务时提到过)此处说的操作原子性也是类似
在这里插入图片描述
什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条 java 语句不一定是原子的,也不一定只是一条指令

针对有些操作,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的,通过加锁操作,也就是把好几个指令给打包成一个原子的了,就像我们上面写的自增,分成了三条指令:

1. 从内存把数据读到 CPU
2. 进行数据更新
3. 把数据写回到 CPU

不保证原子性会给多线程带来什么问题

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大

解决方法:加锁操作,就是把这里的多个操作打包成一个原子的操作

加锁,说是保证原子性,其实不是说让这里的三个操作一次完成,也不是这三步操作过程中不进行调度,而是让其他也想操作的线程阻塞等待了
加锁本质是把并发,变成了串行

只要不是释放锁,仍然得阻塞等待
(虽然 t1这会没在cpu上执行,但是没有释放锁,t2仍然得阻塞等待)

例:

class Counter {
    public int count = 0;

    // 虽然加锁之后,算的慢了,还是比单线程要快
	// 是针对count++加锁了.除了count++之外,还有for循环的代码. for循环代码是可以并发执行的.只是count++串行执行了.
	// 一个任务中,一部分可以并发,一部分串行,仍然是比所有代码都串行要更快的~~

    synchronized public void add() {
        count++; // 给add加锁.其余for循环这里的比较和自增操作都是不必加锁,可以并发执行的

    }
}

public class demo1 {
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) { // t1和t2各自修改各自的局部变量i,这个时候结果是没事的
                counter.increase();
            }
        });
        ...

add 加锁后,程序执行结果:10w

在这里插入图片描述


4、可见性

详细见在下面 三.1、volatile 能保证内存可见性

内存可见性,也会影响到线程安全!

可见性指,一个线程对共享变量值的修改,能够及时地被其他线程看到

内存可见性问题:

  • 如果是频繁读取同一个内存变量,就可能触发优化,后续的读内存被优化成直接读寄存器

  • 一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改之后的值,这个读线程没有感知到变量的变化

  • 归根结底是编译器/jvm 在多线程环境下优化时产生了误判了

解决方法:

  • 禁止编译器优化,保证内存可见性
  • 此时,就需要程序猿,手动干预了。可以给 flag 这个变量加上 volatile 关键字 (只能修饰变量),意思就是告诉编译器,这个变量是"易变”的,一定要每次都重新读取这个变量的内存内容,不要再进行激进的优化了,编译器会保证每次读取变量操作都是从内存读取

5、指令重排序

案例:下面 五 1、1.5 懒汉模式 - 内存可见性 指令重排序

指令重排序,也是编译器优化中的一种操作

列出菜单:
黄瓜
鸡蛋
西红柿
土豆

在上述的基础上,如果能够调整一下代码的执行顺序,执行效果不变,但是效率就提高了
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按
鸡蛋
土豆
黄瓜
西红柿
的方式执行,也是没问题,可以减少路程。这种叫做指令重排序

在这里插入图片描述

咱们写的很多代码,彼此的顺序,谁在前谁在后无所谓,编译器就会智能的调整这里代码的前后顺序从而提高程序的效率

编译器对于指令重排序的前提是 “保证逻辑不变的前提”,再去调整顺序。

如果代码是单线程环境下,编译器的判定一般都是很准,
但是如果代码是多线程的,编译器也可能产生误判
多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价

解决方法:
加上 volatile 关键字,禁止指令重排序,同时还能保证内存可见性

上述分析出的是 5 个典型的原因,不是全部
一个代码究竟是线程安全还是不安全,都得具体问题具体分析,难以一概而论.
如果一个代码踩中了上面的原因,也可能线程安全.
如果一个代码没踩中上面的原因,也可能线程不安全……

结合原因,结合需求,具体问题具体分析
最终抓住的原则:多线程运行代码,不出bug,就是安全的!!!


三、synchronized 关键字 - 监视器锁 monitor lock

监视器锁 monitor lock:JVM 给 synchronized 起了个这个名字,因此代码中有的时候出异常可能会看到这个说法

同步的,这个同步这个词,在计算机中是存在多种意思的
不同的上下文中,会有不同的含义

比如,在多线程中,或者说线程安全中,同步,其实指的是 “互斥”,多个操只有一个成功
比如,在 IO 或者网络编程中,同步相对的词叫做 “异步”,此处的同步和互斥没有任何关系,和线程也没有关系了,表示的是消息的发送方,如何获取到结果

1、synchronized 的使用方式

修饰方法:进入方法就加锁,离开方法就解锁。1.普通方法,2.静态方法。二者加锁的对象不同

线程对对象加锁
锁不是加到方法上的 (虽然synchronized是修饰了方法)

1.1、直接修饰普通的方法

在这里插入图片描述

使用 synchronized 的时候,本质上是在针对某个 “对象” 进行加锁,也就相当于把锁对象指定为 this

修饰普通方法,锁对象就是 this

在这里插入图片描述
synchronized 的本质操作,是修改了 Object 对象中的 “对象头” 里面的一个标记

无论这个对象是个啥样的对象原则就一条,锁对象相同,就会产生锁竞争 (产生阻塞等待)
锁对象不同就不会产生锁竞争 (不会阻塞等待)

  • 如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突,一个线程能够获取到锁(先到先得),另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功
  • 如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突,这俩线程都能获取到各自的锁,不会有阻塞等待了
  • 还是两个线程,一个线程加锁,一个线程不加锁,这个时候是否有锁竞争呢?? 没有的!!!

在这里插入图片描述


1.3、修饰一个静态方法

synchronized 加到静态方法上,(静态方法,有this嘛?)
所谓的 "静态方法” 更严谨的叫法,应该叫做 “类方法” ;普通的方法,更严谨的叫法,叫做 “实例方法”

相当于针对当前类的类对象加锁 Counter.class (反射)

public synchronized static void method() {
    
}

public static void func() {
    synchronized (Counter.class) {
        // 类对象,就是咱们在运行程序的时候,.class 文件被加载到 JVM 内存中的模样
        // 反射机制,都是来自于 .class 赋予的力量
    }
}

1.2、修饰一个代码块

如果要是针对某个代码块加锁,就需要显式/手动指定锁对象 (针对哪个对象加锁),(Java 中的任意对象都可以作为锁对象)
这种随手拿个对象都能作为所对象的用法,这是 Java 中非常有特色的设定 (别的语言都不是这么搞,正常的语言都是有专门的锁对象)

// 锁当前对象
public void increase() {
	synchronized (this) { // // 进入代码块就加锁出了代码块就解锁
	}
}

// 锁类对象
public void method() {
    synchronized (SynchronizedDemo.class) {
    }
}

2、synchronized 的特性

2.1、互斥

synchronized 会起到互斥效果,某个线程执行到某个对象的synchronized 中时,其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

  • 进入 synchronized 修饰的代码块,相当于 加锁
  • 退出 synchronized 修饰的代码块,相当于 解锁

synchronized的力量是jvm提供的,jvm的力量是操作系统提供的,操作系统的力量是CPU提供的
追根溯源,是CPU提供了加锁这样的指令才能让操作系统实现锁
操作系统把锁的API提供给JVM,JVM提供给synchronized

synchronized 的底层是使用操作系统的 mutex lock 实现的

出了 Java 这里的 synchronized 之外,很多别的语言,别的库,加锁解锁,往往是两个分开的操作。比如,加锁 lock(),解锁unlock()
分开写,容易忘记写 unlock !!!
所以 synchronized 基于代码块的方式,就有效解决了上述问题


2.2、刷新内存

synchronized 的工作过程:

1.获得互斥锁
2.从主内存拷贝变量的最新副本到工作的内存
3.执行代码
4.将更改后的共享变量的值刷新到主内存~
5.释放互斥锁
所以 synchronized 也能保证内存可见性
存在争议,无法确定

在这里插入图片描述


2.3、可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

可重入:

  • 直观来讲,同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入的

死锁:

  • 死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止

例如一个段子:一码通崩了,程序猿赶紧赶到公司,要修复程序,到了公司楼下,保安拦住了
保安:请出示一码通!
程序猿说:一码通崩了,我要先上楼,修复了 bug,才能出示一码通
保安:你要出示一码通,才能上楼.
程序猿:我不上楼,就修复不了 bug,就不能出示一码通
保安:你不出示一码通,我不会让你上楼

分析一下,连续锁两次会咋样:

synchronized public void add() {
    synchronized (this) {
        count++;
    }
}

外层先加了一次锁,里层又对同一个对象再加一次锁

锁对象是 this,只要有线程调用 add,
进入 add 方法的时候,就会先加锁.(能够加锁成功)

外层锁: 进入 add 方法,则开始加锁,这次能够加锁成功,当前锁是没有人占用的
里层锁: 然后又进入代码块,开始加锁,这次加锁不能加锁成功,(按照咱们之前的观点来分析),站在 this 的视角(锁对象) 它认为自己已经被另外的线程给占用了,得等外层锁释放了之后,里层锁才能加锁成功)此处是特殊情况。第二个线程,和第一个线程,其实是同一个线程,这里考虑一下是否要特殊开个绿灯呢??

外层锁要执行完整个方法,才能释放
但是要想执行完整个方法,就得让里层锁加锁成功继续往下走
这就成了死锁

这种代码在实际开发中,稍不留神,就容易写出来
如果代码真的死锁了,岂不是程序猿的 bug 就太多太多了嘛
实现 JVM 的大佬们显然也注意到了这一点,就把 synchronized 实现成了==可重入锁,对于可重入锁来说,上述连续加锁操作,不会导致死锁==

可重入锁内部,会记录当前的锁被哪个线程占用的,同时也会记录一个 “加锁次数"
线程 a 针对锁第一次加锁的时候,显然能够加锁成功
锁内部就记录了当前的占用着是 a,同时加锁次数为 1,
后续再 a 对锁进行加锁,此时就不是真加锁,而是单纯的把计数给自增,加锁次数为 2,
后续再解锁的时候,先把计数进行 -1,当锁的计数减到 0 的时候,就真的解锁

可重入锁的意义,就是降低了程序猿的负担 (降低了使用成本,提高了开发效率
但是也带来了代价,程序中需要有更高的开销 (维护锁属于哪个线程,并且加减计数,降低了运行效率


3、死锁的其他场景

一旦程序出现死锁,就会导致线程就跪了 (无法继续执行后续工作了) 程序势必会有严重bug,死锁非常隐蔽的,开发阶段,不经意间就会写出死锁代码,不容易测试出来


3.1、一个线程,一把锁

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


3.2、两个线程,两把锁

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

我和朋友去吃饺子,一个蘸醋,一个蘸辣椒
我拿起了醋,朋友拿起了辣椒
我说,你把辣椒给我,
她说,你把醋给我.
我说,你先把辣椒给我,我用完了,就给你醋,
她说,你先把醋给我,我用完了就给你辣椒

public class ThreadDemo {
    public static void main(String[] args) {
        Object cu = new Object();
        Object lajiao = new Object();

        Thread wo = new Thread(() -> {
            synchronized (cu) {
                try { // 确保两个线程先把第一把锁拿到(线程是抢占式执行的)
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lajiao) {
                    System.out.println("我拿到了醋和辣椒");
                }
            }
        });
        Thread pengyou = new Thread(() -> {
            synchronized (lajiao) {
                try { // 
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (cu) {
                    System.out.println("朋友拿到了醋和辣椒");
                }
            }
        });
        wo.start();
        pengyou.start();
    }
}

——运行结果:

在这里插入图片描述

——jconsole 查看线程调用栈

针对这样的死锁问题,也是需要借助像 jconsole 这样的工具来进行定位的,看线程的状态和调用栈,就可以分析出代码是在哪里死锁了

在这里插入图片描述


3.3、N 个线程,M 把锁

情况更复杂,使用一个教科书上的经典案例哲学家就餐问题

在这里插入图片描述

每个哲学家,会做两件事
1.思考人生 (相当于线程的阻塞状态)
2.吃面条 (相当于线程获取到锁然后执行一些计算)

由于操作系统随机调度,这五个哲学家,随时都可能想吃面条,也随时可能要思考人生 (随机的)
每个哲学家吃面条的时候,都需要拿起他身边的两根筷子 (假设先拿起左手的,后拿起右手的)

每个哲学家都是非常固执的,如果想吃面条的时候,尝试拿筷子发现筷子被别人占用着,就会一直等!

在这个模型中,极端情况下,如果五个哲学家,同时伸出左手,拿起左手的筷子,等待右边的哲学家放下筷子,此时,就死锁了!

死锁也是在日常开发中,一个比较常见的问题


3.4、死锁的四个必要条件

  1. 互斥使用
    一个锁被一个线程占用了之后,其他线程占用不了 (锁的本质,保证原子性的)

  2. 不可抢占
    一个锁被一个线程占用了之后,必须是主动释放,其他的线程不能把这个锁给抢走 (挖墙脚是不行的

  3. 请求和保持
    当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是被该线程持有的

线程1 拿到锁A 之后,再尝试获取锁B,A 这把锁还是保持的 (不会因为获取锁B 就把 A 给释放了)

  1. 循环等待
    等待关系,成环了:A 等B,B 等 C,C 又等 A

线程1 尝试获取到锁A 和 锁B。线程2 尝试获取到锁B 和 锁A
线程1 在获取B 的时候等待线程2 释放B;同时线程2 在获取A 的时候等待线程1 释放 A

当上述四个条件同时具备,才出现死锁。死锁的情况下如果打破上述任何一个条件,便可让死锁消失
前三条都是属于锁本身的特点 (对于synchronized这把锁来说,前三点,你也动不了) (世界上的锁不是只有 synchronized
还会存在一些其他情况的锁,可能和上述的1,2,3条件还有变数)

循环等待是这四个条件里唯一一个和代码结构相关的,也是咱们程序猿可以控制的,实际开发中要想避免死锁,关键要点还是从 4 个条件进行切入

如何避免出现循环等待? 打破必要条件即可!! 突破口,就是循环等待

只要约定好,针对多把锁加锁时候,有固定的顺序即可,所有的线程都遵守同样的规则顺序,不会出现循环等待

筷子编号,约定,让哲学家拿筷子,每个人不是先拿左手,后拿右手了,而是先拿编号小的,后拿编号大的

在这里插入图片描述

可能产生环路等待的代码:

两个线程对于加锁的顺序没有约定, 就容易产生环路等待

// 上面的拿醋和辣椒

不会产生环路等待的代码:

假设 cu是1号,lajiao是2号,约定先拿小的,后拿大的,就不会环路等待

public class ThreadDemo {
    public static void main(String[] args) {
        Object cu = new Object();
        Object lajiao = new Object();

        Thread wo = new Thread(() -> {
            synchronized (cu) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lajiao) {
                    System.out.println("我拿到了醋和辣椒");
                }
            }
        });
        Thread pengyou = new Thread(() -> {
            synchronized (cu) { // 
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lajiao) { // 
                    System.out.println("朋友拿到了醋和辣椒");
                }
            }
        });
        wo.start();
        pengyou.start();
    }
}

——运行:

我拿到了醋和辣椒
朋友拿到了醋和辣椒

但是实际开发中,很少出现这种一个线程需要锁里再套锁这样的情况

synchronized (a) {
    synchronized (b) {
        synchronized (c)
    }
}

如果不嵌套使用锁,也没那么容易死锁

如果我们的使用场景,不得不进行嵌套,要记得,一定要约定好加锁的顺序,所有的线程都按照 a -> b->c 这样的顺序加锁
千万别有的线程 a-> b->c,有的线程 c -> b-> a,就很容易出现环路等待


3.5、Java 标准库中的线程安全类

Java 标准库中有很多线程的类,很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施,在多线程环境下,如果使用线程不安全的类,就需要谨慎

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的,在一些关键方法上都有 synchronized
有了这个操作,在多线程环境下,修改同一个对象就安全一些

加锁这个操作是有副作用 (额外的时间开销)

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

——StringBuffer 的核心方法都带有 synchronized

在这里插入图片描述

还有的虽然没有加锁, 但是不涉及 “修改”,仍然是线程安全的

  • String

这个东西没有 synchronized,String 是不可变对象
无法在多个线程中同时改同一个 String (单线程中都没法改String)

不可变对象和常量 / final 之间没有必然联系
不可变对象意思是,没有提供 public 的修改属性的操作

在这里插入图片描述这个 final 表示 String 不能被继承!和可不可变没关系

正因为不可变对象有这样的特性,有的编程语言,就天然把所有的对象来设计成不可变,然后这样的语言就更方便处理并发
erlang (这个语言就是如此)


三、volatile 关键字

1、volatile 能保证内存可见性

一个具体的栗子:针对同一个变量,一个线程进行读操作 (循环进行很多次),一个线程进行修改操作 (合适的时候执行一次)

预期:t2 把 flag 改成 非0 的值之后,t1 随之就结束循环了

import java.util.Scanner;

class MyCounter {
    public int flag = 0;
}

public class ThreadDemo {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();

        Thread t1 = new Thread(() -> { // t1要循环快速重复读取
            while (myCounter.flag == 0) {
                // 这个循环体空着
            }
            System.out.println( "t1 循环结束");
        });

        Thread t2 = new Thread(() -> { // t2进行修改
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });

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

运行:

请输入一个整数:

在这里插入图片描述

输入 1

在这里插入图片描述

while (myCounter.flag == 0) {} 这里使用汇编来理解,计算机要想执行一些计算,就需要把内存的数据读到 CPU 寄存器中然后再在寄存器中计算,再写回到内存中。大概就是两步操作:

1.load,把内存中 flag 的值,读取到寄存器里
2.cmp,把寄存器的值 和 0 进行比较。据比较结果,决定下一步往哪个地方执行(条件跳转指令)
上述是个循环,这个循环执行速度极快,一秒钟执行百万次以上…

CPU 访问寄存器的速度,比访问内存快太多了。load 执行速度太慢 (相比于 cmp 来说),
循环执行这么多次,在 t2 真正修改之前,当 CPU 连续多次访问内存,发现结果都一样,CPU就想偷懒。JVM 就做出了一个非常大胆的决定,判定好像没人改 flag 值,不再从内存读数据了,而是直接从寄存器里读 (不执行 load 了)

—— 编译器优化的一种方式

实际上是有人在修改的!! -> 在 t2 线程中修改的,但是 JVM/编译器 对于这种多线程的情况,判定可能存在误差

这是 Java 编译器进行代码优化产生的效果,
现代的编译器,不仅仅是 Java、C++、Python,各种主流的语言,里面都会充满了各种非常神奇的 编译器优化操作
编译器是不信任程序猿,编译器就会对程序猿写出的代码做出一些调整,保证原有逻辑不变的前提下,程序的执行效率能够大大提高!
大部分情况下,都是能保证的!但是在多线程中,是可能翻车的
多线程代码执行时候的一个不确定性,编译器编译阶段,很难预知执行行为的,进行的优化可能就会发生误判

内存可见性问题:

  • 如果是频繁读取同一个内存变量,就可能触发优化,后续的读内存被优化成直接读寄存器

  • 一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改之后的值,这个读线程没有感知到变量的变化

  • 归根结底是编译器/jvm 在多线程环境下优化时产生了误判了

解决方法:

  • 禁止编译器优化,保证内存可见性
  • 此时,就需要程序猿,手动干预了。可以给 flag 这个变量加上 volatile 关键字 (只能修饰变量),意思就是告诉编译器,这个变量是"易变”的,一定要每次都重新读取这个变量的内存内容,不要再进行激进的优化了,编译器会保证每次读取变量操作都是从内存读取

修改代码:

class MyCounter {
    public volatile int flag = 0;
}

运行:

请输入一个整数:
1
t1 循环结束

——注意:上述说的内存可见性编译器优化的问题,也不是始终会出现的 (编译器可能存在误判,也不是100%就误判!)

Thread t1 = new Thread(() -> { // t1要循环快速重复读取
    while (myCounter.flag == 0) {
        // 这个循环体空着
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println( "t1 循环结束");
});

这个代码稍微调整了一下,加了 sleep 控制了循环速度之后,即使不加 volatile,代码也正确了 (刚才错误的优化也消失了)
编译器的优化,很多时候是 “玄学问题",应用程序这个角度是无法感知的
因此稳妥的做法,肯定还是把该加 volatile 的地方给都加上

JMM Java Memory Model (Java内存模型),Memory 说成存储更好

从 JMM 的角度重新表述内存可见性问题: (这样的表述,来自于 Java 的官方文档)

  • Java 程序里,主内存,每个线程还有自己的工作内存 (t1的 和 t2的 工作内存不是同一个东西)
  • t1线程 进行读取的时候,只是读取了工作内存的值
  • t2线程 进行修改的时候,先修改的工作内存的值,然后再把工作内存的内容同步到主内存中
  • 但是由于编译器优化,导致 t1 没有重新从主内存同步数据到工作内存,读到的结果就是"修改之前"的结果

主内存 main memory -> 内存。工作内存 work memory -> CPU上存储数据的单元(寄存器)。JMM 就是把上述讲的硬件结构,在Java 中用专门的术语又重新抽象了封装了一遍
在这里插入图片描述

为什么 Java 这里,不直接叫做 “CPU寄存器”,而是专门搞了 “工作内存” 说法呢?
这里的工作内存,不一定只是 CPU的寄存器,还可能包括 CPU的缓存(cache)

  • CPU 从内存取数据,读写速度慢,尤其是频繁取的时候。内存存储空间大,便宜 (相比于寄存器来说)

  • 就可以把这样的数据直接放到寄存器里,后面直接从寄存器来读,寄存器存储空间小,读写速度快,贵

  • 寄存器,空间太紧张,因此就会在 CPU 内部引入一个存储空间:缓存 cache。这个空间,比寄存器大,比内存小,速度比寄存器慢,比内存块

  • 当 CPU 要读取一个内存数据的时候,可能是直接读内存也可能是读 cache 还可能是读寄存器

在这里插入图片描述

引入 cache 之后,硬件结构就更复杂了

工作内存(工作存储区):CPU寄存器+CPU的cache

一方面是为了表述简单,一方面也是为了避免涉及到硬件的细节和差异,Java 里就使用 “工作内存” 这个词一言蔽之了 (上面说到的内存可见性是没有考虑 cache 的简化模型)

有的 CPU 可能没有 cache,有的有。有的 CPU 可能有一个 cache,还可能有多个。现代的 CPU 普遍是 3 级 cache, L1, L2, L3…

存储空间
CPU < L1 < L2 < L3 < 内存

速度
CPU > L1 > L2 > L3 > 内存

成本
CPU > L1 > L2 > L3> 内存

最最常用的数据,放到 CPU寄存器里。其次常用的,放到L1。再次常用的,放到 L2。
如你现在要出远门,带很多行李 1.放口袋(身份证) 2.背包(纸巾) 3.行李箱(衣服)

如果问到了这个内存可见性,可以从CPU,内存,硬件角度回答,也可以从主内存,工作内存,JMM 角度回答


2、volatile 不保证原子性

volatile 只是保证可见性,不保证原子性,volatile 只是处理一个线程读一个线程写的情况,不能使用 volatile 处理两个线程并发++这样的问题,原子性靠 synchronized 保证

synchronized 和 volatile 都能保证线程安全

如果涉及到某个代码,既需要考虑原子性,有需要考虑内存可见性,就把 synchronized和volatile都用上就行了

代码示例:
这个是最初的演示线程安全的代码 (两个线程对同一个变量++)
给 increase 方法去掉 synchronized
给 count 加上 volatile 关键字

static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
    }
}

public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();

    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
        }
    });
    
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
        }
    });
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

此时可以看到, 最终 count 的值仍然无法保证是 100000.

——问 volatile 和 synchronized 的区别:
这俩本来没啥联系,在 java 恰好都是关键字
这个问题这是有 Java 特色的问题
其他语言,加锁不是关键字,如C++,锁就是一个单独的普通类而已

——synchronized 也不能无脑用!synchronized 使用的时候是要付出代价的
代价就是一旦使用 synchronized 很容易使线程阻塞,一旦线程阻塞 (放弃CPU),下次回到CPU,这个时间就不可控了 (可能是沧海桑田)
如果调度不回来,自然对应的任务执行时间也就拖慢了
一句不太客气的话,一旦代码中使用了 synchronized,这个代码大概率就和 “高性能"无缘了
volatile 则不会引起线程阻塞


四、wait 和 notify

1、协调多个线程之间的执行先后顺序

由于线程之间是抢占式执行的,存在处理线程调度随机性的问题的,因此线程之间执行的先后顺序难以预知
但是实际开发中有时候,我们不喜欢随机性,希望合理的协调多个线程之间的执行先后顺序
程序猿发明了一些办法,来控制线程之间的执行顺序,虽然线程在内核里的调度是随机的,但是可以通过一些 api 让线程主动阻塞,主动放弃CPU (给别的线程让路)

join 也是一种控制顺序的方式,更倾向于控制线程结束。

比如,t1 t2 俩线程,希望 t1 先干活,干的差不多了,再让 t2 来干,就可以让 t2 先 wait (阻塞,主动放弃 cpu ) 等 t1 干的差不多了,再通过 notify 通知 t2,把 t2 唤醒,让 t2 接着干
那么上述场景,使用 join 或者 sleep 行不行呢?
使用 join,则必须要 t1 彻底执行完,t2 才能运行,如果是希望 t1 先干 50% 的活,就让 t2 开始行动,join 无能为力
使用 sleep,指定一个休眠时间的,但是 t1 执行的这些活,到底花了多少时间,不好估计

球场上的每个运动员都是独立的 “执行流” , 可以认为是一个 “线程”.
而完成一个具体的进攻得分动作, 则需要多个运动员相互配合, 按照一定的顺序执行一定的动作, 线程1 先 “传球” , 线程2 才能 “扣篮”.

完成这个协调工作,主要涉及到三个方法

  • wait() / wait(long timeout) : 让当前线程进入等待状态
  • notify() / notifyAll() : 唤醒在当前对象上等待的线程

注意:wait, notify, notifyAll 都是 Object 类的方法 (Java 里随便拎出来个对象,都可以有这三个方法!)


2、wait()

一个线程调用 wait 方法的线程,就会陷入阻塞,阻塞到有其他线程通过 notify 来通知

wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常

InterruptedException 这个异常,很多带有阻塞功能的方法都带,可能会涉及提前唤醒,通过 interrupt 方法唤醒

object.wait();,wait 不加任何参数,就是一个"死等",一直等待,直到有其他线程唤醒他
wait 带参数版本,指定了等待的最大时间

  • wait 的带有等待时间的版本,看起来就和 sleep 有点像,其实还是有本质差别的,虽然都是能指定等待时间
  • 虽然也都能被提前唤醒 (wait 是使用 notify 唤醒,sleep 使用 interrupt 唤醒),但是这里表示的含义截然不同
    notify 唤醒 wait,这是不会有任何异常的 (正常的业务逻辑),interrupt 唤醒 sleep 则是出异常了 (表示一个出问题了的逻辑)

代码示例: 观察wait()方法使用

public class demo3 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait 前");
        object.wait(); // 代码中调用 wait 就会发生阻塞
        System.out.println("wait 后");
    }
}

运行结果:

在这里插入图片描述
lllegal 非法的
Monitor 监视器 -> synchronized
State 状态

IllegalMonitorStateException:非法的锁状态异常
锁的状态,无非就是被加锁的状态被解锁的状态

为什么有这个异常,要理解 wait 的操作要是做什么:

wait 做的事情:

  1. 释放当前的锁
  2. 阻塞等待,等待其他线程的通知 (把线程放到等待队列中)
  3. 收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行

wait 结束等待的条件:

  1. 其他线程调用该对象的 notify 方法
  2. wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本,来指定等待时间)
  3. 其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常

当前的锁异常,相当于对象没有加锁,就想解锁了,因此要想使用 wait / notify,就得搭配 synchronized

public class demo3 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait 前");
            object.wait(); // 虽然这里wait是阻塞了. 阻塞在synchronized 代码块里. 实际上,这里的阻塞是释放了锁的.
			// 此时其他线程是可以获取到object这个对象的锁的. 此时这里的阻塞,就处在WAITING状态.
            System.out.println("wait 后");
        }
    }
}

wait 哪个对象,就得针对哪个对象加锁

在这里插入图片描述

此时运行结果:

在这里插入图片描述

这样在执行到 object.wait() 之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法 notify()


3、notify()

notify 方法是唤醒等待的线程

方法notify() 也要搭配 synchronized 使用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知 notify,并使它们重新获取该对象的对象锁。

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

在notify()方法后,当前线程不会马上释放该对象锁,要等到执行 notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁

代码示例: 使用 notify() 方法唤醒线程

public class demo4 {
    private static Object locker = new Object();

    public static void main(String[] args) throws InterruptedException {
        // t1 线程 wait
        Thread t1 = new Thread(() -> {
            System.out.println("t1: wait 前");
            synchronized (locker) { // 1
                try {
                    locker.wait(); // 2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1: wait 后");
        });
        
        // t2 线程 notify
        Thread t2 = new Thread(() -> {
            System.out.println("t2: notify 前");
            // notify 也要先获取到锁,才能通知
            synchronized (locker) { // 3
                locker.notify(); // 4
            }
            System.out.println("t2: notify 后");
        });
        
        t1.start();
        Thread.sleep(5000); // 线程调度不确定性,保证先 wait,后 notity
        // 如果先调用notify,此时没有人wait,此处的wait是无法被唤醒的。这种通知就是无效通知. 但是也不会有副作用.
        // 此处写的sleep 5s 是大概率会让当前的 t1先执行wait的。极端情况下(电脑特别卡),可能线程的调度时间就超过了500 ms 还是可能 t2先执行notify
        t2.start();
    }
}

这四个对象都相同,才能够正确生效
此处的通知得和 wait 配对
如果 wait 使用的对象和 notify 使用的对象不同,此时 notify 不会有任何效果
(notify 只能唤醒在同一个对象上等待的线程)

运行结果:

t1: wait 前
// 五秒后
t2: notify 前
t2: notify 后
t1: wait 后

在这里插入图片描述


4、notifyAll() 方法

wait notify 都是针对同一个对象来操作的
多个线程 wait 的时候,notify 方法只是唤醒某一个等待线程,使用 notifyAll 方法可以一次唤醒所有的等待线程,这些线程再一起竞争锁

例如现在有一个对象 o
有 10 个线程,都调用了 o.wait,此时 10 个线程都是阻塞状态

如果调用了 o.notify,就会把 10 个其中的一个给唤醒 (唤醒哪个?不确定)

针对 notifyAll,就会把所有的10个线程都给唤醒
wait 唤醒之后,会重新尝试获取到锁 (这个过程就会发生竞争)

相对来说,更常用的还是 notify

例:有三个线程,分别只能打印 A,B,C,控制三个线程固定按照 A B C 的顺序来打印

public class ThreadDemo2 {
    // 有三个线程,分别只能打印 A,B,C,控制三个线程固定按照 A B C 的顺序来打印
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("A");
            synchronized (locker1) { // 打印完 A,通知 t2 打印 A
                locker1.notify();
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) { // 等 t1 打印完 A
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("B");
            synchronized (locker2) { // 打印完 B,通知 t3 打印 C
                locker2.notify();
            }
        });
        Thread t3 = new Thread(() -> {
            synchronized (locker2) { // 等 t2 打印完 B
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });
        // 如果确实是先执行 t2 的 wait,后执行 t1 的 notify,没问题
        // 万一调度顺序中出现:先执行 t1 的 notify,后执行 t2 的 wait,那程序就僵住了
        // 解决办法,让 t1 启动的慢一点
        // 保证 t2 和 t3 的 wait 先执行了,再来启动 t1
        t2.start();
        t3.start();
        Thread.sleep(100);
        t1.start();
    }
}

5、wait 和 sleep 的对比

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,

唯一的相同点就是都可以让线程放弃执行一段时间

总结:

  1. wait 需要搭配 synchronized 使用,sleep 不需要
  2. wait 是 Object 的方法, sleep 是 Thread 的静态方法

五、多线程案例

多线程案例 | 单例模式、阻塞队列、定时器、线程池

总结-保证线程安全的思路

1、使用没有共享资源的模型
2、适用共享资源只读,不写的模型

  1. 不需要写共享资源的模型
  2. 使用不可变对象

3、直面线程安全(重点)

  1. 保证原子性
  2. 保证顺序性
  3. 保证可见性

对比线程和进程
——线程的优点
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

——进程与线程的区别
1、进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
2、进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
3、由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
4、线程的创建、切换及终止效率更高

评论 70
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三春去后诸芳尽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值