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

2年前 (2022) 程序员胖胖胖虎阿
338 0 0

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

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


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

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

2、抢占式执行

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

可能的情况举例:
1、按照下面的执行步骤,执行的结果是对的,没有产生bug

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

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

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

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

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

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


3、加锁 synchronized

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

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

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
刚才 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 这个变量就是在堆上. 因此可以被多个线程共享访问

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

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


3、原子性

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

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

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

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

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

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

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

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

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

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


4、可见性

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

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

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

t1 这个线程,在循环读这个变量,按照之前的介绍,读取内存操作,相比于读取寄存器,是一个非常低效的操作,慢3 - 4个数量级,
因此在 t1 中频繁地读取这里的内存的值,就会非常低效,
而且如果 t2 线程迟迟不修改,t1 线程读到的值又始终是一样的值
因此,t1 就有了一个大胆的想法!!
就会不再从内存读数据了,而是直接从寄存器里读 (不执行 load 了)
一旦 t1 做出了这种大胆的假设,此时万一 t2 修改count 值,t1不能感知到了

这是 Java 编译器进行代码优化产生的效果,
现代的编译器,不仅仅是 Java、C++、Python,各种主流的语言,里面都会充满了各种非常神奇的 编译器优化操作
编译器是不信任程序猿 (编译器假设程序猿是个SB,写的代码都是shit),编译器就会对程序猿写出的代码做出一些调整,保证原有逻辑不变的前提下,程序的执行效率能够大大提高!

大部分情况下,都是能保证的!但是在多线程中,是可能翻车的
多线程代码执行时候的一个不确定性,编译器编译阶段,很难预知执行行为的,进行的优化可能就会发生误判

package Thread;

import java.util.Scanner;

public class demo2 {
    private static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("循环结束! t 线程退出!");
        });
        t1.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值:");
        isQuit = scanner.nextInt();
        System.out.println("main 线程执行完毕!");
    }
}

这就是内存可见性,修改了isQuit1 的值,但是 t 线程感知不到内存的变化

请输入一个 isQuit 的值:
1
main 线程执行完毕!
// 程序仍在运行 循环没有退出

解决方法:

  1. 使用 synchronized 关键字
    synchronized 不光能保证指令的原子性,同时也能保证内存可见性,
    synchronized 包裹起来的代码,编译器就不敢轻易的做出上述假设,相当于手动禁用了编译器的优化
  2. 使用 volatile 关键字
    volatile 和原子性无关,但是能够保证内存可见性
    禁止编译器做出上述优化,编译器每次执行判定相等,都会重新从内存读取isQuit 的值
package Thread;

import java.util.Scanner;

public class demo2 {
    private static volatile int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("循环结束! t 线程退出!");
        });
        t1.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值:");
        isQuit = scanner.nextInt();
        System.out.println("main 线程执行完毕!");
    }
}

请输入一个 isQuit 的值:
1
main 线程执行完毕!
循环结束! t 线程退出!

内存可见性,是属于编译器优化范围中的一个典型案例,编译器优化本身是一个玄学的问题,对于普通程序猿来说,啥时候优,啥时候不优化,很难说

		Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("循环结束! t 线程退出!");
        });
        t1.start();

像这个代码,循环中加上 sleep 这里的优化就消失了,也就没有内存可见性问题了


5、指令重排序

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

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

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

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

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

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

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

解决方法:
synchronized 不光能保证原子性,同时还能保证内存可见性,同时还能禁止指令重排序


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

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

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

1、synchronized 的使用方式

1.1、直接修饰普通的方法

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

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
synchronized 的本质操作,是修改了 Object 对象中的 “对象头” 里面的一个标记

两个线程同时尝试对同一个对象加锁的时候,才有竞争,如果是两个线程在针对两个不同对象加锁,就没有竞争


1.2、修饰一个代码块

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

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

1.3、修饰一个静态方法

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

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

synchronized public static void func() {
        
}

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

2、synchronized 的特性

2.1、互斥

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

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

synchronized 用的锁是存在 Java 对象头里的
synchronized 的底层是使用操作系统的 mutex lock 实现的


2.2、刷新内存

synchronized 的工作过程:

1.获得互斥锁
2.从主内存拷贝变量的最新副本到工作的内存
3.执行代码
4.将更改后的共享变量的值刷新到主内存
5.释放互斥锁

所以 synchronized 也能保证内存可见性


2.3、可重入

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

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

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

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

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

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

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

外层锁:进入方法,则开始加锁,这次能够加锁成功,当前锁是没有人占用的
里层锁:进入代码块,开始加锁,这次加锁不能加锁成功,(按照咱们之前的观点来分析),因为锁被外层占用着呢,得等外层锁释放了之后,里层锁才能加锁成功

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

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

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

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


3、死锁的其他场景

3.1、一个线程,一把锁

3.2、两个线程,两把锁

我和朋友去吃饺子,东北人,蘸酱油,陕西人,蘸醋,
我拿起了酱油,朋友拿起了醋
我说,你把醋给我,
她说,你把酱油给我.
我说,你先把醋给我,我用完了,就给你酱油,
她说,你先把酱油给我,我用完了就给你醋

3.3、N 个线程,M 把锁

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

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

每个哲学家,会做两件事
1.思考人生
2.吃面条

每个哲学家啥时候思考人生,啥时候吃面条,是不确定 (随机的)
每个哲学家吃面条的时候,都需要拿起他身边的两根筷子 (假设先拿起左手的,后拿起右手的)

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

