<audio id="audio" title="答疑篇：加餐篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/db/8d3847da3b36e5c590f376694eda7bdb.mp3"></audio>

你好，我是朱晔。

今天，我们继续一起分析这门课的“不定期加餐”篇中5讲的课后思考题。这些题目涉及了Java 8基础知识、定位和分析应用问题相关的几大知识点。

接下来，我们就一一具体分析吧。

### [加餐1 | 带你吃透课程中Java 8的那些重要知识点（一）](https://time.geekbang.org/column/article/212374)

**问题：**对于并行流部分的并行消费处理1到100的例子，如果把forEach替换为forEachOrdered，你觉得会发生什么呢？

答：forEachOrdered 会让parallelStream丧失部分的并行能力，主要原因是forEach遍历的逻辑无法并行起来（需要按照循序遍历，无法并行）。

我们来比较下面的三种写法：

```
//模拟消息数据需要1秒时间
private static void consume(int i) {
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.print(i);
}
//模拟过滤数据需要1秒时间
private static boolean filter(int i) {
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return i % 2 == 0;
}
@Test
public void test() {
    System.setProperty(&quot;java.util.concurrent.ForkJoinPool.common.parallelism&quot;, String.valueOf(10));

    StopWatch stopWatch = new StopWatch();
    stopWatch.start(&quot;stream&quot;);
    stream();
    stopWatch.stop();
    stopWatch.start(&quot;parallelStream&quot;);
    parallelStream();
    stopWatch.stop();
    stopWatch.start(&quot;parallelStreamForEachOrdered&quot;);
    parallelStreamForEachOrdered();
    stopWatch.stop();
    System.out.println(stopWatch.prettyPrint());
}
//filtre和forEach串行
private void stream() {
    IntStream.rangeClosed(1, 10)
            .filter(ForEachOrderedTest::filter)
            .forEach(ForEachOrderedTest::consume);
}
//filter和forEach并行
private void parallelStream() {
    IntStream.rangeClosed(1, 10).parallel()
            .filter(ForEachOrderedTest::filter)
            .forEach(ForEachOrderedTest::consume);
}
//filter并行而forEach串行
private void parallelStreamForEachOrdered() {
    IntStream.rangeClosed(1, 10).parallel()
            .filter(ForEachOrderedTest::filter)
            .forEachOrdered(ForEachOrderedTest::consume);
}

```

得到输出：

```
---------------------------------------------
ns         %     Task name
---------------------------------------------
15119607359  065%  stream
2011398298  009%  parallelStream
6033800802  026%  parallelStreamForEachOrdered

```

从输出中，我们可以看到：

- stream方法的过滤和遍历全部串行执行，总时间是10秒+5秒=15秒；
- parallelStream方法的过滤和遍历全部并行执行，总时间是1秒+1秒=2秒；
- parallelStreamForEachOrdered方法的过滤并行执行，遍历串行执行，总时间是1秒+5秒=6秒。

### [加餐2 | 带你吃透课程中Java 8的那些重要知识点（二）](https://time.geekbang.org/column/article/212398)

**问题1：**使用Stream可以非常方便地对List做各种操作，那有没有什么办法可以实现在整个过程中观察数据变化呢？比如，我们进行filter+map操作，如何观察filter后map的原始数据呢？

答：要想观察使用Stream对List的各种操作的过程中的数据变化，主要有下面两个办法。

第一，**使用peek方法**。比如如下代码，我们对数字1~10进行了两次过滤，分别是找出大于5的数字和找出偶数，我们通过peek方法把两次过滤操作之前的原始数据保存了下来：

```
List&lt;Integer&gt; firstPeek = new ArrayList&lt;&gt;();
List&lt;Integer&gt; secondPeek = new ArrayList&lt;&gt;();
List&lt;Integer&gt; result = IntStream.rangeClosed(1, 10)
        .boxed()
        .peek(i -&gt; firstPeek.add(i))
        .filter(i -&gt; i &gt; 5)
        .peek(i -&gt; secondPeek.add(i))
        .filter(i -&gt; i % 2 == 0)
        .collect(Collectors.toList());
System.out.println(&quot;firstPeek：&quot; + firstPeek);
System.out.println(&quot;secondPeek：&quot; + secondPeek);
System.out.println(&quot;result：&quot; + result);

```

最后得到输出，可以看到第一次过滤之前是数字1~10，一次过滤后变为6~10，最终输出6、8、10三个数字：

