5实战java高并发程序设计--并行模式与算法

由于并行程序设计比串行程序设计复杂得多,因此我强烈建议大家了解一些常见的设计方法。就好像练习武术,一招一式都是要经过学习的。如果自己胡乱打,效果不见得好。前人会总结一些武术套路,对于初学者来说,不需要发挥自己的想象力,只要按照武术套路出拳就可以了。等练到了一定的高度,就不必拘泥于套路了。这些武术套路和招数,对应到软件开发中来就是设计模式。在这一章中,我将重点向大家介绍一些有关并行的设计模式及算法。这些都是前人的经验总结,大家可以在熟知其思想和原理的基础上,再根据自己的需求进行扩展,可能会达到更好的效果。

5.1 探讨单例模式

单例模式是设计模式中使用最为普遍的模式之一。它是一种对象创建模式,用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例。在Java中,这样的行为能带来两大好处。

(1)对于频繁使用的对象,可以省略new操作花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销。

(2)由于new操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻GC压力,缩短GC停顿时间。

使用以上方式创建单例有几点必须特别注意。因为我们要保证系统中不会有人意外创建多余的实例,因此,我们把Singleton的构造函数设置为private。这点非常重要,这就警告所有的开发人员,不能随便创建这个类的实例,从而有效避免该类被错误创建。

首先,instance对象必须是private并且static的。如果不是private,那么instance的安全性无法得到保证。一个小小的意外就可能使得instance变成null。其次,因为工厂方法getInstance()必须是static的,因此对应的instance也必须是static。\

这个单例的性能是非常好的,由于getInstance()方法只是简单地返回instance,并没有任何锁操作,因此它在并行程序中会有良好的表现。但是这种方式有一个明显不足就是Singleton构造函数,或者说Singleton实例在什么时候创建是不受控制的。对于静态成员instance,它会在类第一次初始化的时候被创建。这个时刻并不一定是getInstance()方法第一次被调用的时候。

注意,这个单例还包含一个表示状态的静态成员STATUS。此时,在任何地方引用这个STATUS都会导致instance实例被创建(任何对Singleton方法或者字段的引用,都会导致类初始化,并创建instance实例,但是类初始化只有一次,因此instance实例永远只会被创建一次)

但如果你想精确控制instance的创建时间,那么这种方式就不太友善了。我们需要寻找一种新的方法,一种支持延迟加载的策略,它只会在instance第一次使用时创建对象,具体实现如下:

这个LazySingleton的核心思想是:最初,我们并不需要实例化instance,而当getInstance()方法被第一次调用时,创建单例对象。为了防止对象被多次创建,我们不得不使用synchronized关键字进行方法同步。这种实现的好处是,充分利用了延迟加载,只在真正需要时创建对象。但坏处也很明显,并发环境下加锁,竞争激烈的场合对性能可能产生一定的影响。但总体上,这是一个非常易于实现和理解的方法。

此外,还有一种被称为双重检查模式的方法可以用于创建单例。但我并不打算在这里介绍它,因为这是一种非常丑陋、复杂的方法,甚至在低版本的JDK中都不能保证正确性。因此,绝不推荐大家使用。如果大家阅读到相关文档,我也强烈建议大家不要在这种方法上花费太多时间。

上面介绍的两种单例实现可以说是各有千秋。有没有一种方法可以结合二者的优势呢?答案是肯定的。

上述代码实现了一个单例,并且同时拥有前两种方式的优点。首先getInstance()方法中没有锁,这使得在高并发环境下性能优越。其次,只有在getInstance()方法第一次被调用时,StaticSingleton的实例才会被创建。因为这种方法巧妙地使用了内部类和类的初始化方式。内部类SingletonHolder被声明为private,这使得我们不可能在外部访问并初始化它。而我们只可能在getInstance()方法内部对SingletonHolder类进行初始化,利用虚拟机的类初始化机制创建单例。


5.2 不变模式

在并行软件开发过程中,同步操作似乎是必不可少的。当多线程对同一个对象进行读写操作时,为了保证对象数据的一致性和正确性,有必要对对象进行同步,但是同步操作对系统性能有损耗。为了尽可能地去除这些同步操作,提高并行程序性能可以使用一种不可改变的对象,依靠对象的不变性,可以确保其在没有同步操作的多线程环境中依然保持内部状态的一致性和正确性。这就是不变模式

不变模式天生就是多线程友好的,它的核心思想是,一个对象一旦被创建,它的内部状态将永远不会发生改变。没有一个线程可以修改其内部状态和数据,同时其内部状态也绝不会自行发生改变。基于这些特性,对不变对象的多线程操作不需要进行同步控制。

同时还需要注意,不变模式和只读属性是有一定的区别的。不变模式比只读属性具有更强的一致性和不变性。对只读属性的对象而言,对象本身不能被其他线程修改,但是对象的自身状态却可能自行修改。

比如,一个对象的存活时间(对象创建时间和当前时间的时间差)是只读的,任何一个第三方线程都不能修改这个属性,但是这是一个可变的属性,因为随着时间的推移,存活时间时刻都在发生变化。而不变模式则要求,无论出于什么原因,对象自创建后,其内部状态和数据保持绝对的稳定。

因此,不变模式的主要使用场景需要满足以下两个条件。

● 当对象创建后,其内部状态和数据不再发生任何变化。

● 对象需要被共享,被多线程频繁访问。

在Java语言中,不变模式的实现很简单。为确保对象被创建后,不发生任何改变,并保证不变模式正常工作,只需要注意以下四点即可。

● 去除setter方法及所有修改自身属性的方法。

● 将所有属性设置为私有,并用final标记,确保其不可修改。

● 确保没有子类可以重载修改它的行为。

● 有一个可以创建完整对象的构造函数。以下代码实现了一个不变的产品对象,它拥有序列号、名称和价格三个属性

以下代码实现了一个不变的产品对象,它拥有序列号、名称和价格三个属性。

在不变模式的实现中,final关键字起到了重要的作用。对属性的final定义确保所有数据只能在对象被构造时赋值1次。之后,就永远不发生改变。而对class的final确保了类不会有子类。根据里氏代换原则,子类可以完全替代父类。如果父类是不变的,那么子类也必须是不变的,但实际上我们无法约束这点,为了防止子类做出一些意外的行为,这里干脆把子类都禁用了。

在JDK中,不变模式的应用非常广泛。其中,最为典型的就是java.lang.String类。此外,所有的元数据类、包装类都是使用不变模式实现的。主要的不变模式类型如下。

由于基本数据类型和String类型在实际的软件开发中应用极其广泛,使用不变模式后,所有实例的方法均不需要进行同步操作,保证了它们在多线程环境下的性能。

注意:不变模式通过回避问题而不是解决问题的态度来处理多线程并发访问控制,不变对象是不需要进行同步操作的。由于并发同步会对性能产生不良的影响,因此,在需求允许的情况下,不变模式可以提高系统的并发性能和并发量。


5.3 生产者-消费者模式

生产者-消费者模式是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。在生产者-消费者模式中,通常有两类线程,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费者线程则负责具体处理生产者提交的任务。生产者和消费者之间则通过共享内存缓冲区进行通信。

图5.1展示了生产者-消费者模式的基本结构。三个生产者线程将任务提交到共享内存缓冲区,消费者线程并不直接与生产者线程通信,而是在共享内存缓冲区中获取任务,并进行处理

注意:生产者-消费者模式中的内存缓冲区的主要功能是数据在多线程间的共享,此外,通过该缓冲区,可以缓解生产者和消费者间的性能差。

生产者-消费者模式的核心组件是共享内存缓冲区,它作为生产者和消费者间的通信桥梁,避免了生产者和消费者直接通信,从而将生产者和消费者进行解耦。生产者不需要知道消费者的存在,消费者也不需要知道生产者的存在。

其中,BlockigQueue充当了共享内存缓冲区,用于维护任务或数据队列(PCData对象)。我强烈建议大家先回顾一下第3章有关BlockingQueue的相关知识,它对于理解整个生产者和消费者结构有重要的帮助。PCData对象表示一个生产任务,或者相关任务的数据。生产者对象和消费者对象均引用同一个BlockigQueue实例。生产者负责创建PCData对象,并将它加入BlockigQueue队列中,消费者则从BlockigQueue队列中获取PCData对象。

