什么是多线程的冲突
同一进程内的线程是共享同一内存空间的,所以在多个线程的进程里,线程是可以同时操作这个进程空间的数据的,线程之间可以共享进程的资源:比如代码段、堆空间、数据段、打开的文件等资源,但每个线程也有自己独立的栈空间。如果多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。
举个小栗子:一个房子里(代表一个进程),只有一个厕所(代表共享资源)。屋子里面有两个人A和B(代表两个线程),共用这个厕所。一天A去上厕所了,不过厕所门的锁坏了,就没法锁门。这是B也想去上厕所,直接就开门进去了,然后发现A在里面。
上面这个故事说明,对于共享资源,如果没有上锁,在多线程的环境里,那么就可能会发生翻车现场。
竞争与协作
做个小实验,创建五个线程,它们分别对共享变量 i 自增 1 执行 1000 次
package com.atguigu.juc.atomics;
public class Station{
int i = 0;
public void add() {
for(int m = 0; m < 1000; m++){
try {
//使用sleep()模拟业务时间,如果不加,大概率不会出现并发问题
Thread.sleep(1);
i += 1;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
//实例化站台对象,并为每一个站台取名字
Station station = new Station();
new Thread(station::add,"线程1").start();
new Thread(station::add,"线程2").start();
new Thread(station::add,"线程3").start();
new Thread(station::add,"线程4").start();
new Thread(station::add,"线程5").start();
Thread.sleep(20000);
// 让每一个站台对象各自开始工作
System.out.println(station.i);
Thread.sleep(5000);
System.out.println(station.i);
}
}
运行了几次发现,每次运行得到不同的结果。在计算机里是不能容忍的,虽然是小概率出现的错误,但是小概率事件它一定是会发生的。
为什么会出现这样的问题呢?
为了理解为什么会发生这种情况,我们必须了解编译器为更新计数器 i 变量生成的代码序列,也就是要了解汇编指令的执行顺序。
在这个例子中,我们只是想给 i 加上数字 1,那么它对应的汇编指令执行过程是这样的:
可以发现,只是单纯给 i 加上数字 1,在 CPU 运行的时候,实际上要执行 3 条指令。
设想我们的线程 1 进入这个代码区域,它将 i 的值(假设此时是 50 )从内存加载到它的寄存器中,然后它向寄存器加 1,此时在寄存器中的 i 值是 51。
现在,一件不幸的事情发生了:当前线程被挂起了,线程 2 被调度运行,并进入同一段代码。它也执行了第一条指令,从内存获取 i 值并将其放入到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执行接下来的两条指令,将寄存器中的 i 值 + 1,然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i 值是 51。
最后,又发生一次上下文切换,线程 1 恢复执行。还记得它已经执行了两条汇编指令,现在准备执行最后一条指令。回忆一下, 线程 1 寄存器中的 i 值是51,因此,执行最后一条指令后,将值保存到内存,全局变量 i 的值再次被设置为 51。
简单来说,增加 i (值为 50 )的代码被运行两次,按理来说,最后的 i 值应该是 52,但是由于不可控的调度,导致最后 i 值却是 51。
针对上面线程 1 和线程 2 的执行过程,我画了一张流程图,会更明确一些:
互斥的概念
上面展示的情况称为竞争条件(race condition),当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。
我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。
另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。
同步的概念
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
上面那个栗子:一A去上厕所了,发现厕所门的锁坏了,告诉B一声。这是B想去上厕所的话,就会先问一下A是不是还在里面,然后再开门进去了。这也是互通消息的方式,如果锁没有坏,A直接把门锁上,这就是相互等待的方式。
怎么解决多线程冲突?
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁、解锁操作;
- 信号量:P、V 操作;
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。
锁
使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
信号量
信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。
另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:
- P 操作:将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
- V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
举个类比,2 个资源的信号量,相当于 2 条火车轨道,PV 操作如下图过程: