多线程(四)【线程安全问题】
本文系统讲解了Java多线程中的线程安全问题。主要内容包括:1)线程安全问题的定义,即多个线程共享资源时对同一变量修改产生的冲突;2)问题产生的4个主要原因:线程调度随机性、共享变量修改、非原子操作及内存可见性;3)使用synchronized加锁解决线程安全问题;4)死锁概念及其4个必要条件;5)打破死锁的两种方法;6)volatile解决内存可见性问题;7)wait/notify机制避免线程饿
Hello,各位小伙伴们,这篇文章我将总结和归纳在多线程中最最最重要的内容 --- 线程安全问题,在实际开发中,线程安全问题是程序员最关注,最关心的话题,如果处理的不恰当,那么就会引发程序出现bug,更严重会造成程序的崩溃。
我将把自己所学的关于线程安全问题,即什么是线程安全问题,线程安全问题如何产生,如何解决线程安全问题这几个话题,如果有哪里总结归纳的地方不好,也请大家指出,我们一起学习进步~
1.什么是线程安全问题
2.线程安全问题产生的原因
3.synchronized的使用
4.死锁
5.死锁产生的四个必要条件
6.如何打破死锁
7.volatile
8.wait & notify
9.wait 和 sleep区别
什么是线程安全问题:
一个进程的多个线程,是共享同一份资源的,如果俩个或多个线程,对同一个成员变量进行修改,就可能会产生冲突 ---- bug
如果是单线程执行,对同一个变量修改,是可以的,但是多线程情况下就会出现问题。
我写一个多线程不安全的代码:
定义一个变量count,然后创建俩个线程,在俩个线程中,分别各循环50000次,对count自增++操作,最后打印count变量的值
public class Demo{
private static int count = 0;
public static void main(String[] args) throw InterruptedException{
Thread t1 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
count++;
}
System.out.println("t1 线程退出");
});
Thread t2 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
count++;
}
System.out.println("t2 线程退出");
});
t1.start();
t2.start();
//让main线程等待t1和t2线程执行完毕,main线程才退出,否则count还是0
t1.join();
t2.join();
System.out.println("count = " + count);
}
}


当我们俩次运行上面的代码,count最后的值都不同,说明上面代码就存在线程安全问题,俩个线程t1和t2分别对同一个变量count进行修改操作,引发了冲突
其实在for循环里,只有count++这一条语句,其实在操作系统中,count++其实是由三条指令,分别是:load (从内存中读取数据,加载到寄存器中) add(在寄存器中对count进行自增) save(将寄存器count自增后的值重新加载回内存中)
所以当t1线程在执行count++这三条指令的时候,就很可能被t2线程插队,从而产生bug,我们可以简易画一下t1线程和t2线程在执行count++指令时候的图片,注意,情况是无数种的,我们只是画其中几种:




如果t1和t2线程执行第一张和第一张图片的时候,就不会产生冲突,也就不会产生bug,count++最后的结果也就是正确的,但是线程的调度是随机的,无法保证其他情况不会出现!!!
线程安全问题产生的原因:
1.)操作系统对线程调度是随机的(根本原因)
2.)俩个线程对同一个变量进行修改操作(t1线程和t2线程都对count进行++操作)
3.)修改操作不是原子的(count++这一条操作,其实背后是三条指令,而这三条指
令并不是一口气执行完的,很可能当执行到load或者add的时候,就被其他线程
插队,导致线程安全问题)
4.)内存可见性
5.)指令重排序(后续介绍)
使用synchronized
synchronized可以给指令加锁
注意!此处的加锁,并不是禁止线程的调度,而是为了防止其他线程 “插队” 。
synchronized加锁实现的效果:
count++在cup上会有三条指令,分别是load add save,当我们给count++加上synchronized时,就会变成:lock load add save unlock,假设我们给t1线程和t2线程的count++指令加锁,这时,当t1线程执行到add操作时,操作系统的调度器调度给了t2线程,这时,t2线程无法完成加锁操作,因为t1线程并没有释放锁,所以t2 线程就会一直阻塞在lock这里,不会继续往下执行,当调度器重新调度回t1线程的时候,当t1 线程执行完指令并且释放锁unlock时候,t2线程locke阻塞才是结束,才能加锁成功,并且往下执行load add save

