目录
多线程安全的前言:
造成多线程安全问题的根本:多线程的抢占式执行,带来的随机性.
如果没有多线程,此时代码的执行顺序是固定的(只有一条路可走).代码的顺序固定,程序的结果就是固定的.
如果有了多线程,此时在抢占式执行下,代码的执行顺序,会出现更多的变数!代码的执行顺序的可能性从一种编程了无数种情况.所以就需要保证在这无数这种线程调度顺序的情况下,代码的执行结果都是正确的.
只要有一种情况下,代码的执行结果不正确,就都视为是有bug的,线程就是不安全的.
线程安全问题的代码:
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo13 {
public static void main(String[] args) {
Counter counter = new Counter();
//创建两个线程,两个线程分别针对counter来调用5万次的add方法
Thread t1 = new Thread(()->{
for (int i=0;i<50000;i++){
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i=0;i<50000;i++){
counter.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//打印最终的count值
System.out.println("count = "+counter.count);
}
}
当前的代码的预期结果:100000
实际结果:
当前这个现象是否是bug??
程序不符合需求,就是bug,我们的需求是两个线程各自自增5w次,一共自增10次,预期的结果是10w.
预期结果是10w,但是实际的结果不是10w,而且每次的结果都不一样,这个就称为bug!!!!
这就是一个典型的线程安全问题.
分析原因:
count++,这个操作,本质上要分成三步来完成.
1.先把内存中的值,读取到CPU的寄存器当中. load
2.把CPU寄存器里的数值进行 +1运算. add
3.把得到的结果写回到内存中. save
上述三个操作,就是cpu上执行的三个指令.指令视为是机器语言.
如果是两个线程并发的执行count++,此时就相当于两组load add save进行执行.
此时不同的线程的调度顺序,就可能会产生结果上的差异!!!
有很多种不同的执行顺序,箭头表示时间轴,靠上的先执行,靠下的后执行:
(此处可能的结果有无穷种,我们只列出其中的一部分)
此时只有这两种顺序是正确的,可以完成count自增两次
其他的执行顺序的预期结果与实际结果不同.
出现问题的原因:出现问题的关键就是这两个load操作,t1先load,将内存中count的值读取到cpu,t2在load,t2load的值,是t1修改之前的值,导致后续的操作只让count自增了一次.
t2读到了t1还未提交的值(类似于脏读问题).
当前这个代码,有没有可能正好结果就是10w呢??
也是有可能的,但是概率非常的小.
只要每次调度的顺序,都是
这两种正确的顺序,最终的结果就是10w.
不是10w,结果肯定是小于10w的,那么,当前这个结果,一定是大于5w次的吗???
实际运行的时候,结果基本上都是大于5w次的,但是也不一定.
也有可能
t1自增一次操作,t2在其间自增了很多次,但是最终的结果还是自增一次.
线程安全问题的原因:
到底什么的情况会出现线程安全问题?是所有的多线程的代码都会涉及到线程安全问题吗?
1.根本原因:抢占式执行,随机调度.(这是程序猿无法改变的现实)
2.代码结构
多个线程同时修改同一个变量.
因此可以通过调整代码结构来规避这个问题.但是这种调整,也不是都一定能够使用的,代码的结构也是来源于我们实际工作当中的需求的,如果调整了代码结构导致我们的需求达不到,那这种修改也是没有意义的.
3.原子性
如果修改操作的对象具有原子性,那么出现问题的概率会大大减小.
如果是非原子的,出现问题的概率就非常高了.
原子:不可拆分的基本单位.
count++,可以拆分为load,add,save三个操作(单个指令无法在拆分).
如果++操作是原子的,那么线程安全问题,也就解决了.
4.内存可见性问题
5.指令的重排序
本质上是编译器的优化出现bug.
编译器会针对自己写的代码进行调整,在保持逻辑不变的情况下,进行调整代码的执行顺序,从而加快程序的执行效率.
上述分析的是五个典型的原因,不是全部.
一个代码究竟是线程安全还是不安全,都要具体问题具体分析,不能一概而论.
如果一个代码猜中了上述原因,也可能线程安全.
如果一个代码没踩中上述原因,也可能线程不安全.
最终抓住的原则:多线程的运行代码,不出bug,就是线程安全!!
从原子性入手,解决线程安全问题
通过加锁,把不是原子的,转成原子的.
synchronized关键字
这种操作,就表示加锁.
加了synchronized之后,进入方法就会加锁,出了方法就会解锁.
如果有两个线程同时尝试加锁,此时只有一个能获取锁成功,另一个阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功.
add方法,要做的事情多了加锁和解锁.
t1先加锁,此时t2加锁就没加成,就会阻塞等待,一直等待t1 unlock了之后,才会让t2的lock继续执行.
由于t2的lock的阻塞,就把刚才的t2的load推迟到了t1的save之后,也就避免了脏读.(也就是在t1完成了提交数据之后,t2再来读).
加锁,就保证了原子性,但是这里,其实不是说让add方法的三个操作一次完成,也不是这三步操作过程中不进行调度,而是让其他想操作锁内容的线程阻塞等待了.
加锁的本质就是把并发变成了串行.
synchronized 使用方法
1.修饰方法
1)修饰普通方法
2)修饰静态方法
2.修饰代码块
synchronized后面的括号里可以指定任意你想指定的对象
进入代码块就加锁,出了代码块就解锁.
修饰普通方法,锁对象就是this.
修饰静态方法,锁对象就是类对象(Counter.class).
修饰代码块,显式/手动指定锁对象.
所以,加锁,是要明确执行对哪个对象加锁.如果两个线程针对同一个对象加锁,会产生阻塞等待.(锁竞争/所冲突).如果两个对象针对不同的对象加锁,不会产生阻塞等待.
t1执行add,就加上锁了,针对我们创建的counter对象就加上锁了.
t2执行add的时候,也尝试对counter加锁,但是由于counter已经被t1给占用,因此这里的t2的加锁操作就会阻塞.
可重入
一个线程针对同一个对象,连续加锁两次,是否会有问题?
如果没有问题,就叫做可重入,如果有问题,就叫不可重入的.
锁对象是this,只要有线程调用this,进入add方法的时候,就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁.
站在this的视角(锁对象),它认为自己已经被另外的线程给占用了,这里的第二次加锁是否要阻塞等待呢???
此处是特殊情况,第二个线程,第一个线程,其实是同一个线程,那这里能不能就不再阻塞等待了呢??
如果允许上述操作,这个锁就是可重入的.
如果不允许上述操作(第二次加锁会阻塞等待),就是不可重入的.(这个情况会导致线程就僵住了,形成了死锁).
因为在Java里这种代码是很容易出现的,为了避免不小心就死锁,Java就把synchronized设成可重入的锁了.
是怎么做到可重入的:就是在锁对象里记录一下,当前的锁是哪个线程持有的.如果加锁线程和持有线程是同一个,就直接放过,不产生阻塞.
死锁
产生死锁的几种典型场景
1.一个线程,一把锁,连续加锁两次.
如果锁是不可重入锁,就会死锁.
2.两个线程两把锁.
t1和t2各自先针对锁A和锁B加锁,再尝试获取对方的锁.
public class ThreadDemo14 {
public static void main(String[] args) {
Object locker1 =new Object();
Object locker2 =new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
}
}
});
t1.start();
t2.start();
}
}
3.多个线程多把锁
相当于2的一般情况,用一个教科书上的经典案例来解释.
哲学家就餐问题:
5个哲学家5根筷子,每个哲学家要吃面条,就必须把左手右手的筷子都拿起来.
每个哲学家,都有两种状态:
1.思考人生(相当于线程的阻塞状态)
2.拿起筷子吃面条(相当于线程获取到锁然后执行一些计算)
由于操作系统的随机调度,这五个哲学家,随时都可能想吃面条,也随时可能思考人生.
假设出现了极端情况,就会产生死锁.
同一时刻,所有的哲学家同时拿起左手的筷子,那么所有的哲学家都拿不起右手的筷子都要等待右边的哲学家把筷子放下.
死锁的四个必要条件
1.互斥使用
线程1拿到了锁,线程2就得等着.(锁的基本特性)
2.不可抢占
线程1拿到锁后,必须是线程1主动释放,不能说是线程2把锁强行给获取到.
3.请求和保持
线程1拿到锁A后,再尝试获取锁B,A这把锁还是保持的(不会因为获取锁B就把锁A给释放了).
4.循环等待
线程1尝试获取到锁A和锁B,线程2尝试获取锁B和锁A.线程1再获取锁B的时候等待线程2释放锁B,同时线程2再获取A的时候等待线程1释放锁A.
避免死锁
上述说是4个条件,实际上就一个条件,前三个条件都是锁的基本特性.
循环等待是这4个条件里唯一一个和代码结构相关的,是我们程序员可以控制的.
避免死锁问题,打破必要条件即可,突破口就是循环等待.
办法:给锁编号.然后指定一个固定的顺序(比如从小到大)来加锁.
任意线程加多把锁的时候,都让线程遵守上述顺序,此时循环等待就会自然破除掉了.
这是解决死锁,最简单可靠的方式.