高并发程序设计入门

出处:CSDN博客“小黑手600”

网址:http://blog.csdn.net/johnstrive


说在前面
本文绝大部分参考《JAVA高并发程序设计》,类似读书笔记和扩展。


走入并行世界
概念


同步(synchronous)与异步(asynchronous)
同步和异步通常来形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续执行任务。


异步方法更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的工作。异步方法通常会在另外的线程中“真实”的执行。整个过程不会阻碍调用者的工作。


并发(concurrency)和并行(parallelism)
链接:并发Concurrent与并行Parallel的区别

http://blog.csdn.net/johnstrive/article/details/50633852


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


在并行程序中。临界区资源是保护对象。就比如大家公用的一台打印机,必然是一个人打完另一个人的才能打印,否则就会出乱子。


阻塞(blocking)与非阻塞(non-blocking)
阻塞和非阻塞通常来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程都需要在临界区中等待。等待会导致线程挂起,这种情况就是阻塞。此时如果占用这个资源的线程一直不愿释放资源,那么其他所有阻塞在这个临界区上的线程都不能工作。反之就是非阻塞,它强调没有一个线程可以妨碍其他线程执行。所有线程都会尝试不断前向执行。


死锁(deadlock)、饥饿(starvation)和活锁(livelock)
这三种情况都属于线程活跃性问题。如果发现上述情况,那么相关线程可能就不再活跃,也就是说它可能很难再继续执行任务了。


1 死锁:应该是最糟糕的情况之一。它们彼此相互都占用着其他线程的资源,都不愿释放,那么这种状态将永远维持下去。


死锁是一个很严重的问题,应该避免和小心。就如4辆小汽车,互相都占用对方的车道,无法正常行驶。



2 饥饿
是指一个或多个线程因为种种原因一直无法得到所需要的资源,导致一直无法执行,比如它的线程优先级太低,高优先级的线程一直抢占它所需要的资源。另一种可能是某一个线程一直占用着关键资源不放,导致其他需要这个资源的线程一直无法得到这个资源,无法正常执行。与死锁相比,饥饿还是可能在一段时间内解决的,比如高优先级的线程执行完任务后,不在抢占资源,资源得到释放。

3 活锁
是非常有趣的情况,也是最难解决的情况。这就比如,大家在一个两人宽的桥上走路,双方都很有礼貌。都在第一时间礼让对方,一个往左一个往右,导致两人都无法正常通行。放到线程中,就体现为,两个线程都拿到资源后都主动释放给他人使用,那么就会出现资源不断的在两个线程中跳动,而没有一个线程可以拿到资源后正常执行,这个就是活锁。


并发级别
由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别进行分类,大致上可以分为阻塞、无饥饿、无障碍,无锁和无等待几种。


阻塞(blocking)
一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字,或者重入锁时,我们得到的就是阻塞的线程。
无论是synchronized还是重入锁,都会在视图执行后续代码前得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需要的资源为止。


无饥饿
如果线程间是有优先级的,那么线程调用总是会倾向于满足高优先级的线程。也就是说对同一个资源的分配是不公平的。对于非公平的锁来说,系统允许高优先级的线程插队,这样有可能导致低优先级的线程产生饥饿。但如果锁是公平的,满足先来后到,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源就必须排队。那么所有的线程都有机会执行。


无障碍(obstruction-Free)
无障碍是一种最弱的非阻塞调度。两个线程如果是无障碍的执行,那么他们不会因为临界区的问题导致一方被挂起。大家都可以大摇大摆进入临界区工作。那么如果大家都修改了共享数据怎么办呢?对于无障碍的线程来说,一旦出现这种情况,当前线程就会立即对修改的数据进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。


如果阻塞控制的方式比喻成悲观策略。也就是说系统认为两个线程之间很有可能发生不幸的冲突,因此,保护共享数据为第一优先级。相对来说,非阻塞的调度就是一种乐观策略,他认为多线程之间很有可能不会发生冲突,或者说这种概率不大,但是一旦检测到冲突,就应该回滚。


从这个策略来看,无障碍的多线程程序不一定能顺利执行。因为当临界区的字眼存在严重的冲突时,所有线程可能都进行回滚操作,导致没有一个线程可以走出临界区。所以我们希望在这一堆线程中,至少可以有一个线程可以在有限时间内完成自己的操作,至少这可以保证系统不会再临界区进行无线等待。


一种可行的无障碍实现可以依赖一个“一致性标记”来实现。线程在操作之前,先读取并保持这个标记,在操作完后,再次读取,检查这个标记是否被修改过,如果前后一致,则说明资源访问没有冲突。如果不一致,则说明资源可能在操作过程中与其他写线程冲突,需要重试操作。任何对保护资源修改之前,都必须更新这个一致性标记,表示数据不安全。


