Java高并发程序设计(一)—— 前言

一、并行介绍

1. 为什么需要并行

(1) 业务需要

比方说有一个HTTP的服务器,它要去处理多个客户端的请求。一种比较通常的做法是对每一个客户端请求设置一个线程去做;当然也可以一个线程去处理多个客户端请求,但是需要处理一些多个客户端调度问题,对于代码实现有一些复杂的地方。又比如jvm,当java虚拟机启动后,其实java虚拟机运行了很多线程,有启动main函数的主线程还有gc线程和jit线程等等,这些线程共同维护了java虚拟机的运作,之所以使用这么多线程的原因,是因为业务模块需要,比如jit线程就是为了即时编译而存在,gc就是为了垃圾回收而存在,主线程main函数就是为了运行这些代码而存在。如果没有线程这个概念,那么不使用并行,那么我们怎么在一个串行的程序中,同时去实现这些功能,并且还能让它们比较自然的交替式的去做这些执行,因为它们之间的执行并不是一个执行完了去执行另一个,会有交叉。那么如果我们要完全自己去处理诸如此类的问题,那会是非常困难的,会涉及到很多的任务调度等等。那么使用线程就可以很好的帮我们解决这个问题,调度的问题交给操作系统去做,对我们应用层的开发来说,只需要简单的去处理一些业务上的模块就行了。因此使用并行,使用多线程的一个很重要的原因是因为业务上需要一个逻辑执行的执行单元。为了有这个执行单元,很自然的想到了线程。不用进程是因为进程太大,一个进程创建和销毁的开销,要比线程大得多。所以相对来讲,就使用更低开销的实体。

(2) 性能

使用多线程的程序在多核CPU上的性能一般来说比单线程要好一些。并且在现在java主要用于服务端编程,与并行计算的应用领域(图像处理和服务端编程这两个密集型计算领域)相符。

(3) 摩尔定律的失效

摩尔定律:预计18个月会将芯片的性能提升一倍。

如果将来CPU的内核越来越多,并行计算的需求越来越大。

(4) 并行计算还出于业务模型的需要

  并不是为了提高系统性能,而是在业务上确实需要多个执行单元。

  比如HTTP服务器,为每一个socket连接新建一个处理线程。

  让不同线程承担不同的业务工作

  简化任务调度

二、几个重要概念

 1. 同步(synchronous)和异步(asynchronous)

 Java高并发程序设计(一)—— 前言_第1张图片

 同步调用是对方法调用而言的,如果一个方法调用是同步的,那么在这条时间轴上,同步调用会等待时间返回,方法执行多久就会等待多久。异步调用会瞬间返回,异步调用会返回很快,但并不表示这个调用就完成了,它会在后台起一个线程,一般都是起一个线程,慢慢的做它的事情,所以说,异步调用就是说一个函数调用马上就能返回,但是返回后,我调用的请求并没有执行完成,但是不影响我继续做我下面的事情,我异步调用的工作内容会在另外一个线程当中慢慢去做,这就是异步调用跟同步调用的含义。

 2. 并发(concurrency)和并行(parallelism)

 Java高并发程序设计(一)—— 前言_第2张图片

 

并行:两个进程或线程,同时执行。

并发:两个线程或进程交替执行,有一个调度的过程。

对于单个CPU来讲,不可能出现并行的现象,因为单个CPU同一时间只能处理一个线程或进程,所以一定是分时的调度所有任务,但是对于多个CPU来讲,就是可以并行的执行,因为两个CPU,每个CPU做一件时间,这两个程序就是并行的执行。但是对于外在表象而言,无论是并行还是并发,我们看来都是同时执行的,所以一般来说,不用特别关注。

 3. 临界区

 临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只有一个线程使用它,一旦临界区资源被占用,其它线程想要使用这个资源,就必须等待。

 Java高并发程序设计(一)—— 前言_第3张图片

 

 对于多线程来说,临界区是一个非常重要的概念,我们提到临界区,就表示公共的资源,也就是说所有的线程都能够访问临界区,因为所有的线程都能够访问它,那当多个线程去访问临界区的时候,有可能会把临界区破坏掉,有可能A线程往临界区写了一条数据,还没有写完,这时候B线程往临界区写了一条数据,两个数据一叠加,就可能产生了一条错误的数据。因此临界区是一个需要被控制的区域。因为多个线程进入临界区,有可能把数据改坏掉,所以我希望每次只有一个线程能够进去,当这个线程进去在临界区之后,其它线程如果还想进到临界区里面来,就要进入阻塞队列,进行等待,直到临界区内的线程释放了这个资源(锁),才可以在这个等待队列里再取一个线程,进入临界区。所以,这里讲的是临界区控制方法,临界区本身就是控制资源,需要我们被保护的,当多线程访问的时候,我们需要额外关注它不被破坏的数据或者说是资源。

 4. 阻塞(blocking)和非阻塞(non-blocking)

 阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其它所有需要这个资源的线程就必须在这个临界区中进行等待,等待会导致线程挂起。这种情况就是阻塞。此时,  如果占用资源的线程一直不愿意释放资源,那么其它所有阻塞在这个临界区上的线程都不能工作。

 非阻塞允许多个线程同时进入临界区。

 阻塞和非阻塞通常用来形容多线程间的相互影响。如果说一个线程占用了临界区,其它线程不能再进去,这就是阻塞。因为其它线程要在临界区之外做等待。阻塞的含义就是说线程在操作系统层面被挂起。所以呢,阻塞的方式性能不会太好,根据一般统计,如果一个线程在操作系统层面被挂起,做了上下文切换,通常需要8万个时钟周期来做这件事情。所以这不是一个特别好的方法,但是这是一个特别简单的方法,因为我们把所有的责任都推脱给了操作系统,让操作系统帮助我们去调度,虽然说它的性能不高,但是它能做这件事情,能把这件事情做的很好,代价是花大约8万个时钟周期。

阻塞会有一个问题,如果有一个线程一致不释放资源,其它要使用临界区资源的线程都不能工作,被阻塞。非阻塞允许多个线程同时进入临界区。一个线程进入临界区后,其它线程也可以进去,只要保证数据不被改坏。

 5. 锁(deadlock)、饥饿(starvation)和活锁(livelock)

 Java高并发程序设计(一)—— 前言_第4张图片

死锁:作为阻塞的程序来讲,进入临界区是可能发生阻塞的,就有可能发生死锁的现象。上图中四辆小车就发生了死锁,每一个小车的一条路就认为是一个资源,A车需要B车占用的道路,但该道路已经被B车占用,以此类推,所以没有车可以继续往下走,除非把其中一个停止掉,否则没有一个能继续进行。所以死锁使得整个程序卡死掉,不再提供服务,如果系统出现了这个问题,把线程dump出来,就能找到这个问题。虽然死锁不是一个好现象,但是死锁是一个静态的问题,也就是说,一旦发生死锁,所有线程都卡死了,CPU占用也是零。所以它不会占用CPU,它会被调出去,这是一个静态问题,相对来说比较好分析。

 活锁:举一个例子,假设电梯门开了,电梯里面有一个人,电梯外面也有一个人,电梯里面的人要出来,电梯外面的人要进去。有可能碰到的问题就是,里面的人和外面的人面对面撞在了一起,两人把对方的路都给堵住了,因为门开的时候,谁也不知道对面的人站在哪里。这时,很自然的想法是避让,两人向着同样的方向避让,又把对方的路堵上了。这时,再向反向避让,又把对方的路堵上。以此往复,不停地堵上对方的路。作为线程,一个线程如果抢占到资源后,它发现另外的资源它没有办法拿到,这个时候,它为了避免死锁,因为死锁发生的必要条件是一个线程抢占到了资源而不释放资源,如果抢占了资源并且释放了就可以避免死锁,所以说,它为了避免死锁,因为它没有办法拿到所有资源,所以它就把自己已经占到的资源释放掉,不发生死锁。这时候,另外的一个线程也做了同样的事情,它们都需要同样的资源,一个线程抢到了资源A,一个线程抢到了资源B,然后它们发现都没有办法工作,把资源都释放出来了,这时候两个线程看到两个资源都释放出来了,一个线程抢占了资源A,另一个线程抢占了资源B,这时对方都占着另外的线程,都无法工作,这时两个线程又都释放了资源。以此往复,这就叫活锁。也就是说这个资源在多个线程间跳来跳去,也没有办法进行下去。活锁一旦发生,这个问题更难查。因为它是一个动态的问题,并不是像死锁一样,线程静在那里不动了,线程不停的在动,不停地在重试,但是不能成功或者很长时间成功,性能也会受到很大的影响。

