6实战java高并发程序设计---Java 8/9/10与并发

2014年,Oracle发布了新版本Java 8。对于Java来说,这显然是一个具有里程碑意义的版本。它最主要的改进是增加了函数式编程的功能。就目前来说,Java最令人头痛的问题,也是受到最多质疑的地方,应该就是Java烦琐的语法。这样我们不得不花费大量的代码行数,来实现一些司空见惯的功能,以至于Java程序总是冗长的。但是,这一切将在Java 8的函数式编程中得到缓解。

严格来说,函数式编程与我们的主题并没有太大关系,我似乎不应该在这里提及它。但是,在Java 8中新增的一些与并行相关的API,却以函数式编程的范式出现,为了能让大家更好地理解这些功能,我会先简要地介绍一下Java 8中的函数式编程。


6.1 Java 8的函数式编程简介

6.1.1 函数作为一等公民

在理解函数作为一等公民这句话时,让我们先来看一种非常常用的互联网语言JavaScript,相信大家对它都不会陌生。JavaScript并不是严格意义上的函数式编程,不过,它也不是属于严格的面向对象编程。但是,如果你愿意,那么你既可以把它当作面向对象语言,也可以把它当作函数式语言,因此,把JavaScript称为多范式语言可能更加合适。

注意这里each()函数的参数,这是一个匿名函数,在遍历所有的li节点时,会弹出li节点的文本内容。将函数作为参数传递给另外一个函数,这是函数式编程的特性之一。

这也是一段JavaScript代码,在这段代码中,注意函数f1的返回值,它返回了函数f2。在倒数第2行,返回f2函数并赋值给result,实际上,此时的result就是一个函数,并且指向f2。对result的调用,就会打印n的值。

一个函数可以作为另外一个函数的返回值,也是函数式编程的重要特点。

6.1.2 无副作用

函数的副作用指的是函数在调用过程中,除给出了返回值之外,还修改了函数外部的状态

比如,函数在调用过程中修改了某一个全局状态。函数式编程认为,函数的副用作应该被尽量避免

可以想象,如果一个函数肆意修改全局或者外部状态,当系统出现问题时,我们可能很难判断究竟是哪个函数引起的问题,这对于程序的调试和跟踪是没有好处的。如果函数都是显式函数,那么函数的执行显然不会受到外部或者全局信息的影响,因此,对于调试和排错是有益的。

注意:显式函数指函数与外界交换数据的唯一渠道就是参数和返回值,显式函数不会去读取或者修改函数的外部状态。与之相对的是隐式函数,隐式函数除参数和返回值外,还会读取外部信息,或者修改外部信息

6.1.3 声明式的(Declarative)

函数式编程是声明式的编程方式。命令式(Imperative)程序设计喜欢大量使用可变对象和指令。我们总是习惯于创建对象或者变量,并且修改它们的状态或值,或者喜欢提供一系列指令,要求程序执行。这种编程习惯在声明式的函数式编程中有所变化。对于声明式的编程范式,你不再需要提供明确的指令操作,所有的细节指令将会更好地被程序库封装,你要做的只是提出你的要求,声明你的用意即可

请看下面一段程序,这是一段传统的命令式编程,为了打印数组中的值,我们需要进行一个循环,并且每次需要判断循环是否结束。在循环体内,我们要明确地给出需要执行的语句和参数。

与之对应的声明式编程代码如下:

可以看到,变量数组的循环体居然消失了!println()函数似乎在这里也没有指定任何参数,在此,我们只是简单地声明了用意。有关循环及判断循环是否结束等操作都被简单地封装在程序库中。

6.1.4 不变的对象

在函数式编程中,几乎所有传递的对象都不会被轻易修改。请看以下代码:

代码第2行看似对每一个数组成员执行了加1的操作。但是在操作完成后,在最后一行打印arr数组所有的成员值时,你还是会发现,数组成员并没有变化!在使用函数式编程时,这种状态是一种常态,几乎所有的对象都拒绝被修改。这非常类似于不变模式。

6.1.5 易于并行

由于对象都处于不变的状态,因此函数式编程更加易于并行。实际上,你甚至完全不用担心线程安全的问题。我们之所以要关注线程安全,一个很重要的原因是当多个线程对同一个对象进行写操作时,容易将这个对象“写坏”。但是,由于对象是不变的,因此,在多线程环境下,也就没有必要进行任何同步操作了。这样有利于并行化,同时,在并行化后,由于没有同步和锁机制,其性能也会比较好。

6.1.6 更少的代码

通常情况下,函数式编程更加简明扼要