在这个模型中,如果五个哲学家,同时伸出左手,拿起左手的筷子此时,就死锁了!

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

3.4、死锁的四个必要条件

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

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

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

  4. 环路等待
    等待关系,成环了:A 等B,B 等 C,C 又等 A

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失
前三条都是属于锁本身的特点,实际开发中要想避免死锁,关键要点还是从 4 个条件进行切入

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

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

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

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

Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread() {
	@Override
	public void run() {
		synchronized (lock1) {
			synchronized (lock2) {
			// do something...
			}
		}
	}
};
t1.start();

Thread t2 = new Thread() {
	@Override
	public void run() {
		synchronized (lock2) {
			synchronized (lock1) {
			// do something...
			}
		}
	}
};
t2.start();

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

约定好先获取 lock1, 再获取 lock2 , 就不会环路等待

Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread() {
	@Override
	public void run() {
		synchronized (lock1) {
			synchronized (lock2) {
			// do something...
			}
		}
	}
};
t1.start();

Thread t2 = new Thread() {
	@Override
	public void run() {
		synchronized (lock1) {
			synchronized (lock2) {
			// do something...
			}
		}
	}
};
t2.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

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
还有的虽然没有加锁, 但是不涉及 “修改”,仍然是线程安全的

String

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

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

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例这个 final 表示 String 不能被继承!和可不可变没关系

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


三、volatile 关键字

1、volatile 能保证内存可见性

禁止编译器优化,保证内存可见性

计算机要想执行一些计算,就需要把内存的数据读到 CPU 寄存器中然后再在寄存器中计算,再写回到内存中
CPU 访问寄存器的速度,比访问内存快太多了
当 CPU 连续多次访问内存,发现结果都一样,CPU就想偷懒

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

JMM 就是把上述讲的硬件结构,在Java 中用专门的术语又重新抽象了封装了一遍

一方面,是因为Java 作为一个跨平台的编程语言
要把硬件的细节封装起来 (期望程序猿感知不到CPU,内存等硬件设备) 假设某个计算机没有CPU,或者没有内存,同样可以套在上述的模型中
【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例

另一方面,Java社区,也经常会造一些 “高大上” 的概念
例如包装类和内置类型的转换,自动装箱,自动拆箱,就是隐式类型转换
类加载,双亲委派模型,描述了先去哪个目录找 .class,后去哪个目录找,双重校验锁,其实就是加了两层锁
类似的这种,其实是挺多的

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

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


2、volatile 不保证原子性

volatile 只是保证可见性,不保证原子性,volatile 只是处理一个线程读一个线程写的情况
synchronized 都能处理

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

synchronized 也不能无脑用!synchronized 使用的时候是要付出代价的
代价就是一旦使用 synchronized 很容易使线程阻塞,一旦线程阻塞 (放弃CPU),下次回到CPU,这个时间就不可控了 (可能是沧海桑田)
如果调度不回来,自然对应的任务执行时间也就拖慢了
一句不太客气的话,一旦代码中使用了 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.


四、wait 和 notify

1、wait() 方法

由于线程之间是抢占式执行的,存在处理线程调度随机性的问题的,因此线程之间执行的先后顺序难以预知
但是实际开发中有时候,我们不喜欢随机性,希望合理的协调多个线程之间的执行先后顺序
join 也是一种控制顺序的方式,更倾向于控制线程结束

wait 和 notify 都是 Object 对象的方法,调用 wait 方法的线程,就会陷入阻塞,阻塞到有其他线程通过 notify 来通知

wait 做的事情:
1、使当前执行代码的线程进行等待. (把线程放到等待队列中)
2、释放当前的锁
2、满足一定条件时被唤醒, 重新尝试获取这个锁

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

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

代码示例: 观察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 后");
    }
}

运行结果:

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
lllegal 非法的
Monitor 监视器
State 状态

wait 内部会做三件事
1.先释放锁
⒉.等待其他线程的通知
3.收到通知之后,重新获取锁,并继续往下执行

因此要想使用 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 就会发生阻塞
            System.out.println("wait 后");
        }
    }
}

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

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

此时运行结果:

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

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


2、notify() 方法

notify 方法是唤醒等待的线程

方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

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

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

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

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

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // 进行 wait
            synchronized (locker) {
                System.out.println("wait 前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 后");
            }
        });
        t1.start();

        Thread.sleep(3000);

        Thread t2 = new Thread(() -> {
            // 进行 notify
            synchronized (locker) {
                System.out.println("notify 前");
                locker.notify();
                System.out.println("notify 后");
            }
        });
        t2.start();
    }
}

运行结果:

wait 前
// 三秒后
notify 前
notify 后
wait 后

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


3、notifyAll() 方法

wait notify 都是针对同一个对象来操作的
notify 方法只是唤醒某一个等待线程,使用 notifyAll 方法可以一次唤醒所有的等待线程

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

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

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

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


4、wait 和 sleep 的对比

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

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

总结:

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

五、多线程案例

1、案例一:线程安全的单例模式

单例模式是校招中最常考的设计模式之一

什么是设计模式?
设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏.

软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏

在校招中,有两个设计模式是非常常见的
其一是单例模式,其二是工厂模式

单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例, 这种单例模式,在实际开发中是非常常见,也非常有用的,开发中的很多 “概念” 天然就是单例,JDBC,DataSource,这样的对象,就应该是单例的

单例模式的两种典型实现:

  • 饿汉模式
  • 懒汉模式

