多线程编程4——线程安全问题

一、线程之间是并发执行的,是抢占式随机调度的。

多个线程之间是并发执行的,是随机调度的。我们只能确保同一个线程中代码是按顺序从上到下执行的,无法知道不同线程中的代码谁先执行谁后执行。

比如下面这两个代码:

代码一:

这俩红框框打印的顺序是随机的。

主线程调用start,创建一个线程,是主线程先接着往下执行代码,还是线程先执行run中的代码,这是随机的,看哪行代码先在CPU上调度,就先执行哪行代码。

代码二: 

运行结果: 

主线程先sleep休眠1秒,这时线程趁着主线程休眠,打印thread,然后也进入sleep休眠1秒

主线程和线程谁会先从sleep中醒过来是不确定的。醒来后会回到就绪队列,等待CPU调度。CPU调度是随机的,谁先被调度谁先执行下一行代码。也就是说,控制台输出的结果,是hello在前,还是thread在前,是不确定的。完全看调度器的调度结果。哪行代码先被调度执行先输出哪个结果。

观察运行结果我们发现,有时是thread hello,有时是 hello thread,谁先打印很随机,不可预期。

二、一段出现线程安全问题的代码

什么样的代码会产生线程安全问题呢?

我们来看一下。

创建两个线程,分别对同一个对象(new Counter())的属性(count)进行5w次的自增,打印这个属性的值。我们的预期结果是:属性的值是10_0000

代码如下:

运行结果如下:

 

我们发现,

结果很随机,每次都不同,但不是10_0000,说明代码出现了bug,出现了线程安全问题。

这段代码为什么会出现线程安全问题呢?

问题就出现在 count++ 这个代码上。 

