Spliterator 接口 控制StreamAPI并行拆分数据结构的策略

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/CmdSmith/article/details/85015606

Spliterator

Spliterator 是Java 8新加入的一个接口:这个名字代表“可分迭代器”(splitable iterator)。

和Iterator 一样,Spliterator也用于遍历数据源中的元素,但他是为了并行执行而设计的

开发中一般不用自己开发Spliterator。

Java 8 已经为集合框架中包含的所有数据结构提供了一个默认的Spliterator实现。集合实现了Spliterator接口,接口提供了一个spliterator 方法。这个接口定了若干方法。

public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
}

T 是Spliterator遍历的元素类型。

tryAdvance(试着前进)方法的行为类似于普通的Iterator,因为它会按顺序一个一个使用Spliterator中的元素,并且如果还有其他元素要遍历就返回true。

trySplit是专为Spliterator接口设计的,因为它可以把一些元素划出去分给第二个Spliterator(由该方法返回),让它们两个并行处理。Spliterator还可通过 estimateSize(估计大小)方法估计还剩下多少元素要遍历,因为即使不那么确切,能快速算出来世一个值也有助于让拆分均匀一点。

拆分过程

将Stream 拆分成多个部分的算法是一个递归过程。

如图所示,第一步是对第一个Spliterator调用trySplit,生成第二个Spliterator。第二步对这两个Spliterator调用trySplit,这样总共就有了四个Spliterator。这个框架不断对Spliterator调用trySplit直到它返回null,表名它处理的数据结构不能再分割,如第三步所示。最后,这个递归拆分过程到第四部就终止了,这时所有的Spliterator再调用trySplit时都返回了null。

拆分过程也受Spliteraotr本身的特性影响,而特性是通过characteristics方法声明的。

Spliterator 的特性

Spliterator接口声明的最后一个抽象方法是characteristics,它将返回一个int,代表Spliterator本身特性集的编码。使用Spliterator的客户可以用这些特性来更好地控制和优化它的使用。

表 7-2 Spliterator的特性

特性 含义
ORDERED 元素有特定的顺序(如List),因此Spliterator在遍历和划分时也会遵循这一顺序
DISTINCT 对于任意一对遍历过的元素x和y,x.equals(y)返回false
SIZED 该Spliterator由一个已知大小的源建立(例如Set),因此estimatedSize()返回的是准确值保证遍历的元素不会为null
NONNULL 保证遍历的元素不会为NULL
IMMUTABL Spliterator 的数据源不能修改。这意味着在遍历时不能添加、删除或修改任何元素
CONCURRE 该Spliterator 的数据源可以被其他线程同时修改而无需同步
SUBSIZED 该Spliterator 和所有从它拆分出来的Spliterator都是SIZED

实现自己的Spliterator

// 但丁 神曲
final String SENTENCE = " Nel   mezzo del cammin  di nostra  vita " +
        "mi  ritrovai in una  selva oscura" +
        " che la  dritta  via era   smarrita ";

/***
 * 开发一个简单的方法来数一数一个String中的单词数。
 *
 * 个人理解:
 *
 * 一个字符 可能有四种情况
 *
 * 1:前一个 空格 当前 空格
 * 2:前一个 空格 当前 不是空格
 * 3:前一个 不是空格 当前 空格
 * 4:前一个 不是空格 当前 不是空格
 *
 * 只有第二种情况 计数器才能加1
 * @param s
 * @return
 */
public int countWordsIteratively(String s) {
    int counter = 0;
    boolean lastSpace = true;
    // 逐个遍历String中的所有字符
    for (char c : s.toCharArray()) {
        if (Character.isWhitespace(c)) {
            lastSpace = true;
        } else {
            // 上一个字符是空格,而当前遍历的字符不是空格时,将单词计数器加一
            if (lastSpace) counter ++;
            lastSpace = false;
        }
    }
    return counter;
}

@Test
public void test() {
    System.out.println(countWordsIteratively(SENTENCE));
    // Found 19 words
}

1 以函数式风格重写单词计数器

首先需要把String 转换成一个流。不幸的是,原始类型的流仅限于int、long 和 double,所以只能用 Stream:

Stream<Character> stream = IntStream.range(0, SENTENCE.length())
    .mapToObj(SENTENCE::charAt);

你可以对这个流做规约来计算字数。在规约流时,你得保留由两个变量组成的状态:一个int用来计算到目前为止数过的字数,还有一个boolean用来记得上一个遇到的Character是不是空格。

因为Java没有元组(tuple,用来表示由异类元素组成的有序列表的结构,不需要包装对象),所以你必须创建一个新类WordCounter来把这个状态封装起来,如下所示:

用来在遍历Character流时计数的类

/**
 * 用来在遍历Character流时计数的类
 */
class WordCounter {
    private final int counter;
    private final boolean lastSpace;

    public WordCounter(int counter, boolean lastSpace) {
        this.counter = counter;
        this.lastSpace = lastSpace;
    }

    // 和迭代算法一样 accumulate 方法一个个遍历Character
    // 还是上面所述四种情况,只有第二种情况 counter 才能加1
    public WordCounter accumulate(Character c) {
        if (Character.isWhitespace(c)) {
            return lastSpace ? this : new WordCounter(counter, true);
        } else {
            // 上一个字符是空格,而当前遍历的字符不是空格时,将单词计数器加一
            return lastSpace ? new WordCounter(counter + 1, false) : this;
        }
    }

    // 合并两个 WordCounter,把其计数器加起来
    // 并行时合并的算法
    public WordCounter combine(WordCounter wordCounter) {
        // 仅需要计数器的总和,无需关心lastSpace
        return new WordCounter(counter + wordCounter.counter, wordCounter.lastSpace);
    }