举例:洗碗
1.中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了~~[饿汉]
⒉中午这顿饭,使用了4个碗,吃完之后,先不洗。晚上这顿,只需要2个碗,然后就只洗2个即可~~[懒汉]

第二种是更加高效的操作,—般是褒义词 (在计算机中提高效率)

饿汉的单例模式,是比较着急地去进行创建实例
懒汉的单例模式,是不太着急地去创建实例,只是在用的时候,才真正创建


1.1、饿汉模式

private static Singleton instance;

static 修饰的成员,更准确的说,应该叫做 “类成员” => “类属性 / 类方法”
不加 static 修饰的成员,叫做 “实例成员” => “实例属性 / 实例方法”

—个Java程序中,一个类对象只存在一份 (JVM保证的),进一步的也就保证了类的 static 成员也是只有一份的

注意:
1、为什么用 static:
static 表示的意思和字面这个单词没有任何联系 (历史遗留问题)

追溯到 C 语言,即使是C语言中,static 的效果也是不和字面意思—样了
其实在上古时期,那时候的 static 是表示把变量放到 “静态内存区”,于是就引入了关键字 static

随着计算机的发展,静态内存区这个东西就逐渐没了,但是 static 关键字仍然在,并且被赋予了其他的功能
C++中 static 除了上述 C 中的功能之外,又有了新的用法:修饰一个类的成员变量和成员函数,此处 static 修饰的成员就表示类成员
Java 就把 C++的 static 给继承过来了

为啥这里是使用 static 表示类属性,而不用其他的词呢?
—个编程语言,要想新增关键字,是一件非常有风险的事情

2、类对象 != 对象!
类:就相当于实例的模板,基于模板可以创建出很多的对象来
对象(实例)

类对象,就是 .class 文件,被 JVM 加载到内存后,表现出的模样。类对象里就有 .class 文件中的一切信息
包括:类名是啥,类里有哪些属性,每个属性叫啥名字,每个属性叫啥类型,每个属性是public private…
基于这些信息,才能实现反射

// 通过 Singleton 这个类来实现单例模式,保证 Singleton 这个类只有唯一实例
class Singleton {
    // 1.使用 static 创建一个实例,并且立即进行实例化
    //   这个 instance 对应的实例,就是该类的唯一实例
    private static Singleton instance = new Singleton();
    // 2.为了防止程序猿在其他地方不小心地 new 这个 Singleton,就可以把构造方法设为 private
  
    // 3.提供一个方法,让外面能够拿到唯一实例
    public static Singleton getInstance() {
        return instance;
    }
}

public class demo1 {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
    }
}

针对这个唯一实例的初始化,比较着急,类加载阶段,就会直接创建实例
(程序中用到了这个类,就会立即加载)

饿汉模式中 getlnstance,仅仅是读取了变量的内容
如果多个线程只是读同一个变量,不修改,此时仍然是线程安全的


1.2、懒汉模式 - 未优化

class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static Singleton2 instance = null;

    // 2.把构造方法设为 private
    private Singleton2() {}

    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}

只有在真正使用到 getInstance 的时候才会真的创建实例

一个典型的案例:
notepad 这样的程序,在打开大文件的时候是很慢的 (你要打开一个1G大小的文件,此时 notepad 就会尝试把这 1G 的所有内容都读到内存中) [饿汉]
像一些其他的程序,在打开大文件的时候就有优化 (要打开 1G 的文件,但是只先加载这—个屏幕中能显示出来的部分) [懒汉]


1.3、懒汉模式 - 多线程 线程安全

真正要解决的问题,是实现一个线程安全的单例模式
线程安全不安全,具体指的是多线程环境下,并发的调用 getInstance 方法,是否可能存在 bug

懒汉模式中,既包含了读,又包含了修改
而且这里的读和修改,还是分成两个步骤的 (不是原子的)
存在线程安全问题

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
上述罗列出了一种可能的排序情况,实际情况是有很多种
通过上述分析,就可以看出,当前这个代码中是存在bug,可能导致实例被创建出多份来

如何保证懒汉模式的线程安全呢?加锁

可不是说,代码中有了 synchronized 就—定线程安全,synchronized 加的位置也得正确,不能随便写

使用这里的类对象作为锁对象
(类对象在一个程序中只有唯——份,就能保证多个线程调用 getInstance 的时候都是针对同一个对象进行的加锁)

public static Singleton2 getInstance() {
        synchronized (Singleton.class) { // 类对象作为锁对象*
            if (instance == null) {
                instance = new Singleton2();
            }
        }
        return instance;
    }

1.4、懒汉模式 - 多线程 锁竞争

当前虽然加锁之后,线程安全问题得到解决了,但是又有了新的问题:

对于刚才这个懒汉模式的代码来说, 线程不安全,是发生在 instance 被初始化之前的,未初始化的时候,多线程调用 getinstance,就可能同时涉及到读和修改,但是一旦 instance 被初始化之后 (一定不是 null if 条件一定不成立了),getlnstance 操作就只剩下两个读操作,也就线程安全了

而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前,每次调用 getinstance 都会进行加锁,也就意味着即使是初始化之后 (已经线程安全了),但是仍然存在大量的锁竞争
加锁确实能让代码保证线程安全,也付出了代价 (程序的速度就慢了)

所以为啥不推荐使用 vector hashtable ?? 就是因为这俩类里面就是在无脑加锁

改进方案,让 getInstance 初始化之前,才进行加锁,初始化之后,就不再加锁了,在加锁这里再加上一层条件判定即可
条件就是当前是否已经初始化完成 (instance == null)