我们对上面存在的线程安全问题的代码进行修改,让他不存在线程安全问题:
public class Demo {
private static int count = 0;
public static void main(String[] args) {
//实例化Object类
Object locker = new Object();
Thread t1 = new Thread(() -> {
//如果存在俩个线程对同一个变量进行修改操作,就需要给这俩个线程加的锁传同一个对象
//这样当调度到t2线程,t1线程还未释放锁,t2线程才会产生阻塞等待
synchronized(locker) {
for(int i = 1; i <= 50000; i++) {
count++;
}
System.out.println("t1 线程退出");
}
});
Thread t2 = new Thread(() -> {
//因为t2线程和t1线程都是修改同一个count,所以给synchronized传的对象也是locker,一定要相同
synchronized(locker) {
for(int i = 1; i <= 50000; i++) {
count++;
}
}
System.out.println("t2 线程退出");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}

当我们给俩个线程的count++语句加上锁,就不会产生线程安全问题,解决了 “插队” 问题
给实例类的静态方法加锁:
class Count{
public static int count = 0;
//给Count类的静态方法加锁---当实例化Count对象,调用add方法的时候,就会给count++语句加锁
synchronized private static void add() {
count++;
}
}
public class Demo{
public static void main(String[] args) {
Count c = new Count();
Thread t1 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
c.add();
}
});
Thread t2 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
c.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}

给静态方法加锁:
public class Demo {
public static int count = 0;
synchronized public static void add(){
count++;
}
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
add();//调用add方法的时候,就会自动给count的指令加锁
}
});
Thread t2 = new Thread(() -> {
for(int i = 1; i <= 50000; i++) {
add();//调用add方法的时候,就会自动给count指令加锁
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}

我们之前学习的数据结构中,其实很多标准库中的类集合,大部分都是线程不安全的
线程不安全:ArrayList LinkedList HashMap Queue .......
线程安全:Vector Hashtable. ........
但是可能在以后,vector等这些线程安全的集合类,也可能变成线程不安全的集合类
可能这里大家就会有疑惑,不是说线程不安全问题是需要我们注意和避免和解决的吗?为什么还要将本线程安全的集合类设计为线程不安全呢?
鱼和熊掌不可兼得,当我们将集合类设计为线程安全问题,就会多消耗资源和时间,加锁就会使程序的效率更低,就会产生锁竞争,产生阻塞,可能我们大部分使用到的集合类是安全的,我们程序员也注意到多线程安全问题,就会刻意去避免在使用集合类的时候出现线程安全问题,但是即使没有产生阻塞,加锁本身也可能往往会调用到操作系统内核的逻辑,导致我们程序效率变慢,所以把集合类设置为线程不安全的集合类,如果真的需要加锁,那就让程序员自己手动加锁。
死锁:
在实际开发中,使用锁进行多线程编程的时候,死锁也是非常经典的问题,让我们一一来讨论
1.)一个线程一把锁,连续加锁多次(可重入锁,Java直接帮我们解决)
当我们对某一个语句进行加锁,结果我们可能疏忽忘记,又对这个语句的外面再次进行加锁,我们可以写一个样例:


我们可以看到,调用add方法前,我们对add方法进行加锁,在调用add方法时候,add方法也加锁,当我们进入for循环后加锁,然后继续调用add方法是,add方法想要加锁,就会产生锁竞争,产生阻塞,他必须等待外侧的锁释放了,add方法才能够加锁成功,否则就一直阻塞,但是当我们编译运行的时候会发现,程序正常跑起来,结果也是正确的,这是因为在synchronized中,他会检测这俩把锁是否在同一个线程中,如果是,那么他就忽略了add方法内部的锁,直接跳过,如果是俩个线程,那么就会产生锁竞争,产生阻塞,但是我们要注意,这里死锁的逻辑是存在的,只是synchronized针对这种特殊情况进行特殊处理,在C++,python中,这种情况就会产生死锁~~~
2.)俩个线程俩把锁
俩个线程俩个锁是什么意思呢?我们可以举一个例子:就比如我们的车钥匙和家钥匙,我们的车钥匙在家里面,我们的家钥匙在车里,我们想要进家门,就必须打开车,但是想打开车,就比如打开家,想打开家,就比如打开车,这样就矛盾了,就会产生死锁!!!
下面是俩个线程俩把锁的代码:
class Demo{
public static void sleep(long time) {
try{
Thread.sleep(time);
}catch(InterruptedException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
Objcet locker1 = new Object();
Object locker2 = new Object();
//创建俩个线程
Thread t1 = new Thread(() -> {
synchronized(locker1) {
System.out.println("t1 线程拿到locker1锁");
sleep(1000);
synchronized(locker2) {
System.out.println("t1 线程拿到locker2锁");
sleep(1000);
}
}
});
Thread t2 = new Thread(() -> {
synchronized(locker2) {
System.out.println("t2 线程拿到locker2锁");
sleep(1000);
synchronized(locker1) {
System.out.println("t2 线程拿到locker1锁");
sleep(1000);
}
}
});
t1.start();
t2.start();
}
}

当我们运行程序就会发现,程序好像一直产生阻塞,也无法结束,这就是死锁
因为t1线程拿到了locker1,然后t1线程想继续拿到locker2锁,但是t2线程拿到了locker2锁,所以t1线程就要等到t2线程对locker2锁进行释放,但是t2线程想拿到locker1锁,所以他要等待t1线程对locker1锁进行释放,这就会产生锁冲突,俩个线程各个互相等待对方,就一直阻塞住了~~~
3.)n个线程m把锁
这也是哲学家就餐问题~

