【多线程】深入剖析线程安全问题

💐个人主页:初晴~

📚相关专栏:多线程 / javaEE初阶


前言

线程安全问题是在多线程学习中一个十分重要的话题。多个线程并发执行就容易产生许多冲突与问题,如何协调好每个线程的执行,让多线程编程“多而不乱”,就是线程安全问题学习所要实现的了。这篇文章就让我们来深入探讨线程安全吧

目录

前言

一、概念

二、Synchronized

1、修改共享数据问题

2、解决方法

3、synchronized使用

(1)修饰代码块

(2)修饰方法

4、synchronized特性

(1)互斥

(2)可重入

三、死锁

1、循环依赖

2、哲学家问题

四、volatile

1、内存可见性

2、volatile


一、概念

想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:
        如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

        造成线程不安全的主要原因是线程调度是随机的,这是线程安全问题的罪魁祸⾸,随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数. 程序猿必须保证 在任意执⾏顺序下 , 代码都能正常⼯作.

二、Synchronized

1、修改共享数据问题

让我们先来看一下下面这段代码:

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

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

如果是在单线程的环境下执行类似逻辑,那结果毫无疑问肯定是100000,但是实际结果却相距甚远:

甚至每次运行的结果都不一样:

上⾯的线程不安全的代码中, 涉及到多个线程针对 count 变量进⾏修改. 此时这个 count 是⼀个多个线程都能访问到的 "共享数据"。多线程在同时修改同一个数据时就容易出现问题。
主要是由于修改操作看似是一个操作,实际上操作系统要执行多个指令。比如在执行count++操作时就有三步操作:
1、load   把内存中的数据读取到CPU寄存器中
2、add    把cpu寄存器里的数据+1
3、save   把CPU寄存器里的值写回内存

而CPU在调度执行线程时,随时都有可能切换执行其它线程(抢占式执行,随机调度)

指令是CPU执行的最基本单位,要切换线程,也会等当前线程的指令执行完毕才调走,不会出现指令执行一半的情况。

但由于count++操作需要三个指令,CPU执行了一个指令或两个指令或三个指令的任何时候都有可能被调度走从而使此次count++操作的结果产生偏差,并且由于调度的随机性,这种偏差也是无法预测的,也就导致了我们上面所看到的每次程序执行结果都不同的现象了

还有很多可能得情况,博主就不一一例举了。但只有第一二种情况程序才能正常运行:

这样两个线程的三个指令都能分别完整的被执行完,最后结果就会是count=2了。

错误的情况会类似下面这种:

上述过程中,明明执行了++操作两次,但最终结果却是1。因为这两次加的过程中结果出现了覆盖。

由于五万次循环中,无法确定有多少次的执行顺序是1、2这两种正确的执行顺序,因此最终的结果是不确定的,而这个值是一定小于10万的。

2、解决方法

会出现上述问题的主要原因是java中许多语句的执行都不是原子性的,这导致了如果⼀个线程正在对⼀个变量操作,中途其他线程插⼊进来了,如果这个操作被打断了,结果就可能是错误的。

比如说我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。

那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。

同样的,我们也可以给线程上一把“锁”,把“非原子”的操作变成“原子”,保证线程执行的原子性。在java中,我们就可以用synchronized实现该操作

3、synchronized使用

(1)修饰代码块

用sychronized修饰代码块{},进入{就会自动加锁,出了}就会解锁,如下:

这时我们发现程序报错了。是由于()中需要指定一个锁对象,这个锁对象可以是任何对象,重点是通过锁对象的比较来确定两个线程是否是否要上同一把锁,如果锁对象一致,就会产生锁竞争,只有一个线程执行完毕解锁后,其它竞争线程才能拿到锁执行操作。因此我们应该为这两个线程准备一个锁对象:
public class Main {
    public static int count=0;
    public static Object locker=new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){   
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

这下程序结果就没问题了:

评论 28
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值