《Java8实战》读书笔记06:Parallel Stream 并行流

  • 第7章 并行数据处理与性能
    • 7.1 并行流
      • 7.1.1 将顺序流转换为并行流
      • 7.1.2 测量流性能
      • 7.1.3 正确使用并行流
      • 7.1.4 高效使用并行流
    • 7.2 分支/合并框架
      • 7.2.1 使用 ForkJoinPool 分支合并池 + RecursiveTask 递归任务
      • 7.2.2 使用分支/合并框架的最佳做法
      • 7.2.3 工作窃取
    • 7.3 Spliterator 可分迭代器
      • 7.3.1 拆分过程
      • 7.3.2 实现你自己的 Spliterator
    • 7.4 小结
  • 参考资料

第7章 并行数据处理与性能

7.1 并行流

7.1.1 将顺序流转换为并行流

Integer sum = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8).parallelStream().reduce(0, Integer::sum);
System.out.println(sum); // 36

图7-1 并行归纳操作

除了直接从集合创建并行流,还可以现有的流进行转换:

  • 转并行流:.parallel()
  • 转顺序流:.sequential()
stream.parallel() // 转并行流.filter(...) .sequential() // 转顺序流.map(...) .parallel()) // 转并行流.reduce();

最后一次parallelsequential调用会影响整个流水线。在本例中,流水线会并行执行,因为最后调用的是parallel()

配置并行流使用的线程池
看看流的parallel方法,你可能会想,并行流用的线程是从哪儿来的?有多少个?怎么自定义这个过程呢?
并行流内部使用了默认的ForkJoinPool(7.2节会进一步讲到分支/合并框架),它默认的线程数量就是你的处理器数量,这个值是由 Runtime.getRuntime().availableProcessors()得到的。
但是你可以通过系统属性 java.util.concurrent.ForkJoinPool.common.parallelism来改变线程池大小,如下所示:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12");
这是一个全局设置,因此它将影响代码中所有的并行流。反过来说,目前还无法专为某个并行流指定这个值。一般而言,让ForkJoinPool的大小等于处理器数量是个不错的默认值,除非你有很好的理由,否则我们强烈建议你不要修改它。

7.1.2 测量流性能

并非使用了并行流就一定能更快。

这就说明了并行编程可能很复杂,有时候甚至有点违反直觉。如果用得不对(比如采用了一个不易并行化的操作,如iterate),它甚至可能让程序的整体性能更差,所以在调用那个看似神奇的parallel操作时,了解背后到底发生了什么是很有必要的。
使用更有针对性的方法
那到底要怎么利用多核处理器,用流来高效地并行求和呢?我们在第5章中讨论了一个叫LongStream.rangeClosed的方法。这个方法与iterate相比有两个优点。

  • LongStream.rangeClosed直接产生原始类型的long数字,没有装箱拆箱的开销。
  • LongStream.rangeClosed会生成数字范围,很容易拆分为独立的小块。例如,范围1~20可分为1~5、6~10、11~15和16~20

下面是一个反例,为实现并行处理所需要的准备工作,远大于并行处理的收益。
注意:这里时间都浪费在自动拆装箱上了(为实现并行计算而必须的准备工作),而非sum本身。如果我们最终的操作不是sum而一套复杂的逻辑,相对于它来说,并行计算的准备工作几乎可以忽略不记,此时才能体现出并行的优势。

List<Long> list = LongStream.rangeClosed(1, 10_000_000).boxed().collect(Collectors.toList());
long startTime = System.currentTimeMillis();
long sum = list.parallelStream().mapToLong(Long::longValue).boxed().mapToLong(Long::longValue).boxed().mapToLong(Long::longValue).boxed().mapToLong(Long::longValue).sum();
long endTime  = System.currentTimeMillis();
System.out.println("汇总:" + sum); // 汇总:50000005000000
System.out.println("耗时:" + (endTime - startTime)); // 耗时:1179

优化一下省去无用的准备工作。

long startTime = System.currentTimeMillis();
long sum = LongStream.rangeClosed(1, 10_000_000).reduce(0, (a, b) -> a + b);
long endTime = System.currentTimeMillis();
System.out.println("汇总:" + sum); // 汇总:50000005000000
System.out.println("耗时:" + (endTime - startTime)); // 耗时:45