请看下面这个例子,对于数组中每一个成员,首先判断是否是奇数,如果是奇数,则执行加1,并最终打印数组内所有成员。


6.2 函数式编程基础

在正式进入函数式编程之前,有必要先了解一下Java 8为支持函数式编程所做的基础性的改进,这里将简要介绍一下FunctionalInterface注释、接口默认方法和方法句柄。

6.2.1 FunctionalInterface注释

Java 8提出了函数式接口的概念。所谓函数式接口,简单地说,就是只定义了单一抽象方法的接口。比如下面的定义:

注释FunctionalInterface用于表明IntHandler接口是一个函数式接口,该接口被定义为只包含一个抽象方法handle(),因此它符合函数式接口的定义。如果一个函数满足函数式接口的定义,那么即使不标注为@FunctionalInterface,编译器依然会把它看作函数式接口。这有点像@Override注释,如果你的函数符合重载的要求,无论你是否标注了@Override,编译器都会识别这个重载函数,但如果你进行了标注,而实际的代码不符合规范,那么就会得到一个编译错误的提示。

这里需要强调的是,函数式接口只能有一个抽象方法,而不是只能有一个方法

这里需要强调的是,函数式接口只能有一个抽象方法,而不是只能有一个方法。这分两点来说明:首先,在Java 8中,接口运行存在实例方法(参见下节的“接口默认方法”);其次,任何被java.lang.Object实现的方法,都不能视为抽象方法,因此NonFunc接口不是函数式接口,因为equals()方法在java.lang.Object中已经实现。

函数式接口的实例可以由方法引用或者lambda表达式进行构造,我们将在后面进一步举例说明。

6.2.2 接口默认方法(接口里面竟然可以定义的方法里面可以有方法体,实现类可以不重写接口里的默认方法 ,实现类可以直接拿来用,不需要重写方法,有继承的味道)

在Java 8之前的Java版本,接口只能包含抽象方法。但从Java 8开始,接口也可以包含若干个实例方法。这一改进使得Java 8拥有了类似于多继承的能力。一个对象实例,将拥有来自多个不同接口的实例方法。

注意上述代码中Mule实例同时拥有来自不同接口的实现方法,这在Java 8之前是做不到的。从某种程度上说,这种模式可以弥补Java单一继承的一些不便。但同时也要知道,它也将遇到和多继承相同的问题,如图6.2所示。如果IDonkey也存在一个默认的run()方法,那么同时实现它们的Mule就会不知所措,因为它不知道应该以哪个方法为准。

6.2.3 lambda表达式

lambda表达式可以说是函数式编程的核心。lambda表达式即匿名函数,它是一段没有函数名的函数体,可以作为参数直接传递给相关的调用者,lambda表达式极大地增强了Java语言的表达能力

和匿名对象一样,lambda表达式也可以访问外部的局部变量,如下所示:

上述代码可以编译通过,正常执行并输出6。与匿名内部对象一样,在这种情况下,外部的num变量必须声明为final,这样才能保证在lambda表达式中合法地访问它

奇妙的是,对于lambda表达式而言,即使去掉上述的final定义,程序依然可以编译通过!但千万不要以为这样你就可以修改num的值了。实际上,这只是Java 8做了一个小处理,它会自动地将在lambda表达式中使用的变量视为final

6.2.4 方法引用

方法引用是Java 8中提出的用来简化lambda表达式的一种手段。它通过类名和方法名来定位一个静态方法或者实例方法。

方法引用在Java 8中的使用非常灵活。总的来说,可以分为以下几种。

● 静态方法引用:ClassName::methodName。

● 实例上的实例方法引用:instanceReference::methodName。

● 超类上的实例方法引用:super::methodName。

● 类型上的实例方法引用:ClassName::methodName。

● 构造方法引用:Class::new。

● 数组构造方法引用:TypeName[]::new。

首先,方法引用使用“::”定义,“::”的前半部分表示类名或者实例名,后半部分表示方法名称。如果是构造函数,则使用new表示

对于第一个方法引用“User::getName”,表示User类的实例方法。在执行时,Java会自动识别流中的元素(这里指User实例)是作为调用目标还是调用方法的参数。在“User::getName”中,显然流内的元素都应该作为调用目标,因此实际上,在这里调用了每一个User对象实例的getName()方法,并将这些User的name作为一个新的流。同时,对于这里得到的所有name,使用方法引用System.out::println进行处理。这里的System.out为PrintStream对象实例,因此,这里表示System.out实例的println方法。系统也会自动判断,流内的元素此时应该作为方法的参数传入,而不是调用目标。

