线程安全性问题

个人主页: 不漫游-CSDN博客

目录

前言

什么是线程安全性问题?

原因

解决办法

​synchronized​ 关键字(针对原因2和3)

​volatile 关键字 

针对可见性问题

针对重排序问题

注意

 死锁

死锁的定义

死锁的必要条件

死锁的场景

1.重复加锁

2.嵌套锁

3.哲学家就餐问题


前言

在上次讲线程状态的文章里提到了BLOCKED状态--->http://t.csdnimg.cn/T17sQ

因为线程 对于 锁 的竞争引起的堵塞。接着详细展开讲解~

什么是线程安全性问题?

话不多说,先看一段代码:

public class demo17 {
    public static void main(String[] args) throws InterruptedException {
             int[] count = {0};
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                    count[0]++;

            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                    count[0]++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count[0]);
    }
}

分析代码,发现使用两个线程t1 t2 来累加 数组 count[0] 的值,在不运行的情况下, 是不是觉得结果是20000.

但实际运行结果却是这样:

等等。

每次都是随机值,很明显这就是一个bug。

原因

1.线程在操作系统中,随机调度,抢占式执行。

这是根本原因。 

2.多个线程,同时修改同一个变量

3.修改操作不是原子的。

首先了解一下什么是原子性? 

它指的是一个操作是不可分割的,要么完全执行,要么完全不执行。

在多线程环境中,如果一个操作是原子的,那么多个线程同时执行这个操作时,不会出现竞争条件或数据不一致的问题。

但是!!!​count[0]++​ 这个操作在Java中不是原子操作。它实际上包含以下三个步骤:

1.读取 ​count[0]​ 的当前值。
2.将读取的值加1。
3.将新值写回 ​count[0]​。


如果两个线程同时执行这些步骤,可能会发生以下情况:

线程t1读取 ​count[0]​ 的值(假设已经到100)。
线程t2也读取 ​count[0]​ 的值(仍然是100)。
线程t1将读取的值加1(得到101)并写回。
线程t2也将读取的值加1(得到101)并写回。
结果是 ​count[0]​ 的值只增加了1,而不是2,因为两个线程都基于相同的初始值进行了递增操作。

这还是只是一种情况,而且因为原因1中的随机调度和抢占式执行。两个线程的执行顺序几乎是随机的 。也就导致了每次执行的结果都是不一样的。

这也就导致了线程安全问题的出现。 

4.内存可见性问题

在多线程编程中,一个线程对某个共享变量做了修改,但其他线程可能看不到这个修改,还以为是原来的值~

举个例子

想象你和你的家人共享一个家庭冰箱(这是你们的共享资源)。冰箱门上有一个便签,用来记录冰箱里有哪些食物(这个便签就是你们的共享变量)。

你添加食物:你往冰箱里添加了一些食物,并在便签上更新了食物清单(这相当于一个线程修改了共享变量的值)。
你的家人看到便签:你的家人在准备晚餐时,查看便签上的食物清单,并根据清单上的内容决定做什么菜(这相当于其他线程看到了共享变量的最新值)。


但是内存可见性问题就是便签上的信息没有及时被其他人看到:
于是他们决定去买这些食物,但实际上冰箱里已经有了(这相当于其他线程没有看到共享变量的最新值,而是看到了原来的值)

5.指令重排序问题

指令重排序是指编译器和处理器为了优化程序的执行效率,可能会对程序中的指令顺序进行调整。

在多线程环境下,指令重排序可能会导致不可预测的行为,因为不同线程之间的操作顺序可能会被打乱。

举个例子:
假设正在准备一顿晚餐,需要完成以下步骤:

洗菜->切菜->煮饭->炒菜.
现在,假设你和你的朋友同时在厨房准备晚餐,你们各自负责不同的步骤,但有些步骤需要共享资源(比如同一个锅)。如果你们没有协调好,可能会出现以下情况:

你开始洗菜,然后切菜。
你的朋友开始煮饭,然后试图炒菜。
由于你们没有协调好,你的朋友在煮饭完成后试图炒菜,但此时你还没有切好菜,导致炒菜无法进行。这就相当于指令重排序在多线程环境下导致的问题。

解决办法

简单来讲就是,通过加锁的这种方式,使一个线程在执行  count[0]++ 的操作时,其他线程的 count[0]++ 不能插队进来。如图~

看着是不是很像等待 join(),但效率是快多了~

​synchronized​ 关键字(针对原因2和3)

这个关键字就代表上锁,基本用法是:

synchronized(//需要一个锁对象进行后续的判定){
//需要打包到一起的代码
}

重新加上这个关键字: 

public class demo17 {
    private static final Object lock = new Object(); //定义 lock 对象

    public static void main(String[] args) throws InterruptedException {
             int[] count = {0};
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    count[0]++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    count[0]++;
                }
            }
        });
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();


        System.out.println(count[0]);
    }
}

加上后,就能正常执行了~

​volatile 关键字 

针对可见性问题

比如先看这段代码

public class demo23  {
    private static boolean flag = false;