一个线程具体要执行,需要先编译成很多的CPU指令。可以这样理解,一个线程是完成某个任务,这个任务可以拆分成一个一个的小步骤,每个小步骤就是一个指令。CPU调度时只认得指令,它不认识count++这个操作。(指令:可以视为机器语言,由0和1组成。 load,add,save这些相当于汇编指令,比机器指令好记,和机器指令是一一对应的,通过编译器可以将汇编指令转换成机器指令。不同的CPU会支持不同的机器指令,load,add,save这三个操作是CPU中已经支持的指令

count++操作本质上要分成3步:

1、先把内存中的值,读取到CPU的寄存器中(load)

2、把CPU寄存器里的数值进行 +1 运算(add)

3、把得到的结果写回内存中(save)

load,add,save这三个操作,就是CPU上执行的3条指令。

就拿我们写的其中的一部分代码举例,

上面这个代码要执行就需要编译成很多CPU指令:

 本质上是 cmp指令

方法调用,是 call指令

load、add、save这三条指令

针对 i 的load、add、save这三条指令

那为什么说count++操作本质上要分成三条指令,就导致了线程安全问题呢?

我们已经知道,count++最终落实到CPU上执行,会分成这三个指令来去执行(load、add、save),那么,如果是两个线程并发的执行count++,此时就相当于两组 load add save 进行执行。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都有可能被调度走,CPU让别的线程来执行,正是这样的切换,导致会出现不同的线程调度顺序,出现线程安全问题。(哪个线程中的count++先执行是随机的。调度一次执行到了这个count++哪个指令也是随机的。)

调度顺序会出现无数种可能性,下面列出几种:

(1)load、add、save 是一起的,都执行完才被调度走

(2)执行到任意一个指令,线程都有可能被调度走,CPU去执行别的线程,过一会又回来接着执行这个线程了

(3)左:两个CPU某一时刻同时调度到了两个线程的相同指令;右:一个线程才执行完load,另一个线程已经执行好几轮load、add、save操作了 

这些不同的调度顺序,产生了不同的结果。

比如说, ,如果是这个顺序,我们来分析一下是怎么执行的。

为了方便画图,我们假设线程t1 只会被左边那个CPU调度,线程t2 只会被右边那个CPU调度(真正在你的电脑上跑这串代码,肯定是可以被你电脑上任意CPU调度的。)

第一步:load —— 把内存中的值加载到CPU的寄存器中(CPU里有个重要的组成部分,寄存器。CPU里进行的运算都是针对寄存器里的数据进行的,也就是说,CPU不能直接操作内存)

于是,第一个CPU寄存器中存了个 0

第二步:add —— CPU寄存器里的数值进行+1运算

于是,第一个CPU寄存器里的值变为1

第三步:save —— 得到的结果写回内存中

于是,内存中的值变为1

第四步:load —— 把内存中的值加载到CPU的寄存器中

于是,第二个CPU寄存器中存了个1

第五步:add —— CPU寄存器里的数值进行+1运算

于是,第二个CPU寄存器里的值变为2

第六步:save —— 得到的结果写回内存中

于是,内存中的值变为2

count自增2次,结果为2,符合预期。

我们再来看另一种可能的调度顺序,,如果是这个顺序,我们来分析一下是怎么执行的。

第一步:load —— 把内存中的值加载到CPU的寄存器中

于是,第二个CPU寄存器中存了个0

第二步:load —— 把内存中的值加载到CPU的寄存器中

于是,第一个CPU寄存器中也存了个 0

第三步:add —— CPU寄存器里的数值进行+1运算

于是,第二个CPU寄存器里的值变为1

第四步:save —— 得到的结果写回内存中

于是,内存中的值变为1

第五步:add —— CPU寄存器里的数值进行+1运算

于是,第一个CPU寄存器里的值变为1

第六步:save —— 得到的结果写回内存中

于是,内存中的值变为1

count自增2次,结果为1,不符合预期。 线程t1 load的值,是线程t2修改之前的值,t1 读到了 t2 还没save 的数据。于是出现问题。

也就是说,只要其中一个线程读到了另一个线程还没save 的数据,结果就会出问题。

这么多种调度顺序,只有这两种情况满足不会读到还没save 的数据的所以,只有两个线程每次的调度顺序都是这两种,才会打印10_0000。这种概率非常非常小。输出结果基本上都是小于10_0000。

那么结果一定会大于5_0000吗?

不一定。也有可能出现一个线程才执行完load,另一个线程已经执行好几轮load、add、save操作了  这种情况。这就是,count自增好几次,结果为1了。

所以,这段代码一定会出现线程安全问题,且结果很随机。 

三、出现线程安全问题的原因

为什么会产生线程安全问题呢?

1、根本原因:操作系统是抢占式执行,随机调度的。

2、代码结构:多个线程同时修改同一个变量。(上述代码中,两个线程修改同一个变量count。出现线程安全问题的概率非常大。) 

  • 一个线程,修改一个变量,没事
  • 多个线程,同时读取同一个变量,没事
  • 多个线程,同时修改不同的变量,也没事

 3、原子性:修改操作不是原子性的。如果修改的操作是原子的,出现线程安全问题的概率比较小。如果是非原子的,出现线程安全问题的概率就非常大了。(原子:不可拆分的基本单位。单个指令就是原子的。上述代码中,count++可以拆分成load、add、save这三个指令,所以count++不是原子的,出现线程安全问题的概率非常大。)

4、内存可见性问题:如果是一个线程读取,一个线程修改,也可能出现线程安全问题,读取的结果不符合预期。

5、指令重排序:编译器对你写的代码进行优化,保证逻辑不变的情况下,进行调整,来加快程序的执行效率。调整时,可能会调整代码的执行顺序,从而可能会出现线程安全问题。

上述是5个典型的线程安全问题的原因,但并不是全部。

一个代码究竟线程安全不安全,要具体问题具体分析。如果一个代码踩中了上面的原因,也可能线程安全。如果一个代码没踩中上面的原因,也可能线程不安全。

要结合原因,结合需求,具体问题具体分析。多线程运行代码,只要不出bug,就是线程安全的。

四、如何解决线程安全问题?

根据 出现线程安全问题的原因,从中入手解决线程安全问题。

我们可以通过调节代码结构来规避线程安全问题。

但是代码结构也是来源于需求的,不一定就能调整。是方案没错,但普适性不是特别强。

最主要的解决线程安全问题的方法就是从原子性入手。把非原子的操作变成 “原子” 的

就比如上面那段出现线程安全问题的代码,

正是因为 count++操作不是原子的,本质上要分成load、add、save这三条指令,当两个线程并发的执行count++时,就相当于两组 load add save 进行执行。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都有可能被调度走,CPU让别的线程来执行,正是这样的切换,导致会出现不同的线程调度顺序,后一个线程 读到了 还没save 的数据,出现线程安全问题

我们从原子性入手,就非常好解决了。只要 把count++这个非原子操作,变成 “原子” 的,保证线程的load、add、save 3个操作都执行完才会被调度走,让后一个线程读到的是 save过 的数据,问题就解决了。​​​​​​​

如何把非原子的操作变成 “原子” 的呢?

通过 synchronized关键字 加锁。

synchronized

synchronized 是一个关键字,表示加锁。要明确锁对象,是对 对象进行加锁!!!

synchronized 的使用方法:

1、修饰方法:(1)修饰普通方法(2)修饰静态方法 

2、修饰代码块

虽然 synchronized 修饰方法和代码块,但锁不是加到方法或其他上面,是线程对对象加锁加锁是给对象加锁一定要明确锁对象是哪个!!!

  1. 修饰普通方法:锁对象是this
  2. 修饰静态方法:锁对象是类对象(Counter.class)
  3. 修饰代码块:手动指定锁对象

加锁的规则: (就3条,很简单,不要自己加戏)

1、如果两个线程针对同一个对象加锁,会出现锁冲突/锁竞争,一个线程能够获取到锁(先到先得),另一个线程只能阻塞等待(BLOCKED),一直阻塞到上一个线程释放锁(解锁),它才能获取锁(加锁)成功。【注:另一个线程对这个对象加锁不成功,线程阻塞。但不代表这个锁对象不能用,还是可以获取锁对象的属性,调用锁对象的没加锁的方法的。只能说线程阻塞了,不能说对象不能用了。这两个概念不一样。】

2、如果两个线程针对不同的对象加锁,不会出现锁冲突/锁竞争。这俩线程都能获取到各自的锁,不会有阻塞等待了。

3、如果两个线程,一个线程加锁,一个线程不加锁,也不会出现锁冲突/锁竞争。

对 synchronized 的使用方法进行分别介绍​​​​​​​

1、修饰普通方法

进入方法就加锁,出了方法就解锁。锁对象是this,谁调用这个方法谁就是this

比如通过 synchronized关键字 修饰 add方法,进入add方法就加锁,出了add方法就解锁。以此来把 方法中count++变成 “原子的” 。

synchronized关键字 可以放在public前,也可以放在public后,下面两种都可以。


加了 synchronized关键字 后,我们再来看一下这个调度顺序,,会不会发生改变呢?

肯定会的。

如上,

(1)首先,加了 synchronized关键字 后,load前面和save后面会多个lock(加锁) 和 unlock(解锁)操作。

(2)线程t2 进入add方法,对counter 进行加锁(counter调用add方法,counter就是this,就是锁对象),接着往下执行load,此时 t1也想去加锁,不好意思,加锁不能成功,只能阻塞等待。

(3)一直等到 t2 unlock 之后,t1才可能lock成功。

t1 加锁的阻塞等待,就把 t1的load 推迟到 t2的save之后,t1读到的就是t2 save过 的数据。这就避免了线程安全问题,输出的结果就是符合预期的 10_0000啦。

加锁,保证了 “原子性” ,避免了线程安全问题。当然,这不是说,load、add、save这三个操作就必须一次性完成。执行到任意一个指令的时候,线程都有可能被调度走,过一会再被调度回来继续往下执行。虽然线程这会儿没在CPU上执行,但只要这个线程没有释放锁,其他线程想要获取锁就只能阻塞等待。

加锁之后,代码的执行速度会变慢,(因为多了加锁和解锁的操作),但还是要比单线程要快的。比如,上述代码,线程t1和线程t2 只有调用add方法时,才会加锁,只能串行。其余for循环这里的比较和自增操作,又不需要加锁,是可以并发执行的。一个任务中,一部分并发,一部分串行,仍然比所有代码都串行要快。

为啥红框框部分可以并发执行,没有线程安全问题?

因为这里的 i 是局部变量,t1 和 t2线程中各自都有,t1 和 t2线程各自修改各自的局部变量,完全不会出现问题。

2、修饰静态方法

进入方法就加锁,出了方法就解锁。锁对象是类对象

 

上述例子的锁对象是 Counter.class 

3、修饰代码块

进入代码块就加锁,出了代码块就解锁。手动指定锁对象。

用修饰代码块这个使用方法,来举个

如果两个线程,一个线程加锁,一个线程不加锁,也不会出现锁冲突/锁竞争

的例子:

 

synchronized(this){  } 在小括号里 手动指定锁对象

这里指定的锁对象是this。 t1线程对 counter 加锁,t2线程 没对 counter 加锁。就不会出现锁竞争,不会阻塞等待。运行结果不符合预期。

如果给 t2线程也给 counter加锁,那么就属于两个线程针对同一个对象counter加锁,就会出现锁竞争,只有一个线程能获取到锁,另一个线程只能阻塞等待(BLOCKED)。​​​​​​​运行结果符合预期。

 

synchronized是可重入锁

一个线程针对同一个对象连续加锁多次,没问题就是可重入的。有问题,就是不可重入。 

 

如上代码,可以看出 synchronized是可重入的。

具体的解释: 

锁对象是this,只要有线程调用add,进入add方法的时候,就会先加锁,假如此时能够加锁成功,接着代码往下执行,会遇到代码块,再次尝试加锁。

站在锁对象的视角,它认为自己已经被线程给占用了。那会不会让第二个线程阻塞等待呢?

synchronized 能检查出两个线程是同一个,允许加锁。可重入。

如果不允许加锁,会阻塞等待,那就是不可重入。

总结:

无论这个对象是个啥样的对象,原则就一条,看锁对象相不相同

锁对象相同,就会产生锁竞争(产生阻塞等待);

锁对象不同,就不会产生锁竞争(不会产生阻塞等待);​​​​​​​

相关推荐

  1. Python线编程

    2024-02-02 15:36:03       9 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-02-02 15:36:03       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-02-02 15:36:03       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-02-02 15:36:03       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-02-02 15:36:03       20 阅读

热门阅读

  1. 手写Spring框架之: HelloSpring

    2024-02-02 15:36:03       34 阅读
  2. Activiti7 整合SpringBoot

    2024-02-02 15:36:03       26 阅读
  3. ✅Redis-管道技术

    2024-02-02 15:36:03       28 阅读
  4. centos gcc4.8升级8

    2024-02-02 15:36:03       36 阅读
  5. 云计算运维 · 第三阶段 · git

    2024-02-02 15:36:03       25 阅读
  6. 2024美赛C完整思路

    2024-02-02 15:36:03       27 阅读
  7. C++ inline 函数工作原理

    2024-02-02 15:36:03       31 阅读
  8. 微信小程序for循环嵌套

    2024-02-02 15:36:03       25 阅读