五名哲学家围着一张桌子吃面条,但是俩俩之间只有一根筷子,如果某一个哲学家拿起左右俩边筷子吃面条,吃完就把筷子放下,下一位哲学家接着吃,就不会产生线程安全问题,但是当每位哲学家同时拿起左手边的筷子时,每位哲学家右手就没有筷子了,他们也不会善罢甘休,没有一个哲学家愿意退一步放下左手的筷子,这时每位哲学家宁愿等,也不愿意放~~所以就会一直等,一直阻塞住,这也就是死锁~~~
死锁的四个必要条件(任意打破一个就可以避免死锁的产生)
1.)锁是互斥的(对于synchronized来说是改变不了的)
2.)锁不可被抢占(对于synchronized来说也是改变不了的)
就别如t1线程已经获取到了locker1锁,t2线程也想获取locker1的锁,那么只能等待t1线程释
放,t2线程才能获取到locker1锁,而t2线程是不能直接抢占t1线程未释放的锁的~~~
3.)保持和请求
t1线程已经获取到了locker1锁,t2线程获取到了locker2锁,但是t1线程还是想获取locker2锁
那么t1线程就保持持有locker1锁(不释放),然后尝试请求获取locker2锁,如果获取失败
就一直阻塞等待
4.)循环等待
就是刚刚我们举的例子,车钥匙在家里,家要是在车里,想打开家,就必须拿到家钥匙,想
拿到家钥匙,就必须打开车~~~~
如何打破死锁
1.)打破保持和请求(避免代码中出现嵌套的场景)
当t1线程获取到locker1锁后,t2线程获取locker2锁,如果t1线程还想继续获取locker2锁,那么就不要在locker1锁里获取,而是在locker1锁释放后获取,t2线程想获取locker1锁,也不要在locker2锁内部获取,而是在locker2锁外面获取,这样就可以打破死锁
具体代码:
public class Demo{
public static void sleep(long time) {
try{
Thread.sleep(time);
}catch(InterruptedException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
//创建俩个线程
Thread t1 = new Thread(() -> {
synchronized(locker1){
System.out.println("t1线程获取到locker1锁");
sleep(1000);
}
//打破保持和请求
synchronized(locker2) {
System.out.println("t1线程获取到locker2锁");
sleep(1000);
}
});
Thread t2 = new Thread(() -> {
synchronized(locker2) {
System.out.println("t2线程获取到locker2锁");
sleep(1000);
}
synchronized(locker1) {
System.out.println("t2线程获取到locker1锁");
sleep(1000);
}
});
t1.start();
t2.start();
}
}

2.)打破循环等待
让t1线程获取锁的顺序是从小到大的,同理,让t2线程获取锁的顺序也是从小到大的,这样就可以避免死锁的产生
具体代码:
public class Demo{
public static void sleep(long time){
try{
Thread.sleep(time);
}catch(InterruptedException e){
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized(locker1){
System.out.println("t1 线程获取到locker1锁");
sleep(1000);
synchronized(locker2) {
System.out.println("t1 线程获取到locker2锁");
sleep(10000);
}
}
});
Thread t2 = new Thread(() -> {
synchronized(locker1) {
System.out.println("t2 线程获取到locker1锁");
sleep(1000);
synchronized(locker2) {
System.out.println("t2 线程获取到locker2锁");
sleep(1000);
}
}
});
t1.start();
t2.start();
}
}

