[JavaEE初阶] 线程安全问题的原因和解决方案

努力努力,月薪过亿!!!
格局打开~~~


前言

线程安全这里可能会出道面试题,在日常工作中也是很重要的内容.下面,我们来具体探讨一下吧~~


1. 线程安全问题的概念

首先,什么是不安全的线程呢?
线程是抢占式执行,随机调度,所以,线程调度的顺序不可预知.所以,必须在所有可能的调度顺序下,都能保证正确的结果,这样的线程为安全线程,否则为不安全的线程.

如下代码

class Counter{
    public int count = 0;
    public void add(){
        count++;
    }
}
public class ThreadAdd {
    public static void main(String[] args) {
        Counter c = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10_0000; i++) {
                c.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10_0000; i++) {
                c.add();
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count的值为" + c.count);
    }
}

执行结果如下,两个线程分别对count执行+100000操作,结果却比200000小,这是为什么呢?
在这里插入图片描述
由于i++操作需要三个操作才能执行完.
1.先把内存中的值,读取到CPU寄存器中,load.
2.进行++操作,add.
3.把结果写回到内存中.save.
由于两个线程进行++操作,线程调度顺序有多少种呢?大家猜一猜
答案:有无数种.
为啥呢?
我们看下图,以此类推,有无数种排列方式.那他们的结果如何呢?
在这里插入图片描述

上图第一种,t1从内存中拿到count值0,执行+1操作,count = 1,再放回内存,t2从内存中拿到count值1,执行+1操作,count = 2,再放回内存,这个顺序是没问题的.

上图第二种,t1先拿到count值0,t2也取出count值0,t2执行+1操作,count = 1,t1执行+1操作,count = 1,t2把count值1放回寄存中,之后t1再把count值1覆盖到寄存器中,此时,两个线程执行完++操作,最终count值为1.
之后的操作类似.
我们发现,只有像上图第一种第三种这样把操作中的load,add,save集中操作的才是结果正确的.其余全都不对.

2. 线程安全问题的原因

1.产生安全问题的根本原因是线程是抢占式执行,随即调度
2.与代码结构有关,出现多个代码同时修改同一个变量的情况,导致最终结果不可预控.上图就是这种情况.
3.操作不是原子性,就是类似上图的++操作,load,add,save如果必须是全都一次性执行完才能执行下次的++操作,就能避免线程安全的问题.
4.内存可见性问题,如果我们在读数据的时候,这个数据正在被另一个修改,那么,这个读到的数据就不是正确的.
5.指令重排序,这是编译器优化产生的bug,有时,编译器觉得你的代码复杂度太高了,自作主张给你的代码优化了,产生了结果的不可预知.
以上五个问题不是全部原因,具体问题具体分析,不可一概而论.

3. 线程安全问题解决–加锁

我们从原子性方面解决问题,我们将++操作原子化,完整执行完一次++操作后,才能执行下次的++操作.

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

在这里插入图片描述

用synchronized修饰add()方法,对调用add的对象c加锁,只有执行完add()方法之后,出去了synchronized修饰的范围,程序会自动给对象c解锁.
c加锁过程中,别的对象若想调用对象c,就会造成线程阻塞,必须等待c执行完add()函数,才有机会使用对象c.实现了c调用add()函数时,进行++操作的原子性.

3. synchronized

1.synchronized修饰普通方法
调用方法时,对调用的对象加锁,进方法自动加锁,出方法自动解锁.

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

2.synchronized修饰静态方法
调用方法时,对这个类进行加锁,线程调用这个静态方法时,别的线程无法使用该类

	synchronized public static void fun(){
        System.out.println("这里是synchronized修饰静态方法");
    }

3.synchronized修饰代码块
如下面代码,缩小了锁的范围,进代码块对调用方法的对象加锁,出代码块,自动对对象解锁.但要注意的是,这里需要手动指定加锁的对象,可根据需要自行指定.

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

4. 死锁

死锁是一个很重要很麻烦的事情,一旦出现死锁,线程就无法继续执行.但死锁很隐蔽,开发时不经意间会写出来,测试时,又不容易测出来,比较麻烦~

4.1 产生死锁的情况

1.一个线程已经对一个对象加锁了,又尝试对这个对象再加一把锁.如下代码所示.这时如果锁是可重入锁,线程正常执行,否则,就会导致死锁.很幸运,Java中的synchronized是可重入锁,但C++,Python中的锁是不可重入锁,同一个线程对一个对象加两把锁,就会导致死锁.

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

2.两个线程互相等待对方的锁,造成循环等待,产生死锁.
如下代码所示,线程t1对对象d1加锁,同时线程t2对对象d2加锁,之后,t1再尝试对d2加锁,同时t2再尝试对对象d1加锁.两个线程都在等待对方释放资源,互不相让,造成循环等待,产生死锁.

public class ThreadLockProblem {
    public static void main(String[] args) {
        Object d1 = new Object();
        Object d2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (d1) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (d2) {
                    System.out.println("汤老湿把酱油和醋都拿到了");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (d2) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (d1) {
                    System.out.println("师娘把酱油和醋都拿到了");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

结果如下,产生死锁,不打印任何内容.
在这里插入图片描述
3.多个线程多把锁,哲学家问题,如下图,每个哲学家左手右手都有一根筷子,只有拿到两只筷子的人才能吃到好吃的.如果所有人都同时拿起左手边的筷子,那么所有人都要等另一只筷子,导致了死锁.在这里插入图片描述

4.3 产生死锁的必要条件

1.互斥使用,这是线程的基本特性.线程1拿到资源后,其他线程也想使用就只能等待.
2.不可抢占,线程1对对象A加锁后,其他线程若想对对象A加锁,便只能等待线程1释放锁才行
3.请求和保持,线程1已经对对象A加锁时,再尝试对对象B加锁,此时,线程仍可以保持对对象A的锁.
4…循环等待,线程t1对对象d1加锁,同时线程t2对对象d2加锁,之后,t1再尝试对d2加锁,同时t2再尝试对对象d1加锁.两个线程都在等待对方释放资源,互不相让,造成循环等待.

以上前三种是线程的特性,不可强制改变,唯一可控的是第四点.
避免出现循环等待.

4.4 避免死锁的方法

给对象加锁时,给对象锁编号,线程都按照固定的顺序进行加锁.
线程1先对对象A加锁,再对B加锁,线程2也先对对象A加锁,再对对象B加锁.如下图所示
在这里插入图片描述


评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值