class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static Singleton2 instance = null;
    // 2.把构造方法设为 private
    private Singleton2() {}
    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) { // 类对象作为锁对象*
                if (instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}

这俩条件—模一样,只是一个美丽的巧合而已,这俩条件起到的效果 / 预期的目的是完全不—样的
上面的条件判定的是是否要加锁
下面的条件判定的是是否要创建实例
碰巧这两个目的都是判定 instance 是否为 null

在这个代码中,看起来两个—样的if条件是相邻的,但是实际上这两个条件的执行时机是差别很大的!
加锁可能导致代码出现阻塞,外层条件是 10:16 执行的,里层条件可能是 10:30 执行的…
在这个执行的时间差中间, instance 也是很可能被其他线程给修改的

如果去掉了里层的 if 就变成了刚才那个典型的错误代码,加锁没有把读+修改这操作进行打包

	public static Singleton2 getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                instance = new Singleton2();
            }
        }
        return instance;
    }

1.5、懒汉模式 - 多线程 可见性

当前这个代码中还存在一个重要的问题
如果多个线程,都去调用这里的 getlnstance
就会造成大量的读i nstance 内存的操作 => 可能会让编译器把这个读内存操作优化成读寄存器操作

—旦这里触发了优化,后续如果第一个线程已经完成了针对 instance 的修改,那么紧接着后面的线程都感知不到这个修改仍然把 instance 当成 null

内存可见性问题,可能会引起第一个 if 判定失效
但是对于第二个 if 判定影响不大 ( synchronized 本身也能保证内存可见性),因此这样的内存可见性问题,只是引起了第一层条件的误判,也就是导致不该加锁的给加锁了,但是不会引起第二层 if 的误判 (不至于说创建多个实例)

if (instance == null) { // 判定失效
	synchronized (Singleton.class) {
		if (instance == null) { //不影响
			instance = new Singleton2();
		}
	}
}

instance 加上 volatile 即可

// 这个代码是完全体的线程安全单例模式
class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static volatile Singleton2 instance = null;

    // 2.把构造方法设为 private
    private Singleton2() {}

    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) { // 类对象作为锁对象*
                if (instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}

2、案例二:阻塞队列

2.1、生产者消费者模型

队列先进先出
阻塞队列同样也是一个符合先进先出规则的队列,相比于普通队列,阻塞队列又有一些其他方面的功能!
1、线程安全
2、产生阻塞效果

1). 如果队列为空,尝试出队列,就会出现阻塞阻塞到队列不为空为止
2). 如果队列为满,尝试入队列,也会出现阻塞阻塞到队列不为满为止

基于上述特性,就可以实现 “生产者消费者模型”

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
此处的阻塞队列就可以作为生产者消费者模型中的交易场所

生产者消费者模型,是实际开发中非常有用的一种多线程开发手段!尤其是在服务器开发的场景中
假设,有两个服务器, AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据


优点1:解耦合

优点1: 能够让多个服务器程序之间更充分的解耦合
【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
如果不使用生产者消费者模型,此时 A 和 B 的耦合性是比较强的,在开发 A 代码的时候就得充分了解到 B 提供的一些接口,开发 B 代码的时候也得充分了解到 A 是怎么调用的,
—旦想把 B 换成 C,A 的代码就需要较大的改动,而且如果 B 挂了,也可能直接导致A也顺带挂了…

使用生产者消费者模型,就可以降低这里的耦合
【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
对于请求:A是生产者,B是消费者.
对于响应:A是消费者,B是生产者.
阻塞队列都是作为交易场所

A 只需要关注如何和队列交互,不需要认识B
B 也只需要关注如何和队列交互,也不需要认识 A
队列是不变
如果B挂了,于A没啥影响,如果把B换成C,A也完全感知不到…


优点2:削峰填谷

优点2: 能够对于请求进行 “削峰填谷
【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
未使用生产者消费者模型的时候,如果请求量突然暴涨 (不可控)

A暴涨=>B暴涨
A 作为入口服务器,计算量很轻,请求暴涨,问题不大
B 作为应用服务器,计算量可能很大,需要的系统资源也更多,如果请求更多了,需要的资源进一步增加,如果主机的硬件不够,可能程序就挂了

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
A请求暴涨 => 阻塞队列的请求暴涨

由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力
B 这边仍然按照原来的速度来消费数据,不会因为A的暴涨而引起暴涨,B就被保护的很好,就不会因为这种请求的波动而引起崩溃

“削峰”:这种峰值很多时候不是持续的,就一阵,过去了就又恢复了心
“填谷”:B 仍然是按照原有的频率来处理之前积压的数据

实际开发中使用到的 “阻塞队列” 并不是一个简单的数据结构了,而是一个 / 一组专门的服务器程序,并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能 (对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数.……)
这样的队列又起了个新的名字,"消息队列” (未来开发中广泛使用到的组件)
kafka 就是业界一个比较主流的消息队列,消息队列的实现,有很多种,核心功能都差不多

生活中,也有这种 “削峰填谷”:

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
到了雨季,水流量就会很大,三峡大坝关闸蓄水,保护下游水流量不是太大,不至于出现洪灾
到了早季,水流量很小,三峡大坝就开闸放水,给下游提供更充分的水源,不至于出现旱灾


2.2、实现阻塞队列