volatile ---- 解决内存可见性问题
通过下面代码,我们可以观察看我输入了flag的值,但是线程1任然没退出,一直在死循环
public class Demo{
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(flag == 0) {
//啥也不干
}
System.out.println("t1线程退出");
});
Thread t2 = new Thread(() -> {
System.out.println("请任意输入flag的值:");
Scanner s = new Scanner(System.in);
flag = s.next();
System.out.println("t2线程退出");
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

我们可以看到,flag的值确实修改了,t2线程也退出了,但是t1线程迟迟未退出
因为cpu执行的速度非常的快,一秒钟可能达到上万次,当我们输入flag之前,while循环可能已经不断从内存读取flag的值到寄存器,如何比较flag是否为0,一直反复操作这个动作可能已经上万次了,此处编译器就做了大胆的优化,直接把load指令优化了,就不再从内存读取flag的值了,后续循环过程中直接读取寄存器上的flag值,所以当我们再t2线程修改flag的值,t1线程也就无法感知到了,因为t1线程并没有从内存中读取flag的值。
这一系列的原因都是因为编译器优化导致的,因为编译器也想保持代码逻辑不变的情况下,自动修改代码内容,让程序运行效率更快!!!~~~~
所以我们可以使用volatile关键字,让编译器知道我这个变量是敏感的~~随时会被修改,请你不要优化~~
public class Demo{
private static volatile int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(flag == 0) {
//啥也不干
}
System.out.println("t1线程退出");
});
Thread t2 = new Thread(() -> {
System.out.println("请任意输入flag的值:");
Scanner s = new Scanner(System.in);
flag = s.next();
System.out.println("t2线程退出");
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

Java官方也给出了解释:
Java进程中,每个线程都有一个工作内存(work memory),这些进程会共享同一个主内存(main memory),针对某个数据进行修改读取操作时候:
修改:先把主内存中的数据拷贝到工作内存,再工作内存中修改后,写回主内存
读取:把数据从主内存拷贝到工作内存,从工作内存中读取
所以内存可见性问题:
t1线程while 循环的flag的值是工作内存中flag的值,而t2线程修改的是主内存中flag的值,由于t1工作内存中flag的值是主内存拷贝的副本,所以t2线程修改主内存,不会影响到t1线程中的工作内存
但是上面的代码中,while循环里面是啥也不干的,如果当我们加入sleep,这时我们再次修改flag的值,t1线程也会被终止,难道sleep也有和volatile同样效果吗???
并不是,因为之前的while循环主体啥也不干,所以就会触发编译器的优化,但是加入了sleep,编译器不会优化load这一条指令,因为sleep背后的逻辑更加复杂,指令更多,所以在循环体中做各种复杂的操作时,就可能会引起编译器优化失效~~~·
wait & notify
在编程过程中,因为多线程的调度是随机的,当我们的代码疏忽或者失误,就可能导致某一个线程或者多个线程一直无法执行,导致一个在执行一个线程,这就会导致线程饿死,为了解决线程饿死问题,我们就引入了wait和notify,来避免线程饿死问题。

wait作用:释放锁并且阻塞,同时等待其他线程通知,接收到通知后,如果其他线程解锁,
那么wait就重新加锁,从阻塞状态变回就绪状态。
notify作用:随机唤醒某个线程的wait,当其他线程执行到wait的时候,就会阻塞并且等待
notify的通知,当wait接收到notify的通知就会唤醒阻塞,然后重新获取锁往下执行
注意!!!wait和notify必须在synchronized中使用
public class Demo{
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized(locker){
System.out.println("t1 线程wait之前");
try{
locker.wait();//释放锁给t2线程,同时接收t2线程通知,然后在这里阻塞
}catch(InterruptedException e){
throw new RuntimeException(e);
}
System.out.println("t1 线程wait之后");
}
});
Thread t2 = new Thread(() -> {
synchronized(locker) {
System.out.println("t2 线程notify之前");
Scanner s = new Scanner(System.in);
System.out.println("随机输入一个值,启动notify");
s.next();
locker.notify();
System.out.println("t2 线程notify之后");
}
});
t1.start();
t2.start();
}
}

