Java学习day096 并发(六)(线程安全的集合:高效的映射、集和队列、映射条目的原子更新、对并发散列映射的批操作、并发集视图、写数组的拷贝、并行数组算法、较早的线程安全集合)

使用的教材是java核心技术卷1,我将跟着这本书的章节同时配合视频资源来进行学习基础java知识。

day096   并发(六)(线程安全的集合:高效的映射、集和队列、映射条目的原子更新、对并发散列映射的批操作、并发集视图、写数组的拷贝、并行数组算法、较早的线程安全集合)

如果多线程要并发地修改一个数据结构,例如散列表,那么很容易会破坏这个数据结构。例如,一个线程可能要开始向表中插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中,被剥夺了控制权。如果另一个线程也开始遍历同一个链表,可能使用无效的链接并造成混乱,会抛出异常或者陷人死循环。

可以通过提供锁来保护共享数据结构,但是选择线程安全的实现作为替代可能更容易些。当然,前一节讨论的阻塞队列就是线程安全的集合。在下面各小节中,将讨论Java类库提供的另外一些线程安全的集合。


1.高效的映射、集和队列

java.util.concurrent包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConcurrentLinkedQueue。这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。

与大多数集合不同,size方法不必在常量时间内操作。确定这样的集合当前的大小通常需要遍历。

集合返回弱一致性(weakly consistent)的迭代器。这意味着迭代器不一定能反映出它们被构造之后的所有的修改,但是,它们不会将同一个值返回两次,也不会拋出ConcurrentModificationException异常。

并发的散列映射表,可高效地支持大量的读者和一定数量的写者。默认情况下,假定可以有多达16个写者线程同时执行。可以有更多的写者线程,但是,如果同一时间多于16个,其他线程将暂时被阻塞。可以指定更大数目的构造器,然而,恐怕没有这种必要。


2.映射条目的原子更新

ConcurrentHashMap原来的版本只有为数不多的方法可以实现原子更新,这使得编程多少有些麻烦。假设我们希望统计观察到的某些特性的频度。作为一个简单的例子,假设多个线程会遇到单词,我们想统计它们的频率。

可以使用ConcurrentHashMap<String,Long>吗?考虑让计数自增的代码。显然,下面的代码不是线程安全的:

Long oldValue =map.get(word);
Long newValue = oldValue == null ?1: oldValue+1;
map.put(word, newValue);//Error-might not replace oldValue

可能会有另一个线程在同时更新同一个计数。

传统的做法是使用replace操作,它会以原子方式用一个新值替换原值,前提是之前没有其他线程把原值替换为其他值。必须一直这么做,直到replace成功:

do
{
    oldValue =map.get(word);
    newValue=oldValue=null ? 1 : oldValue + 1;
} while (!map.replace(word, oldValue,newValue));

或者,可以使用一个 ConcurrentHashMap<String,AtomicLong>,或者在 Java SE 8中,
还可以使用 ConcurrentHashMap<String,LongAdder>。更新代码如下:

map.putlfAbsent(word, new LongAdder());
map.get(word).increment();

第一个语句确保有一个LongAdder可以完成原子自增。由于putlfAbsent返回映射的的值(可能是原来的值,或者是新设置的值),所以可以组合这两个语句:

map.putlfAbsent(word, new LongAdder()).increraent();

JavaSE8提供了一些可以更方便地完成原子更新的方法。调用compute方法时可以提供一个键和一个计算新值的函数。这个函数接收键和相关联的值(如果没有值,则为mill),它会计算新值。例如,可以如下更新一个整数计数器的映射:

map.compute(word, (k, v) -> v = null ?1: v+1 );

另外还有computelfPresent和computelfbsent方法,它们分别只在已经有原值的情况下计算新值,或者只有没有原值的情况下计算新值。可以如下更新一个LongAdder计数器映射:

map.computelfAbsent(word,k->newLongAdder())_increment();