    public int getCounter() {
        return counter;
    }

}

每次遍历到Stream中的一个新的Character时,就会调用accumulate方法。

Stream<Character> stream = IntStream.range(0, SENTENCE.length()).mapToObj(SENTENCE::charAt);
System.out.println("Found " + countWords(stream) + " words");

Found 19 words

调用第二个方法combine时,会对作用于Character流的两个不同子部分的两个WordCounter的部分结果进行汇总,也就是把两个WordCounter内部的计数器加起来。

/**
 * 计算 单词数
 * @param stream
 * @return
 */
private int countWords(Stream<Character> stream) {
    WordCounter wordCounter = stream.reduce(new WordCounter(0, true), WordCounter::accumulate, WordCounter::combine);
    return wordCounter.getCounter();
}

我们以函数式实现的主要原因之一就是能轻松地并行处理。

2 让WordCounter 并行处理

System.out.println("Found " + countWords(stream.parallel()) + " words");

这次输出

Found 24 words

不对,原因是原始的String在任意位置拆分,所有有时一个词会被分为两个词,然后数了两次。这就说明,拆分流会影响结果,而顺序流换成并行流就有可能出错。

解决方案就是要确保String 不是在随机位置拆开的,而只能在词尾拆开。

要做到这一点,必须为Character实现一个Spliterator,它只能在两个词之间拆开String,并由此创建并行流。

/**
 * 定义如何拆分以并行
 */
class WordCounterSpliterator implements Spliterator<Character> {

    private final String string;

    private int currentChar = 0;

    public WordCounterSpliterator(String string) {
        this.string = string;
    }

    @Override
    public boolean tryAdvance(Consumer<? super Character> action) {
        // 处理当前字符
        // 个人理解,一步一步处理分支上的字符
        action.accept(string.charAt(currentChar++));
        // 如果还有字符要处理,则返回true
        return currentChar < string.length();
    }

    @Override
    public Spliterator<Character> trySplit() {
        int currentSize = string.length() - currentChar;
        if (currentSize < 10) {
            // 返回null 表示要解析的String已经足够小,可以顺序处理
            return null;
        }

        // 将试探拆分位置设定为要解析的String的中间
        for (int splitPos = currentSize / 2 + currentChar;
                splitPos < string.length(); splitPos++) {
            if (Character.isWhitespace(string.charAt(splitPos))) {
                // 创建一个新的 WordCounterSpliterator 来解析String从开始到拆分位置的部分
                Spliterator<Character> spliterator =
                        new WordCounterSpliterator(string.substring(currentChar, splitPos));

                // 将这个WordCounterSpliterator的起始位置设为拆分位置
                currentChar = splitPos;
                return spliterator;
            }
        }
        return null;
    }

    @Override
    public long estimateSize() {
        return string.length() - currentChar;
    }

    @Override
    public int characteristics() {
        return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;
    }
}

这个Spliterator由要解析的String创建,并遍历了其中的Character,同时保存了当前正在遍历的字符位置

tryAdvance 方法把String中当前位置的Character传给了Consumer,并让位置加一。作为参数传递的Consumer是一个Java内部类,在遍历流时将要处理的Character传给了一系列要对其执行的函数。这里只有一个规约函数,即WordCounter类的accumulate方法。如果新的指针位置小于String的总长,且还有要遍历的Character,则tryAdvance返回true。

trySplit 方法是 Spliterator中最重要的一个方法,因为它定义了拆分要遍历的数据结构的逻辑。就像在代码清单 7-1 中实现的RecursiveTask的compute方法一样(分支-合并框架的使用方式),首先要设定不再进一步拆分的下限。这里用了一个非常低的下限——10个Character,仅仅是为了保证程序会对那个比较短的String做几次拆分。在实际应用中,就像分支-合并的例子那样,你肯定要用更高的下限来避免生成太多的任务。如果剩余的Character数量低于下限,你就返回null表示无需进一步拆分。相反,如果你需要执行拆分,就把试探的拆分位置设在要解析的String块的中间。但我们没有直接使用这个拆分位置,因为要避免把词在中间断开,于是就往前找,直到找到一个空格。一旦找到了适当的拆分位置,就可以创建一个新的Spliterator来遍历从当前位置到拆分位置的子串;把当前位置this设为拆分位置,因为之前的部分将由新Spliterator来处理,最后返回。

还有需要遍历的元素的estimatedSize()就是这个Spliterator解析的String的总长度和当前遍历的位置的差。

最后,characteristic 方法告诉框架这个Spliterator是ORDERED(顺序就是String中各个Character的次序)、SIZED(estimatedSize 方法返回值是精确的)、SUBSIZED(trySplit方法创建的其他Spliterator也有确切大小)、NONNULL(String中不能有为NULL的Character)和IMMUTABLE(在解析String时不能再添加Character,因为String本身是一个不可变类)的。

3 运用WordCounterSpliterator

Spliterator<Character> spliterator = new WordCounterSpliterator(SENTENCE);
Stream<Character> stream = StreamSupport.stream(spliterator, true);
System.out.println("Found " + countWords(stream) + " words");
// Found 19 words

Spliterator 让你控制拆分数据结构的策略

Spliterator 可以在第一次遍历、第一次拆分或第一次查询估计大小时绑定元素的数据源,而不是在创建时就绑定,它称为延迟绑定(late-binding)的Spliterator。

猜你喜欢

转载自blog.csdn.net/CmdSmith/article/details/85015606