无锁(lock-free)
无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区的资源进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。
在无锁的调度中,一个典型的特点是可能会包含一个无穷循环。在这个循环中线性不断尝试修改共享数据。如果没有冲突,修改成功,那么线程退出,否则尝试重新修改。但无论如何,无锁的并行总能保证有一个线程可以胜出,不至于全军覆没。至于临界区中竞争失败的线程,则不断重试。如果运气不好,总是不成功,则会出现类似饥饿的现象,线程会停止不前。


无等待(wait-free)
无锁是要求至少有一个线程在有限步内完成操作,而无等待则是在无锁的基础之上进一步扩展。他要求所有线程都必须在有限步内完成操作。这样就不会引起饥饿问题。如果限制这个步骤上限,还可以分为有界无等待和线程无关的无等待几种,它们之间的区别只是对循环次数的限制不同。


一种典型的无等待结构是RCU(read-copy-update)。它的基本思想是,对数据的读可以不加控制,因此所有读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据时,先取得原始数据的副本,接着只修改副本数据,修改完后,在合适的时机回写数据。


有关并行的两个重要定律
Amdahl定律
加速比定义:加速比= 优化前系统耗时/优化后系统耗时


根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果取决于CPU的数量以及系统中串行程序的比重。CPU数量越多,串行化比重越低,则优化效果越好。仅提高CPU核数不降低系统串行程序比重,也无法提高系统性能。


Gustafson定律
根据Gustafson定律,我们更容易发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断增加CPU核数,就可以提高系统性能。


JAVA内存模型(JMM)
由于并发程序要比串行程序复杂的多,其中一个重要的原因是并发程序下数据访问的一致性和安全性将受到严重的挑战。因此我们需要在深入了解并行机制之前,再定义一种规则,保证多线程程序可以有效的,正确的协同工作。而JMM也就为此而生。JMM的关键技术点都是围绕多线程的原子性、可见性和有序性来建立的。


原子性(atomicity)
指一个操作是不可中断的。即使多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。


比如对一个静态变量int i赋值,A线程赋值1,B线程赋值-1,那么这个变量i的结果可能是1或者-1,没有其他情况。这就是原子性。


但如果是给一个long型赋值的话就没那么幸运了。在32位系统下,long型数据的读写不是原子性的(因为long有64位)。


在32位的java虚拟机上运行如下例子,就会出现非原子性的问题了。


理想的结果可能是什么都不输出,但是,一旦运行,就会有大量的输出一下信息



我们可以看到读取线程居然读取到不可能存在的数据。因为32为系统中的long型数据的读和写不是原子性的,多线程之间互相干扰了。


如果我们给出结果中几个数值的2进制,大家就会更清晰的认识了。



上面这几个数值的补码形式,也是在计算机内真实存储的内容。不难发现4294966852其实是111或333的前32为夹杂着-444的后32位的数据。而-4294967185其实是-999或-444夹杂111后32位的数据。换句话说,由于并行的关系数字被写乱了。或者读的时候读串位了。
通过这个例子,大家应该对原子性应该有基本的认识。


可见性(visibility)


可见性是指当一个线程修改了一个共享变量。其他线程是否可以立即知道这个修改。对于串行程序来说这个问题是不存在的。但这个问题在并行程序中就很有可能出现。如果一个线程修改了某一个全局变量。其他线程未必可以马上知道这个修改。如果CPU1和CPU2上各运行了一个线程,它们共享变量t。由于编译器优化或者硬件优化缘故。在CPU1上的线程将变量t进行了优化,将其缓存在cache中或者寄存器里。这种情况下如果CPU2上的某个线程修改了t的实际值,那么CPU1上的线程可能就无法意识到这个改动,依旧会读取cache或者寄存器中的旧值。因此就产生了可见性的问题。可见性问题在并行程序中也是需要重点关注的问题之一。


可见性问题是一个综合性问题,处理上述提到的缓存优化和硬件优化会导致可见性问题外,指令重排以及编译器的优化,都有可能导致这个问题。


附两个例子便于理解可见性问题。



有序性(ordering)

有序性是三个问题中最难理解的,对于一个线程的执行代码而言,我们总是习惯性的认为代码的执行是从先往后的,依次执行的。这么理解也不能完全说是错误的。在一个线程的情况下确实是从先往后。但是在并发时,程序的执行就可能出现乱序,写在前面的代码可能会后执行。


有序性的问题的原因是因为程序在执行的时候,可能发生指令重排,重排后的指令和原指令的顺序未必一致。


指令重排有一个基本的前提是,保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此在串行代码中不必担心这个问题。而在多线程间就无法保证了。


so,问题来了。为什么会指令重排呢?这完全是基于性能考虑。


我们知道一条指令的执行是可以分很多步骤的。简单的说可以分如下几步:


  • 取指 IF

  • 译码和取寄存器操作数 ID

  • 执行或者有效地址计算 EX

  • 存储器访问 MEM

  • 回写 WB