这与之前看到的putlfAbsent调用几乎是一样的,不过LongAdder构造器只在确实需要一个新的计数器时才会调用。首次增加一个键时通常需要做些特殊的处理。利用merge方法可以非常方便地做到这一点。这个方法有一个参数表示键不存在时使用的初始值。否则,就会调用你提供的函数来结合原值与初始值。(与compute不同,这个函数不处理键。)

map.merge(word,1L,(existingValue,newValue)->existingValue+newValue);

或者,更简单地可以写为:

map.merge(word,1L,Long::sum);

再不能比这更简洁了。

如果传入 compute 或 merge 的函数返回 null, 将从映射中删除现有的条目。


3.对并发散列映射的批操作

JavaSE8为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。

有3种不同的操作:

•搜索(search)为每个键或值提供一个函数,直到函数生成一个非null的结果。然后搜索终止,返回这个函数的结果。

•归约(reduce)组合所有键或值,这里要使用所提供的一个累加函数。

•forEach为所有键或值提供一个函数。

每个操作都有4个版本:

•operationKeys:处理键。

•operatioriValues:处理值。

•operation:处理键和值。

•operatioriEntries:处理Map.Entry对象。

对于上述各个操作,需要指定一个参数化阈值(/wa/Zefc/w/AresAoW)。如果映射包含的元素多于这个阈值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈值Long.MAX_VALUE。如果希望用尽可能多的线程运行批操作,可以使用阈值1。

下面首先来看search方法。有以下版本:

U searchKeys(long threshold, BiFunction<? super K, ? extends U> f)
U searchVaiues(long threshold, BiFunction<? super V, ? extends U> f)
U search(long threshold, BiFunction<? super K, ? super V,? extends U> f)
U searchEntries(long threshold, BiFunction<Map.Entry<K, V>, ? extends U> f)

例如,假设我们希望找出第一个出现次数超过1000次的单词。需要搜索键和值:

String result=map.search(threshold,(k,v)->v>1000?k:null);

result会设置为第一个匹配的单词,如果搜索函数对所有输人都返回null,则返回null。forEach方法有两种形式。

第一个只为各个映射条目提供一个消费者函数,例如:

map.forEach(threshold,(k,v)->System.out.println(k+"->"+v));

第二种形式还有一个转换器函数,这个函数要先提供,其结果会传递到消费者:

map.forEach(threshold,
    (k,v)->k+"->"+v,//Transformer
    System.out::println);//Consumer

转换器可以用作为一个过滤器。只要转换器返回null,这个值就会被悄无声息地跳过。例如,下面只打印有大值的条目:

map.forEach(threshold,
    (k,v)->v>1000?k+"->"+v:null,//Filter and transformer
    System.out::println);//The nulls are not passed to the consumer

reduce操作用一个累加函数组合其输入。例如,可以如下计算所有值的总和:

Long sum=map.reduceValues(threshold,Long::sum);

与forEach类似,也可以提供一个转换器函数。可以如下计算最长的键的长度:

Integer maxlength = map.reduceKeys(threshold,
String::length,//Transformer
Integer::max); //Accumulator

转换器可以作为一个过滤器,通过返回null来排除不想要的输入。在这里,我们要统计多少个条目的值>1000:

Long count = map.reduceValues(threshold,
v -> v > 1000 ?1 L : null,
Long::sum);

对于int、long和double输出还有相应的特殊化操作,分别有后缀Tolnt、ToLong和ToDouble。需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射为空时返回默认值。

long sum = map. 「educeValuesToLong(threshold,
Long::longValue,//Transformer to primitive type
0,// Default value for empty map
Long::sura); //Primitive type accumulator

4.并发集视图

假设你想要的是一个大的线程安全的集而不是映射。并没有一个ConcurrentHashSet类,而且你肯定不想自己创建这样一个类。当然,可以使用ConcurrentHashMap(包含“假”值),不过这会得到一个映射而不是集,而且不能应用Set接口的操作。

静态newKeySet方法会生成一个Set<K>,这实际上是ConcurrentHashMap<K,Boolean>的一个包装器。

Set<String> words = ConcurrentHashMap.<String>newKeySet();

