1.多线程带来的风险(线程安全)
我们首先运行下面的代码,我们明明自增了10w次结果显示为0,说明main线程先执行了打印,那该如何解决这一问题,我们可以加t.join使main线程等待,先把t1、t2执行完
但是此时的结果和我们预期的结果不一样,这样的代码很明显有bug,实际执行的效果和预期的效果不同就是bug,这样的问题多线程并发执行引起的问题,如果把两个线程变成串行执行就会得到预期的结果
很明显,当前bug是由于多线程的并发执行代码引起的bug,这样的bug就称为线程安全问题或者叫做"线程不安全",反之如果一个代码在多线程的环境下,也不会出现类似上述的bug这样的代码就叫“线程安全”
接下来我们来解析count++这条指令,这个操作看起来是一行代码实际上对应到三个CPU指令
1.load把内存中的值(count变量)读取到CPU寄存器
2.add,把指定寄存器的值进行+1操作(结果还是在这个寄存器中)
3.save,把寄存器中的值写回到内存中
cpu执行这三条指令的过程中,随时可能触发线程调度的切换,
比如12线程切换走……线程切换回来3 等等多种情况
由于操作系统调度是随机的,执行任何一个指令的过程中都可能触发上述的“线程切换”操作。
接下来我们可以通过画图的方式来模拟cpu上的操作
错误调度方式:
那上面的代码编译是都都是大于5w有没有可能小于5w?
答案是可能小于5w,有时候可能进行多次结果还是1,如下图所示
当前这两个线程各自自增5w次,如果t1第一次自增过程中t2完成了5w次自增,t1还剩下49999次自增,最低我们是无法预测的,但是我们可以知道是会出现少于5w次的情况。其实不管是自增100次还是10w次,都有可能出现线程不安全的问题,只是概率的大小问题。如果循环一共100次就算不串行加入join输出的结果还是100,但是也有可能出现少于100的情况只是概率变小了。同时因为一次自增50次次数比较小,有可能在执行t2.start()之前t1就算完了,等后续t2再执行就变成串行的了。串行执行就是一个线程完全执行完成之后再执行另外一个线程。
2.线程安全问题产生的原因
通过上面的示例我们可以知道线程安全问题产生的原因有以下几点
1.根本原因是:操作系统对线程的调度是随机的,抢占式执行的
2.多线程同时修改一个变量,抢占式执行策略最初诞生多任务操作系统的时候,非常重大的发明,后世的操作系统都是一脉相承的,如果是多个线程读取同一个变量那就没有问题,因为读取操作是读入已经操作好的数据
3.修改操作不是原子,原子性指的是不可再分的情况,如果修改操作只是对应一个CPU指令,就可以认为是原子性的。同时java中的++、--、+=、-=也不是原子性操作,但是在java中“ = ”就是原子性的操作,但是在C++中就不一定了
4.内存可见性问题引起的线程不安全问题
5.指令重排序引起的线程不安全
3.那如何解决线程安全问题呢?
1.关于系统的随机调度问题我们是无法解决的,这是操作系统的底层设定我们也左右不了
2.多个线程同时修改同一个变量的问题,这点是和代码结构相关,我们可以调整代码结构规避一些线程不安全的代码,但是这样的方法并不通用,有些情况需求上就是需要多线程同时修改同一个变量。但是java中的String就是采取“不可变”特性确保线程安全问题,那String是咋实现不可变的效果的呢?String里面没有提供public的修改方法和final没有任何关系,String的fian用来实现‘不可继承’
3.关于第三点:修改操作不是原子的。Java中解决线程安全问题最主要的方案就是加锁,通过加锁让不是原子的操作打包成原子的操作,计算机中的锁和生活中的锁一样互斥排他 。一旦把锁锁上其他人想加锁就得阻塞等待,计算机中不允许暴力拆锁,只能阻塞等待。那解决上述问题我们就可以通过在count++之前加锁,之后再进行count++计算完毕之后再解锁,加锁之后执行三步的过程中其他线程就没法插入了。
2.1关于锁
加锁解锁本身就是操作系统提供的api,很多编程语言都是对于这样的api进行封装了,大多数的封装风格都是采取两个函数。在java中使用synchronized这样的关键字搭配代码块来实现效果。
关于锁在代码中的使用
上述代码加锁之后的实现:
关于加锁我们需要注意的是:
1.两个线程针对同一个对象加锁才能产生互斥效果(一个线程加锁之后另一个线程就得阻塞等待,等待第一个线程释放锁才有机会),如果是不同的对象,此时不会产生互斥效果,线程安全问题没有得到改变。
2.线程安全问题不是你写了synchronized就可以,而是要正确的使用锁。synchronized()代码块要合适,synchronized()指定锁对象也得合适
2.2 synchronized 变种写法
这里面的锁加在this对象里面,说明是同一个对象Counter,此时也是加锁成功的。当然我们也可以把synchronized加在方法外面也是同样的效果。同时像StringBuffer、Vector这些对象方法上就是带有synchronized 也是针对this进行加锁。
同样的效果:
StringBuffer内部带有Synchronized:
当然这样的用法也存在一些特殊的情况,static修饰的方法不存在this,此时synchronized修饰方法,相当于针对类对象加锁
2.3关于死锁的问题
看起来是两次一样的加锁很没必要,但是在实际开发中,就很容易写出这样的代码。这样会产生阻塞等待,此时我们要等到第一次加锁释放,第二次加锁的阻塞才会接触(才能够继续执行)。一旦调用的层次比较深就会出现死锁的问题。
两次加锁:
关于死锁的定义:按照之前对锁的设定,第二次加锁的时候就会产生阻塞等待,直到第一次的锁被释放,才能获得到第二个锁,但是释放第一个锁也是由该线程完成的,结果线程躺平了,啥也不干,也就无法进行解锁这样的操作,这时就会产生死锁的现象。
但是在java中为了很好的解决这些问题,在synchronized中引入了可重入的概念,但是在c++中不具有这样的特性很容易就产生死锁的现象。
引入可重入加锁这样的概念之后,当某个线程针对一个锁加锁成功后,后续线程再次针对这个锁进行加锁,不会触发阻塞等待,而是直接往下走,因为当前这把锁就是被这个线程持有的,但是如果其他线程尝试加锁就会正常阻塞。
可重入加锁的实现原理,关键在于锁对象内部保存,当前是哪个线程持有的这把锁,后续有线程针对这个锁进行加锁的时候,对比一下锁持有者的线程和当前加锁的线程是不是同一个。
针对一个线程多次加锁的过程synchronized采用计数的方法来进行快速解锁的操作,如图所示:
如何自己实现一个可重入锁(重点)
1.在锁内部记录当前哪个线程持有锁,后续每次加锁都进行判断
2.通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁
2.4关于死锁
1.前面引入可重入锁,在java中一个线程加锁多次根本不会出现死锁的现象,但是,两个线程两把锁,每个线程都获取到一把锁之后,尝试获取对方的锁,这样其实是会出现死锁的现象。如下图所示:
上述代码就是出现死锁这样的现象,什么也没打印但是也没有显示进程结束。 此时在JDK中的环境显示就会看到blocked这样的字眼,这就是因为竞争锁而产生阻塞的缘故。
同时这里的死锁是必须拿到第一把锁之后,再尝试拿到第二把锁(不能释放第一把锁),那如果上述代码不加sleep是否会出现一样的现象。如果没有sleep那么t1可能拿到了objiect1 和objiect2,这个时候t2都还没动,自然无法构成死锁。
2.接着我们来讲一下第二种死锁的现象,N个线程M把锁的现象。
此时我们可以举一个哲学家就餐的问题,一个桌子围着五个哲学家同时桌子上摆放着五根筷子和一碗面条,哲学家的状态一是思考人生(放下筷子)二是吃面条(拿起筷子),5个哲学家随机触发吃面条和思考人生,5个哲学家就相当于是5个线程,5根筷子就相当于5把锁,每个线程只需要拿到其中的两根即可,大部分情况下,上述模型可以很好的运转在一些极端的情况下会造成死锁的现象,比如同一时刻大家都想吃面条,同时拿起左手的筷子,此时任何一个哲学家都无法拿起右手的筷子,任何一个哲学家都吃不成面条,此时就出现死锁的现象。
2.5如何避免死锁
那如何避免代码中出现死锁呢?首先我们需要知道构成死锁的条件是什么从而一一化解
1.锁是互斥的(锁的基本特征),一个线程拿到锁之后另一个线程再尝试取锁必须阻塞等待
2.锁是不可抢占的,线程一拿到锁线程二也尝试拿到这个锁,线程二必须阻塞等待,线程二不能直接剥夺过来,这也是锁的基本特性
3.请求和保持,一个线程拿到锁1之后,不释放锁1的前提下获取锁2,如果先放下左手筷子再去获取右手筷子,就不会构成死锁。因此解决这种情况使用的方法就是不要去嵌套,但是这种办法的通用性不够,有些情况确实是需要拿到多个锁,再进行某个操作。
4.循环等待,多个线程多把锁之间的等待,构成了循环,A等待B,B也等待A或者A等待B,B等待C…… 但是如果约定好加锁的顺序就可以破除循环等待
2.6关于死锁的小结
1.构成死锁的场景
- 一个线程多把锁,通过可重入锁解决
- 两个线程两把锁互相获取对方的锁,我们需要了解如何去编写这样的代码
- N个线程M把锁,我们可以通过举例哲学家就餐的问题
2.死锁的必要条件
- 锁是互斥
- 不可剥夺
- 请求和保持
- 循环等待
3.如何避免死锁
- 把嵌套的锁改成并列的锁
- 加锁的顺序做出约定