我们的汇编指令也不是一步就执行完了。在CPU的实际工作中,还是要分几步去执行的。当然,每个步骤涉及的硬件也可能不同。比如,取指会用到PC寄存器和存储器,译码会用到指令寄存器组,执行会使用ALU(算术逻辑单元(arithmetic and logic unit) 是能实现多组算术运算和逻辑运算的组合逻辑电路,简称ALU。主要功能是二进制算数运算),写回时需要寄存器组。


由于一个步骤可能使用不同的硬件完成,因此,就发明了流水线技术来执行指令。


  • 指令1 IF ID EX MEM WB

  • 指令2 IF ID EX MEM WB


可以看到,到两条指令执行时,第一条指令其实还未执行完,这样的好处是,假设每一步需要1毫秒,那么第2条指令需要等待5毫秒才能执行。而通过流水线技术,指令2就只需等待1毫秒。这样有了流水线就可以让CPU高效的执行。但是,流水线总是害怕被中断。流水线满载的时候性能确实相当不错,但是一旦中断,所有硬件设备都会进入停顿期,再次满载又需要几个周期,因此性能损失会比较大,所以我们就需要想办法来不让流水线中断。


之所以需要指令重排就是避免流水线中断,或尽量少的中断流水线。当然指令重排只是减少中断的一种技术,实际上CPU设计中,我们还有更多的软硬件技术来防止中断。具体大家就自己探究吧。


通过例子我们加深下理解。
示例 1 :
A = B + C执行过程。
左边是汇编指令,LW表示load,其中LW R1,B表示把B的值加载到R1寄存器中。ADD就是加法,把R1,R2的值想加放到R3中。SW表示store,就是将R3寄存器的值保存到变量A中。



左边是指令由上到下执行,右边是流水线情况。在ADD上的大叉表示一个中断。因为R2中的数据还没准备好,所以ADD操作必须进行一次等待。由于ADD的延迟,后面的指令都要慢一拍。


示例 2 :



执行过程如下:


其实就是将中断的时间去做别的事情,如load数据。这样时间就可以规划衔接好。有点儿像项目管理中优化关键路径。由此可见,指令重排对于提高CPU处理性能是十分必要的,虽然确实带来了乱序的问题,但这点儿牺牲完全值得的。


哪些指令不能重排:
虽然java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的。

  • 原则基本包括以下:

1  程序顺序原则:一个线程内保证语义的串行性



2  volatile规则:volatile变量的写,先发生与读,这保证了volatile变量的可见性。
3  锁规则:解锁(unlock)必然发生在随后的加锁(lock)前


4  传递性:A先于B,B先于C,那么A必然先于C

5  线程的start()方法先于它的每一个动作
6  线程的所有操作先于线程的终结(Thread.join())
7  线程的中断(interrupt())先于被中断线程的代码
8  对象的构造函数执行、结束先于finalize()方法


基础
线程生命周期



线程所有的状态都在Thread.State枚举类中定义:




线程的基本操作
启动初始化及基本方法

参考多线程基础:http://blog.csdn.net/johnstrive/article/details/50601534#t97


终止线程
一个线程执行完后会结束,无须手动关闭,但是如一些系统性服务的线程基本都是一个大的循环,一般情况不会终止。


如何才能正常关闭线程呢?JDK提供了一个Thread.stop方法就可以立即关闭一个线程。但是这个方法太暴力,基本不会使用。并且stop()方法也是标记要废弃的方法。stop()强行的将执行中的线程关闭,可能会造成数据不一致问题。


看图说话:

举个栗子:


如何正确的stop,如何不写坏对象,请看修改后的代码如下,我们采用自己的方式去达到线程stop,当然还有其他更好的方案。




线程中断


线程中断是重要的线程协作机制,中断就是让线程停止执行,但这个停止执行非stop()的暴力方式。JDK提供了更安全的支持,就是线程中断。


线程中断并不会使线程立即停止,而是给线程发送一个通知,告诉目标线程有人希望你退出。至于目标线程接到通知后什么时候停止,完全由目标线程自行决定。这点很重要,如果线程接到通知后立即退出,我们就又会遇到类似stop()方法的老问题。


与线程有关的三个方法:

1、中断线程
public void Thread.interrupt()
说明:Thread.interrupt() 是一个实例方法,他通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断了。


2、判断是否被中断
public boolean Thread.isInterrupted()
说明:Thread.isInterrupted() 也是实例方法,他判断当前线程是否被中断(通过检查中断标志位)


3、判断是否被中断,并清除当前中断状态
public static boolean Thread.interrupted()
说明:Thread.interrupted() 是静态方法,判断当前线程的中断状态,但同时会清除当前线程的中断标志位状态。


实例1
看起来和stopMe的手法一样,但是中断功能更为强劲,比如遇到sleep()或wait()这样的操作时,就只能用中断标识了。




实例2


等待(wait)和通知(notify)

