<audio id="audio" title="答疑篇：代码篇思考题集锦（二）" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/f8/20ff92d160e92fb43de58a4d197d00f8.mp3"></audio>

你好，我是朱晔。

今天，我们继续一起分析这门课第7~12讲的课后思考题。这些题目涉及了数据库索引、判等问题、数值计算、集合类、空值处理和异常处理的12道问题。

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

### [07 | 数据库索引：索引并不是万能药](https://time.geekbang.org/column/article/213342)

**问题1：**在介绍二级索引代价时，我们通过EXPLAIN命令看到了索引覆盖和回表的两种情况。你能用optimizer trace来分析一下这两种情况的成本差异吗？

答：如下代码所示，打开optimizer_trace后，再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了，最后可以关闭optimizer_trace功能：

```
SET optimizer_trace=&quot;enabled=on&quot;;
SELECT * FROM person WHERE NAME &gt;'name84059' AND create_time&gt;'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace=&quot;enabled=off&quot;;

```

假设我们为表person的NAME和SCORE列建了联合索引，那么下面第二条语句应该可以走索引覆盖，而第一条语句需要回表：

```
explain select * from person where NAME='name1';
explain select NAME,SCORE from person where NAME='name1';

```

通过观察OPTIMIZER_TRACE的输出可以看到，索引覆盖（index_only=true）的成本是1.21而回表查询（index_only=false）的是2.21，也就是索引覆盖节省了回表的成本1。

索引覆盖：

```
analyzing_range_alternatives&quot;: {
  &quot;range_scan_alternatives&quot;: [
  {
    &quot;index&quot;: &quot;name_score&quot;,
    &quot;ranges&quot;: [
      &quot;name1 &lt;= name &lt;= name1&quot;
    ] /* ranges */,
    &quot;index_dives_for_eq_ranges&quot;: true,
    &quot;rowid_ordered&quot;: false,
    &quot;using_mrr&quot;: false,
    &quot;index_only&quot;: true,
    &quot;rows&quot;: 1,
    &quot;cost&quot;: 1.21,
    &quot;chosen&quot;: true
  }
]

```

回表：

```
&quot;range_scan_alternatives&quot;: [
  {
    &quot;index&quot;: &quot;name_score&quot;,
    &quot;ranges&quot;: [
      &quot;name1 &lt;= name &lt;= name1&quot;
    ] /* ranges */,
    &quot;index_dives_for_eq_ranges&quot;: true,
    &quot;rowid_ordered&quot;: false,
    &quot;using_mrr&quot;: false,
    &quot;index_only&quot;: false,
    &quot;rows&quot;: 1,
    &quot;cost&quot;: 2.21,
    &quot;chosen&quot;: true
  }
]

```

**问题2：**索引除了可以用于加速搜索外，还可以在排序时发挥作用，你能通过EXPLAIN来证明吗？你知道，针对排序在什么情况下，索引会失效吗？

答：排序使用到索引，在执行计划中的体现就是key这一列。如果没有用到索引，会在Extra中看到Using filesort，代表使用了内存或磁盘进行排序。而具体走内存还是磁盘，是由sort_buffer_size和排序数据大小决定的。

排序无法使用到索引的情况有：

- 对于使用联合索引进行排序的场景，多个字段排序ASC和DESC混用；
- a+b作为联合索引，按照a范围查询后按照b排序；
- 排序列涉及到的多个字段不属于同一个联合索引；
- 排序列使用了表达式。

