JAVA并发理论总结

一、并发编程bug的源头(可见性、原子性、有序性)

CPU、内存、I/O设备的访问速度差异大,为提高计算机性能的利用,计算机做了以下三点:
1.CPU增加了缓存,平衡与内存差异。
2.操作系统增加了进程、线程、分时复用CPU,进而均衡CPU与I/O设备的速度差异。
3.编译程序优化指令执行顺序,使得缓存能够更加合理的利用同样的,这也为并发程序带来了三个问题:可见性、原子性、有序性。

可见性:一个线程对共享变量的修改,对另一个线程可见
现在的计算机处于多核时代,每颗CPU都有自己的缓存,这样与内存就带有数据不一致的问题,当线程A在CPU1将变量带入缓存进行+1,同时,线程B在CPU2也将变量读入缓存进行+1,我们的预期是变量+2,但最终的结果是变量+1。这就是没有考虑可见性带来的bug。

原子性:一个或者多个操作在CPU内不被中断的特性。
操作系统有了多线程,同时支持分时复用,一个进程在CPU执行一个时间片,时间片到点,记录数据,切换线程。这就带来了原子性的问题。线程A读取变量到缓存中,但这时CPU切换内存,线程A被阻塞,线程B读取变量,并修改了变量的值,然后唤醒了线程A,线程A并不知道变量已经被修改,仍旧继续执行修改变量操作,出现bug。

有序性:程序代码按照代码的先后顺序执行
编译器为了增加性能,有时优化代码的同时会改变代码的执行顺序,在单一线程这或许没有什么问题,但在并发的条件下这就有可能带来问题。比如线程A创建一个对象,对象的new操作在我们的理解是:1分配一个内存M,2在M中初始化对象,3将M的地址赋予变量,但编译器优化后顺序会变成:1分配一个内存M,2将M的地址赋予变量,3在M中初始化对象。倘若线程A运行完第二步,切换到线程B,线程B看到对象已经被创建(实际上只是分配地址),对对象进行运算,出现BUG。(这里有可能对有序性和原子性产生疑惑,若编译器没有优化,线程A执行完第二步,变量还是没有分配地址,那即使切换到线程B也不会对变量进行操作)

二、Java内存模型(解决可见性、有序性)

可见性是缓存带来的问题,有序性是编译优化带来的问题,解决它们的方法是禁用它们,但这会让性能下降,失去了并发的意义。这就需要内存模型

Java内存模型是一些很复杂的规范。简单说,规范了JVM如何按需禁用缓存和编译优化的方法。这些方法有violatile,synchronized和final,以及六项Happens-Before规范。

synchronized可以修饰代码块,方法。(理论篇)
final修饰的变量为常量,可以让编译器尽情优化,在1.5之后只要我们提供正确的构造函数不造成“逸出”,final常量就不会出什么问题。
(逸出:构造函数初始化还没完成就将对象赋予别人)

被violate修饰的变量值,是禁用缓存的,即变量的修改只能从内存层面上进行。但同样会带来一个问题,我不能什么变量都使用violate修饰,这样就会失去缓存的意义。
同时violate修饰的变量也会带来一个问题那就是可见性问题。(不是指被修饰的变量有可见性问题)。
对于程序员来说,内存模型重要的一部分就是Happens-Before规范,这个规范都是关于可见性的。
Happens-Before
程序的顺序性
在一个线程中某个变量的修改对后面的代码是可见的。v可以“看见”x=3了。
2. volatile变量规则
volatile的读操作可以看到前面对这个变量的写操作。
3. 传递性
若 A 被 B 可见 B被C可见 那么A被C可见
上面的代码,x对v可见,write()中对v的写操作对reader()中v的读操作可见,那么reader()中对v的读操作是可以看到x 的值,也就是说读到vtrue时,我们能看到x3,不管它是不是在缓存。这对于vioatile是加强的。
4.管程中锁的规则
对 一个锁的解锁操作 被对 同一个锁的加锁操作 可见
锁作为一个对象,我们在线程A中对锁的值做一个修改,那么当线程B也去获得这个锁时,它是可以看到这个锁的值被修改了。
5.线程start()规则
线程A启动线程B使用B.start()那么运行到B启动后,B是可以看见线程A启动B之前的操作。
6.线程join()规则
线程A等待线程B运行结束使用B.join(),当线程A被唤醒是可以看到线程B的操作。
Happens-Before规范解决的是可见性问题。
synchronized,final,violatile解决的是有序性。

三、互斥锁(解决原子性)

原则性是因为CPU切换线程,如果我们保证一个线程不被中断,是否就解决了原子性问题?显然没有,现在是多核时代,保证原子性我们需要保证一段代码同一时刻只有一个线程执行,这就是互斥。如果我们可以对保证对共享变量的修改操作是互斥的,那么就能保证原子性。

