目录
- Day8:多线程(6)
- 1. 内存可见性问题
- 1.1 内存可见性问题介绍
- 1.2 内存可见性问题解决
- 2. 线程的等待通知机制
- 2.1 线程饿死
- 2.2 等待通知机制
- 2.2.1 wait
- 2.2.2 notify
Day8:多线程(6)
回顾
Ⅰ.可重入特性:同一个线程针对同一个对象,多次加锁(嵌套枷锁),解决可重入问题:
- 需要让锁对象记录哪个线程持有的锁
- 引入引用计数
Ⅱ.死锁:由于不正确的加锁,导致程序中的某些线程被卡死了(严重bug)
- 一个线程,一把锁,连续加锁多次,并且这个锁是不可重入锁(synchronized不适用)
- 两个线程,两把锁,线程1获取到锁A,线程2获取到锁B,然后线程1和线程2分别尝试获取对方的锁
- N个线程,M把锁:哲学家就餐问题,可以用银行家算法解决
Ⅲ.产生死锁的必要条件(缺一不可)
- 互斥(锁的基本特性)
- 不可抢占/不可剥夺(锁的基本特性)
- 请求和保持
- 循环等待
避免锁嵌套使用,如果确实需要嵌套使用锁,一定要约定好加锁的顺序
1. 内存可见性问题
1.1 内存可见性问题介绍
package thread;
import java.util.Scanner;
public class Demo23 {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (count==0){
}
System.out.println("t1执行结束");
});
Thread t2 =new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
预期效果:t1首先会进入循环,用户输入非0整数,就会使t1线程退出循环,结束线程
但是t1实际上并没有真正出现退出的情况,上述问题产生的原因,就是内存可见性,上述代码,是一个线程写,一个线程读,下面通过站在指令的角度来理解:
while (count==0){
}
上述while循环涉及如下指令:
- load:从内存读取count数据到cpu寄存器
-
cmp:比较的同时会产生跳转
- 条件成立,继续顺序执行;
- 条件不成立,就跳转到另一个地址来执行
于是产生了内存可见性问题:
- 由于上述代码,循环体是空着的,后续就没有别的指令
- 当前循环体速度很快,短时间内出现大量的load和cmp反复执行的效果,load执行消耗的时间会比cmp多很多
- 另外,JVM还发现每次load执行的结果,其实是一样的(在t2修改之前),干脆JVM就把上述load操作优化掉了,只是第一次真正进行load,后续再执行到对应的代码,就不再真正load了,而是直接读取刚才已经load过的寄存器中的值了
上述过程,确实是多线程产生的问题,但是另一方面,也是编译器优化/VM优化产生的问题,正常来说,优化操作,需要保证逻辑是等价的。但是很遗憾,编译器/JVM 在单线程代码中,优化是比较靠谱的,一旦程序引入多线程了编译器/JVM 判断也就不那么准确了
但是,如果上述代码中,循环体内存在IO操作或者阻塞操作(sleep),这就会使循环的执行速度大幅度降低,由于IO操作不能被优化掉(IO操作反复执行的结果是不相同的),而且IO操作的速度慢于load,于是此时就没有优化load的必要的
1.2 内存可见性问题解决
引入volatile关键字,给变量修饰上这个关键字之后,此时编译器就知道这个变量是“反复无常的”,不饿能按照上述策略进行优化了
package thread;
import java.util.Scanner;
public class Demo23 {
private volatile static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (count == 0) {
}
System.out.println("t1 执行结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
private volatile static int count = 0;
:告诉编译器,不要触发上述优化,具体在Java中是让javac生成字节码的时候产生了“内存屏障”(javac生成的特定的字节码)相关的指令,但是这个操作和之前synchronized保证的原子性没有任何关系
volatile是专门针对内存可见性的场景来解决问题的
-
类似的,上述内存可见性也可以使用synchronized在一定程度上解决,引入synchronized是因为加锁操作本身太重量了,相对于load来说,开销更大,编译器自然就不会对load优化了(类似前面讲到的加sleep/IO操作)
-
优化是javac和java配合完成的工作,统称为编译器优化
-
在Java中,编译器有且仅有javac,java是jvm(运行环境)
-
IDEA是IDE,是集成开发环境,涵盖代码编译器、依赖管理器、编译器、调试器、工程管理工具等
-
编译器什么时候优化,什么时候不优化,也是一个“玄学”问题
JMM:Java内存模型
JMM对上述问题的表述:当t1执行的时候,要从工作内存(CPU寄存器+缓存)中读取count的值,而不是从主内存中,后续t2修改count,也是会先修改工作内存,同步拷贝到主内存,但是由于t1没有重新读取主内存,最终导致t1没有感知到t2的修改
2. 线程的等待通知机制
之前提到的join是等待线程结束,此处提到的等待通知,等待代码中会给我们进行显式的通知(不一定要结束),可以更加精细的控制线程之间的执行顺序了
系统内部,线程是抢占式执行,随即调度的,程序员也是有手段干预的,通过“等待”的方式,能够让线程一定程度的按照预期的顺序来执行,虽然无法主动让某个线程被调度,但是可以主动让某个线程等待(就给别的线程机会了)
2.1 线程饿死
如果某个线程频繁获取释放锁,由于获取的太快,以至于其他线程得不到CPU资源,这种问题称为线程饿死
系统中的线程调度是无序的,上述情况很可能出现,虽然不会像死锁那样卡死,但是可能会卡住一下,对于程序的效率是有影响的
例子
- 比如:1号线程要进行某个工作,进行这个工作,需要前提条件,如果前提条件不满足,就得稍后重试
- 比如:某个线程要从一个队列中读取元素,结果当前队列是空着的,就只能稍后读取了
2.2 等待通知机制
等待通知机制就能够解决上述问题
通过条件,判定当前逻辑是否能够执行,如果不能执行,就主动wait(主动进行阻塞),这样就把执行的机会让给别的线程了,避免该线程进行一些无意义的重试,等到后续条件时机成熟了(需要其他线程进行通知),再让阻塞的线程被唤醒
2.2.1 wait
package thread;
public class Demo24 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("等待之前");
synchronized (object){
object.wait();
}
System.out.println("等待之后");
}
}
-
object.wait();
是Object类提供的方法,任何一个对象都有这个方法 -
此处wait也是会被Interrupt打断,wait和sleep一样,能够自动清空标志位
-
wait内部做的事情不仅仅是阻塞等待,还要解锁,准且来说,wait解锁的同时进行等待,相比之下sleep是阻塞等待但是和锁无关,所以线程得先加上锁,那么,wait必须放到synchronized内部使用
2.2.2 notify
通过另一个线程,调用notify来唤醒阻塞的线程
package thread;
import java.util.Scanner;
public class Demo25 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() ->{
synchronized (locker){
System.out.println("t1等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1等待之后");
}
});
Thread t2 = new Thread(() ->{
Scanner scanner = new Scanner(System.in);
synchronized (locker){
System.out.println("t2通知之前");
// 借助 scanner 控制阻塞. 用户输入之前, 都是阻塞状态.
scanner.next();
locker.notify();
System.out.println("t2通知之后");
}
});
t1.start();
t2.start();
}
}
用户输入内容之后,此时就会使next接触阻塞,进一步的执行到notify,notify就会唤醒上述wait操作,从而使t1能够回到RUNNABLE状态,并且参与调度
倘若:t2先执行,有可能t2先执行了notify,此时t1还没有wait,那么locker上没有现成的wait,直接notify不会有任何效果(也不会抛出异常),但是后续t1进入wait之后,没有别的线程将其唤醒了
package thread;
public class Demo26 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("t2 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2 等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker) {
System.out.println("t3 通知之前");
locker.notify();
System.out.println("t3 通知之后");
}
});
t1.start();
t2.start();
Thread.sleep(100);
t3.start();
}
}
notify只能唤醒多个等待线程中的一个,notify唤醒的线程是随机的
locker.wait(1000);
:超过1000ms还没有被notify,就自动唤醒
locker.notifyAll();
:唤醒所有的等待线程