《实战 Java 高并发程序设计》笔记——第5章 并行模式与算法

本文是《实战 Java 高并发程序设计》笔记,介绍了单例模式、不变模式、生产者-消费者模式以及 Future 模式。强调了设计模式在并行编程中的重要性,特别是如何利用它们优化并发性能。文中详细解释了各种模式的实现、优缺点及应用场景,并给出了 Java 中的实现示例。
摘要由CSDN通过智能技术生成

声明:

本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

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

5.1 探讨单例模式

单例模式是设计模式中使用最为普遍的模式之一。它是一种对象创建模式,用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例

在 Java 中,这样的行为能带来两大好处

  • 对于频繁使用的对象,可以省略 new 操作花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
  • 由于 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 实例永远只会被创建一次)。比如:

在这里插入图片描述

上述 println 会打印出:

在这里插入图片描述

可以看到,即使系统没有要求创建单例,new Singleton() 也会被调用。

如果大家觉得这个小小的不足并不重要,我认为这种单例模式是一种不错的选择。它容易实现,代码易读而且性能优越。

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

在这里插入图片描述

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

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

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

在这里插入图片描述

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

5.2 不变模式

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

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

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

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

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

  • 当对象创建后,其内部状态和数据不再发生任何变化。
  • 对象需要被共享,被多线程频繁访问。

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

  • 去除 setter 方法以及所有修改自身属性的方法。
  • 将所有属性设置为私有,并用 final 标记,确保其不可修改。
  • 确保没有子类可以重载修改它的行为。
  • 有一个可以创建完整对象的构造函数。

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

在这里插入图片描述

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

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

  • java.lang.String
  • java.lang.Boolean
  • java.lang.Byte
  • java.lang.Character
  • java.lang.Double
  • java.lang.Float
  • java.lang.Integer
  • java.lang.Long
  • java.lang.Short

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

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

5.3 生产者-消费者模式

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

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

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

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

同时,由于内存缓冲区的存在,允许生产者和消费者在执行速度上存在时间差,无论是生产者在某一局部时间内速度高于消费者,还是消费者在局部时间内高于生产者,都可以通过共享内存缓冲区得到缓解,确保系统正常运行

在这里插入图片描述

生产者-消费者模式的主要角色如表 5.1 所示。

在这里插入图片描述

图 5.2 显示了生产者-消费者模式一种实现的具体结构。

在这里插入图片描述

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

基于图 5.2 所示结构,实现一个基于生产者-消费者模式的求整数平方的并行程序。

首先,生产者线程的实现如下,它构建 PCData 对象,并放入 BlockingQueue 队列中。

在这里插入图片描述

对应的消费者的实现如下。它从 BlockingQueue 队列中取出 PCData 对象,并进行相应的计算。

在这里插入图片描述

PCData 作为生产者和消费者之间的共享数据模型,定义如下:

在这里插入图片描述

在主函数中,创建三个生产者和三个消费者,并让它们协作运行。在主函数的实现中,定义 LinkedBlockingQueue 作为 BlockingQueue 的实现类。

在这里插入图片描述

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

5.5 Future 模式

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

Future 模式有点类似在网上买东西。如果我们在网上下单买了一个手机,当我们支付完成后,手机并没有办法立即送到家里,但是在电脑上会立即产生一个订单。这个订单就是将来发货或者领取手机的重要凭证,这个凭证也就是 Future 模式中会给出的一个契约。在支付活动结束后,大家不会傻傻地等着手机到来,而是可以各忙各的。而这张订单就成为了商家配货、发货的驱动力。当然,这一切你并不用关心。你要做的,只是在快递上门时,开一下门,拿一下货而已。

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

如图 5.6 所示,显示了通过传统的同步方法,调用一段比较耗时的程序。客户端发出 call 请求,这个请求需要相当长一段时间才能返回。客户端一直等待,直到数据返回,随后,再进行其他任务的处理。

在这里插入图片描述

使用 Future 模式替换原来的实现方式,可以改进其调用过程,如图 5.7 所示。

下面的模型展示了一个广义 Future 模式的实现,从 Data_Future 对象可以看到,虽然 call 本身仍然需要很长一段时间处理程序。但是,服务程序不等数据处理完成便立即返回客户端一个伪造的数据(相当于商品的订单,而不是商品本身),实现了 Future 模式的客户端在拿到这个返回结果后,并不急于对其进行处理,而去调用了其他业务逻辑,充分利用了等待时间,这就是 Future 模式的核心所在。在完成了其他业务逻辑的处理后,最后再使用返回比较慢的 Future 数据。这样,在整个调用过程中,就不存在无谓的等待,充分利用了所有的时间片段,从而提高系统的响应速度。

在这里插入图片描述

5.5.1 Future 模式的主要角色

为了让大家能够更清晰地认识 Future 模式的基本结构。在这里,我给出一个非常简单的 Future 模式的实现,它的主要参与者如表 5.2 所示。

在这里插入图片描述

它的核心结构如图 5.8 所示。

在这里插入图片描述

5.5.2 Future 模式的简单实现

