JAVA高并发——原子类和ConcurrentHashMap的增强、发布订阅模式

1、原子类增强

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

1.1、更快的原子类:LongAdder

大家对AtomicInteger的基本实现机制应该比较了解。它会在一个死循环内,不断尝试修改目标值,直到修改成功。如果竞争不激烈,那么修改成功的概率就很高,否则修改失败的概率就很高。在大量修改失败时,这些原子操作就会进行多次循环尝试,因此性能就会受到影响。

那么当竞争激烈的时候,我们应该如何进一步提高系统的性能呢?一个基本的解决方案就是使用热点分离将竞争的数据进行分解,基于这个思路,大家应该可以想到对传统AtomicInteger等原子类的改进方法。虽然在CAS操作中没有锁,但是像减小锁粒度这种分离热点的思想依然可以使用。一种可行的方案就是仿造ConcurrentHashMap,将热点数据分离。比如,可以将AtomicInteger的内部核心数据value分离成一个数组,每个线程访问时,通过哈希等算法映射到其中一个数字进行计数,而最终的计数结果则为这个数组的求和累加,下图显示了这种优化思路:
在这里插入图片描述
其中,热点数据value被分离成多个cell(单元),每个cell独自维护内部的值,当前对象的实际值由所有的cell累加合成,这样,热点就进行了有效的分离,提高了并行度。LongAdder正是使用了这种思想。

在实际的操作中,LongAdder并不会一开始就动用数组进行数据处理,而是将所有数据都先记录在一个称为base的变量中。在多线程条件下,如果大家修改base都没有冲突,那么也没有必要扩展为cell数组。但是,一旦base修改发生冲突,就会初始化cell数组,使用新的策略。如果使用cell数组更新后,发现在某一个cell上的更新依然发生冲突,那么系统就会尝试创建新的cell,或者将cell的数量加倍,以减小冲突的可能。

下面我们简单分析一下increment()方法(该方法会将LongAdder自增1)的内部实现:
在这里插入图片描述
它的核心是第4行的add()方法。最开始cells数组为null,因此数据会向base增加(第6行)。但是如果与base的操作冲突,则会进入第7行,并设置冲突标记uncontended为true。接着,如果判断出cells数组不可用,或者当前线程对应的cell为null,则直接进入longAccumulate()方法;否则会尝试使用CAS操作更新对应的cell数据,如果成功则退出,失败则进入longAccumulate()方法。

在longAccumulate()方法中,会对cells数组进行扩容,扩容的目录进一步避免冲突。比如,有8个线程,cell数量也是4个,那么冲突很容易发生。但是,如果有8个线程、16个cell,那么取得相同cell并引起冲突的概率就大大降低了。下面截取了longAccumulate()方法中的相关部分:
在这里插入图片描述
同时,为了更快地进行多线程对cell的访问,在JDK内部,已经充分考虑了伪共享的问题:
在这里插入图片描述
在cell对象的定义中使用了@sun.misc.Contended,虚拟机会自动为Cell.value的前后生成足够多的padding数据,使得一个Cell对象实例可以占据一个缓存行。这样无论不同线程如何访问Cell数组中的不同成员,都不会出现缓存行失效的问题。

下面我们简单地对LongAdder、原子类及同步锁进行性能测试。测试方法是使用多个线程对同一个整数进行累加,我们观察一下使用3种不同方法时所消耗的时间。

首先,定义一些辅助变量:
在这里插入图片描述
上述代码指定了测试线程数量、目标总数,以及3个初始值为0的整型变量:acount、lacount和count。它们分别表示使用AtomicLong、LongAdder和锁进行同步时的操作对象。

下面是使用同步锁时的测试代码:
在这里插入图片描述
上述代码第10行定义线程SyncThread,它使用加锁方式增加count的值。在第30行定义的testSync()方法中,使用线程池控制多线程进行累加操作。

下面使用类似的方法实现原子类累加计时统计:
在这里插入图片描述
同理,以下代码使用LongAdder实现类似的功能:
在这里插入图片描述
注意,由于在LongAdder中,将单个数值分解为多个不同的段,因此在累加后,上述代码中第11行的increment()方法并不能返回当前的数值。要取得当前的实际值,需要使用第12行的sum()方法重新计算。这个计算是有额外成本的,但即使加上这个额外成本,LongAdder的表现还是比AtomicLong好。

执行这些代码,就可以得到锁、原子类和LongAdder三者的性能比较数据,如下所示:
在这里插入图片描述
可以看到,就计数性能而言,LongAdder已经超越了普通的原子类了。其中,锁操作耗时约1784ms,普通原子类操作耗时约695ms,而LongAdder仅需要227ms。

LongAdder的另外一个优势是避免了伪共享。注意,LongAdder并不是直接使用padding这种看起来比较碍眼的做法,而是引入了一种新的注释@sun.misc.Contended。

对于LongAdder中的每一个cell,它的定义如下:
在这里插入图片描述
可以看到,在上述代码第1行声明了cell类为sun.misc.Contended, Java虚拟机自动为cell解决伪共享问题。

当然,在我们自己的代码中也可以使用sun.misc.Contended来解决伪共享问题,但是需要额外使用虚拟机参数-XX:-RestrictContended,否则,这个注释将被忽略。

1. 2、LongAdder功能的增强版:LongAccumulator

LongAccumulator是LongAdder的“亲兄弟”,它们有公共的父类Striped64,LongAccumulator内部的优化方式和LongAdder的是一样的。它们都将一个long型整数分割,并存储在不同的变量中,以防止多线程竞争。两者的主要逻辑是类似的,LongAccumulator是LongAdder的功能扩展。对于LongAdder来说,它只是每次对给定的整数执行一次加法,而LongAccumulator则可以实现任意函数操作。