    public static void main(String[] args) {
        Thread writer = new Thread(() -> {
            try {
                Thread.sleep(1000); //休眠1秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true; //修改共享变量的值
            System.out.println("flag是true");
        });
        Thread reader = new Thread(() -> {
            while (!flag) {
                //空循环,等待 flag 变为 true
            }
            System.out.println("flag是false");
        });
        writer.start();
        reader.start();
    }
}

一般分析的话,reader 线程看到flag 变为true,两个打印语句都可以正常执行。 

但结果是这样:

​writer线程 对 ​flag​ 的修改可能只反映在它所在 CPU 核心的缓存中,而没有立即同步到主内存中。因此,​reader在读取 ​flag​ 时,可能还是从自己的缓存中读取,从而看到的是旧值。

所以reader就一直等,就阻塞了~

忘了计算机是怎么工作的?--->http://t.csdnimg.cn/PH0l2

加上volatile关键字

    private static volatile boolean flag = false;

 结果就可以正常输出了。

针对重排序问题

 比如这段代码~

public class demo24 {
        private static  int a = 0;
        private static  int b = 0;
        private static  int x = 0;
        private static  int y = 0;

        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(() -> {
                a = 1; 
                x = b; 
            });

            Thread t2 = new Thread(() -> {
                b = 1; 
                y = a; 
            });

            t1.start();
            t2.start();

            System.out.println("x = " + x + ", y = " + y);
        }
    }

结果是这样。

我们期望 ​x​ 和 ​y​ 的值分别是 1 和 1,因为 ​a​ 和 ​b​ 都被设置为 1。然而,由于指令重排序,导致 ​x​ 和 ​y​ 的值分别是 0 和 0。

加上volatile关键字

        private static volatile int a = 0;
        private static volatile int b = 0;
        private static volatile int x = 0;
        private static volatile int y = 0;

 结果

注意

1.锁对象的作用:上面写的后续的判定就是指 是否是对 “同一个对象” 加锁.

记住,锁对象是什么不重要,重要的是多个线程的锁对象是否是同一个。

因为不是同一对象,各自加锁后也互不影响。也就不会出现阻塞或锁竞争。

比如下图~ 

public class demo17 {
    private static final Object lock1 = new Object(); //定义 lock1 对象
    private static final Object lock2 = new Object(); //定义 lock2 对象

    public static void main(String[] args) throws InterruptedException {
             int[] count1 = {0};
             int[] count2 = {0};
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock1) {
                    count1[0]++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock2) {
                    count2[0]++;
                }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();


        System.out.println(count1[0]);
        System.out.println(count2[0]);
    }
}

2.锁对象的类型不能是int double 这种内置的类型,只要是Object类的子类就可以~

看下图~ 

3.当 ​synchronized​ 关键字修饰方法时,这意味着当一个线程正在执行该方法时,其他线程必须等待,直到当前线程执行完毕。

这也好理解,毕竟上了锁嘛~

 死锁

死锁的定义

死锁是指两个或多个线程在执行过程中,每个线程都需要其他线程的资源,但每个线程都上锁了,就这样一直阻塞。

死锁的必要条件

互斥:资源不能同时被多个线程使用。例如,一个文件不能同时被两个线程写入。

请求和保持:线程已经占有了至少一个资源,但又申请新的资源,而该资源被其他线程占有,所以它必须等待。

不可抢占:已经分配给某个线程的资源不能被强制拿走,只能由占有它的线程主动释放。

循环等待:线程之间形成了一个等待资源的环形链,每个线程都在等待链中下一个线程占有的资源。

死锁的场景

1.重复加锁

Java中的 ​synchronized​ 关键字是可重入的,所以这段代码可以正常运行。

public class demo25 {
    private static final Object lock = new Object();

    public static void main(String[] args) {

        Thread t = new Thread(()->{
            synchronized (lock){
                System.out.println("hello-1");
                synchronized (lock){
                    System.out.println("hello-2");
                }
            }
        });

        t.start();
    }
}

然而,如果使用的是不可重入锁或者其他编译语言,重复加锁就会导致问题。 

2.嵌套锁

public class demo26{
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("hello");
                synchronized (lock2) {
                    System.out.println("world");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("world");
                synchronized (lock1) {
                    System.out.println("hello");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

t1​ 首先获取 ​lock1​,然后尝试获取 ​lock2​,但t2​ 首先获取 ​lock2​,然后尝试获取 ​lock1​。

因为两个线程同时运行,而 ​t1线程​ 获取了 ​lock1​,而t2​ 线程获取了 ​lock2​,那么它们将陷入死锁,因为每个线程都在等待对方释放锁。 结果就是这样了-->一直陷入等待中!

3.哲学家就餐问题

典型模型-->哲学家就餐问题:

假设有五位哲学家围坐在一张圆桌旁,他们要么思考,要么吃饭。每位哲学家之间有一只筷子,这样每位哲学家左右各有一只筷子。哲学家需要两只筷子才能吃饭,但每次只能拿起一只筷子。

对于这种情况,如果不采取适当的同步措施,很容易导致死锁。

例如,所有哲学家同时拿起左边的筷子,然后等待右边的筷子,这样就会形成一个循环等待,导致所有哲学家都无法吃饭。

解决方案:

为筷子进行编号。例如,哲学家必须先拿编号较小的筷子,再拿编号较大的筷子。

这样可以打破循环等待的条件,从而避免死锁的发生。

看到最后,如果觉得文章写得还不错,希望可以给我点个小小的赞,您的支持是我更新的最大动力

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值