如果一个类中存在同名的实例方法和静态函数,那么编译器就会感到很困惑,因为此时,它不知道应该使用哪个方法进行调用。它既可以选择同名的实例方法,将流内元素作为调用目标,也可以使用静态方法,将流元素作为参数。


6.3 一步一步走入函数式编程

lambda表达式。表达式由“->”分割,左半部分表示参数,右半部分表示实现体。因此,我们也可以简单地理解lambda表达式只是匿名对象实现的一种新的方式。实际上,也是这样的。下图使用lambda表达式,并且使用方法引用

addThen()


6.4 并行流与并行排序

Java 8可以在接口不变的情况下,将流改为并行流。这样,就可以很自然地使用多线程进行集合中的数据处理。

6.4.1 使用并行流过滤数据

接着,使用函数式编程统计给定范围内所有的质数。

上述代码是串行的,将它改造成并行计算非常简单,只需要将流并行化即可。parallel()方法得到一个并行流,然后在并行流上进行过滤,此时,PrimeUtil.isPrime()函数会被多线程并发调用,应用于流中的所有元素。记住下面的IntStream和parallel两个方法

6.4.2 从集合得到并行流

在函数式编程中,我们可以从集合得到一个流或者并行流。下面这段代码试图统计集合内所有学生的平均分:

从集合对象List中,我们使用stream()方法可以得到一个流。如果希望将这段代码并行化,则可以使用parallelStream()函数。

可以看到,将原有的串行方式改造成并行执行是非常容易的。

6.4.3 并行排序

除了并行流,对于普通数组,Java 8也提供了简单的并行功能。比如,对于数组排序,我们有Arrays.sort()方法,当然这是串行排序,但在Java 8中可以使用新增的Arrays.parallelSort()方法直接使用并行排序。比如,你可以这样使用:

除了并行排序,Arrays中还增加了一些API用于数组中数据的赋值,比如:

当然,以上过程是串行的。但是只要使用setAll()方法对应的并行版本,你就可以很快将它执行在多个CPU上。


6.5 增强的Future:CompletableFuture

CompletableFuture是Java 8新增的一个超大型工具类。为什么说它大呢?因为它实现了Future接口,而更重要的是,它也实现了CompletionStage接口。CompletionStage接口也是Java 8中新增的,它拥有多达约40种方法!是的,你没有看错,这看起来完全不符合设计中所谓的“单方法接口”原则,但是在这里,它就这么存在了。这个接口拥有如此众多的方法,是为函数式编程中的流式调用准备的。通过CompletionStage接口,我们可以在一个执行结果上进行多次流式调用,以此可以得到最终结果。比如,你可以在一个CompletionStage接口上进行如下调用:

6.5.1 完成了就通知我

CompletableFuture和Future一样,可以作为函数调用的契约。向CompletableFuture请求一个数据,如果数据还没有准备好,请求线程就会等待。而让人惊喜的是,我们可以手动设置CompletableFuture的完成状态。

上述代码在第1~17行定义了一个AskThread线程。它接收一个CompletableFuture作为其构造函数,它的任务是计算CompletableFuture表示的数字的平方,并将其打印。代码第20行,我们创建一个CompletableFuture对象实例。第21行,我们将这个对象实例传递给这个AskThread线程,并启动这个线程。此时,AskThread在执行到第12行代码时会阻塞,因为CompletableFuture中根本没有它所需要的数据,整个CompletableFuture处于未完成状态。第23行用于模拟长时间的计算过程。当计算完成后,可以将最终数据载入CompletableFuture,并标记为完成状态(第25行)。当第25行代码执行后,表示CompletableFuture已经完成,因此AskThread就可以继续执行了。

6.5.2 异步执行任务

通过CompletableFuture提供的进一步封装,我们很容易实现Future模式那样的异步调用。比如:

6.5.3 流式调用

在前文中我已经提到,CompletionStage的40个接口是为函数式编程做准备的。在这里,就让我们看一下,如何使用这些接口进行函数式的流式API调用。

我们在第15行执行CompletableFuture.get()方法,目的是等待calc()函数执行完成。由于CompletableFuture异步执行的缘故,如果不进行这个等待调用,那么主函数不等calc()方法执行完毕就会退出,随着主线程的结束,所有的Daemon线程都会立即退出,从而导致calc()方法无法正常完成

6.5.4 CompletableFuture中的异常处理

如果CompletableFuture在执行过程中遇到异常,那么我们可以用函数式编程的风格来优雅地处理这些异常。CompletableFuture提供了一个异常处理方法exceptionally():