饥饿:因为某种原因,比如你的线程可能优先级很低,别的线程优先级很高,所以调度的时候,调度不到你。比如多线程同时在一个临界区上,结果操作系统只调度优先级高的线程,因为我优先级很低,所以我就调度不到,我就不能继续往下执行,我就会被饿死,因为我分不到足够的资源。再比如,在做数据竞争的时候,我竞争这个数据,比如说做原子修改,原子操作,我总是失败,这种时候,我也可能被饿死,因为我不能往下再走。

 6. 并行的级别

 Java高并发程序设计(一)—— 前言_第5张图片

 

 一般认为,并发分为阻塞和非阻塞。对于非阻塞,我们可以进一步细分为无障碍、无锁、无等待。

 阻塞:当一个线程进入临界区后,其它线程必须等待。

无障碍:无障碍是一种最弱的非阻塞调度;自由出入临界区;无竞争时,有限步内完成操作;有竞争时,回滚数据。

无障碍是一种 非阻塞的调度,它并不要求一个线程进入临界区后,其它线程一定等待。它是说线程可以自由的进入临界区。无障碍是一种最弱的非阻塞调度。跟阻塞调度相比,阻塞调度可以认为它是一种悲观的策略,它会认为很多线程一起修改数据,很有可能把数据都改坏,所有每次只允许一个线程进入临界区修改数据。而非阻塞调度,相对来讲比较乐观,如果很多线程一起修改数据,也未必会把数据改坏,所以临界区可以放开把所有线程都进来,但是它是一种宽进严出的策略,进的时候所有的线程都可以进入临界区,做自己想做的事情,读也好,写也好,但是出来的时候就不一定了,如果它发现一个线程在临界区的操作遇到了数据竞争,跟别人产生了冲突,就会回滚这条数据,重试这个操作。比方说我们要去读取一个坐标系中的一对坐标X和Y,那么它先读X,再读Y,当它读到Y的时候,它发现有其它的线程修改了X,这个时候这个线程就会认为此时读到的Y是没有意义的,所以它会重试,再重新读取一次,直到读到的X和Y是没有问题的为止。所以它是一个会不断重试的调度策略,它有一个保证,如果没有数据竞争,这个线程必然它能够在有限步的时间当中,把任务执行完成,这个无障碍的调度当中,所有的线程都相当于拿去一个系统当前的快照,它们都在不断重试,直到拿到的数据是有效的为止。

无锁:是无障碍的,保证有一个线程可以胜出。

Java高并发程序设计(一)—— 前言_第6张图片

无障碍是所有线程都可以进入临界区,如果发生了竞争,它并不保证临界区中的线程能够顺利出来,因为它发现自己的数据每次去读取或者每次去操作总是跟别人产生了冲突,那么它就会不停地尝试不停地尝试,那如果在临界区当中有10个线程,线程1修改了部分数据,线程1被线程2干扰了,线程2被线程3干扰了,以此类推,线程1有可能最后去干扰线程10,如果它们之间是彼此干扰的,最终会导致它们所有的都卡死在里面,系统的性能会受到比较严重的影响。那为此,无锁必须在无障碍的上面加上一个约束,(无锁首先是无障碍的,所有的线程都首先能够进入这个临界区),保证在一次竞争当中,有一个线程是必然能胜出的,它就能保证在临界区中的线程至少有一个是能够顺利走出去的,而不是说全部在里面卡死掉,如果至少有一个线程能够出去,那么就能够有第二个线程出去,因为加入有100个线程竞争,有一个线程能胜利出去,剩下的99个竞争,又必然有一个线程胜利出去,再剩下的98个线程竞争,又必然有一个线程胜利出去,以此类推,使得系统能够顺畅的执行下去。