用下面的构造函数创建一个LongAccumulator实例:
在这里插入图片描述
第一个参数accumulatorFunction就是需要执行的二元函数,第二个参数是初始值。

下面这个例子展示了LongAccumulator的使用方法,它将通过多线程访问若干个整数,并返回遇到的最大的那个数:
在这里插入图片描述
上述代码第2行构造了LongAccumulator实例。由于我们要过滤最大值,因此传入Long::max函数句柄。当有数据通过accumulate()方法传入LongAccumulator后(第9行),LongAccumulator会通过Long::max识别最大值并保存在内部(很可能是cell数组内,也可能是base)。代码第16行通过longValue()方法对所有的cell进行Long::max操作,得到最大值。

2、ConcurrentHashMap的增强

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

2.1、forEach操作

新版本的ConcurrentHashMap增加了一些foreach操作,如下所示:
在这里插入图片描述
这些forEach操作的接口是一个Consumer或者BiConsumer,用于对Map的数据进行消费。

2.2、reduce操作

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

下图显示了ConcurrentHashMap支持的reduce操作:
在这里插入图片描述
下面是一个reduce操作的示例,用于并行计算ConcurrentHashMap中所有value的总和。第一个参数parallelismThreshold表示并行度,表示一个并行任务可以处理的元素个数(估算值)。如果设置为Long.MAX_VALUE,则表示完全禁用并行,如果设置为1,则表示最大限度地使用并行:
在这里插入图片描述

2.3、条件插入

在应用开发中,一个十分常见的场景是条件插入,即当元素不存在时需要创建并且将对象插入Map中,而当Map中已经存在该元素时,则直接获得当前Map中的元素,从而避免多次创建。这样可以起到对象复用的作用,对于大型重量级对象有很好的优化效果。下面的代码展示了这个场景:
在这里插入图片描述
上述代码第12~18行,首先判断对象是否存在,如果不存在则创建对象并返回,如果存在则直接返回对象。代码实现比较简单,但是忽略了一个问题,那就是这段代码不是线程安全的。当多个线程同时访问getOrCreate()方法时,可能出现重复创建对象的情况。简单的处理方法是将getOrCreate()方法设置为同步方法,但这样做会极大地降低该方法的性能。同时,这里所说的重复创建对象的可能性很小,仅仅可能发生在第一次创建对象前后。一旦对象创建,就不再需要同步了。在这种场景中我们迫切地需要一种线程安全的高效方法——computeIfAbsent():
在这里插入图片描述

2.4、search操作

基于ConcurrentHashMap还可以做并发search,下图中有几个search操作方法:
在这里插入图片描述
search操作会在Map中找到第一个使得Function返回不为null的值。比如,下面的代码将找到Map中可以被25整除的数(由于Hash的随机性和并行的随机性,因此得到结果也是随机的):
在这里插入图片描述

2.5、其他新方法

1.mappingCount()方法

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

2.newKeySet()方法

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

3、发布订阅模式

在JDK 9中,引入了一种新的并发编程架构——反应式编程。那么什么是反应式编程呢?反应式编程用于处理异步流中的数据。每当应用程序收到数据,便会对它进行处理。反应式编程以流的形式处理数据,因此其内存使用效率更高。

在反应式编程中,核心的两个组件是发布者(Publisher)和订阅者(Subscriber)。发布者将数据发布到流中,订阅者则负责处理这些数据,发布订阅模式的工作流程如下图所示:
在这里插入图片描述
以下是反应式编程的主要API:

在这里插入图片描述
其中Subscriber是订阅者,用来处理数据:

  • onSubscribe():订阅者注册后被调用的第一个方法。
  • onNext():当有下一个数据项准备好时,进行通知。
  • onError():当发生无法恢复的异常时被调用。
  • onComplete():当没有更多数据需要处理时被调用。

Subscription表示对订阅数据的处理:

  • request():设定请求的数据个数。
  • cancel():Subscriber停止接收新的消息。

3.1、简单的发布订阅模式案例

本节介绍一个简单的发布订阅模式案例。首先看一下订阅者:
在这里插入图片描述
在这里插入图片描述

上述代码实现了一个订阅者,第6行的onSubscribe()方法在注册后首先被调用。第9行代码请求一个数据流中的数据,这行代码非常重要,没有它订阅者将无法消费数据。当数据流中有可用数据时,调用第14行的onNext()方法。处理完成后,通过request()方法再次请求剩余的数据。当出现错误或者完成后,进行通知,结束程序。

下面是数据发布的示例:
在这里插入图片描述
在这里插入图片描述
第1行代码创建SubmissionPublisher对象,表示数据的发布者。第5行和第6行代码向SubmissionPublisher中注册两个订阅者。第14行代码将数据发布到SubmissionPublisher中,数据发布后,通过close()方法关闭发布者。第24行等待订阅者处理完毕。

3.2、数据处理链

发布订阅模式还可以通过数据处理链对数据进行流式处理。一个泛化的数据转换模块如下:
在这里插入图片描述
在这里插入图片描述
其中,第2行的function包含数据转换的具体逻辑。在第17行的onNext()方法中,使用该逻辑对数据进行处理,并同时将处理结果再次发布,以便进行后续处理。

下述代码建立针对数据的处理链:
在这里插入图片描述

第7~11行代码建立数据处理链。这里的规则是,对于数据流中的数据进行两种不同的业务处理,在一条处理流中将字母转为大写,在另外一条数据流中将字母转为小写。接着打印输出转换后的两类数据(第10和11行),处理逻辑如下图所示:
在这里插入图片描述

  • 20
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值