为了支持多线程之间的协作,JDK提供了两个非常重要的等待方法wait()和nofity()方法。这两个方法并不是Thread类中的,而是Object类,这意味着任何对象都可以调用这两个方法。


比如线程A调用了obj.wait()方法,那么线程A就会停止执行而转为等待状态,进入obj对象的等待队列。这个等待队列可能有多个线程,因为系统运行多个线程同时等待同一个对象。其他线程调用obj.notify()方法时,它就会从等待队列中随机选择一个线程并将其唤醒。注意着个选择是不公平的,是随机的。


obj.wait()方法并不是可以随便调用。他必须包含在对应的synchronized语句中。无论是wait还是notify都必须首先获得目标对象的一个监视器。而正确执行wait方法后,会释放这个监视器,这样其他等待obj上的线程才能获得这个监视器,不至于全部无法执行。


在调用obj.notify()前,同样也必须获得obj的监视器,索性wait方法已经释放了监视器。唤醒某个线程后(假设唤醒了A),A线程要做的第一件事并不是执行后续的代码,而是要尝试重新获得obj监视器。而这个监视器也正是A执行wait方法前所只有的那个obj监视器。如果暂时无法获得。A还必须要等待这个监视器。当A获得监视器后,才能真正意义上的继续执行。


注意:wait方法和sleep方法都可以让线程等待若干时间,处理wait方法可以唤醒之外,另外一个主要区别是wait方法会释放目标对象的锁,而sleep方法不会释放。


例子:


挂起(suspend)和继续执行(resume)线程


这两个方法虽然已经不推荐使用了。但是这里再提一下,不推荐使用suspend挂起线程是因为suspend挂起线程后不释放锁资源,导致其他线程想要访问这个锁资源时都会被等待。无法正常运行。而suspend挂起的线程居然还是RUNNABLE状态,这也严重影响了我们队系统当前状态的判断。


示例




示例2

通过wait和notify方式实现suspend和resume效果。这种方式类似于我们自己实现stop那样


等待线程结束(join)和谦让(yield)

一个线程的输入可能依赖于另一或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行,JDK提供了join操作来实现这个功能。方法签名:



join的本质是让调用线程wait()在当前线程对象实例上。当执行完成后,被等待的线程会在退出前调用notifyAll()通知所有的等待线程继续执行。因此,需要注意,不要在应用程序中,在Thread上使用类似wait()或者notify()等方法,因为这很有可能影响系统API的工作,或者被系统API锁影响。


yield是一个静态方法,一旦执行,它会使当前线程让出CPU,然后继续加入争抢CPU的线程中。


volatile与JMM


当我们使用volatile来修饰变量,就等于告诉虚拟机这个变量极有可能被某些程序或者线程修改。为了确保这个变量修改后,应用程序范围内的所有线程都能够看到。虚拟机就必须采用一些特殊的手段保证这个变量的可见性。这样就可以解决之前咱们在32位虚拟机上用多线程修改long 的值了。


volatile并不代表锁,他无法保证一些符合操作的原子性。他只能保证一个线程修改了数据后,其他线程能够看到这个改动,但当多个线程同时修改某一个数据时,却依然会产生冲突。他只能保证单个变量的完整性和可见性。保证原子性还的靠类似synchronized方式去解决。


线程组
如果线程数量很多,而且功能分配比较明确,就可以将相同的线程放置在一个线程组里面。


守护线程(daemon)

守护线程是一个特殊的线程,他在后台完成系统性的服务,比如垃圾回收等等。用户线程可以理解为系统工作线程,他们会完成业务操作。当用户线程全部结束后,系统就无事可做了。守护线程守护的对象也不存在了。因此当一个程序中就只有守护线程时,java虚拟机就会自然退出。



线程优先级
优先级高的线程在竞争资源的时候回更加有优势,更可能抢占到资源,当然这只是一个概率问题,高优先级的线程可能也会抢占失败。他和线程优先级调度以及底层操作系统有密切关系。在各平台上表现不一。


线程安全与synchronized
非线程安全写入例子


就算是volatile修饰的变量,也无法保证正确写入,要从根本上解决这个问题,我们就必须保证多个线程之间是完全同步的。也就是Thread1在写入时,Thread2既不能读也不能写。这时我们就得通过synchronized关键字来解决了。它的工作是对同步代码加锁,使得每一次,只能有一个线程进入同步块。从而保证线程间的安全性,说白了就是让并行程序串行执行。


synchronized用法
1、指定加锁对象:对给定对象加锁,进入同步代码块要获得给定对象的锁。



2、直接作用于实例方法:相当于对当前实例加锁,进入同步代码块要获得当前实例的锁。




3 、 直接作用于静态方法:相当于对当前类加锁,进入同步块要获得当前类的锁。
说明: 这个锁的影响范围更广,只要是调用这个类的方法,都必须拿到这个类的锁。

3.1 、 加到静态方法上