在这个实现中,有一个核心接口 Data,这就是客户端希望获取的数据。在 Future 模式中,这个 Data 接口有两个重要的实现,分别是

  • RealData,也就是真实数据,这就是我们最终需要获得的,有价值的信息。
  • FutureData,它就是用来提取 RealData 的一个 “订单”,因此 FutureData 是可以立即返回得到的。

下面是 Data 接口:

在这里插入图片描述

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

注意: FutureData 是 Future 模式的关键。它实际上是真实数据 RealData 的代理,封装了获取 RealData 的等待过程

在这里插入图片描述

RealData 是最终需要使用的数据模型。它的构造很慢。在这里,使用 sleep() 函数模拟这个过程,简单地模拟一个字符串的构造。

在这里插入图片描述

接下来就是我们的客户端程序,Client 主要实现了获取 FutureData,并开启构造 RealData 的线程。并在接受请求后,很快的返回 FutureData。注意,它不会等待数据真的构造完毕再返回,而是立即返回 FutureData,即使这个时候 FutureData 内并没有真实数据

在这里插入图片描述

最后,就是我们的主函数 Main,它主要负责调用 Client 发起请求,并消费返回的数据。

在这里插入图片描述

5.5.3 JDK 中的 Future 模式

Future 模式是如此常用,因此 JDK 内部已经为我们准备好了一套完整的实现。显然,这个实现要比我们前面提出的方案复杂得多。在这里,我们将简单向大家介绍一下它的使用方式。

首先,让我们看一下 Future 模式的基本结构,如图 5.9 所示。其中 Future 接口就类似于前文描述的订单或者说是契约。通过它,你可以得到真实的数据。RunnableFuture 继承了 Future 和 Runnable 两个接口,其中 run() 方法用于构造真实的数据。它有一个具体的实现 FutureTask 类。FutureTask 有一个内部类 Sync,一些实质性的工作,会委托 Sync 类实现。而 Sync 类最终会调用 Callable 接口,完成实际数据的组装工作。

在这里插入图片描述

Callable 接口只有一个方法 call() ,它会返回需要构造的实际数据。这个 Callable 接口也是这个 Future 框架和应用程序之间的重要接口。如果我们要实现自己的业务系统,通常需要实现自己的 Callable 对象。此外, FutureTask 类也与应用密切相关,通常,我们会使用 Callable 实例构造一个 FutureTask 实例,并将它提交给线程池

下面我们将展示这个内置的 Future 模式的使用:

在这里插入图片描述

上述代码实现了 Callable 接口,它的 call() 方法会构造我们需要的真实数据并返回。当然这个过程可能是缓慢的,这里使用 Thread.sleep() 模拟它:

在这里插入图片描述

上述代码就是使用 Future 模式的典型。第 4 行,构造了 FutureTask 对象实例,表示这个任务是有返回值的。构造 FutureTask 时,使用 Callable 接口,告诉 FutureTask 我们需要的数据应该如何产生。接着再第 8 行,将 FutureTask 提交给线程池。显然,作为一个简单的任务提交,这里必然是立即返回的,因此程序不会阻塞。接下来,我们不用关心数据是如何产生的。可以去做一些额外的事情,然后在需要的时候可以通过 Future.get()(第 18 行)得到实际的数据。

除了基本的功能外,JDK 还为 Future 接口提供了一些简单的控制功能:

在这里插入图片描述

5.6 并行流水线

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

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

在这里插入图片描述

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

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

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

在这里插入图片描述

类似的思想可以借鉴到程序开发中。即使(B+C)* B/2 无法并行,但是如果你需要计算一大堆 B 和 C 的值,你依然可以将它流水化。首先将计算过程拆分为三个步骤:

  • P1:A=B+C
  • P2:D=AXB
  • P3:D=D/2

上述步骤中 P1、P2 和 P3 均在单独的线程中计算,并且每个线程只负责自己的工作。此时,P3 的计算结果就是最终需要的答案。

P1 接收 B 和 C 的值,并求和,将结果输入给 P2。P2 求乘积后输入给 P3。P3 将 D 除以 2 得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到(B+C)* B/2 的结果。

为了实现这个功能,我们需要定义一个在线程间携带结果进行信息交换的载体:

在这里插入图片描述

P1 计算的是加法:

在这里插入图片描述

上述代码中,P1 取得封装了两个操作数的 Msg,并进行求和,将结果传递给乘法线程 P2(第 9 行)。当没有数据需要处理时,P1 进行等待

P2 计算乘法:

在这里插入图片描述

和 P1 非常类似,P2 计算相乘结果后,将中间结果传递给除法线程 P3。

P3 计算除法:

在这里插入图片描述

P3 将结果除以 2 后输出最终的结果。

最后是提交任务的主线程,这里,我们提交 100 万个请求,让线程组进行计算:

在这里插入图片描述

上述代码第 13 行,将数据提交给 P1 加法线程,开启流水线的计算。在多核或者分布式场景中,这种设计思路可以有效地将有依赖关系的操作分配在不同的线程中进行计算,尽可能利用多核优势。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bm1998

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值