7.1.3 正确使用并行流

这一节举了一个开启并行踩坑的例子。forEach有坑

public class ParallelStreams {public static long sideEffectParallelSum(long n) {Accumulator accumulator = new Accumulator();LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add); // 开启并行流,数据竞争翻车return accumulator.total;}
}
public class Accumulator {public long total = 0;public void add(long value) {total += value; // 非原子性操作}
}
public static long measureSumPerf(Function<Long, Long> adder, long n) {long fastest = Long.MAX_VALUE;for (int i = 0; i < 10; i++) {long start = System.nanoTime();long sum = adder.apply(n);long duration = (System.nanoTime() - start) / 1_000_000;System.out.println("结果: " + sum);if (duration < fastest) fastest = duration;}return fastest;
}
public static void main(String[] args) {System.out.println("并行流耗时: " +  measureSumPerf(ParallelStreams::sideEffectParallelSum, 10_000_000L) + "毫秒" );}

7.1.4 高效使用并行流

一般而言,想给出任何关于什么时候该用并行流的定量建议都是不可能也毫无意义的,因为任何类似于“仅当至少有一千个(或一百万个或随便什么数字)元素的时候才用并行流)”的建议对于某台特定机器上的某个特定操作可能是对的,但在略有差异的另一种情况下可能就是大错特错。尽管如此,我们至少可以提出一些定性意见,帮你决定某个特定情况下是否有必要使用并行流。

  • 如果有疑问,测量。把顺序流转成并行流轻而易举,但却不一定是好事。我们在本节中已经指出,并行流并不总是比顺序流快。此外,并行流有时候会和你的直觉不一致,所以在考虑选择顺序流还是并行流时,第一个也是最重要的建议就是用适当的基准来检查其性能。
  • 留意装箱。自动装箱和拆箱操作会大大降低性能。Java 8中有原始类型流(IntStream、LongStream、DoubleStream)来避免这种操作,但凡有可能都应该用这些流。
  • 有些操作本身在并行流上的性能就比顺序流差。特别是limit和findFirst等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。例如,findAny会比findFirst性能好,因为它不一定要按顺序来执行。你总是可以调用unordered方法来把有序流变成无序流。那么,如果你需要流中的n个元素而不是专门要前n个的话,对无序并行流调用limit可能会比单个有序流(比如数据源是一个List)更高效。
  • 还要考虑流的操作流水线的总计算成本。设N是要处理的元素的总数,Q是一个元素通过流水线的大致处理成本,则N*Q就是这个对成本的一个粗略的定性估计。Q值较高就意味着使用并行流时性能好的可能性比较大。
  • 对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。
  • 要考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高得多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。另外,用range工厂方法创建的原始类型流也可以快速分解。最后,你将在7.3节中学到,你可以自己实现Spliterator来完全掌控分解过程。
  • 流自身的特点,以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。例如,一个SIZED流可以分成大小相等的两部分,这样每个部分都可以比较高效地并行处理,但筛选操作可能丢弃的元素个数却无法预测,导致流本身的大小未知。
  • 还要考虑终端操作中合并步骤的代价是大是小(例如Collector中的combiner方法)。
    如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通
    过并行流得到的性能提升。

表7-1按照可分解性总结了一些流数据源适不适于并行。
表7-1 流的数据源和可分解性

可分解性
ArrayList 极佳
LinkedList
IntStream.range 极佳
Stream.iterate
HashSet
TreeSet

7.2 分支/合并框架

7.2.1 使用 ForkJoinPool 分支合并池 + RecursiveTask 递归任务

要把任务提交到这个池,必须创建RecursiveTask<R>的一个子类,其中R是并行化任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(当然它可能会更新其他非局部机构)。要定义RecursiveTask,只需实现它唯一的抽象方法compute:protected abstract R compute();
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便再拆分时,生成单个子任务结果的逻辑。正由于此,这个方法的实现类似于下面的伪代码:

if (任务足够小或不可分) { 顺序计算该任务
} else { 将任务分成两个子任务递归调用本方法,拆分每个子任务,等待所有子任务完成合并每个子任务的结果
}

一般来说并没有确切的标准决定一个任务是否应该再拆分,但有几种试探方法可以帮助你做出这一决定。我们会在7.2.1节中进一步澄清。递归的任务拆分过程如图7-3所示。

再举一个例子:为一个数字范围(这里用一个long[]数组表示)求和。如前所述,你需要先为RecursiveTask类做一个实现,就是下面代码清单中的ForkJoinSumCalculator

/*** 为一个数字范围(这里用一个long[]数组表示)求和* 继承 RecursiveTask 来创建可以用于分支/合并框架的任务*/
public class ForkJoinSumCalculator extends java.util.concurrent.RecursiveTask<Long> {private final long[] numbers;                   // 要求和的数组private final int start;                        // 子任务处理的数组的起始位置private final int end;                          // 子任务处理的数组的终止位置public static final long THRESHOLD = 10_000;    // 不再将任务分解为子任务的数组大小/*** 公共构造函数用于创建主任务*/public ForkJoinSumCalculator(long[] numbers) {this(numbers, 0, numbers.length);}/*** 私有构造函数用于以递归方式为主任务创建子任务*/private ForkJoinSumCalculator(long[] numbers, int start, int end) {this.numbers = numbers;this.start = start;this.end = end;}/*** 覆盖 RecursiveTask 抽象方法*/@Overrideprotected Long compute() {// 该任务需要求和的量 lengthint length = end - start;// 如果:length 小于阈值直接计算出结果,返回。if (length <= THRESHOLD) return computeSequentially();// 否则:对半拆分任务:// 【递归】创建第一个子任务,处理前一半ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length / 2);// 【递归】再创建第二个子任务,处理后一半ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length / 2, end);// 利用 ForkJoinPool 异步执行第一个子任务leftTask.fork();// 同步执行第二个子任务 (同步可以直接拿结果,异步需要 join、get)Long rightResult = rightTask.compute();// 读取第一个子任务的结果,如果尚未完成就等待Long leftResult = leftTask.join();// 该任务的结果是两个子任务结果的组合return leftResult + rightResult;}/*** 在子任务不再可分时计算结果的简单算法*/private long computeSequentially() {System.out.println("线程【"+Thread.currentThread().getName()+"】开始计算:" + start + "到" + end);long sum = 0;for (int i = start; i < end; i++) {sum += numbers[i];}return sum;}
}

测试代码:

public static void main(String[] args) throws ExecutionException, InterruptedException {long n = 1_000_000;long[] numbers = LongStream.rangeClosed(1, n).toArray();ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);ForkJoinPool forkJoinPool = new ForkJoinPool();// ForkJoinPool 同步执行,返回结果Long sum = forkJoinPool.invoke(task);System.out.println("【invoke执行】数组中所有元素的和为:" + sum); // 数组中所有元素的和为:500000500000// 异步执行,不返回forkJoinPool.execute(task);Long join = task.get();System.out.println("【execute执行】数组中所有元素的和为:" + join); // 数组中所有元素的和为:500000500000// 异步执行,返回一个 Future 用于将来合适的时机,再主动获取结果ForkJoinTask<Long> forkJoinTask = forkJoinPool.submit(task);Long result = forkJoinTask.get();System.out.println("【submit执行】数组中所有元素的和为:" + result); // 数组中所有元素的和为:500000500000
}

实际应用中多个ForkJoinPool没有意义,所以请以单列模式来使用它。
ForkJoinPool默认构造函数会条用Runtime.availableProcessors获得内核的数量,包括超线程生成的虚拟内核。

运行ForkJoinSumCalculator
当把ForkJoinSumCalculator任务传给ForkJoinPool时,这个任务就由池中的一个线程执行,这个线程会调用任务的compute方法。该方法会检查任务是否小到足以顺序执行,如果不够小则会把要求和的数组分成两半,分给两个新的ForkJoinSumCalculator,而它们也由ForkJoinPool安排执行。因此,这一过程可以递归重复,把原任务分为更小的任务,直到满足不方便或不可能再进一步拆分的条件(本例中是求和的项目数小于等于10 000)。这时会顺序计算每个任务的结果,然后由分支过程创建的(隐含的)任务二叉树遍历回到它的根。接下来会合并每个子任务的部分结果,从而得到总任务的结果。这一过程如图7-4所示。