当我们随机输入一个值,启动notify后,notify就会唤醒t1线程的wait,但是此时的wait仍然是阻塞,此时wait状态时BLOCKED,因为此时锁还在t2线程手里,需要等到t2线程释放锁,t1线程的wait才会从阻塞状态变回就绪状态,然后重新获取到锁,然后继续往下执行。
当有多个线程有wait的时候,一个notify只能随机唤醒其中一个线程的wait,所以notify还有另外一个版本,notifyAll,它可以唤醒所有的wait
public class Demo{
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized(locker){
System.out.println("t1 线程wait之前");
try{
locker.wait();
}catch(InterruptedException e){
throw new RuntimeException(e);
}
System.out.println("t1 线程wait之后");
}
});
Thread t2 = new Thread(() -> {
synchronized(locker){
System.out.println("t2 线程wait之前");
try{
locker.wait();
}catch(InterruptedException e){
throw new RuntimeException(e);
}
System.out.println("t2 线程wait之后");
}
});
Thread t3 = new Thread(() -> {
synchronized(locker){
System.out.println("t3 线程wait之前");
try{
locker.wait();
}catch(InterruptedException e){
throw new RuntimeException(e);
}
System.out.println("t3 线程wait之后");
}
});
t1.start();
t2.start();
t3.start();
Thread t4 = new Thread(() -> {
synchronized(locker){
System.out.println("t4 线程notify之前");
System.out.println("请随机输入一个值,启动notify");
Scanner s = new Scanner(System.in);
s.next();
locker.notify();
System.out.println("t4 线程notify后");
}
});
t4.start();
}
}

wait 和 sleep 区别:
1.) wait 的设计是为了被notify,sleep的设计是为了按照一定的时间阻塞
2.)wait 必须搭配锁使用,sleep不需要
3.)一执行到wait 就会释放锁,然后重新获取锁,而sleep在锁里面休眠不会释放锁
4.)wait 也可以使用interrupt唤醒,但是更希望是通过notify发送通知唤醒,被notify
唤醒之后,还可以再次wait 和 notify,而sleep被interrupt唤醒后,就直接把线程
终止掉了~~~
更多推荐



所有评论(0)