3.2 、 加到类上

实例1


实例二:

除了线程同步,确保线程安全外,synchronized还可以保证线程见的可见性和有序性。


并发下的ArrayList
直接看示例


错误的加锁
将锁加在int类型上。



似乎加锁的逻辑没问题,但是Integer在java中属于不变对象,也就是对象一旦创建就不可修改了。和String一样。所以这里的i每次都是一个新的integer对象,锁都加到了不同的对象上。


for循环的i每次实际上是使用了Integer.valueOf()方法创建一个新的integer对象,并将它赋值为变量i。也就是

i = Integer.valueOf(i.intValue() + 1)


JDK并发包
同步控制
重入锁ReentrantLock

重入锁可以完全替代synchronized关键字,并且性能也好于synchronized。但从JDK6.0开始,synchronized的性能有所提升,两者在性能上差不多。


需要注意的是使用重入锁时,我们必须指定何时上锁,何时释放。正因为这也,重入锁对逻辑控制的灵活性要远远好于synchronized。在退出临界区时,务必释放锁。否则 后果你懂得。


之所以叫重入锁,是因为一个线程可以两次获得同一把锁,在释放的时候也必须释放相同次数的锁。


示例


中断响应ReentrantLock.lockInterruptibly()


对synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它得到了这把锁,要么它保持等待。而重入锁则提供了另外一种可能,那就是线程可以在等待的过程中中断,我们可以根据需要取消对锁的请求。也就是说,如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以停止了。


lockInterruptibly()方法是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,中断响应。


示例


锁申请等待限时ReentrantLock.tryLock


除了等待外部通之外,避免死锁还有另外一种方法,就是限时等待,给定一个等待时间让线程自动放弃。


tryLock(时长,计时单位),若超过设定时长还没得到锁就返回false,若成功获得锁就返回true。


tryLock(),若没有参数,当前线程会尝试获得锁,如果申请锁成功,则返回true,否则立即返回false。这种模式不会引起线程等待,因此不会产生死锁。


示例


公平锁ReentrantLock(true)

公平锁会按照实际的先后顺序,保证先到先得,它不会产生饥饿,只要排队,最终都可以等到资源。在创建重入锁时,通过有参构造函数,传入boolean类型的参数,true表示是公平锁。实现公平所必然要维护一个有序队列,所以公平锁的实现成本高,性能相对也非常低,默认情况下,锁是非公平的。


示例


ReentrantLock的以上几个重要的方法