```
firstPeek：[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
secondPeek：[6, 7, 8, 9, 10]
result：[6, 8, 10]

```

第二，**借助IDEA的Stream的调试功能**。详见[这里](https://www.jetbrains.com/help/idea/analyze-java-stream-operations.html)，效果类似下图：

<img src="https://static001.geekbang.org/resource/image/3e/5e/3ee49c0589286bba37dd66032530d65e.png" alt="">

**问题2：**Collectors类提供了很多现成的收集器，那我们有没有办法实现自定义的收集器呢？比如，实现一个MostPopularCollector，来得到List中出现次数最多的元素，满足下面两个测试用例：

```
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector&lt;&gt;()).get(), is(2));
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector&lt;&gt;()).get(), is('c'));

```

答：我来说下我的实现思路和方式：通过一个HashMap来保存元素的出现次数，最后在收集的时候找出Map中出现次数最多的元素：

```
public class MostPopularCollector&lt;T&gt; implements Collector&lt;T, Map&lt;T, Integer&gt;, Optional&lt;T&gt;&gt; {
    //使用HashMap保存中间数据
    @Override
    public Supplier&lt;Map&lt;T, Integer&gt;&gt; supplier() {
        return HashMap::new;
    }
    //每次累积数据则累加Value
    @Override
    public BiConsumer&lt;Map&lt;T, Integer&gt;, T&gt; accumulator() {
        return (acc, elem) -&gt; acc.merge(elem, 1, (old, value) -&gt; old + value);
    }
    //合并多个Map就是合并其Value
    @Override
    public BinaryOperator&lt;Map&lt;T, Integer&gt;&gt; combiner() {
        return (a, b) -&gt; Stream.concat(a.entrySet().stream(), b.entrySet().stream())
                .collect(Collectors.groupingBy(Map.Entry::getKey, summingInt(Map.Entry::getValue)));
    }
    //找出Map中Value最大的Key
    @Override
    public Function&lt;Map&lt;T, Integer&gt;, Optional&lt;T&gt;&gt; finisher() {
        return (acc) -&gt; acc.entrySet().stream()
                .reduce(BinaryOperator.maxBy(Map.Entry.comparingByValue()))
                .map(Map.Entry::getKey);
    }

    @Override
    public Set&lt;Characteristics&gt; characteristics() {
        return Collections.emptySet();
    }
}

```

### [加餐3 | 定位应用问题，排错套路很重要](https://time.geekbang.org/column/article/221982)

**问题：**如果你现在打开一个App后发现首页展示了一片空白，那这到底是客户端兼容性的问题，还是服务端的问题呢？如果是服务端的问题，又如何进一步细化定位呢？你有什么分析思路吗？

答：首先，我们需要区分客户端还是服务端错误。我们可以先从客户端下手，排查看看是否是服务端问题，也就是通过抓包来看服务端的返回（一般而言客户端发布之前会经过测试，而且无法随时变更，所以服务端出错的可能性会更大一点）。因为一个客户端程序可能对应几百个服务端接口，先从客户端（发出请求的根源）开始排查问题，更容易找到方向。

服务端没有返回正确的输出，那么就需要继续排查服务端接口或是上层的负载均衡了，排查方式为：

- 查看负载均衡（比如Nginx）的日志；
- 查看服务端日志；
- 查看服务端监控。

如果服务端返回了正确的输出，那么要么是由于客户端的Bug，要么就是外部配置等问题了，排查方式为：

- 查看客户端报错（一般而言，客户端都会对接SAAS的异常服务）；
- 直接本地启动客户端调试。

### [加餐4 | 分析定位Java问题，一定要用好这些工具（一）](https://time.geekbang.org/column/article/224816)

**问题1：**JDK中还有一个jmap工具，我们会使用jmap -dump命令来进行堆转储。那么，这条命令和jmap -dump:live有什么区别呢？你能否设计一个实验，来证明下它们的区别呢？

答：jmap -dump命令是转储堆中的所有对象，而jmap -dump:live是转储堆中所有活着的对象。因为，jmap -dump:live会触发一次FullGC。

写一个程序测试一下：

```
@SpringBootApplication
@Slf4j
public class JMapApplication implements CommandLineRunner {

    //-Xmx512m -Xms512m
    public static void main(String[] args) {
        SpringApplication.run(JMapApplication.class, args);
    }
    @Override
    public void run(String... args) throws Exception {
        while (true) {
            //模拟产生字符串，每次循环后这个字符串就会失去引用可以GC
            String payload = IntStream.rangeClosed(1, 1000000)
                    .mapToObj(__ -&gt; &quot;a&quot;)
                    .collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
            log.debug(payload);
            TimeUnit.MILLISECONDS.sleep(1);
        }
    }
}

```

然后，使用jmap不带和带live分别生成两个转储：

```
jmap -dump:format=b,file=nolive.hprof 57323
jmap -dump:live,format=b,file=live.hprof 5732

```

可以看到，nolive这个转储的不可到达对象包含了164MB char[]（可以认为基本是字符串）：

<img src="https://static001.geekbang.org/resource/image/8e/9b/8e4f3eea80edfe6d867cab754967589b.png" alt="">

而live这个转储只有1.3MB的char[]，说明程序循环中的这些字符串都被GC了：

<img src="https://static001.geekbang.org/resource/image/18/87/18403a0b683c3b726700d5624f968287.png" alt="">

**问题2：**你有没有想过，客户端是如何和MySQL进行认证的呢？你能否对照[MySQL的文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)，使用Wireshark观察分析这一过程呢？

答：一般而言，认证（握手）过程分为三步。

首先，服务端给客户端主动发送握手消息：

<img src="https://static001.geekbang.org/resource/image/29/b2/29f5e4a9056b7b6aeb9d9ac2yy5e97b2.png" alt="">

Wireshark已经把消息的字段做了解析，你可以对比[官方文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)的协议格式一起查看。HandshakeV10消息体的第一个字节是消息版本0a，见图中红色框标注的部分。前面四个字节是MySQL的消息头，其中前三个字节是消息体长度（16进制4a=74字节），最后一个字节是消息序列号。

然后，客户端给服务端回复的HandshakeResponse41消息体，包含了登录的用户名和密码：

<img src="https://static001.geekbang.org/resource/image/a0/96/a0b37df4f3e92f7e8602409a7ca0f696.png" alt="">

可以看到，用户名是string[NUL]类型的，说明字符串以00结尾代表字符串结束。关于MySQL协议中的字段类型，你可以参考[这里](https://dev.mysql.com/doc/internals/en/string.html)。

最后，服务端回复的OK消息，代表握手成功：

<img src="https://static001.geekbang.org/resource/image/d7/e7/d746f34df74cfedb4d294db1e2b771e7.png" alt="">

这样分析下来，我们可以发现使用Wireshark观察客户端和MySQL的认证过程，非常方便。而如果不借助Wireshark工具，我们只能一个字节一个字节地对照协议文档分析内容。

其实，各种CS系统定义的通讯协议本身并不深奥，甚至可以说对着协议文档写通讯客户端是体力活。你可以继续按照这里我说的方式，结合抓包和文档，分析一下MySQL的查询协议。

### [加餐5 | 分析定位Java问题，一定要用好这些工具（二）](https://time.geekbang.org/column/article/230534)

**问题：**Arthas还有一个强大的热修复功能。比如，遇到高CPU问题时，我们定位出是管理员用户会执行很多次MD5，消耗大量CPU资源。这时，我们可以直接在服务器上进行热修复，步骤是：jad命令反编译代码-&gt;使用文本编辑器（比如Vim）直接修改代码-&gt;使用sc命令查找代码所在类的ClassLoader-&gt;使用redefine命令热更新代码。你可以尝试使用这个流程，直接修复程序（注释doTask方法中的相关代码）吗？

答：Arthas的官方文档有[详细的操作步骤](https://alibaba.github.io/arthas/redefine.html)，实现jad-&gt;sc-&gt;redefine的整个流程，需要注意的是：

- redefine命令和jad/watch/trace/monitor/tt等命令会冲突。执行完redefine之后，如果再执行上面提到的命令，则会把redefine的字节码重置。 原因是，JDK本身redefine和Retransform是不同的机制，同时使用两种机制来更新字节码，只有最后的修改会生效。
- 使用redefine不允许新增或者删除field/method，并且运行中的方法不会立即生效，需要等下次运行才能生效。

以上，就是咱们这门课里面5篇加餐文章的思考题答案了。至此，咱们这个课程的“答疑篇”模块也就结束了。

关于这些题目，以及背后涉及的知识点，如果你还有哪里感觉不清楚的，欢迎在评论区与我留言，也欢迎你把今天的内容分享给你的朋友或同事，一起交流。