需要互斥执行的代码块成为临界区,线程进入临界区需要占有临界区对应的互斥锁,我们选择的锁最好与临界区有着对应关系。同时一个临界区对一个锁,就好球赛座位票一样,一个座位一个锁,若不然就变成了黑心资本家了(当然嵌套上锁是可以的)。一个锁可以锁住多个资源,但性能会下降,以为会导致分布代码执行变成串行,用不同的锁管理不同的资源实现精细化管理,这种锁叫细粒度锁。

当被锁资源多,且有关联性,那么我们就可能需要选择锁粒度更高的锁。
(原子性的本质就是操作的中间状态对外不可见)

锁最好是不变的,以为锁的值改变了,也意味着锁变了。
(加锁的本质就是在锁对象的对象头写上当前线程id,所以锁对象不能改变)

Java提供synchronized支持锁技术。synchronized支持锁方法和代码块
方法被synchronized修饰后,会变成互斥访问的,即同一时刻只有一个线程可以使用这个方法,那么这个方法的锁是什么呢?
若为静态方法,锁是当前类的Class对象。
若为非静态方法,锁是当前实例对象this。
(同一类的不同对象可以同时调动互斥方法,因为不同对象也意味着锁不同)

四、死锁问题

介绍了锁机制,那么就一定会出现死锁问题。

前面我们有提到锁粒度更高的锁性能差,但是又必须保证原子性。那么我们就对临界区不同资源加不同锁,嵌套加锁,但这同时也可能带来死锁问题,如加锁顺序为AB,但其他部分的代码加锁顺序为BA,那么倘若这两个部分代码同时执行,就会死锁。

死锁:一组互相竞争资源的线程互相等待,导致“永久阻塞”的现象。
死锁发生条件:(同时具备)
互斥:共享资源 X 和 Y 只能被一个线程占用。
占有且等待:线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X。
不可抢占:其他线程不能强行抢占线程 T1 占有的资源。
循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程T1 占有的资源,就是 循环等待。

破坏其中一个条件就可以破坏死锁。

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待 。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
    (Java的java.util.concurrent包中提供的lock)
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性 化后自然就不存在循环了

前面我们举了例子AB,BA顺序加锁问题,按照破坏第三条,只要我们选择一个标准,让加锁的顺序是固定的即可。即申请A,B两部分资源必须按照A->B的顺序执行,就不存在循环等待。

五、线程协作:等待-通知

在破坏死锁条件的1中,申请所有资源,可能很多人认为使用循环直到获取所有资源,但这种方式消耗的CPU资源太多了,其实可以使用我们熟悉的生产者-消费者模型去思考得到,等待-通知。

等待-通知:线程A首先获取互斥锁,当条件不满足时,释放互斥锁,进入等待队列;其他线程获得互斥锁,当线程A所需条件满足时,其他线程唤醒线程A,线程A重新等待互斥锁。

等待-通知的实现,Java提供的synchronized,wait,notify,notifyall可以实现这一机制(具体实现不做介绍)

尽量使用notifyall,避免部分线程永远不被通知。
有资源A,B,C,D,线程1占有AB,线程2占有CD,线程3申请AB,进入等待,线程4申请CD,进入等待,线程1执行结束唤醒线程,若使用notify,随机抽取一个线程执行,如果选到线程4,线程4不需要AB,继续等待,那么线程3就不会再有唤醒的可能了。

(wait会释放锁,sleep不会)

我们遇到的线程问题很多,总共分为三种:安全性,活跃性,性能问题。
安全性:前面我们以及提到的代码可见性,原子性,有序性。并不是所有代码都要考虑是否符合这三点,要不然工作量会飞涨。只有存在共享数据,并且数据会改变的时候需要考虑,即多个线程对同一数据读取并修改,需要考虑这三点。

    多个线程同时访问同一数据会发生两个问题,数据竞争,竞态执行。
    数据竞争我们可以采取加锁的方法,但不能避免所有问题,还有竞态执行,指的是程序的执行结果依赖线程的执行顺序(比拼运气)。两个线程完全同时执行(避开了简单加锁),那么两者的结果就只能指望它们最后的执行顺序,这明显不具有稳定性。因此,我们需要更加安全的加锁模式。

活跃性:指的是部分操作无法执行,不“活跃”,比如之前提到的死锁,还有活锁和饥饿。
如果死锁可以看作两个贪婪的人不断争抢,不愿放手,那么活锁就是两个谦虚的人互相谦让,两个线程不断“谦让”那最后谁都没有占有资源。
解决活锁的方法也很简单,就是设置一个随机时间,线程谦让随机时间,然后再去尝试占有资源。
饥饿指的是,线程因无法访问所需资源一直无法进行。1,保证资源充足,2公平分配资源,3,避免持有锁的线程长时间运行。可以使用公平锁,即先来后到锁。
性能问题:锁的概念好像一直贯穿并发编程,但是也会带来性能的问题。锁的过度使用是会导致川航化,带来性能问题。JavaSDK并发包,就是在解决部分并发区域的性能问题。一、使用无锁的算法和数据结构(线程本地存储,写入时复制,乐观锁),二、减少锁持有时间(Java并发包中的ConcurrentHashMap,读写锁)
性能的衡量标准:吞吐量、延迟、并发量。