使用前面的测试代码跑一下发现比用并行流的版本要差,但这只是因为必须先要把整个数字流都放进一个long[],之后才能在ForkJoinSumCalculator任务中使用它。

7.2.2 使用分支/合并框架的最佳做法

虽然分支/合并框架还算简单易用,不幸的是它也很容易被误用。以下是几个有效使用它的最佳做法。

  • 对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个子任务的计算都开始之后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
  • 不应该在RecursiveTask内部使用ForkJoinPool的invoke方法。相反,你应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
  • 对子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用它似乎很自然,但这样做的效率要比直接对其中一个调用compute低。这样做你可以为其中一个子任务重用同一线程,从而避免在线程池中多分配一个任务造成的开销。
  • 调试使用分支/合并框架的并行计算可能有点棘手。特别是你平常都在你喜欢的IDE里面看栈跟踪(stack trace)来找问题,但放在分支合并计算上就不行了,因为调用compute的线程并不是概念上的调用方,后者是调用fork的那个。
  • 和并行流一样,你不应理所当然地认为在多核处理器上使用分支/合并框架就比顺序计算快。我们已经说过,一个任务可以分解成多个独立的子任务,才能让性能在并行化时有所提升。所有这些子任务的运行时间都应该比分出新任务所花的时间长;一个惯用方法是把输入/输出放在一个子任务里,计算放在另一个里,这样计算就可以和输入/输出同时进行。此外,在比较同一算法的顺序和并行版本的性能时还有别的因素要考虑。就像任何其他Java代码一样,分支/合并框架需要“预热”或者说要执行几遍才会被JIT编译器优化。这就是为什么在测量性能之前跑几遍程序很重要,我们的测试框架就是这么做的。同时还要知道,编译器内置的优化可能会为顺序版本带来一些优势(例如执行死码分析——删去从未被使用的计算)。

对于分支/合并拆分策略还有最后一点补充:你必须选择一个标准,来决定任务是要进一步拆分还是已小到可以顺序求值。我们会在下一节中就此给出一些提示。

7.2.3 工作窃取

任务分的够细,能者多劳,先干完活的线程会随机去别的线程尾巴上偷一个任务过来做。直到所有任务都被做完。

图7-5 分支/合并框架使用的工作窃取算法

7.3 Spliterator 可分迭代器

Spliterator是Java 8加入新接口;与Iterator的区别在于Spliterator是为并行而设计的。Java 8已经为集合框架中包含的所有数据结构提供了默认实现。所以我们可以这样:Spliterator<Integer> spliterator = list.spliterator();

代码清单7-3 Spliterator接口

public interface Spliterator<T> { boolean tryAdvance(Consumer<? super T> action); Spliterator<T> trySplit(); long estimateSize(); int characteristics();
}
定义 说明
T Spliterator遍历的元素的类型
tryAdvance 方法类似于普通IteratorhasNext(),负责按顺序遍历Spliterator中的元素,如果还有下一个则返回true
trySplit 负责根据一定条件对数据进行拆分。分出一部分元素用于创建影分身 (Spliterator),并返回这个影分身,让它一起来搓丸子。
estimateSize 估计还剩下多少元素要遍历。便于计算如何拆分更均匀。
characteristics Spliterator的特性,详见:表7-2 Spliterator的特性

7.3.1 拆分过程

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

图7-6 递归拆分过程

表7-2 Spliterator的特性

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

7.3.2 实现你自己的 Spliterator

然后书就给了个例子,以函数式风格实现了单词计数器。(但是这里还没有实现Spliterator所以开启并行流就会出错)