6.5.5 组合多个CompletableFuture

CompletableFuture还允许你将多个CompletableFuture进行组合。一种方法是使用thenCompose()方法,一个CompletableFuture可以在执行完成后,将执行结果通过Function接口传递给下一个CompletionStage实例进行处理(Function接口返回新的CompletionStage实例):

另外一种组合多个CompletableFuture的方法是thenCombine()方法

方法thenCombine()首先完成当前CompletableFuture和other的执行。接着,将这两者的执行结果传递给BiFunction(该接口接收两个参数,并有一个返回值),并返回代表BiFunction实例的CompletableFuture对象。

上述代码中,首先生成两个CompletableFuture实例(第6~7行),接着使用thenCombine()方法组合这两个CompletableFuture,将两者的执行结果进行累加(由第9行的(i, j) -> (i + j)实现),并将其累加结果转为字符串输出。

6.5.6 支持timeout的 CompletableFuture

在JDK 9以后CompletableFuture增加了timeout功能。如果一个任务在给定时间内没有完成,则直接抛出异常。


6.6 读写锁的改进:StampedLock

StampedLock是Java 8中引入的一种新的锁机制,可以认为它是读写锁的一个改进版本。读写锁虽然分离了读和写的功能,使得读与读之间可以完全并发。但是,读和写之间依然是冲突的。读锁会完全阻塞写锁,它使用的依然是悲观的锁策略,如果有大量的读线程,它也有可能引起写线程的“饥饿”。


6.7 原子类的增强

在之前的章节中已经提到了原子类的使用,无锁的原子类操作使用系统的CAS指令,有着远远超越锁的性能,是否有可能在性能上更上一层楼呢?答案是肯定的。Java 8引入了LongAdder类,它在java.util.concurrent.atomic包下,因此,可以推测,它也使用了CAS指令

6.7.1 更快的原子类:LongAdder

LongAdder使用了热点分离的方法,类似concurrentHashMap的多段锁,降低锁粒度


6.8 ConcurrentHashMap的增强

在JDK 1.8以后,ConcurrentHashMap有了一些API的增强,其中很多增强接口与lambda表达式有关,这些增强接口大大方便了应用的开发。

6.8.1 foreach操作

新版本的ConcurrentHashMap增加了一些foreach操作,如下所示。

6.8.2 reduce操作

和foreach操作类似,reduce操作对Map的数据进行处理的同时会将其转为另一种形式。可以认为这是foreach操作的Function版本。图6.6显示了支持的reduce操作。

下面是一个reduce操作的示例,用于并行计算ConcurrentHashMap中所有value的总和。第一个参数parallelismThreshold表示并行度,表示一个并行任务可以处理的元素个数(估算值)。如果设置为Long.MAX_VALUE,则表示完全禁用并行,设置为1则表示使用最大并行可能。

6.8.3 条件插入computeIfAbsent()

在应用开发中,一个十分常见的场景是条件插入,即当元素不存在时需要创建并且将对象插入Map中,而当Map中已经存在该元素时,则直接获得当前在Map中的元素,从而避免多次创建。这样可以起到对象复用的功能,对于大型重量级对象有很好的优化效果。

上述代码第12~18行,首先判断对象是否存在,如果不存在则创建并返回,如果存在,则直接返回该对象。代码实现比较简单,但是忽略了一个问题,那就是这段代码不是线程安全的。当多个线程同时访问getOrCreate()方法时,还是可能出现重复创建对象的情况。简单的处理方法是将getOrCreate()方法设置为同步方法,但这样做会极大地降低该方法的性能。同时,这里所说的重复创建对象的可能性也很小,仅仅可能发生在第一次创建对象前后。一旦对象创建,就不再需要同步了。因此,在这种场合我们迫切地需要一种线程安全的高效方法—computeIfAbsent()函数。

6.8.4 search操作

基于ConcurrentHashMap还可以做并发搜索,图6.7中有几个搜索函数。

6.8.5 其他新方法

1.mappingCount()方法返回Map中的条目总数。有别于size()方法,该方法返回是long型数据。因此,当元素总数超过整数最大值时,应该使用这个方法。同时,该方法并不返回精确值,如果在执行该方法时,同时存在并发的插入或者删除操作,则结果是不准确的。

2.newKeySet()方法在JDK中,Set的实现依附于Map,实际上,Set是Map的一种特殊情况。如果需要一个线程安全的高效并发HashSet,那么基于ConcurrentHashMap的实现是最好的选择。该方法是一个静态工厂方法,返回一个线程安全的Set。

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

猜你喜欢

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