【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指令:
- 把内存中的
coun
t 的值,加载到 CPU 寄存器中 ——load
- 把寄存器中的值+1 ——
add
- 把寄存器的值写回到内存的
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 拿到锁A 之后,再尝试获取锁B,A 这把锁还是保持的 (不会因为获取锁B 就把 A 给释放了)
- 循环等待
等待关系,成环了: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 做的事情:
- 释放当前的锁
- 阻塞等待,等待其他线程的通知 (把线程放到等待队列中)
- 收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行
wait 结束等待的条件:
- 其他线程调用该对象的 notify 方法
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本,来指定等待时间)
- 其他线程调用该等待线程的 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 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间
总结:
- wait 需要搭配 synchronized 使用,sleep 不需要
- wait 是 Object 的方法, sleep 是 Thread 的静态方法
五、多线程案例
总结-保证线程安全的思路
1、使用没有共享资源的模型
2、适用共享资源只读,不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
3、直面线程安全(重点)
- 保证原子性
- 保证顺序性
- 保证可见性
对比线程和进程
——线程的优点
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
——进程与线程的区别
1、进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
2、进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
3、由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
4、线程的创建、切换及终止效率更高