先来了解一下,Java 标准库中的阻塞队列的用法,基于这个内置的阻塞队列,实现一个简单的生产者消费者模型
再自己实现一个阻塞队列

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class demo3 {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new LinkedBlockingQueue<>();
        queue.put("hello");
        String s = queue.take();
        /* 不带阻塞
        queue.offer();
        queue.poll();
        queue.peek();*/
    }
}

自己来实现一个阻塞队列,先实现一个普通的队列,再加上线程安全,再加上阻塞

队列可以基于数组实现,也可以基于链表实现
此处基于数组实现阻塞队列更简单,就直接写数组版本

循环队列

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


单线程

class MyBlockingQueue {
    // 保存数据的本体
    private int[] data = new int[1000];
    // 有效元素个数
    private int size = 0;
    // 队首下标
    private int head = 0;
    // 队尾下标
    private int tail = 0;

    // 入队列
    public void put(int value) {
        // 1、
        if (size == data.length) {
            // 队列满了,暂时先直接返回
            return;
        }
        // 2、把新的元素放入 tail 位置
        data[tail] = value;
        tail++;
        // 3、处理 tail 到达数组末尾的情况
        if (tail >= data.length) {
            tail = 0;
        }
        // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
        // tail = tail % data.length;
        // 4、插入完成,修改元素个数
        size++;
    }

    // 出队列
    public Integer take() {
        // 1、
        if (size == 0) {
            // 如果队列为空,返回一个非法值
            return null;
        }
        // 2、取出 head 位置的元素
        int ret = data[head];
        head++;
        // 3、head 到末尾 重新等于 0
        if (head >= data.length) {
            head = 0;
        }
        // 4、数组元素个数--
        size--;
        return ret;
    }
}

public class TestDemo {
    public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue();
        queue.put(1);
        queue.put(2);
        queue.put(3);
        queue.put(4);
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
    }
}

线程安全与阻塞

1、接下来,让这个队列支持线程安全 。保证多线程环境下,调用这里的 put 和 take 没有问题的,
看了之后, put 和 take 里面的每一行代码都是在操作公共的变量。既然如此,直接就给整个方法加锁即可
(加上 synchronized 已经是线程安全的了)

2、接下来,实现阻塞效果
关键要点,使用 wait 和 notify 机制
对于 put 来说,阻塞条件,就是队列为满,对于 take 来说,阻塞条件,就是队列为空

针对哪个对象加锁就使用哪个对象 wait, 如果是针对 this 加锁,就 this.wait
put 中的 wait 要由 take 来唤醒,只要 take 成功了一个元素,不就队列不满了嘛,不就可以进行唤醒了嘛
对于 take 中的等待,条件是队列为空,队列不为空,也就是 put 成功之后,就来唤醒

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
当前代码中,put 和 take 两种操作不会同时 wait (等待条件是截然不同的,一个是为空,一个是为满)

如果有人在等待,notify 能唤醒,如果没人等待,notify 没有任何副作用

notify 只能唤醒随机的一个等待的线程,不能做到精准
要想精准,就必须使用不同的锁对象
想唤醒 t1,就 o1.notify,让 t1 进行 o1.wait。想唤醒 t2,就 o2.notify,让 t2 进行 o2.wait

package thread;

class MyBlockingQueue {
    // 保存数据的本体
    private int[] data = new int[1000];
    // 有效元素个数
    private int size = 0;
    // 队首下标
    private int head = 0;
    // 队尾下标
    private int tail = 0;

    // 专门的所对象
    private Object loker = new Object();

    // 入队列
    public void put(int value) throws InterruptedException {
        synchronized (loker) {
            // 1、
            if (size == data.length) {
                // 队列满了,暂时先直接返回
                // return;
                loker.wait();
            }
            // 2、把新的元素放入 tail 位置
            data[tail] = value;
            tail++;
            // 3、处理 tail 到达数组末尾的情况
            if (tail >= data.length) {
                tail = 0;
            }
            // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
            // tail = tail % data.length;
            // 4、插入完成,修改元素个数
            size++;
            // 如果入队列成功,则对了非空,唤醒 take 中的 wait
            loker.notify();
        }
    }

    // 出队列
    public Integer take() throws InterruptedException {
        synchronized (loker) {
            // 1、
            if (size == 0) {
                // 如果队列为空,返回一个非法值
                // return null;
                loker.wait();
            }
            // 2、取出 head 位置的元素
            int ret = data[head];
            head++;
            // 3、head 到末尾 重新等于 0
            if (head >= data.length) {
                head = 0;
            }
            // 4、数组元素个数--
            size--;
            // take 成后,唤醒 put 中的 wait
            loker.notify();
            return ret;
        }
    }
}

public class TestDemo {
    public static void main(String[] args) throws InterruptedException {
        MyBlockingQueue queue = new MyBlockingQueue();
        queue.put(1);
        queue.put(2);
        queue.put(3);
        queue.put(4);
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
    }
}


生产者消费者模型

package thread;

class MyBlockingQueue {
    // 保存数据的本体
    private int[] data = new int[1000];
    // 有效元素个数
    private int size = 0;
    // 队首下标
    private int head = 0;
    // 队尾下标
    private int tail = 0;

    // 专门的所对象
    private Object loker = new Object();

    // 入队列
    public void put(int value) throws InterruptedException {
        synchronized (loker) {
            // 1、队列满了
            if (size == data.length) {
                // 队列满了,暂时先直接返回
                // return;
                loker.wait();
            }
            // 2、把新的元素放入 tail 位置
            data[tail] = value;
            tail++;
            // 3、处理 tail 到达数组末尾的情况
            if (tail >= data.length) {
                tail = 0;
            }
            // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
            // tail = tail % data.length;
            // 4、插入完成,修改元素个数
            size++;
            // 如果入队列成功,则对了非空,唤醒 take 中的 wait
            loker.notify();
        }
    }

