Java并发编程理论入门理解笔记

Java并发编程理论入门理解笔记

参考材料:极客时间《Java并发编程实战》–王宝令

并发编程Bug的源头

你我都知道,编写正确的并发程序是一件非常困难的事情,并发程序的Bug往往会诡异的出现,然后又诡异的消失。为了能够轻松并精准地解决并发编程中的Bug,了解这些Bug出现的源头是必须的。

计算机中有很主要的三个部分:CPU、内存和I/O设备,我们清楚的知道,其三者之间是存在速度的差异的。为了合理的利用CPU的高性能,平衡这三者的速度差异,计算机系统结构、操作系统以及编译程序都做出了方案去进行优化,使得我们的程序可以更加高效率地运行,但同时也会带来相应的问题。(这里我们就不得不提一件事情,任何的解决方案在解决当前问题的同时,也往往会引起其他的问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避)

在计算机系统结构中,我们引入了缓存,用于均衡内存和CPU之间的速度差异。但是在这个多核的时代,每一个CPU中都有自己的缓存,这个时候,CPU缓存与内存的数据一致性就没有那么容易保持了,简单来说就是两个CPU之间对各自的缓存中的变量进行的操作对另一个CPU是不可见的,这就带来的可见性的问题(一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性)。

在操作系统中,我们增加了进程、线程,以分时复用CPU,进而均衡CPUI/O设备的速度差异。操作系统允许某一个进程先执行一小段时间,然后待过了一段时间之后,操作系统会重新选择一个进程来执行,这个一段时间,我们就称为时间片。Java并发程序是基于多线程的,这自然也会涉及到任务切换。因为CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这就导致在Java并发编程的过程中出现原子性的问题(我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性)。

在编译程序中,我们会优化指令的执行次序,使得缓存能够得到更加合理的利用。但是由于编译程序对原有的语句的执行顺序进行了改变,在某些情况下会影响到程序的最终结果。这就是编译程序带来的有序性问题(有序性即程序执行的顺序按照代码的先后顺序执行)。

在这里我们以及清楚的了解了到了并发编程Bug的主要的源头:可见性、原子性和有序性。只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以理解、可以诊断的。那么我们该如何去解决这三个问题呢?

Java内存模型

Java内存模型(Java Memory ModelJMM)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。站在一个程序员的角度上进行理解,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatilesynchronizedfinal三个关键字,以及Happens-Before规则。所以说Java内存模型为我们解决可见性和有序性问题提供了帮助。

volatile关键字其实并非是Java语言特有的,在C语言里也有,它最原始的意义就是禁用CPU缓存。如果我们将一个变量使用volatile修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile语义增强是离不开Happens-Before规则,所谓Happens-Before规则要表达的是:前面一个操作的结果对后续操作是可见的。这里只说明以下的六项规则:

  1. 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作Happens-Before于书写在后面的操作。

  2. 管程锁定规则:一个unlock操作Happens-Before于后面对一个锁的lock操作。

  3. volatile变量规则:对一个volatile变量的写操作Happens-Before于后面对这个变量的读操作。

  4. 线程启动规则:Thread对象start()方法Happens-Before于此线程的每一个动作。

  5. 线程终止规则:线程中所有操作Happens-Before于对此线程的终止检测,我们可以通过Thread.join()方法和Thread.isAlive()的返回值等手段检测线程是否终止执行。

  6. 线程中断线程:对线程interrupt()方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt()方法检测到是否有中断发生。

  7. 对象终结规则;一个对象的初始化完成(构造函数结束)Happens-Before于它的finalize()方法的开始。

  8. 传递性:如果操作A``Happens-Before于操作B,操作B``Happens-Before于操作C,那就可以得出操作A``Happens-Before于操作C的结论。(这条规则更准确的说是Happens-Before的一个特性)

Java内存模型底层怎么实现的,主要是通过内存屏障禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。

互斥锁

我们已经明白了可见性和顺序性问题的解决办法,那么我们该怎么解决原子性问题呢。“同一时刻只有一个线程执行”这个条件就非常重要了,我们称之为互斥。

而谈到互斥,我们首先就会想到利用锁解决问题。我们把一段需要互斥执行的代码称为临界区。线程进入临界区之前,首先尝试加锁,如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁。

但是我们一定不能忽略两个问题:我们的锁是什么?我们保护的又是什么?这就一定要注意我们的锁和资源是有对应关系的。

所以最终的锁模型应该是这样的:对于我们要保护的资源都会有一个对应的锁,在进出临界区时都要添上加锁和解锁的操作。

这里要注意锁和资源之间是有关联关系的。通常受保护资源和锁之间的关联关系是N:1的关系。也就是用一把锁去保护多个资源(可以是一个资源)。不过值得注意的是,当我们要保护多个资源的时候,首先要区分这些资源的关联关系。