就重入锁实现来看,他主要集中在java 层面。在重入锁实现中,主要包含三个要素:
1 原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
2 等待队列。所有没有请求成功的线程都进入等待队列进行等待。当有线程释放锁后,系统就从当前等待队列中唤醒一个线程继续工作。
3 阻塞原语park()和unpack(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。


Condition条件
Condition是与重入锁ReentrantLock相关联的,通过Lock接口的Condition newCondition()方法可以生产一个和当前重入锁绑定的Condition实例,利用Condition对象,我们就可以让线程在合适的时间等待,或者在特定的时刻得到通知继续执行。


Condition接口提供了如下基本方法:
await() 会使当前线程等待,同时释放当前锁,当其他线程中使用singal()或者singalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和wait()类似。
awaitUninterruptibly()和await()方法类似,但它不会再等待过程中响应中断。
singal() 用于唤醒一个等待队列中的线程。singalAll()是唤醒所有等待线程。


示例




信号量 Semaphore

Semaphore可以指定多个线程同时访问某一个资源,在构造Semaphore对象时,必须指定信号量的准入数,即同时能申请多少个许可,当每个线程每次只能申请一个许可时,就相当于有多少线程可以同时访问某个资源。


示例



ReadWriteLock 读写锁

读写分离锁可以有效的减少所竞争,以提升系统性能。但需要注意是的线程间 读读、读写、写写中后两者依然需要互斥。


系统中,读的次数远远大于写的操作,读写锁就可以发挥最大的功效
示例


倒计时 CountDownLatch

CountDownLatch主要用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。


示例


循环栅栏 CyclicBarrier

CyclicBarrier是另一种多线程并发控制工具,和CountDownLatch类似,但是它可以在计数器完成一次计数后,执行某个动作。


示例


线程阻塞 LockSupport

LockSupport可以在线程任意位置让线程阻塞,它弥补了由resume()在前发生导致线程无法正常执行的问题。和wait()相比,它不需要获得锁也不会抛出InterruptedException异常。
LockSupport的静态方法park()可以阻塞当前线程。还有parkNanos()、parkUntil()等方法,它们实现了一个限时的等待。

unpack()继续执行。即使unapt()操作发生在park()之前它也可以使下一次的park()操作立即返回。


示例


线程复用:线程池


多线程的软件设计方法确实可以最大限度地发挥现代处理器多核处理的计算能力,提高系统的吞吐量和性能,但是,若不加控制和管理的随意使用线程,对系统的性能反而会产生不利的影响。


大量的线程会抢占宝贵的内存资源,如果处理不当可能会导致Out ofMemory异常。大量的线程回收也会给GC带来很大的压力。


在实际生产环境中,线程的数量必须得到控制,盲目的大量创建线程对系统性能是有伤害的。


为了避免频繁的创建和销毁线程,可以让创建的线程进行复用。当完成工作时,并不着急关闭,而是将这个线程退回到线程池,方便其他人使用。



JDK对线程池的支持

ThreadPoolExecutor表示一个线程池。Executors类则扮演线程池工厂角色,通过Executors可以取得一个具有特定功能的线程池。从UML图中亦可知,ThreadPoolExecutor实现了Executor接口,因此通过这个接口,任何Runnable对象都可以被ThreadPoolExecutor线程池调度。



Executor框架提供了各种类型的线程池,主要有以下工厂方法。


以上方法返回了具有不同工作特性的线程池,具体说明如下:


  1. newFixedThreadPool,返回一个固定数量的线程池。当一个新任务提交时,如果有空闲线程,则执行。否则新任务暂存在一个任务队列中,待有空闲时,便处理在任务队列中的任务。

  2. newSingleThreadExecutor,返回一个线程的线程池。当多余一个新任务提交时,会暂存在一个任务队列中,待有空闲时,按先入先出的顺序处理在任务队列中的任务。

  3. newCachedThreadPool,返回一个可根据实际情况调整线程数量的线程池,线程数量不确定,若有空闲,则会有限复用线程。否则创建新线程处理任务。所有线程在当前任务执行完后,将返回线程池待复用。

  4. newSingleThreadScheduledExecutor,返回一个ScheduledExecutorService对象,线程池大小为1。ScheduledExecutorService在Executor接口之上扩展了在给定时间执行某任务的功能。如果在某个固定的延时之后执行,或周期性执行某个任务。可以用这个工厂。

  5. newScheduledThreadPool,返回一个ScheduledExecutorService对象,但该线程可以指定线程数量。


固定线程池

示例:


计划任务
newScheduledThreadPool返回一个ScheduledExecutorService对象,可以根据实际对线程进行调度。

ScheduledExecutorService不会立即安排执行任务,他类似Linux中的crontab工具。如果任务遇到异常,则后续的所有子任务都会停止执行。因此,必须保证异常被及时处理,为周期性任务的稳定调度提供条件。


示例


核心线程池的内部实现

对于核心的几个线程池,无论是newFixedThreadPool()、newSingleThreadExecutor()还是newCacheThreadPool方法,虽然看起来创建的线程具有完全不同的功能特点,但其内部均使用了ThreadPoolExecutor实现。

由以上线程池的实现可以看到,它们都只是ThreadPoolExecutor类的封装。我们看下ThreadPoolExecutor最重要的构造函数:


workQueue
只提交但未执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象,根据队列功能分类,在ThreadPoolExecutor的构造函数中可使用以下几种BlockingQueue。


1. 直接提交的队列:该功能由synchronousQueue对象提供,synchronousQueue对象是一个特殊的BlockingQueue。synchronousQueue没有容量,每一个插入操作都要等待一个响应的删除操作,反之每一个删除操作都要等待对应的插入操作。如果使用synchronousQueue,提交的任务不会被真实的保存,而总是将新任务提交给线程执行,如果没有空闲线程,则尝试创建线程,如果线程数量已经达到了最大值,则执行拒绝策略,因此,使用synchronousQueue队列,通常要设置很大的maximumPoolSize值,否则很容易执行拒绝策略。


2. 有界的任务队列:有界任务队列可以使用ArrayBlockingQueue实现。ArrayBlockingQueue构造函数必须带有一个容量参数,表示队列的最大容量。public ArrayBlockingQueue(int capacity)。当使用有界任务队列时,若有新任务需要执行时,如果线程池的实际线程数量小于corePoolSize,则会优先创建线程。若大于corePoolSize,则会将新任务加入等待队列。若等待队列已满,无法加入,则在总线程数不大于maximumPoolSize的前提下,创建新的线程执行任务。若大于maximumPoolSize,则执行拒绝策略。可见有界队列仅当在任务队列装满后,才可能将线程数量提升到corePoolSize以上,换言之,除非系统非常繁忙,否则确保核心线程数维持在corePoolSize。


3. 无界的任务队列:无界队列可以通过LinkedBlockingQueue类实现。与有界队列相比,除非系统资源耗尽,无界队列的任务队列不存在任务入队失败的情况。若有新任务需要执行时,如果线程池的实际线程数量小于corePoolSize,则会优先创建线程执行。但当系统的线程数量达到corePoolSize后就不再创建了,这里和有界任务队列是有明显区别的。若后续还有新任务加入,而又没有空闲线程资源,则任务直接进入队列等待。若任务创建和处理的速度差异很大,无界队列会保持快速增长,知道耗尽系统内存。


4. 优先任务队列:带有优先级别的队列,它通过PriorityBlokingQueue实现,可以控制任务执行的优先顺序。它是一个特殊的无界队列。无论是ArrayBlockingQueue还是LinkedBlockingQueue实现的队列,都是按照先进先出的算法处理任务,而PriorityBlokingQueue根据任务自身优先级顺序先后执行,在确保系统性能同时,也能很好的质量保证(总是确保高优先级的任务优先执行)。


回顾
ThreadPoolExecutor的任务调度逻辑



newFixedThreadPool()方法的实现,它返回一个corePoolSize和maximumPoolSize一样的,并使用了LinkedBlockingQueue任务队列(无界队列)的线程池。当任务提交非常频繁时,该队列可能迅速膨胀,从而系统资源耗尽。
newSingleThreadExecutor()返回单线程线程池,是newFixedThreadPool()方法的退化,只是简单的将线程池数量设置为1.
newCachedThreadPool()方法返回corePoolSize为0而maximumPoolSize无穷大的线程池,这意味着没有任务的时候线程池内没有现场,而当任务提交时,该线程池使用空闲线程执行任务,若无空闲则将任务加入SynchronousQueue队列,而SynchronousQueue队列是直接提交队列,它总是破事线程池增加新的线程来执行任务。当任务执行完后由于corePoolSize为0,因此空闲线程在指定时间内(60s)被回收。对于newCachedThreadPool(),如果有大量任务提交,而任务又不那么快执行时,那么系统变回开启等量的线程处理,这样做法可能会很快耗尽系统的资源,因为它会增加无穷大数量的线程。


使用自定义线程池时,要根据具体应用的情况,选择合适的并发队列作为任务的缓冲。当线程资源紧张时,不同的并发队列对系统行为和性能的影响均不同。


ThreadPoolExecutor核心调度代码


拒绝策略

线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。


JDK内置的拒绝策略如下:


1. AbortPolicy : 直接抛出异常,阻止系统正常运行。
2. CallerRunsPolicy : 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
3. DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
4. DiscardPolicy : 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。


以上内置拒绝策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足实际需要,完全可以自己扩展RejectedExecutionHandler接口。RejectedExecutionHandler的定义如下。

实例1:


自定义线程创建ThreadFactory
自定义线程池

线程池的作用就是为了线程复用,也就是避免线程频繁的创建
但是,最开始的线程从何而来,就是ThreadFactory.

ThreadFactory是一个接口,它有一个方法是创建线程

Thread newThread(Runnable r);


自定义线程可以跟踪线程何时创建,自定义线程名称/组/优先级信息.
甚至可以设置为守护线程.总之自定义线程池可以让我们更加自由的设置线程池中的所有线程状态.

实例1


扩展线程池

我们想监控每个人物的执行开始时间 结束时间等细节,我们可以通过扩展ThreadPoolExecutor扩展线程池.他提供了beforExecute(),afterExecute(),和terminated()三个接口对线程池进行控制。
实例1


优化线程池线程数量

线程池的大小对系统性能有一定的影响,过大或过小的线程数量都无法发挥最优的系统性能,因此要避免极大和极小两种情况。


在《java Concurrency in Practice》中给出了一个估算线程池大小的经验公式:
Ncpu = CPU数量
Ucpu = 目标CPU的使用率(0 ≤ Ucpu ≤ 1 )
W/C = 等待时间与计算时间的比率
最优的池大小等于
Nthreads = Ncpu * Ucpu * (1+W/C)
在java中可以通过Runtime.getRuntime().availableProcessors()取得可用CPU数量。


JDK并发容器

JDK除了提供主语同步控制,线程池等基本工具外,为了提高大家的效率,还未大家准备了一批好用的容器类,包括链表,队列,HashMap等,它们都是线程安全的。


ConcurrentHashMap : 一个高效的线程安全的HashMap
CopyOnWriteArrayList : 在读多写少的场景中,性能非常好,远远高于vector.
ConcurrentLinkedQueue : 高效并发队列,使用链表实现,可以看成线程安全的LinkedList.
BlockingQueue : 一个接口,JDK内部通过链表,数组等方式实现了这个接口,表示阻塞队列,非常适合用作数据共享通道.
ConcurrentSkipListMap : 跳表的实现,这是一个Map,使用跳表数据结构进行快速查找.
另外Collections工具类可以帮助我们将任意集合包装成线程安全的集合


线程安全的HashMap

大家都制定HashMap在多线程环境中是线程不安全的,会产生相互引用的错误。
通过Collections.synchronizedMap(new HashMap<>());来包装一个线程安全的HashMap。
它使用委托,将自己所有的Map相关的功能交给HashMap实现,而自己负责包装线程安全。
它其实是通过指定对象mutex实现对这个m的互斥操作。


虽然可以实现线程安全,但无论写入还是读取都需要获取mutex这把锁,所以性能不是太好,
我们倾向于使用ConcurrentHashMap来实现高并发下的安全的HashMap.


高效读写队列 ConcurrentLinkedQueue

队列Queue是常用的数据结构之一,JDK提供了一个ConcurrentLinkedQueue类用来实现高并发的队列


高效读取 CopyOnWriteArrayList

很多场景中都是读远远高于写操作,那么每次对读取进行加锁其实是一种资源浪费。根据读写锁的思想,读锁和读锁之间不冲突。


但是读操作会受到写操作的阻碍,在写操作发生时,读就必须等待,否则可能读到不一致的数据.同理读操作正在进行的时候,程序也不能进行写入。


JDK提供了CopyOnWriteArrayList类,读取是完全不加锁的,并且写入也不会阻塞读取操作,这样一来性能大大提升了。


其实就是在写操作时进行一次自我复制,当List需要修改时,并不修改原有内容(这对于保证当前读线程的数据一致性非常重要);而对原内容进行一次复制,将修改内容写入副本.写完后,再将修改完的副本替换原来的操作,这就保证了写操作不会影响读了。



数据共享通道 BlockingQueue

解决多线程数据共享问题,可以使用 BlockingQueue 接口来实现:

public interface BlockingQueue<E> extends Queue<E>


具体实现类如下:
1. ArrayBlockingQueue
2. DelayedWorkQueue
3. DelayQueue
4. LinkedBlockingQueue
5. SynchronousQueue
6. BlockingDeque
7. PriorityBlockingQueue

ArrayBlockingQueue 基于数组实现,更适合做有界队列,因为可容纳的最大元素需要在创建时指定,毕竟数组动态扩展不太方便。


LinkedBlockingQueue 基于链表实现,适合做无界队列,或者边界值非常大的队列,它不会因为初始容量大,而一口气吃掉内存。

BlockingQueue之所以适合作为数据共享通道,关键还在blocking上,blocking阻塞的意思,当服务线程(指不断获取队列中消息进行处理的线程)处理完成队列中的消息后,它如何知道吓一跳消息何时到来。


一种简单的办法是不断间隔循环和监控这个队列,但会造成不必要的资源浪费。循环周期也难以确定,而blockingQueue会让服务线程在队列为空的时候等待,当有新消息进入队列后自动将线程唤醒。

ArrayBlockingQueue 内部元素都放置在一个对象数组中,final Object[] items;


向队列中压入元素可以使用offer()方法和put()方法,对于offer(),如果队列已经满了,它会立即返回false,这不是我们需要的。


put()方法是将元素压入队列末尾。但如果队列满了,它会一直等待,知道队列中有空闲的位置。


从队列中弹出元素可以使用pull()和take()方法,它们都是从头部获取一个元素,不同的是如果队列为空pull()直接返回null,而take()方法会等待,知道队列内有可用元素。


因此put()方法和take()方法提现了blocking的关键。


为了做好等待和通知两件事在ArrayBlockingQueue内部定义了一些字段,当执行take()操作时,如果队列为空,则让当前线程等待在notEmpty上,新元素入队时,则进行一次notEmpty通知。


随机数据结构 跳表 SkipList

跳表是一种用来快速查找的数据结构,有点类似平衡树,对于平衡树的插入和删除往往可能导致平衡树进行一次全局的调整。


而对跳表的插入和删除只需要对局部进行操作即可.这样带来的好处是,在高并发情况下,如果是平衡树,那么会需要一个全局锁来保证线程安全.而跳表只需要部分锁即可,所以在并发数据结构中,JDK使用跳表来实现一个Map。


跳表的另一个特点是随机算法,跳表本身维护了多个链表,并且链表是分层的.没上面一层链表都是下面一层的子集,一个元素插入到哪些层完全是随机的.


跳表内所有链表的元素都是排序的.查找时可以从顶链表开始,一旦发现被查找的元素大于当前链表中的值,就会转入下一层链表继续查找.也就是说搜索是跳跃式的.


跳表是一种空间换时间的算法.使用跳表实现Map和哈希算法实现Map的另一个不同是,哈希不会保存元素的顺序,而跳表内的所有元素都是排序的.因此对跳表遍历时会得到一个有序的结果.

跳表的内部有几个关键的数据结构组成.首先是Node,一个Node表示一个节点,每个Node还会指向下一个Node,因此还有next元素.对Node的所有操作,使用CAS方法。



另一个重要的是Index元素,顾名思义是索引的意思,内部还包装了Node,同时增加了向下down和向右right的引用.


整个跳表是根据Index进行全网的组织.


此外对于每一层的表头,还需要记录当前处于那一层,为此还需要一个HeadIndex的数据结构,表示链表的头部第一个Index.

对于跳表的操作就是组织好这些Index之间的连接关系.


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值