六、并发万能钥匙——管程

操作系统解决并发问题的方法是信号量,而Java选择更易于面对对象实现的管程。
管程和信号量是等价的,信号量既可以实现管程,管程也可以实现信号量。

管程指管理共享变量的操作过程。(管程在操作系统中处于用户态)
管程模型MESA。
MESA模型解决互斥问题:将共享变量及其对共享变量的操作统一封装起来,只提供几个方法,并且在这些方法上面加锁,线程通过这些方法互斥的访问共享变量里的内容,是否和面对对象的封装相似,这或许就是Java选择管程的原因。

MESA模型解决同步问题:线程从入口获得锁进入管程,当在线程A需求条件不满足,线程A进入条件变量的等待队列并释放入口锁,线程B进入管程若线程A的条件变量满足,则唤醒该变量等待队列中的线程,线程A离开管程,重新等待入口锁。线程A重回管程里,这段时间内线程A所需条件不一定仍是满足的,并且线程A会在wait()的地方开始执行,并不是cong管程的第一行代码开始执行,所以wait()操作要用while循环框起来。

Java内部的管程方案只能放一个条件变量,而Java SDK包中的支持多个。
并发编程的核心问题互斥和同步都可以用管程解决,所以管程是Java并发编程的万能钥匙。

七、Java并发的实现——线程

Java线程的生命状态:
NEW(初始化状态)
RUNNABLE(可运行 / 运行状态)
BLOCKED(阻塞状态)
WAITING(无时限等待)
TIMED_WAITING(有时限等待)
TERMINATED(终止状态)

它们的关系如下
Runnable->Blocked:线程等待synchronized
Runnable->Wating:1.获得锁后调用wait()方法。2,等待其他线程的结束(线程名.join()),3LockSupport.park()方法
Runnable->Timed_Waiting:sleep(),wait(参数),join(参数),
LockSupport.parkNanos(Object blocker,long deadline)
LockSupport.parkUntil(longdeadline)。
New->Runnable:start()
Runnable->Terminated:线程运行结束,stop(),interrupt()。(stop和interrupt的差别是stop方法无视线程的部分锁无释放,直接杀死,导致部分类型的锁无法释放。interrupt则是通知线程,线程有一定时间释放锁)
(可以使用线程dump来追踪并发bug,而线程的状态就是很好的依据。)

我们都知道并发是通过线程实现的,那么我们创建多少线程才算合适呢?
线程的本质就是提升效能,效能的提升有两种方法,一、算法优化 二、CPU与I/O利用率。
对于CPU密集型计算:线程的数量可以尝试设置成CPU核数+1
对于I/O密集型计算:线程数量CPU核数*[1+(I/O耗时 / CPU耗时)]

线程的数量并没有一个能直接计算出来的值,需要我们根据项目的类型进行尝试,公式只能作为一个计算的锚点,让我们离最优解相对近一些。

我们前面提到原子性的本质操作的中间状态对外不可见,因此方法中的局部变量也是线程安全的。

Java虚拟机会把方法放到Java方法栈中,一个线程就是一个栈,一个方法就是一个栈帧,当我们在方法A中调用方法B,在方法B中调用方法C(类似递归),那么每个方法就会作为一个栈帧,压入栈内,而方法里的局部变量就是栈帧中的值,局部变量和方法是同生共死,同时局部变量不会超出自身所在栈帧,因此局部变量不会共享,既然没有共享,就没并发bug。
我们创建的对象在堆上,局部变量在栈上,是因为堆上的对象可以越界访问栈帧上的局部变量。

这样的思路叫做线程封闭,变量只在所处线程上存在,不和其他变量共享,因此线程安全。在JDBC中也有这又的应用。数据库连接池保证,一个Connection对象被一个线程捕获后,不会再分配给其他线程,这样Connection就不会有并发问题。

八、面向对象的并发程序

利用封装,将共享变量封装作为对象属性封装在内部,对所有公共方法指定并发访问策略(互斥的访问公共方法)(管程MESA模型)。这就好比锁是座位票,位置是方法或代码块,但总要有些保安在门口检查座位票,要不然门票将变得没有意义。

共享变量间的条件设置,许多共享变量之间有条件限制,诸如,库存下限比上限小等。那么在代码设计的时候我们加上if条件,但又会因此出现竞态执行的问题。
因此我们在设计共享变量的时候,一定要注意它们之间的制约条件,同时避免竞态执行。

指定并发访问策略:1,避免共享 2,不变模式 3管程及其他并发工具
在使用其他并发工具时要注意:1,使用成熟的并发工具 2迫不得已才使用低级的同步原语 3避免过早优化。

注意事项:
锁应该是私有的,不可变的,不可重用的。
封装共享变量,控制并发访问路径,可以解决竞态执行
在触发 InterruptedException异常的同时,JVM 会同时把线程的中断标志位清除,因此在catch块重新设置,即重新调用interrupt方法。

猜你喜欢

转载自blog.csdn.net/weixin_43372169/article/details/110449264