public class Demo {private int countWords(Stream<Character> stream) {WordCounter wordCounterStart = new WordCounter(0, true); // 取第一个字符WordCounter wordCounter = stream.reduce(wordCounterStart,           // 初始值WordCounter::accumulate,    // 累加器WordCounter::combine        // 合并器);return wordCounter.getCounter();    // 获取统计结果}class WordCounter {private final int counter;private final boolean lastSpace;public WordCounter(int counter, boolean lastSpace) {this.counter = counter;this.lastSpace = lastSpace;}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;}}public WordCounter combine(WordCounter wordCounter) {return new WordCounter(counter + wordCounter.counter, false);}public int getCounter() {return counter;}}public static void main(String[] args) {final String SENTENCE = " Nel mezzo del cammin di nostra vita mi ritrovai in una selva oscura ché la dritta via era smarrita ";Stream<Character> stream = IntStream.range(0, SENTENCE.length()).mapToObj(SENTENCE::charAt);System.out.println("共 " + new Demo().countWords(stream) + " 个单词");}
}

问题出现在并行流在拆分元素时出了问题,我们实现Spliterator来支持并行处理。
添加一个WordCounterSpliterator实现,并修改一下main方法的内容:

static class WordCounterSpliterator implements Spliterator<Character> {private final String string;private int currentChar = 0;public WordCounterSpliterator(String string) {this.string = string;}// 对拆分后的内容进行遍历@Overridepublic boolean tryAdvance(Consumer<? super Character> action) {// 这里的消费者 action 就是上面的累加器 WordCounter::accumulateaction.accept(string.charAt(currentChar++));             return currentChar < string.length();}// 拆分规则:取当前字符串的中间位置,如果不是空格就向下移一位,直到找到空格的位置进行拆分。// 这样就不会切断单词了。@Overridepublic Spliterator<Character> trySplit() {int currentSize = string.length() - currentChar;if (currentSize < 10) {return null;}for (int splitPos = currentSize / 2 + currentChar; splitPos < string.length(); splitPos++) {if (Character.isWhitespace(string.charAt(splitPos))) {Spliterator<Character> spliterator = new WordCounterSpliterator(string.substring(currentChar,splitPos));currentChar = splitPos;return spliterator;}}return null;}@Overridepublic long estimateSize() {return string.length() - currentChar;}@Overridepublic int characteristics() {return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;}}public static void main(String[] args) {final String SENTENCE = " Nel mezzo del cammin di nostra vita mi ritrovai in una selva oscura ché la dritta via era smarrita ";Spliterator<Character> spliterator = new WordCounterSpliterator(SENTENCE);Stream<Character> stream = StreamSupport.stream(spliterator, true);System.out.println("共 " + new Demo().countWords(stream) + " 个单词");}
  • 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本身是一个不可变类)的。

7.4 小结

  • 内部迭代让你可以并行处理一个流,而无需在代码中显式使用和协调不同的线程。
  • 虽然并行处理一个流很容易,却不能保证程序在所有情况下都运行得更快。并行软件的行为和性能有时是违反直觉的,因此一定要测量,确保你并没有把程序拖得更慢。
  • 像并行流那样对一个数据集并行执行操作可以提升性能,特别是要处理的元素数量庞大,或处理单个元素特别耗时的时候。
  • 从性能角度来看,使用正确的数据结构,如尽可能利用原始流而不是一般化的流,几乎总是比尝试并行化某些操作更为重要。
  • 分支/合并框架让你得以用递归方式将可以并行的任务拆分成更小的任务,在不同的线程上执行,然后将各个子任务的结果合并起来生成整体结果。
  • Spliterator定义了并行流如何拆分它要遍历的数据。
  • 只要将流转成并行,就可以自动获得并行处理的能力。但当前业务是否适合并行处理,需要自己测试并优化。 小节 7.1 并行流 主要介绍了这方面的经验。

参考资料

Interface Spliterator<T> (Java Platform SE 8 )

《Java8实战》读书笔记06:Parallel Stream 并行流相关推荐

  1. Java8实战读书笔记-第3章 λ表达式

    可以在函数式接口上使用λ表达式,函数式接口就是只定义一个抽象方法的接口(函数式接口只可以定义一个抽象接口,但是可以定义多个默认方法). Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提 ...

  2. 《Java8实战》笔记汇总