注意:生产者-消费者模式很好地对生产者线程和消费者线程进行解耦,优化了系统整体结构。同时,由于缓冲区的作用,允许生产者线程和消费者线程存在执行上的性能差异,从一定程度上缓解了性能瓶颈对系统性能的影响。


5.4 高性能的生产者-消费者模式:无锁的实现

ConcurrentLinkedQueue是一个高性能的队列,但是BlockingQueue队列只是为了方便数据共享。

而ConcurrentLinkedQueue队列的秘诀就在于大量使用了无锁的CAS操作。同理,如果我们使用CAS来实现生产者-消费者模式,也同样可以获得可观的性能提升。不过正如大家所见,使用CAS进行编程是非常困难的,但有一个好消息是,目前有一个现成的Disruptor框架,它已经帮助我们实现了这一个功能。

5.4.1 无锁的缓存框架:Disruptor

Disruptor框架是由LMAX公司开发的一款高效的无锁内存队列。它使用无锁的方式实现了一个环形队列(RingBuffer),非常适合实现生产者-消费者模式,比如事件和消息的发布。Disruptor框架别出心裁地使用了环形队列来代替普通线形队列,这个环形队列内部实现为一个普通的数组。

生产者和消费者正常工作。根据Disruptor框架的官方报告,Disruptor框架的性能要比BlockingQueue队列至少高一个数量级以上。如此诱人的性能,当然值得我们尝试!

5.4.3 提高消费者的响应时间:选择合适的策略

当有新数据在Disruptor框架的环形缓冲区中产生时,消费者如何知道这些新产生的数据呢?或者说,消费者如何监控缓冲区中的信息呢?为此,Disruptor框架提供了几种策略,这些策略由WaitStrategy接口进行封装,主要有以下几种实现

● BlockingWaitStrategy:这是默认的策略。使用BlockingWaitStrategy和使用BlockingQueue是非常类似的,它们都使用锁和条件(Condition)进行数据的监控和线程的唤醒。因为涉及线程的切换,BlockingWaitStrategy策略最节省CPU,但是在高并发下它是性能表现最糟糕的一种等待策略。

● SleepingWaitStrategy:这个策略对CPU的消耗与BlockingWaitStrategy类似。它会在循环中不断等待数据。它会先进行自旋等待,如果不成功,则使用Thread.yield()方法方法让出CPU,并最终使用LockSupport.parkNanos(1)进行线程休眠,以确保不占用太多的CPU数据。因此,这个策略对于数据处理可能会产生比较高的平均延时。它比较适合对延时要求不是特别高的场合,好处是它对生产者线程的影响最小。典型的应用场景是异步日志。

● YieldingWaitStrategy:这个策略用于低延时的场合。消费者线程会不断循环监控缓冲区的变化,在循环内部,它会使用Thread.yield()方法让出CPU给别的线程执行时间。如果你需要一个高性能的系统,并且对延时有较为严格的要求,则可以考虑这种策略。使用这种策略时,相当于消费者线程变成了一个内部执行了Thread.yield()方法的死循环。因此,你最好有多于消费者线程数量的逻辑CPU数量(这里的逻辑CPU指的是“双核四线程”中的那个四线程,否则,整个应用程序恐怕都会受到影响)。

● BusySpinWaitStrategy:这个是最疯狂的等待策略了。它就是一个死循环!消费者线程会尽最大努力疯狂监控缓冲区的变化。因此,它会吃掉所有的CPU资源。只有对延迟非常苛刻的场合可以考虑使用它(或者说,你的系统真的非常繁忙)。因为使用它等于开启了一个死循环监控,所以你的物理CPU数量必须要大于消费者的线程数。注意,我这里说的是物理CPU,如果你在一个物理核上使用超线程技术模拟两个逻辑核,另外一个逻辑核显然会受到这种超密集计算的影响而不能正常工作。

5.4.4 CPU Cache的优化:解决伪共享问题

什么是伪共享问题呢?我们知道,为了提高CPU的速度,CPU有一个高速缓存Cache。在高速缓存中,读写数据的最小单位为缓存行(Cache Line),它是从主存(Memory)复制到缓存(Cache)的最小单位,一般为32字节到128字节。