当然,如果原来有一个映射,keySet方法可以生成这个映射的键集。这个集是可变的。如果删除这个集的元素,这个键(以及相应的值)会从映射中删除。不过,不能向键集增加元素,因为没有相应的值可以增加。JavaSE8为ConcurrentHashMap增加了第二个keySet方法,包含一个默认值,可以在为集增加元素时使用:

Set<String> words=map.keySet(1L);
words.add("]ava");

如果"Java”在words中不存在,现在它会有一个值1。


5.写数组的拷贝

CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数,这样的安排是很有用的。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组,但是,集合的数组已经被替换了。因而,旧的迭代器拥有一致的(可能过时的)视图,访问它无须任何同步开销。


6.并行数组算法

在JavaSE8中,Arrays类提供了大量并行化操作。静态Arrays.parallelSort方法可以对一个基本类型值或对象的数组排序。例如,

String contents = new String(Fi1es.readAllBytes(
Paths.get("alice.txt")), StandardCharsets.UTF_8);//Read file into string
String[] words = contents.split("[\\P{L}]+");//Split along nonletters
Arrays.parallelSort(words);

对对象排序时,可以提供一个Comparator。

Arrays,parallelSort(words,Comparator.comparing(String::length));

对于所有方法都可以提供一个范围的边界,如:

values,parallelSort(values,length/2,values,length);//Sort the upper half

parallelSetAll方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算相应位置上的值。

Arrays.parallelSetAll(values,i->i%10);//Fills values with 0123456789012...

显然,并行化对这个操作很有好处。这个操作对于所有基本类型数组和对象数组都有相应的版本。最后还有一个parallelPrefix方法,它会用对应一个给定结合操作的前缀的累加结果替换各个数组元素。这是什么意思?这里给出一个例子。考虑数组[1,2,3,4,...]和x操作。执行Arrays.parallelPrefix(va丨ues,(x,y)->x*y)之后,数组将包含:

[1,1x2,1x2x3,lx2xBx4,...]

可能很奇怪,不过这个计算确实可以并行化。首先,结合相邻元素,如下所示:

[1,1x2,,3x4,5,5x6,7,7x8]

灰值保持不变。显然,可以在不同的数组区中并行完成这个计算。下一步中,通过将所指示的元素与下面一个或两个位置上的元素相乘来更新这些元素:

[1,1x2,1x2xB,1x2x3x4,,:>6,5x6x7,5x6x7x8]

这同样可以并行完成。log(»)步之后,这个过程结束。如果有足够多的处理器,这会远远胜过直接的线性计算。这个算法在特殊用途硬件上很常用,使用这些硬件的用户很有创造力,会相应地调整算法来解决各种不同的问题。


7.较早的线程安全集合

从Java的初始版本开始,Vector和Hashtable类就提供了线程安全的动态数组和散列表的实现。现在这些类被弃用了,取而代之的是AnayList和HashMap类。这些类不是线程安全的,而集合库中提供了不同的机制。任何集合类都可以通过使用同步包装器(synchronizationwrapper)变成线程安全的:

List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Col1ections.synchronizedMap(new HashMap<K, V>());

结果集合的方法使用锁加以保护,提供了线程安全访问。应该确保没有任何线程通过原始的非同步方法访问数据结构。最便利的方法是确保不保存任何指向原始对象的引用,简单地构造一个集合并立即传递给包装器,像我们的例子中所做的那样。

如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“客户端”锁定:

synchronized (synchHashMap)
{
    Iterator<K> iter = synchHashMap.keySet().iterator();
    while (iter.hasNext()) . ..;
}

如果使用“foreach”循环必须使用同样的代码,因为循环使用了迭代器。注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出ConcurrentModificationException异常。同步仍然是需要的,因此并发的修改可以被可靠地检测出来。

最好使用java.Util.Conciirrent包中定义的集合,不使用同步包装器中的。特别是,假如它们访问的是不同的桶,由于ConcurrentHashMap已经精心地实现了,多线程可以访问它而且不会彼此阻塞。有一个例外是经常被修改的数组列表。在那种情况下,同步的ArrayList可以胜过CopyOnWriteArrayList。


 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值