    // 出队列
    public Integer take() throws InterruptedException {
        synchronized (loker) {
            // 1、队列为空
            if (size == 0) {
                // 如果队列为空,返回一个非法值
                // return null;
                loker.wait();
            }
            // 2、取出 head 位置的元素
            int ret = data[head];
            head++;
            // 3、head 到末尾 重新等于 0
            if (head >= data.length) {
                head = 0;
            }
            // 4、数组元素个数--
            size--;
            // take 成后,唤醒 put 中的 wait
            loker.notify();
            return ret;
        }
    }
}

public class TestDemo {
    private static MyBlockingQueue queue = new MyBlockingQueue();

    public static void main(String[] args) throws InterruptedException {
        // 实现一个而简单的生产者消费者模型
        Thread producer = new Thread(() -> {
            int num = 0;
            while (true) {
                try {
                    System.out.println("生产了:" + num);
                    queue.put(num);
                    num++;
                    // 当消费者生产地慢,消费者就得跟着生产者的步伐走
                    // Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        producer.start();

        Thread customer = new Thread(() -> {
            while (true) {
                try {
                    int num = queue.take();
                    System.out.println("消费了:" + num);
                    // 消费者消费地慢
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }}
        });
        customer.start();
    }
}

3、案例三:定时器

3.1、Timer

定时器也是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后,就唤醒并执行之前设定好的任务

定时器是一种实际开发中非常常用的组件

比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连
比如一个 Map,希望里面的某个 key 在 3s 之后过期(自动删除)

类似于这样的场景就需要用到定时器

join (指定超时时间),sleep (休眠指定时间,是基于系统内部的定时器,来实现的

先介绍标准库的定时器用法,然后再看看如何自己实现一个定时器

标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule (安排)
schedule 包含两个参数,第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行 (单位为毫秒)

import java.util.Timer;
import java.util.TimerTask;

public class demo5 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello time");
            }
        }, 3000);
        System.out.println("main");
    }
}

运行结果:
首先打印:main
几秒后 打印:hello time
但是程序没有结束

Timer内部是有专门的线程,来负责执行注册的任务的

Timer 内部都需要:

  1. 管理很多的任务
  2. 执行时间到了的任务

3.2、管理任务

1、描述任务
创建一个专门的类来表示一个定时器中的任务 (TimerTask)

// 创建一个类,表示一个任务
class MyTask {
    // 任务具体要做什么
    private Runnable runnable;
    // 任务具体什么做,保存任务要执行的毫秒级时间戳
    private long time;

    // delay 是一个时间戳,不是绝对的时间戳的值
    public MyTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    public void run() {
        runnable.run();
    }
}

2、组织任务
使用一定的数据结构把一些任务给放到一起,通过—定的数据结构来组织

假设现在有多个任务过来了—个小时之后,去做作业.三个小时之后,去上课,10分钟之后,去休息—会
安排任务的时候,这些任务的顺序是无序的,但是执行任务的时候,这就不是无序的了,按照时间先后来执行!
咱们的需求就是,能够快速找到所有任务中,时间最小的任务

此时我们发现可以用,在标准库中,有一个专门的数据结构 PriorityQueue

private PriorityQueue<MyTask> queue = new PriorityQueue<>();

但是此处的队列要考虑到线程安全问题,可能在多个线程里进行注册任务,同时还有一个专门的线程来取任务执行,此处的队列就需要注意线程安全问题
所以我们得使用 PriorityBlockingQueue既带有优先级又带有阻塞队列

private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
class MyTimer {
    // 定时器内部要能够存放多个任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable, long delay) {
        MyTask task = new MyTask(runnable, delay);
        queue.put(task); // 任务放入堆
    }
}

3、执行时间到了的任务
需要先执行时间最考前的任务
就需要有一个线程,不停地去检查当前优先队列的队首元素,看看当前最靠前的这个任务是不是时间到了

// 创建一个类,表示一个任务
class MyTask {
    // 任务具体要做什么
    private Runnable runnable;
    // 任务具体什么做,保存任务要执行的毫秒级时间戳
    private long time;

    // delay 是一个时间戳,不是绝对的时间戳的值
    public MyTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    public void run() {
        runnable.run();
    }

    public long getTime() {
        return time;
    }
}

class MyTimer {
    // 定时器内部要能够存放多个任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable, long delay) {
        MyTask task = new MyTask(runnable, delay);
        queue.put(task); // 任务放入堆
    }

    public MyTimer() {
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    // 取出队首元素
                    MyTask task = queue.take();
                    // 再计较这个任务有没有到时间
                    long curTime = System.currentTimeMillis();
                    if (curTime < (task).getTime()) { // 没到时间,任务放回堆
                        queue.put(task);
                    } else { // 时间到了,执行任务
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

public class demo5 {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello timer");
            }
        }, 3000);
        System.out.println("main");
    }
}

3.3、执行时间

上述代码中存在两个非常严重的问题:

第—个缺陷: MyTask 没有指定比较规则

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
像刚才咱们实现的 MyTask 这个类的比较规则,并不是默认就存在的,这个需要咱们手动指定,按照时间大小来比较的
标准库中的集合类,很多都是有一定的约束限制的,不是随便拿个类都能放到这些集合类里面去的