    <Java8实战>笔记(01):为什么要关心Java8 <Java8实战>笔记(02):通过行为参数传递代码 <Java8实战>笔记(03):Lambda表达式 & ...

  3. Go语言实战读书笔记

    2019独角兽企业重金招聘Python工程师标准>>> Go语言实战读书笔记 第二章 通道(channel).映射(map)和切片(slice)是引用类型.引用类型的对象需要使用ma ...

  4. Apache Kafka实战读书笔记(推荐指数:☆☆☆☆☆)

    Apache Kafka实战读书笔记(推荐指数:☆☆☆☆☆) 认识AK 快速入门 安装和启动 小案例 消息引擎系统 消息引擎范型 AK的概要设计 吞吐量/延时 消息持久化 负载均衡和故障转移: 伸缩性 ...

  5. 数据之道读书笔记-06面向“自助消费”的数据服务建设

    数据之道读书笔记-06面向"自助消费"的数据服务建设 数据底座建设的目标是更好地支撑数据消费,在完成数据的汇聚.整合.联接之后,还需要在供应侧确保用户更便捷.更安全地获取数据.一方 ...

  6. 强化学习读书笔记 - 06~07 - 时序差分学习(Temporal-Difference Learning)

    强化学习读书笔记 - 06~07 - 时序差分学习(Temporal-Difference Learning) 学习笔记: Reinforcement Learning: An Introductio ...

  7. Spring4实战读书笔记

    Spring4实战读书笔记 首先,我们需要明白,为什么我们需要引入Spring,也就是说Spring的好处.个人觉得主要是在于两方面解耦和对bean的管理. 第一部分:Spring核心 共分为四个章节 ...

  8. iPhone与iPad开发实战读书笔记

    iPhone开发一些读书笔记 手机应用分类 1.教育工具 2.生活工具 3.社交应用 4.定位工具 5.游戏 6.报纸和杂志的阅读器 7.移动办公应用 8.财经工具 9.手机购物应用 10.风景区相关 ...

  9. 机器学习实战---读书笔记: 第11章 使用Apriori算法进行关联分析---2---从频繁项集中挖掘关联规则

    #!/usr/bin/env python # encoding: utf-8''' <<机器学习实战>> 读书笔记 第11章 使用Apriori算法进行关联分析---从频繁项 ...

最新文章

  1. 白鹭引擎助力《迷你世界》研发团队开发3D小游戏版
  2. .NET代码混淆学习和解决视频批量转换中.wmv转换出错问题
  3. JAVA 类加载 随记
  4. CANN 5.0黑科技解密 | 算力虚拟化,让AI算力“物尽其用”
  5. Linux c开发工程师的面试题,C+工程师常见的面试题总结
  6. ubuntu14.04 caffe安装前先要将gcc版本降到4.7.x
  7. 原生Django常用API 参数
  8. 西门子V90 PN控制FB284块的个人理解
  9. java 替换所有中文_java 替换中文
  10. Python爬虫实战, QQ空间自动点赞
  11. ClickHouse磁盘清理
  12. 详解会议中控系统及其优点特点有哪些?
  13. 阿里企业邮箱:密码登录
  14. iOS 图片捏合放大缩小 点击放大缩小
  15. 感激爸妈----您们辛苦了
  16. 【拖拽】拖动原理 拖动基本思路
  17. 设备安全--IPS部署与维护
  18. 复旦大学计算机导师评价与简介
  19. Greenplum常用导数据方法及性能测试
  20. 中国高铁进军海外主打廉快全 将贯通三条战略线路

热门文章

  1. 谈谈Django REST Framework(DRF)中的序列化器
  2. Carthage 安装以及初步实用
  3. JAVA poi合并任意列 相同数据单元格
  4. 人脸识别主要算法原理和公司
  5. 茶楼管理系统如何选择
  6. Axure RP 9交互原型设计软件增加了哪些新功能
  7. Axure RP快捷键指令汇总
  8. 计算机考试电脑阅卷,你写的字可能给电脑阅卷带来了很大困难,电脑:这试卷看不下去...
  9. hdu1232畅通工程(并查集)
  10. 因为这款工具的存在:游戏开发的门槛被降到了人人都会