当两个变量存放在一个缓存行时,在多线程访问中,可能会影响彼此的性能。在图5.4中,假设变量X和Y在同一个缓存行,运行在CPU1上的线程更新了变量X,那么CPU2上的缓存行就会失效,同一行的变量Y即使没有修改也会变成无效,导致Cache无法命中。接着,如果在CPU2上的线程更新了变量Y,则导致CPU1上的缓存行失效(此时,同一行的变量X变得无法访问)。这种情况反复发生,无疑是一个潜在的性能杀手。如果CPU经常不能命中缓存,那么系统的吞吐量就会急剧下降
为了避免这种情况发生,一种可行的做法就是在变量X的前后空间都先占据一定的位置(把它叫作padding,用来填充用的)。这样,当内存被读入缓存时,这个缓存行中,只有变量X一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体失效的情况,如图5.5所示。(即x占一个缓存行,Y占一个缓存行)


5.5 Future模式

Future模式是多线程开发中非常常见的一种设计模式,它的核心思想是异步调用。当我们需要调用一个函数方法时,如果这个函数执行得很慢,那么我们就要进行等待。但有时候,我们可能并不急着要结果。因此,我们可以让被调者立即返回,让它在后台慢慢处理这个请求。对于调用者来说,则可以先处理一些其他任务,在真正需要数据的场合再去尝试获得需要的数据。

对于Future模式来说,虽然它无法立即给出你需要的数据,但是它会返回一个契约给你,将来你可以凭借这个契约去重新获取你需要的信息

5.5.1 Future模式的主要角色

5.5.2 Future模式的简单实现

在这个实现中,有一个核心接口Data,这就是客户端希望获取的数据。在Future模式中,这个Data接口有两个重要的实现,一个是RealData,也就是真实数据,这就是我们最终需要获得的、有价值的信息。另外一个就是FutureData,它是用来提取RealData的一个“订单”。因此FutureData可以立即返回。

FutureData实现了一个快速返回的RealData包装。它只是一个包装,或者说是一个RealData的虚拟实现。因此,它可以很快被构造并返回。当使用FutrueData的getResult()方法时,如果实际的数据没有准备好,那么程序就会阻塞,等RealData准备好并注入FutureData中才最终返回数据。
注意:FutureData是Future模式的关键。它实际上是真实数据RealData的代理,封装了获取RealData的等待过程。


5.6 并行流水线

并发算法虽然可以充分发挥多核CPU的性能。但不幸的是,并非所有的计算都可以改造成并发的形式。那什么样的算法是无法使用并发进行计算的呢?简单来说,执行过程中有数据相关性的运算都是无法完美并行化的

假如现在有两个数,B和C,计算(B+C)×B/2,这个运行过程就是无法并行的。原因是,如果B+C没有执行完成,则永远算不出(B+C)×B,这就是数据相关性。如果线程执行时所需的数据存在这种依赖关系,那么就没有办法将它们完美的并行化。

遇到这种情况时,有没有什么补救措施呢?答案是肯定的,那就是借鉴日常生产中的流水线思想。

比如,现在要生产一批小玩偶。小玩偶的制作分为四个步骤:第一,组装身体;第二,在身体上安装四肢和头部;第三,给组装完成的玩偶穿上一件漂亮的衣服;第四,包装出货。为了加快制作进度,我们不可能叫四个人同时加工一个玩具,因为这四个步骤有着严重的依赖关系。如果没有身体,就没有地方安装四肢;如果没有组装完成,就不能穿衣服;如果没有穿上衣服,就不能包装发货。因此,找四个人来做一个玩偶是毫无意义的。

但是,如果你现在要制作的不是1个玩偶,而是1万个玩偶,那情况就不同了。你可以找四个人,第一个人只负责组装身体,完成后交给第二个人;第二个人只负责安装头部和四肢,完成后交付第三人;第三人只负责穿衣服,完成后交付第四人;第四人只负责包装发货。这样所有人都可以一起工作,共同完成任务,而整个时间周期也能缩短到原来的1/4左右,这就是流水线的思想。一旦流水线满载,每次只需要一步(假设一个玩偶需要四步)就可以产生一个玩偶。

