线程安全的集合笔记
如果多个线程要并发地修改一个数据结构,例如散列表,那么很容易破获这个数据结构。例如,一个线程可能开始向表里插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中,这个线程的控制权被抢占。如果另一个线程开始遍历同一个链表,可能使用无效的链接并造成混乱,可能会抛出异常或者陷入无限循环。
可以通过锁来保护共享的数据结构,但是选择线程安全的实现可能更为容易。
1、阻塞队列
很多线程问题可以使用一个或多个队列以优雅而安全的方式来描述。生产者队列向队列插入元素,消费者线程则获取元素。使用队列,可以安全地从一个线程向另一个线程传递数据。
当试图向队列添加元素而队列已满,或是想从队列移出元素而队列为空的时候,阻塞队列(blocking queue)将导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的工具。工作线程可以周期性地将中间结果存储在阻塞队列中。其他工作线程移出中间结果,并进一步进行修改。
阻塞线程方法
| 方法 | 正常动作 | 特殊情况下的动作 |
|---|---|---|
| add | 添加一个元素 | 如果队列满,则抛出IllegalStateException异常 |
| element | 返回队头元素 | 如果队列空,则抛出NoSuchElementException异常 |
| offer | 添加一个元素并返回true | 如果队列满,则返回false |
| peek | 返回队头元素 | 如果队列空,则返回null |
| poll | 移除并返回队头元素 | 如果队列空,则返回null |
| put | 添加一个元素 | 如果队列满,则阻塞 |
| remove | 移除并返回队头元素 | 如果队列空,则抛出NoSuchElementException异常 |
| take | 移除并返回队头元素 | 如果队列空,则阻塞 |
| java.util.concurrent包提供了阻塞队列的几个变体。默认情况下,LinkedBlockingQueue的容量没有上界,但是,也可以选择指定一个最大容量。LinkedBlockingDeque是一个双端队列。ArrayBlockingQueue在构造时需要指定容量,并且有一个可选的参数来指定是否需要公平性。若设置了公平参数,那么等待了最长时间的线程会优先得到处理。通常,公平性会降低性能,只有在确实非常需要时才使用公平参数。 | ||
| PriorityBlockingQueue是一个优先队列,而不是先进先出队列。元素按照它们的优先级顺序移除。这个队列没有容量上限,但是,如果队列是空的,获取元素的操作会阻塞。 | ||
| 接下来的程序展示了如何使用阻塞队列来控制一组线程。程序在一个目录及其所有子目录下搜索文件,打印出包含指定关键字的行。 |
package com.company.Synchronize12.blockingQueue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Scanner;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Created by kzm on 2020/11/3 7:44
*/
public class BlockingQueueTest {
private static final int FILE_QUEUE_SIZE = 10;
private static final int SEARCH_THREADS = 100;
private static final Path DUMMY = Paths.get("");
private static BlockingQueue<Path> queue = new ArrayBlockingQueue<>(FILE_QUEUE_SIZE);
public static void main(String[] args) {
try (Scanner in = new Scanner(System.in)){
System.out.print("Enter base directory (e.g. /opt/jdk-9-src): ");;
String directory = in.nextLine();
System.out.print("Enter keyword (e.g. volatile): ");
String keyword = in.nextLine();
Runnable enumerator = () -> {
try {
enumerate(Paths.get(directory));
queue.put(DUMMY);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
};
new Thread(enumerator).start();
for (int i = 1; i <= SEARCH_THREADS; i++){
Runnable searcher = () -> {
try {
boolean done = false;
while (!done){
Path file = queue.take();
if (file == DUMMY){
queue.put(file);
done = true;
}
else search(file, keyword);
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
};
new Thread(searcher).start();
}
}
}
/**
* 递归枚举给定目录及其子目录中的所有文件
* @param directory
* @throws IOException
* @throws InterruptedException
*/
public static void enumerate(Path directory) throws IOException, InterruptedException {
try (Stream<Path> children = Files.list(directory)){
for (Path child : children.collect(Collectors.toList())){
if (Files.isDirectory(child))
enumerate(child);
else
queue.put(child);
}
}
}
/**
* 在文件中搜索给定的关键字并打印所有匹配的行
* @param file
* @param keyword
* @throws IOException
*/
public static void search(Path file, String keyword) throws IOException {
try (Scanner in = new Scanner(file, String.valueOf(StandardCharsets.UTF_8))){
int lineNumber = 0;
while (in.hasNextLine()){
lineNumber++;
String line = in.nextLine();
if (line.contains(keyword)){
System.out.printf("%s:%d:%s%n", file, lineNumber, line);
}
}
}
}
}
生产者线程枚举所有子目录下的所有文件并把它们放到一个阻塞队列中。
同时启动了大量搜索线程。每个搜索线程从队列中取出一个文件,打开它,打印所有包含该关键字的行,然后取出下一个文件。使用了一个小技巧在工作结束后终止这个应用。为了发出完成信号,枚举线程会在队列中放置一个虚拟对象。当搜索线程取到了这个虚拟对象时,将其放回并终止。
注意,这里不需要显示的线程同步。在这个应用程序中,我们使用队列数据结构作为一种同步机制。
2、高效的映射、集和队列
java.util.concurrent包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue。
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分尽可能减少竞争。
集合返回弱一致性的迭代器。这意味着迭代器不一定能反映出它们构造之后的所有更改,但是,它们不会将同一个值返回两次,也不会抛出ConcrrentModificationException异常。与之形成对照的是,对于java.util包中的集合,如果集合在迭代器构造之后发生改变,集合的迭代器将抛出一个ConcurrentModificationException异常。
并发散列映射可以高效地支持大量阅读器和一定数量地书写器。默认情况下认为可以有至多16个同时运行地书写器进程。当然可以有更多的书写器进程,但是,同一时间如果多于16个,其他线程将暂时阻塞。
3、映射条目的原子更新
在老版本的Java中,必须使用replace操作,它会以原子方式用一个新值替换原值,前提是没有其他线程把原值替换为其他值。必须一直这么做,直到替换成功:
do
{
oldValue = map.get(word);
newValue = oldValue == null ? 1 : oldValue + 1;
}
while (!map.replace(word, oldValue, newValue));
或者,可以使用一个ConcurrentHashMap<String, AtomicLong>,以及以下更新代码:
map.putIfAbsent(word, new AtomicLong());
map.get(word).incrementAndGet();
很遗憾,这会为每个自增构造一个新的AtomicLong,而不管是否需要。
如今,Java API提供了一些新方法,可以更方便地完成原子更新。调用compute方法时可以提供一个键和一个计算新值的函数。这个函数接受键和相关联的值(如果没有值,则为null),它会重新计算新值。例如,可以如下更新一个整数计数器的映射:
map.compute(word, (k,v) -> v == null ? 1 : v + 1);
另外还有computeIfPresent和computeIfAbsent方法,它们分别只在已经有原值的情况下计算新值,或者只在没有原值的情况下计算新值。
首次增加一个键时通常需要做些特殊的处理。利用merge方法可以非常方便地做到这一点。这个方法有一个参数表示键不存在时使用的初始值。否则,就会调用你提供的函数来结合原值与初始值。
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue);
或者,更简单地可以写为:
map.merge(word, 1L, Long::sum);
下面的程序使用了一个并发散列映射来统计一个目录数的Java文件中的所有单词。
package com.company.Synchronize12.concurrentHashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Scanner;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Created by kzm on 2020/11/4 12:26
*/
public class CHMDemo {
public static ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
/**
* 将给定文件中的所有单词添加到并发哈希图
* @param file
*/
public static void process(Path file){
try (Scanner in = new Scanner(file)){
while (in.hasNext()){
String word = in.next();
map.merge(word, 1L, Long::sum);
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 返回给定目录的所有后代
* @param rootDir
* @return
* @throws IOException
*/
public static Set<Path> descendants(Path rootDir) throws IOException {
try (Stream<Path> entries = Files.walk(rootDir)){
return entries.collect(Collectors.toSet());
}
}
public static void main(String[] args) throws IOException, InterruptedException {
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(processors);
Path pathToRoot = Paths.get(".");
for (Path p : descendants(pathToRoot)){
if (p.getFileName().toString().endsWith(".java"))
executor.execute(() -> process(p));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
map.forEach((k, v) -> {
if (v >= 10)
System.out.println(k + " occurs " + v + " times");
});
}
}
4、对并发散列映射的批操作
Java API为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。这里不会冻结映射的当前快照。除非你恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。
有3中不同的操作:
- search(搜索)为每个键或值应用一个函数,直到函数生成了一个非null的结果。然后搜索终止,返回这个函数的结果。
- reduce(归约)组合所有键或值,这里要使用所提供的一个累加函数。
- forEach为所有键或值应用一个函数。
每个操作都有4个版本: - operationKeys: 处理键。
- operationValue: 处理值。
- operation: 处理键和值。
- operationEntries: 处理Map.Entry对象。
对于上述各个操作,需要指定一个参数化阈值(parallelism threshold)。如果映射包含的元素多于这个阈值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈值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 ? 1L : null,
Long::sum);
对于int、long和double输出还有相应的特殊化操作,分别有后缀Tolnt、ToLong和ToDouble。需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射为空时返回默认值。
long sum = map.reduceValuesToLong(threshold,
Long::longValue,// transformer to primitive type
0,// default value for empty map
Long::sura); // primitive type accumulator
5、并发集视图
静态newKeySet方法会生成一个Set
Set<String> words = ConcurrentHashMap.<String>newKeySet();
当然,如果原来有一个映射,keySet方法可以生成这个映射的键集。这个集是可变的。如果删除这个集的元素,这个键(以及相应的值)会从映射中删除。不过,不能向键集增加元素,因为没有相应的值可以增加。JavaSE8为ConcurrentHashMap增加了第二个keySet方法,包含一个默认值,可以在为集增加元素时使用:
Set<String> words = map.keySet(1L);
words.add("Java");
如果"Java”在words中不存在,现在它会有一个值1。
6、写数组的拷贝
CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数,这样的安排是很有用的。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组,但是,集合的数组已经被替换了。因而,旧的迭代器拥有一致的(可能过时的)视图,访问它无须任何同步开销。
7、并行数组算法
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);// 排序上半部分
parallelSetAll方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算相应位置上的值。
Arrays.parallelSetAll(values, i -> i % 10);// fills values with 0123456789012...
显然,并行化对这个操作很有好处。这个操作对于所有基本类型数组和对象数组都有相应的版本。最后还有一个parallelPrefix方法,它会用对应一个给定结合操作的前缀的累加结果替换各个数组元素。
8、较早的线程安全集合
从Java的初始版本开始,Vector和Hashtable类就提供了线程安全的动态数组和散列表的实现。现在这些类被弃用了,取而代之的是ArrayList和HashMap类。这些类不是线程安全的,而集合库中提供了不同的机制。任何集合类都可以通过使用同步包装器(synchronization wrapper)变成线程安全的:
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()) ...;
}
如果使用"for each"循环必须使用同样的代码,因为循环使用了迭代器。注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出ConcurrentModificationException异常。同步仍然是需要的,因此并发的修改可以被可靠地检测出来。
最好使用java.Util.Concurrent包中定义的集合,不使用同步包装器中的。特别是,假如它们访问的是不同的桶,由于ConcurrentHashMap已经精心地实现了,多线程可以访问它而且不会彼此阻塞。有一个例外是经常被修改的数组列表。在那种情况下,同步的ArrayList可以胜过CopyOnWriteArrayList。

浙公网安备 33010602011771号