其实，这些原因都和索引的结构有关。你可以再有针对性地复习下[第07讲](https://time.geekbang.org/column/article/213342)的聚簇索引和二级索引部分。

### [08 | 判等问题：程序里如何确定你就是你？](https://time.geekbang.org/column/article/213604)

**问题1：**在实现equals时，我是先通过getClass方法判断两个对象的类型，你可能会想到还可以使用instanceof来判断。你能说说这两种实现方式的区别吗？

答：事实上，使用getClass和instanceof这两种方案都是可以判断对象类型的。它们的区别就是，getClass限制了这两个对象只能属于同一个类，而instanceof却允许两个对象是同一个类或其子类。

正是因为这种区别，不同的人对这两种方案有不同的喜好，争论也很多。在我看来，你只需要根据自己的要求去选择。补充说明一下，Lombok使用的是instanceof的方案。

**问题2：**在“hashCode 和 equals 要配对实现”这一节的例子中，我演示了可以通过HashSet的contains方法判断元素是否在HashSet中。那同样是Set的TreeSet，其contains方法和HashSet的contains方法有什么区别吗？

答：HashSet基于HashMap，数据结构是哈希表。所以，HashSet的contains方法，其实就是根据hashcode和equals去判断相等的。

TreeSet基于TreeMap，数据结构是红黑树。所以，TreeSet的contains方法，其实就是根据compareTo去判断相等的。

### [09 | 数值计算：注意精度、舍入和溢出问题](https://time.geekbang.org/column/article/213796)

**问题1：**[BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html)提供了8种舍入模式，你能通过一些例子说说它们的区别吗？

答：@Darren同学的留言非常全面，梳理得也非常清楚了。这里，我对他的留言稍加修改，就是这个问题的答案了。

第一种，ROUND_UP，舍入远离零的舍入模式，在丢弃非零部分之前始终增加数字（始终对非零舍弃部分前面的数字加1）。 需要注意的是，此舍入模式始终不会减少原始值。

第二种，ROUND_DOWN，接近零的舍入模式，在丢弃某部分之前始终不增加数字（从不对舍弃部分前面的数字加1，即截断）。 需要注意的是，此舍入模式始终不会增加原始值。

第三种，ROUND_CEILING，接近正无穷大的舍入模式。 如果 BigDecimal 为正，则舍入行为与 ROUND_UP 相同； 如果为负，则舍入行为与 ROUND_DOWN 相同。 需要注意的是，此舍入模式始终不会减少原始值。

第四种，ROUND_FLOOR，接近负无穷大的舍入模式。 如果 BigDecimal 为正，则舍入行为与 ROUND_DOWN 相同； 如果为负，则舍入行为与 ROUND_UP 相同。 需要注意的是，此舍入模式始终不会增加原始值。

第五种，ROUND_HALF_UP，向“最接近的”数字舍入。如果舍弃部分 &gt;= 0.5，则舍入行为与 ROUND_UP 相同；否则，舍入行为与 ROUND_DOWN 相同。 需要注意的是，这是我们大多数人在小学时就学过的舍入模式（四舍五入）。

第六种，ROUND_HALF_DOWN，向“最接近的”数字舍入。如果舍弃部分 &gt; 0.5，则舍入行为与 ROUND_UP 相同；否则，舍入行为与 ROUND_DOWN 相同（五舍六入）。

第七种，ROUND_HALF_EVEN，向“最接近的”数字舍入。这种算法叫做银行家算法，具体规则是，四舍六入，五则看前一位，如果是偶数舍入，如果是奇数进位，比如5.5 -&gt; 6，2.5 -&gt; 2。

第八种，ROUND_UNNECESSARY，假设请求的操作具有精确的结果，也就是不需要进行舍入。如果计算结果产生不精确的结果，则抛出ArithmeticException。

**问题2：**数据库（比如MySQL）中的浮点数和整型数字，你知道应该怎样定义吗？又如何实现浮点数的准确计算呢？

答：MySQL中的整数根据能表示的范围有TINYINT、SMALLINT、MEDIUMINT、INTEGER、BIGINT等类型，浮点数包括单精度浮点数FLOAT和双精度浮点数DOUBLE和Java中的float/double一样，同样有精度问题。

要解决精度问题，主要有两个办法：

- 第一，使用DECIMAL类型（和那些INT类型一样，都属于严格数值数据类型），比如DECIMAL(13, 2)或DECIMAL(13, 4)。
- 第二，使用整数保存到分，这种方式容易出错，万一读的时候忘记/100或者是存的时候忘记*100，可能会引起重大问题。当然了，我们也可以考虑将整数和小数分开保存到两个整数字段。

### [10 | 集合类：坑满地的List列表操作](https://time.geekbang.org/column/article/216778)

**问题1：**调用类型是Integer的ArrayList的remove方法删除元素，传入一个Integer包装类的数字和传入一个int基本类型的数字，结果一样吗？

答：传int基本类型的remove方法是按索引值移除，返回移除的值；传Integer包装类的remove方法是按值移除，返回列表移除项目之前是否包含这个值（是否移除成功）。

为了验证两个remove方法重载的区别，我们写一段测试代码比较一下：

```
private static void removeByIndex(int index) {
    List&lt;Integer&gt; list =
            IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
    System.out.println(list.remove(index));
    System.out.println(list);
}
private static void removeByValue(Integer index) {
    List&lt;Integer&gt; list =
            IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
    System.out.println(list.remove(index));
    System.out.println(list);
}

```

测试一下removeByIndex(4)，通过输出可以看到第五项被移除了，返回5：

```
5
[1, 2, 3, 4, 6, 7, 8, 9, 10]

```

而调用removeByValue(Integer.valueOf(4))，通过输出可以看到值4被移除了，返回true：

```
true
[1, 2, 3, 5, 6, 7, 8, 9, 10]

```

**问题2：**循环遍历List，调用remove方法删除元素，往往会遇到ConcurrentModificationException，原因是什么，修复方式又是什么呢？

答：原因是，remove的时候会改变modCount，通过迭代器遍历就会触发ConcurrentModificationException。我们看下ArrayList类内部迭代器的相关源码：

```
public E next() {
    checkForComodification();
    int i = cursor;
    if (i &gt;= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i &gt;= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

```

要修复这个问题，有以下两种解决方案。

第一种，通过ArrayList的迭代器remove。迭代器的remove方法会维护一个expectedModCount，使其与 ArrayList 的modCount保持一致：

```
List&lt;String&gt; list =
        IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
for (Iterator&lt;String&gt; iterator = list.iterator(); iterator.hasNext(); ) {
    String next = iterator.next();
    if (&quot;2&quot;.equals(next)) {
        iterator.remove();
    }
}
System.out.println(list);

```

第二种，直接使用removeIf方法，其内部使用了迭代器的remove方法：

```
List&lt;String&gt; list =
        IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
list.removeIf(item -&gt; item.equals(&quot;2&quot;));
System.out.println(list);

```

### [11 | 空值处理：分不清楚的null和恼人的空指针](https://time.geekbang.org/column/article/216830)

**问题1：**ConcurrentHashMap的Key和Value都不能为null，而HashMap却可以，你知道这么设计的原因是什么吗？TreeMap、Hashtable等Map的Key和Value是否支持null呢？

答：原因正如ConcurrentHashMap的作者所说：

> 
The main reason that nulls aren’t allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can’t be accommodated. The main one is that if map.get(key) returns null, you can’t detect whether the key explicitly maps to null vs the key isn’t mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.


如果Value为null会增加二义性，也就是说多线程情况下map.get(key)返回null，我们无法区分Value原本就是null还是Key没有映射，Key也是类似的原因。此外，我也更同意他的观点，就是普通的Map允许null是否是一个正确的做法，也值得商榷，因为这会增加犯错的可能性。

Hashtable也是线程安全的，所以Key和Value不可以是null。

TreeMap是线程不安全的，但是因为需要排序，需要进行key的compareTo方法，所以Key不能是null，而Value可以是null。

**问题2：**对于Hibernate框架，我们可以使用@DynamicUpdate注解实现字段的动态更新。那么，对于MyBatis框架来说，要如何实现类似的动态SQL功能，实现插入和修改SQL只包含POJO中的非空字段呢？

答：MyBatis可以通过动态SQL实现：

```
&lt;select id=&quot;findUser&quot; resultType=&quot;User&quot;&gt;
  SELECT * FROM USER
  WHERE 1=1
  &lt;if test=&quot;name != null&quot;&gt;
    AND name like #{name}
  &lt;/if&gt;
  &lt;if test=&quot;email != null&quot;&gt;
    AND email = #{email}
  &lt;/if&gt;
&lt;/select&gt;

```

如果使用MyBatisPlus的话，实现类似的动态SQL功能会更方便。我们可以直接在字段上加@TableField注解来实现，可以设置insertStrategy、updateStrategy、whereStrategy属性。关于这三个属性的使用方式，你可以参考如下源码，或是[这里](https://mp.baomidou.com/guide/annotation.html#tablefield)的官方文档：

```
/**
	     * 字段验证策略之 insert: 当insert操作时，该字段拼接insert语句时的策略
	     * IGNORED: 直接拼接 insert into table_a(column) values (#{columnProperty});
	     * NOT_NULL: insert into table_a(&lt;if test=&quot;columnProperty != null&quot;&gt;column&lt;/if&gt;) values (&lt;if test=&quot;columnProperty != null&quot;&gt;#{columnProperty}&lt;/if&gt;)
	     * NOT_EMPTY: insert into table_a(&lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column&lt;/if&gt;) values (&lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;#{columnProperty}&lt;/if&gt;)
	     *
	     * @since 3.1.2
	     */
	    FieldStrategy insertStrategy() default FieldStrategy.DEFAULT;
	

	    /**
	     * 字段验证策略之 update: 当更新操作时，该字段拼接set语句时的策略
	     * IGNORED: 直接拼接 update table_a set column=#{columnProperty}, 属性为null/空string都会被set进去
	     * NOT_NULL: update table_a set &lt;if test=&quot;columnProperty != null&quot;&gt;column=#{columnProperty}&lt;/if&gt;
	     * NOT_EMPTY: update table_a set &lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column=#{columnProperty}&lt;/if&gt;
	     *
	     * @since 3.1.2
	     */
	    FieldStrategy updateStrategy() default FieldStrategy.DEFAULT;
	

	    /**
	     * 字段验证策略之 where: 表示该字段在拼接where条件时的策略
	     * IGNORED: 直接拼接 column=#{columnProperty}
	     * NOT_NULL: &lt;if test=&quot;columnProperty != null&quot;&gt;column=#{columnProperty}&lt;/if&gt;
	     * NOT_EMPTY: &lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column=#{columnProperty}&lt;/if&gt;
	     *
	     * @since 3.1.2
	     */
	    FieldStrategy whereStrategy() default FieldStrategy.DEFAULT;

```

### [12 | 异常处理：别让自己在出问题的时候变为瞎子](https://time.geekbang.org/column/article/220230)

**问题1：**关于在finally代码块中抛出异常的坑，如果在finally代码块中返回值，你觉得程序会以try或catch中的返回值为准，还是以finally中的返回值为准呢？

答：以finally中的返回值为准。

从语义上来说，finally是做方法收尾资源释放处理的，我们不建议在finally中有return，这样逻辑会很混乱。这是因为，实现上finally中的代码块会被复制多份，分别放到try和catch调用return和throw异常之前，所以finally中如果有返回值，会覆盖try中的返回值。

**问题2：**对于手动抛出的异常，不建议直接使用Exception或RuntimeException，通常建议复用JDK中的一些标准异常，比如[IllegalArgumentException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html)、[IllegalStateException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalStateException.html)、[UnsupportedOperationException](https://docs.oracle.com/javase/8/docs/api/java/lang/UnsupportedOperationException.html)。你能说说它们的适用场景，并列出更多常见的可重用标准异常吗？

答：我们先分别看看IllegalArgumentException、IllegalStateException、UnsupportedOperationException这三种异常的适用场景。

- IllegalArgumentException：参数不合法异常，适用于传入的参数不符合方法要求的场景。
- IllegalStateException：状态不合法异常，适用于状态机的状态的无效转换，当前逻辑的执行状态不适合进行相应操作等场景。
- UnsupportedOperationException：操作不支持异常，适用于某个操作在实现或环境下不支持的场景。

还可以重用的异常有IndexOutOfBoundsException、NullPointerException、ConcurrentModificationException等。

以上，就是咱们这门课第7~12讲的思考题答案了。

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