如果是保护没有关联关系的多个资源的话,若使用一把锁来进行保护,则会带来性能太差的问题,对此我们可以使用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁我们称之为细粒度锁。如果是保护有关联关系的多个资源的话,我们可以直接使用该类作为锁进行保护,但是这样会导致该类的所有相同的操作变成串行的,性能会变得很差,那我们又该怎么解决呢?我们可以将锁进行细粒化。使用细粒化锁可以提高并行度,是性能优化的一个重要的手段。

死锁

上述说到,可以使用细粒化锁对性能进行优化,但是同时也会导致死锁的出现。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

我们可以使用资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以用来描述资源和线程的状态)。其中资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程表示线程已经获得该资源,线程指向资源则表示线程请求资源,但尚未得到。

说回死锁,对于并发程序一旦出现死锁,一般我们就直接选择重启应用。因此,解决死锁问题的最好办法还是规避死锁。那么该怎么规避呢?

首先要了解死锁发生的四个条件:

  1. 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。

  2. 占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。

  3. 非抢占:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。

  4. 循环等待:有一组等待进程 {P0, P1,…, Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,…,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。

我们只要破坏这四个条件中的一个就可以成功避免死锁的发生。但是值得注意的是:锁为的就是互斥,所以该条件是没有办法破坏的。不过其他三个条件是可以被破坏的:

  1. 破坏占有并等待条件:我们可以一次性申请所有的资源,这样就不存在等待了。对此,我们可以将“同时申请”这个操作作为一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,我们就把这个角色定位Allocator。它有两个重要功能:一个是申请所有资源apply()和同时释放资源free()。然后对应的类中持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。

  2. 破坏占有并等待条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。对于这一点我们可以借助java.util.concureent这个包提供的Lock类。

  3. 破坏循环等待条件:可以靠按序申请资源来预防。所谓申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,在申请资源序号大的,这样线性化后自然就不存在循环了。我们可以对不同的对象进行编号,可以使用ID属性的大小等。

等待-通知机制

对于apply()方法的实现一般我们采用循环等待的方式,虽然apply()方法这样就可以用于破坏占有并等待的条件,但是存在一个问题,如果apply()方法操作耗时长,或者并发量冲突非常大,就有可能导致循环上万次才能获取锁,太消耗CPU了。

在这种场景下,最好的方案应该其实是采用等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。我们可以采用Java语言中内置的synchronized配合wait()notify()notifyAll()这三个方法来实现等待-通知机制。

我们先使用synchronized来实现互斥锁,并配有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区,当有一个线程进入临界区后,其他线程就只能进入等待队列中。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,组要进入等待状态。可以调用Java中的wait()方法,调用该方法后,当前的线程就会被阻塞,并且进入另一个等待队列中,这个等待队列也是互斥锁的等待队列。线程进入等待队列的同时,会释放持有的线程锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区。

当线程要求的条件满足时,就可以使用notify()notifyAll()方法,用以通知等待队列中的线程,告诉它们条件曾经满足过。因为这两个方法只能保证在通知时间点,条件是满足的。而通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很有可能条件已经不满足了。值得注意的是:wait()notify()notifyAll()这三个方法能被调用前提是已经获取了相应的互斥锁,所以都是在synchronized{}内部调用的。

在解决实际问题的时候,我们需要着重关注一下四个重要的因素:互斥锁、线程要求的条件、何时等待以及何时通知。我们可以采用以下的范式(范式,意味者经典做法)解决条件曾经满足的这个问题:


while (条件不满足) {
    wait();
}

另一个值得注意的是:在代码中,我们尽量使用notifyAll()来实现通知机制。

并发编程注意的问题

并发编程中我们需要注意的问题由很多,主要由三个方面:安全性问题、活跃性问题以及性能问题。

所谓线程安全,本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。而引发这样的问题,主要还是归因于并发Bug出现的三个主要源头:原子性问题、可见性问题和有序性问题。在编写代码的时候,只有一种情况需要认真分析这三个源头:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取保护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫作数据竞争。竞态条件,指的是程序的执行结果依赖线程执行的顺序。对于数据竞争和竞态条件这两个问题,我们可以采用互斥这个技术方案,实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。从逻辑上来看,我们可以同一归为:锁。

所谓活跃性问题,指的是某个操作无法执行下去。我们常见的死锁就是一种经典的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。活锁指的是:有时线程没有发生阻塞,但仍然会存在执行不下去的情况。一般解决活锁的时候,会采用将等待的时间设为随机值。所谓饥饿指的是线程因无法访问所需资源而无法执行下去的情况。而解决饥饿的问题有三种方案:一是保证资源充足,二是公平分配资源,三就是避免持有锁的线程长时间执行。

过度使用锁就有可能引发性能问题。我们可以使用无锁的算法和数据结构,或者减少锁持有的时间。性能方面有三个指标非常重要:吞吐量、延迟和并发量。

  1. 吞吐量:指的单位时间内能处理的请求数量。吞吐量越高,说明性能越好。

  2. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。

  3. 并发量:指的是同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。

管程

在Java中管程就可以认为是一把解决并发问题的万能钥匙。Java采用的是管程技术,synchronized关键字及wait()notify()notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓的等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以Java选择了管程。而所谓管程,指的是管理共享变量以及对共享变量的操作过程,让它们支持并发。

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hassen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型。

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作同一封装起来,对外只提供线程安全的操作方法。这一点管程模型和面向对象就是高度契合。管程在解决同步问题的时候,引入了条件变量的概念,每个条件变量都对应有一个等待队列。当线程进入管程内部时,如果条件不满足就会进入到对应条件变量的等待队列中,此时如果条件又一次满足时,就会得到通知,然后从条件等待队列里面出来,然后重新进入到入口等待队列中。

对于MESA模型,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的。与另两个模型的区别就是当条件满足后,如何通知相关线程。

  1. Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。

  2. Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。

  3. MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进入到入口等待队列里面。这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。但是也有一个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

再次声明:除非深思熟虑,否则尽量使用notifyAll()。当满足以下三个条件时,在使用notify():

  1. 所有等待线程拥有相同的等待条件

  2. 所有等待线程被唤醒后,执行相同操作

  3. 只需要唤醒一个线程

Java线程

通用的线程生命周期基本上可以用五态模型表示:初始状态、可运行状态、运行状态、休眠状态和终止状态。详细情况如下:

  1. 初始状态:指的是线程已经被创建,到那时还不允许分配CPU执行。这个状态仅仅是编程语言层面上线程被创建,但是操作系统层面上真正的线程还没有创建。

  2. 可运行状态:指的是线程可以分配CPU执行。这种情况下,真正的操作系统层面的线程已经被成功创建了。

  3. 运行状态:当一个可运行状态的线程被分配到CPU就转换为运行状态。

  4. 休眠状态:运行状态的线程如果调用一个阻塞的API或者等待某个事件,那么线程的状态就会转变为休眠状态。

  5. 终止状态:线程执行完或者出现异常就会进入休眠状态,也就意味着线程的生命周期结束。

对于不同的编程语言会对上述的生命周期进行简化合并,或者是细化。Java中就细化了休眠状态,共有六种状态:NEW(初始化状态)、RUNNABLE(可运行/运行状态)、BLOCKED(阻塞状态)、WAITING(无时限等待)、TIMED_WAITING(有时限等待)、TERMINATED(终止状态)。其中BLOCKEDWAITINGTIMED_WAITING这三种状态就是对休眠状态的细分,也可以说是导致休眠状态的三种原因。那么上述状态之间有时如何转换的呢?

  1. RUNNABLEBLOCKED的状态转换:只有当线程等待synchronized的隐式锁,才会触发这种转换,从RUNNABLE状态转换为BLOCKED状态。当等待的线程获得该隐式锁的时候就会从BLOCKED转换为RUNNABLE状态。

  2. RUNABLEWAITING状态的转换:三种场景:当调用无参数的Object.wait(),或者调用Thread.join()方法,或是LockSupport.park()方法。

  3. RUNNABLETIMED_WAITING状态的转换:五种场景:调用带超时参数的Thread.sleep(long millis)方法、调用带超时参数的Object.wait(long timeout)方法、调用带超时参数的Thread.join(long millis)方法、调用带超时参数的LockSupport.parkNanos(Object blocker, long deadline)方法、调用带超时参数的LockSupport.parkUntil(long deadline)方法。

  4. NEWRUNNABLE状态的转换:调用线程对象的strat()方法就可以了。、

  5. RUNNABLETERMINATED状态的转换:线程执行完run()方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。我们也可以直接调用stop()方法和interrupt()方法。(stop()方法会真的杀死线程,不给线程喘息的机会,不会调用释放锁的操作,是非常危险的,故不建议使用,且已经是过时的方法。interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以忽视这个通知)

使用多线程,本质上就是提升程序性能。所谓的提升性能,从度量的角度,主要是降低延迟,提高吞吐量。对应的方法,基本上有两个方向:一个是优化算法,另一个是将硬件的性能发挥到极致。后者就与并发编程息息相关。而计算机的主要硬件有两类:一个是I/O,一个CPU。简言之,在并发领域,提升性能本质上就是提升硬件的利用率,再具体点说,就是提升I/O的利用率和CPU的利用率。我们可以使用多线程来解决CPUI/O设备综合利用率的问题。对于CPU密集型计算,线程的数量一般会设置为“CPU核数+1”。对于I/O密集型计算,线程数 = CPU核数 * [1 + (I/O / CPU耗时)]。值得注意的是公式化性能是有些不妥的。

局部变量是线程安全的,因为方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经称为解决并发问题的一个重要的技术,同时还有个响当当的名字叫作线程封闭,比较官方的解释就是:仅在单线程内访问数据。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值