无等待:无锁的;要求所有线程都必须在有限步内完成;无饥饿的。

无锁是保证每次至少有一个线程在有限步骤内完成它的操作,其它线程不停的竞争,直到有一个线程胜出。无等待更进一步,无等待首先要求是无锁,它首先保证是能进,并且至少有一个能出,同时无等待再提高要求,它要求所有进入临界区内的线程,所有线程都能够在有限步内当中离开临界区完成操作,这个要求提的很高,意味着所有的线程都能够进入临界区,并且所有线程都能够在若干步当中离开临界区,完成你的操作,使得系统操作非常顺畅。无等待级别是并行的最高级别,它基本上是可以让整个系统发挥到最好的效率。无等待必然是无饥饿的,因为所有的线程都能够在有限步内完成,所以不会有线程在临界区内出不去。无等待典型案例,线程分为读写,如果只有读线程,没有写线程,那么所有的读线程之间必然是无等待的,因为所有的线程进来都能进,出去因为不会修改数据,所以数据一致,因此所有的读都是无等待的。但是如果有一个写线程在里面,写线程会修改数据,会导致读线程不是无等待的。可以提出一种算法进行改进,因为写可能会影响到读,所以每次写之前,把数据拷贝一份副本在我的写线程里面,写线程拿到一份副本,然后写线程修改这份副本,而不是修改原始数据,修改数据的过程可能需要一点时间,因为我修改的是写线程中的副本数据,不是原始数据,所以这个修改的过程也不影响线程读,因此在这种情况下,所有的读线程一样可以是无等待的,它们都能够在有限步内完成自己的操作,而所有的写线程相对来讲,因为每个线程都是写自己的副本,因此写线程也是无等待的,因为写线程不需要跟彼此间做同步,最后需要做同步的是,将写完成的数据覆盖掉原始数据而已,而这个覆盖原始数据的动作是非常快的,因为不需要做大量的操作,只不过是引用或指针做一个替换,不管哪个写线程胜出,总是能够保证替换上去的数据是一致的,而并不像其他的一些写的算法一样,可能会把数据写坏,因为不同的线程写的不同数据,因为都写副本,最终只是指针指向谁的问题,所以数据必然是安全的。无等待的实现相对来说比较麻烦,也会有一些技巧的内容比较多,相对来讲,无锁内容更为广泛。

三、两个重要定律

 1. Amdahl定律(阿姆达尔定律)

Java高并发程序设计(一)—— 前言_第7张图片

 

 Amdahl定律主要定义了加速比,当把一个程序改造成了并行程序之后,或者给一个程序使用多个CPU执行这个程序之后,到底把这个程序变得有多快。

加速比定义:加速比 = 优化前系统耗时/优化后系统耗时。

比如上图,优化前有5个步骤,每个步骤消耗100个时间单位,现在对步骤2和步骤5进行优化,两个步骤进行了并行操作,用两个CPU来执行,优化后的时间是400个时间单位。加速比就是1.25。

 2. Gustafson定律(古斯塔夫森定律)

 Java高并发程序设计(一)—— 前言_第8张图片

 

 Gustafson定律和Amdahl定律类似,说明处理器个数,串行比例和加速比之间的关系。程序执行时间分为两部分,串行时间和并行时间,程序执行需要花费多长时间呢,就是串行时间+并行时间(a+b)。系统总执行时间:串行执行的时候的总时间+(处理器个数*并行执行的总时间),即总执行时间=a+n*b,程序执行时间是指使用n个CPU对程序进行优化后的花费时间(a+b)。因此加速比为:加速前时间/加速后时间。

结论:只要有足够的并行化,那么加速比和CPU个数成正比。只要能够把程序足够的并行化,加速比和CPU个数成正比。

两个定律说明加速比角度不同,Amdahl定律说明,只加CPU没用,需要提高串行化比例。Gustafson定律说明,在串行化比例已经在的情况下,加CPU越多,程序性能越高。

综合两个定律结论:要想要程序提高性能,处理好n,处理好F。提高程序并行化比重,增加CPU。

 

你可能感兴趣的:(Java高并发程序设计(一)—— 前言)