P1:A=B+CP2:D=A×BP3:D=D/2上述步骤中的P1、P2和P3均在单独的线程中计算,并且每个线程只负责自己的工作。此时,P3的计算结果就是最终需要的答案。P1接收B和C的值并求和,将结果输入给P2。P2求乘积后输入给P3。P3将D除以2得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到(B+C)×B/2的结果。为了实现这个功能,我们需要定义一个在线程间携带结果进行信息交换的载体


5.7 并行搜索

对于有序数据,通常可以采用二分查找法。对于无序数据,则只能挨个查找。本节将讨论有关并行的无序数组的搜索实现。

一种简单的策略就是将原始数据集合按照期望的线程数进行分割。如果我们计划使用两个线程进行搜索,那么就可以把一个数组或集合分割成两个。每个线程各自独立搜索,当其中有一个线程找到数据后,立即返回结果即可。

5.8 并行排序

对于大部分排序算法来说,都是串行执行的。当排序元素很多时,若使用并行算法代替串行算法,显然可以更有效地利用CPU。但将串行算法改造成并行算法并非易事,甚至会极大地增加原有算法的复杂度。在这里,我将介绍几种相对简单的,但是也足以让人眼前一亮的平行排序算法。

5.8.1 分离数据相关性:奇偶交换排序

大家可以看到,在每次迭代的交换过程中,由于每次交换的两个元素存在数据冲突,对于每个元素,它既可能与前面的元素交换,也可能和后面的元素交换,因此很难直接改造成并行算法。如果能够解开这种数据的相关性,就可以比较容易地使用并行算法来实现类似的排序。奇偶交换排序就是基于这种思想的

对于奇偶交换排序来说,它将排序过程分为两个阶段,奇交换和偶交换。对于奇交换来说,它总是比较奇数索引及其相邻的后续元素。而偶交换总是比较偶数索引和其相邻的后续元素。并且,奇交换和偶交换会成对出现,这样才能保证比较和交换涉及数组中的每一个元素。\

从图5.13中可以看到,由于将整个比较交换独立分割为奇阶段和偶阶段,这就使得在每一个阶段内,所有的比较和交换是没有数据相关性的。因此,每一次比较和交换都可以独立执行,也就可以并行化了。

5.9 并行算法:矩阵乘法(看不懂)

5.10 准备好了再通知我:网络NIO

Java NIO是New IO的简称,它是一种可以替代Java IO的一套新的IO机制。它提供了一套不同于Java 标准IO的操作机制。严格来说,NIO与并发并无直接的关系,但是使用NIO技术可以大大提高线程的使用效率。

Java NIO中涉及的基础内容有通道(Channel)、缓冲区(Buffer)、文件IO和网络IO。有关通道、缓冲区及文件IO在这里不打算详细介绍了,大家可以去博文视点社区下载本书推荐的参考文献。在这里,我想多花一点时间详细介绍一下有关网络IO的内容。

对于标准的网络IO来说,我们会使用Socket进行网络的读写。为了让服务器可以支持更多的客户端连接,通常的做法是为每一个客户端连接开启一个线程。让我们先回顾一下这方面的内容。

5.10.1 基于Socket的服务端多线程模式

这里以一个简单的Echo服务器为例。对于Echo服务器,它会读取客户端的一个输入,并将这个输入原封不动地返回给客户端。这看起来很简单,但是麻雀虽小五脏俱全。为了完成这个功能,服务器还是需要有一套完整的Socket处理机制。因此,这个Echo服务器非常适合进行学习。实际上,我认为任何业务逻辑简单的系统都很适合学习,大家不用为了去理解业务上复杂的功能而忽略了系统的重点。

5.11 读完了再通知我:AIO

AIO是异步IO的缩写,即Asynchronized IO。虽然NIO在网络操作中提供了非阻塞的方法,但是NIO的IO行为还是同步的。对于NIO来说,我们的业务线程是在IO操作准备好时,得到通知,接着就由这个线程自行进行IO操作,IO操作本身还是同步的。但对于AIO来说,则更进了一步,它不是在IO准备好时再通知线程,而是在IO操作已经完成后,再给线程发出通知。因此,AIO是完全不会阻塞的。此时,我们的业务逻辑将变成一个回调函数,等待IO操作完成后,由系统自动触发。

发布了33 篇原创文章 · 获赞 1 · 访问量 5515

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104432903