第二个缺陷: 如果不加任何限制,这个循环就会执行的非常快

如果队列中的任务是空着的,就还好,这个线程就再这里阻塞了 (没问题)
就怕队列中的任务不空,并且任务时间还没到
上述操作,称为 “忙等”,等确实是等了,但是又没闲着。既没有实质性的工作产出,同时又没有进行休息
忙等这种操作是非常浪费CPU的

可以基于 wait 这样的机制来实现
wait 有一个版本,指定等待时间不需要 notify,时间到了自然唤醒) 计算出当前时间和任务的目标之间的时间差,就等待这么长时间即可

既然是指定一个等待时间,为啥不直接用 sleep ,而是要再用一下 wait 呢
sleep 不能被中途唤醒的
wait 能够被中途唤醒

在等待过程中,可能要插入新的任务! 新的任务是可能出现在之前所有任务的最前面的,
schedule 操作中,就需要加上一个 notify 操作

package thread;

import java.util.PriorityQueue;
import java.util.concurrent.PriorityBlockingQueue;

// 创建一个类,表示一个任务
class MyTask implements Comparable<MyTask> {
    // 任务具体要做什么
    private Runnable runnable;
    // 任务具体什么做,保存任务要执行的毫秒级时间戳
    private long time;

    // delay 是一个时间戳,不是绝对的时间戳的值
    public MyTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    public void run() {
        runnable.run();
    }

    public long getTime() {
        return time;
    }

    @Override
    public int compareTo(MyTask o) {
        return (int) (this.time - o.time); // 时间小在前
    }
}

class MyTimer {
    // 定时器内部要能够存放多个任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable, long delay) {
        MyTask task = new MyTask(runnable, delay);
        queue.put(task); // 任务放入堆
        // 每次任务插入成功后,都唤醒一下,让线程检查一下队首的任务,时间是否到了要执行
        synchronized (locker) {
            locker.notify();
        }
    }

    private Object locker = new Object();

    public MyTimer() {
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    // 取出队首元素
                    MyTask task = queue.take();
                    // 再计较这个任务有没有到时间
                    long curTime = System.currentTimeMillis();
                    if (curTime < (task).getTime()) { // 没到时间,任务放回堆
                        queue.put(task);
                        // 指定等待时间 避免忙等
                        synchronized (locker) {
                            locker.wait(task.getTime() - curTime);
                        }
                    } else { // 时间到了,执行任务
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

public class demo5 {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello timer");
            }
        }, 3000);
        System.out.println("main");
    }
}

总结:

  1. 描述—个任务: runnable + time
  2. 使用优先队列来组织若干个任务. PriorityBlockingQueue
  3. 实现schedule方法来注册任务到队列中.
  4. 创建一个扫描线程,这个扫描线程不停的获取到队首元素,并且判定时间是否到达.
    另外要注意,让 MyTask 类能够支持比较,以及注意解决这里的忙等问题.

4、案例四:线程池

4.1、用户态 / 内核态

进程,比较重,频繁创建销毁,开销大
解决方案:进程池 or 线程

线程,虽然比进程轻了,但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的
解决方案:线程池 or 协程

把线程提前创建好,放到池子里
后面需要用线程,直接从池子里取,就不必从系统这边申请了。线程用完了,也不是还给系统,而是放回池子里,以备下次再用

为森么线程放在池子里,就比从系统这边申请释放来的更快呢?

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
}
咱们**自己写的代码,**就是在最上面的应用程序这一层来运行的。这里的代码都称为 “用户态” 运行的代码。

有些代码,需要调用操作系统的API,进一步的逻辑就会在内核中执行。

例如,调用一个 System.out.println。本质上要经过write系统调用,进入到内核中,内核执行—堆逻辑,控制显示器输出字符串…

在内核中运行的代码,称为 “内核态” 运行的代码。

创建线程,本身就需要内核的支持 (创建线程本质是在内核中搞个PCB,加到链表里)
调用的Thread.start其实归根结底,也是要进入内核态来运行。

而把创建好的线程放到" 池子里",由于池子就是用户态实现的
这个放到池子 / 从池子取,这个过程不需要涉及到内核态,就是纯粹的用户态代码就能完成

一般认为,纯用户态的操作,效率要比经过内核态处理的操作,要效率更高。

例如:滑稽老铁去银行处理业务,柜员说需要省份证复印件

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
1、滑稽老铁,自己来到大厅的复印机这里进行复印。纯用户态的操作。(完全自己完成的,整体的过程可控)
2、滑稽老铁,把身份证给柜员,让柜员去帮他复印,这个过程就相当于交给了内核态完成一些工作。(不是自己完成的,整体不可控的)
咱们也不知道柜员身上有多少任务。可能从柜台消失之后,是给你复印去了。
但是他可能还会顺手做一些其他的事情。数一下钱 / 清点一下票据 / 上个厕所 / 给女神发个消息…

认为内核态效率低,倒不是说—定就真的低。而是代码进入了内核态,就不可控了。
内核啥时候给你把活干完,把结果给你。(有的时候快,有的时候慢)


4.2、ThreadPoolExecutor

先学习—下 Java 标准库中,线程池的使用,然后再自己实现一个线程池

标准库的线程池叫做 ThreadPoolExecutor 这个东西用起来有点麻烦
java.util.concurrent (并发)
Java 中很多和多线程相关的组件都在这个 concurrent 包里

构造方法:

ThreadPoolExecutor(int corePoolSize,
                   int maximumPoolSize, 
                   long keepAliveTime, 
                   TimeUnit unit, 
                   BlockingQueue<Runnable> workQueue, 
                   ThreadFactory threadFactory,  
                   RejectedExecutionHandler handler)`
创建一个新 ThreadPoolExecutor 给定的初始参数。 

int corePoolSize 核心线程数 (正式员工的数量)

int maximumPoolSize 最大线程数 (正式员工 + 临时工)

long keepAliveTime 允许临时工摸鱼的时间

TimeUnit unit 时间的单位 (s, ms, us…)

BlockingQueue<Runnable> workQueue, 任务队列
线程池会提供一个 submit 方法让程序猿把任务注册到线程池中,加到这个任务队列中

ThreadFactory threadFactory , 线程工厂,线程是怎么创建出来的

RejectedExecutionHandler handler 拒绝策略
当任务队列满了,怎么做:
1.直接忽略最新的任务
2.阻塞等待
3.直接丢弃最老的任务

把一个线程池,象成是一个"公司"公司里有很多员工在干活
把员工分成两类:
1、正式员工,正式员工允许摸鱼.
2、临时工,临时工不允许摸鱼
开始的时候,假设公司要完成的工作不多,正式员工完全就能搞定,就不需要临时工。
如果公司的工作突然猛增了,正式员工加班也搞不定了,就需要雇佣一批临时工
过了一段时间,工作量又降低了,现在的活正式员工也就能搞定了,甚至还有富裕(正式员工可以摸鱼了) 临时工就更摸鱼了呗,于是就把这些临时工给辞退


线程池中线程的个数:

虽然线程池的参数这么多,但是使用的时候最最重要的参数,还是第一组参数,线程池中线程的个数

有一个程序,这个程序要并发的 / 多线程的来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适? [不仅仅是面试题,也是工作中需要思考的话题]

针对这个问题,网上的很多说法,是不正确的!
网上一种典型的回答:假设机器有 N 核CPU,线程池的线程数目,就设为 1N,1.2N,1.5N, 2N…
只要能回答出一个具体的数字,都—定是错的!

正确做法:通过性能测试的方式,找到合适的值

例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求,就可以对这个服务器进行性能测试,

比如构造一些请求,发送给服务器,要测试性能,这里的请求就需要构造很多,比如每秒发送 500 / 1000 / 2000…根据实际的业务场景,构造一个合适的值

根据这里不同的线程池的线程数,来观察,程序处理任务的速度,程序持有的CPU的占用率,
当线程数多了,整体的速度是会变快,但是CPU占用率也会高
当线程数少了,整体的速度是会变慢,但是CPU占用率也会下降

需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点

不同类型的程序,因为单个任务,里面CPU上计算的时间和阻塞的时间是分布不相同的
因此光去拍脑门出来一个数字往往是不靠谱

搞了多线程,就是为了让程序跑的更快嘛,为啥要考虑不让CPU占用率太高呢?
对于线上服务器来说,要留有一定的冗余!随时应对一些可能的突发情况!(例如请求突然暴涨)
如果本身已经把 CPU 快占完了,这时候突然来—波请求的峰值,此时服务器可能直接就挂了


Executors

标准库中还提供了一个简化版本的线程池 Executors
本质是针对 ThreadPoolExecutor 进行了封装,提供了一些默认参数

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class demo6 {
    public static void main(String[] args) {
        // 创建一个固定的线程数目的线程池,参数指定了线程的个数
        ExecutorService pool = Executors.newFixedThreadPool(10);
        // 创建一个自动扩扩容的线程池,会根据任务量自动扩容
        Executors.newCachedThreadPool();
        // 创建一个只有一个线程的线程池
        Executors.newSingleThreadExecutor();
        // 创建一个带有定时器功能的线程池,类似于 Timer
        Executors.newScheduledThreadPool(10);
    }
}
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class demo6 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello threadPool");
                }
            });
        }
    }

4.3、实现一个线程池

线程池里面有:
1、先能够描述任务 (直接使用 Runnable)
2、需要组织任务 (直接使用 BlockingQueue)
3、能够描述工作线程
4、还需要组织这些线程
5、需要实现,往线程池里添加任务

package thread;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class MyThreadPool {
    // 1、描述一个任务,直接用 Runnable,不需要额外类
    // 2、使用一个数据结构来组织若干个任务
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
    // 3、描述一个线程,工作线程的功能就是从任务队列中取任务来执行
    static class Worker extends Thread {
        // 当前线程池中,有若干个 Worker 线程,这些线程内部都持有了上述的任务队列
        private BlockingQueue<Runnable> queue = null;

        public Worker(BlockingQueue<Runnable> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            // 拿到上面的队列
            while (true) {
                try {
                    // 循环地获取任务队列中的任务
                    // 队列为空,直接阻塞。队列非空,就获取内容
                    Runnable runnable = queue.take(); // 获取任务
                    runnable.run(); // 执行任务
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    // 4、创建一个数据结构来组织若干个线程
    private List<Thread> workers = new ArrayList<>();

    public MyThreadPool(int n) {
        // 在构造方法中,创建若干个线程,放到上述数组中
        for (int i = 0; i < n; i++) {
            Worker worker =  new Worker(queue);
            worker.start();
            workers.add(worker);
        }
    }
    // 5、创建一个方法,能够允许程序员放任务到线程池中
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class demo7 {
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello threadPoll");
                }
            });
        }
    }
}

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

相关文章

暂无评论

暂无评论...