阿里手册自测要点

Posted by Haiming on June 2, 2025

(四) OOP 规约

基本类型和包装类型的使用

  1. 关于基本数据类型与包装数据类型的使用标准,手册强制规定在哪些场景下必须使用包装数据类型?推荐在哪些场景下使用基本数据类型?手册对此给出了怎样的个人理解?使用基本类型接收数据库查询结果(可能为 null)时存在什么风险?[8, 9, 13-15]

他们的差别实际上就是有没有null, 以及需要考虑自动拆装箱

  1. 所有的 POJO 和 RPC 调用, 都必须使用包装类型, 因为可以用 null 表示服务不正常或者值缺失. 如果用基本类型, 无法表达这一语义.

  2. 函数的局部变量使用基本数据类型,:

    1. 避免自动拆装箱的性能损失

    2. 局部变量的使用范围很小, 开发者可控.

一定不能用基本类型接收数据库的值

数据库查询结果可能是null, 那么在转换成基本类型的时候, 需要拆箱, 这就会导致直接报出 NPE.

布尔类型的默认getter 规则

  1. 对于 基本数据类型 boolean 的属性,始终使用 isXxx() 作为 getter 方法

  2. 对于 包装数据类型 Boolean 的属性,使用 getXxx() 作为 getter 方法。同时,要避免该 Boolean 属性对应的 isXxx() 方法与之共存。

String.split() 方法的注意点

  1. 使用索引访问 String 的 split 方法得到的数组时,手册推荐注意什么?为什么不传入 limit 参数时,split() 方法会从尾部处理连续的空字符串?
  1. 使用索引访问 split() 的数组时候, 需要检查最后一个分隔符后面是否有内容, 避免下标越界

  2. 不传入 limit 参数, 等于传入 limit = 0, split() 会从后向前检查:

    1. 连续

    2. 空字符串, 注意空格不算

    且剔除. 可以当成自动做了 trim()

(六) 集合处理

hashCode 和 equals() 的使用方式

  1. 关于 hashCodeequals 的处理,手册强制规定了哪些规则?针对 Set 或 Map 的 key 的对象类型,有何特殊要求?[19, 22, 23]

首先分析一下 hashCode 和 equals 的作用.

hashCode 是快速定位对象可能存在的桶 Bucket, equals() 方法是在同一个桶之中确定对象是否相等.

下面是准则:

  1. 重写 equals() 必须重写 hashCode

  2. Set 或者 map 的 equals() 和 hashCode 都必须重写.

这是因为 Java 官方对于 Object 类中 equals 和 hashCode 方法定义了一套约定(Contract)。虽然手册没有直接列出这套约定,但这是一个基础的 Java 编程原则,手册的强制规定是基于此。这套约定的核心是:

  • 如果两个对象通过 equals() 方法比较是相等的(即 a.equals(b) 返回 true),那么这两个对象调用 hashCode() 方法必须产生相同的整数结果

  • 反之则不一定成立:如果两个对象通过 hashCode() 方法产生的整数结果相同,它们通过 equals() 方法比较不一定是相等的(这称为哈希冲突)

我们用一个极端例子来举例:

某个程序员希望这一个类之中所有对象都相等, 所以重写了 equals() 方法, 直接返回 true.

如果没有重写 hashCode, 那么在判断是否相等的时候, hashCode 不相同, 直接就不相等了. 这个 equals() 白写

而 map 或者 Set 之中, 我们保存的不重复的语义是开发自定义的, 所以必须覆写 equals() 和 hashCode, 才能保证业务逻辑上应该相等的对象会被当做相等对待.

Collectors.toMap() 的 value 不能为 null

  1. 使用 Collectors.toMap() 方法时,为什么要注意当 value 为 null 时会抛出 NPE 异常?手册从底层实现(HashMap#merge)角度是如何解释的?

因为 Collectors.toMap() 方法的底层调用的是 HashMap#merge, 然而在 HashMap#merge 之中, 会在入口处进行如下判断:

if (value == null || remappingFunction == null) throw new NullPointerException();

所以会直接抛出异常

那么为什么 HashMap#merge 这个方法不能传入 null value 呢?

merge 方法是为了 合并 值, 那么 null 值如何参与合并呢?

  1. 有null 值会让合并更加复杂: remappingFunction 的签名通常是 BinaryOperator<V>,它接收两个 V 类型的参数(旧 value 和 新 value),并返回一个 V 类型的结果。如果允许 null 值作为输入,那么 remappingFunction 的实现就需要显式地处理 null 输入的情况。

  2. null 是有歧义的: 是没有这个key, 还是这个key 的value 被显式的设置成了 null?

所以在合并这个方法中, 强制两个对象都有值, 并且显式的做校验, 确实是更好的方式

集合转数组的注意事项

必须使用 toArray(T[] array), 其中 array 是类型一致, 长度为 0 的空数组

  1. 不能使用无参的 toArray(): 其返回的是 Object[], 会产生 ClassCastException

  2. 长度为0, toArray(T[] array) 会动态创建一个和实际集合大小完全一致的数组, 避免额外的内存分配和数据拷贝

Arrays.asList() 返回对象不能用修改集合的方法 (add/remove/clear)

  1. 使用工具类 Arrays.asList() 把数组转换成集合时,为什么不能使用其修改集合相关的方法(add/remove/clear)?这样做会抛出什么异常?手册解释 Arrays.asList() 返回的是什么类型的对象,并将其体现为哪种设计模式?

其体现的是适配器模式, 所以只能对元素层面做修改, 如果使用了 add/remove 这些会干扰原始 list 长度的方法, 就会违背底层的 Array 的长度限制, 不是一个纯粹的适配器(只为了适配不同查询接口, 底层数据只有一份)

泛型通配符 <? extends T><? super T> 的接收/写入数据规定

  1. 泛型通配符 <? extends T><? super T> 在接收/读取数据和写入数据(add 方法)方面各有何强制规定?手册引用了什么原则来解释?请简述这两种通配符的使用场景和原因(个人理解部分)
  1. <? extends T> 只能读取, 不能写入, 因为读取的时候对象在编译时会被当做 T 类型(哪怕实际是 T 类型的一个子类), 可以安全的赋值给一个 T 类型的变量 不可以写入: 为了保证类型安全.

例如,如果你有一个 List<? extends Number>,它可能实际是一个 List 或 List。如果你被允许添加一个 Number 对象(比如 new BigDecimal(10)),那么如果这个列表实际是 List,就会出现类型不匹配的问题。编译器在编译时无法确定 ? extends T 具体代表 T 的哪个子类,为了防止将一个不兼容的子类或 T 本身添加到实际的集合类型中,它禁止了 add 操作

  1. <? super T> 只能写入, 不能读取. 写入的时候可以添加类型为 T 的对象, 以及任何类型是 T 的子类的对象.

    读取的时候只能认为其都是 Object 这个最上层的父类.

PECS 原则 (Producer Extends Consumer Super)

PECS 原则解释:

  • Producer Extends (生产者使用 Extends): 当您需要从泛型集合中读取数据(即集合作为数据的生产者)时,使用 <? extends T>。例如,List<? extends Number> 可以从中读取 Number 或其子类(如 Integer, Double),将它们作为 Number 类型来使用。但不能往里面添加元素。

  • Consumer Super (消费者使用 Super): 当您需要向泛型集合中写入数据(即集合作为数据的消费者)时,使用 <? super T>。例如,List<? super Integer> 可以向其中添加 Integer 或其子类(如 int 字面量),因为它们都能安全地被存储在 Integer 的任何父类类型中。但从里面读取元素时,只能当作 Object

泛型限制集合/无泛型限制集合的使用注意事项

  1. 在无泛型限制的集合赋值给泛型集合时,手册强制规定在使用集合元素时需要注意什么?为什么?手册个人认为这种情况主要出现在哪里的代码中?泛型的引入(JDK5 后)是为了解决什么问题?为了保持兼容性,JDK5 引入泛型后采取了哪些措施(类型擦除、原始类型、桥接方法)?请简要解释类型擦除和桥接方法的作用。
  1. 在没有泛型限制的集合赋值给泛型集合的时候, 规定在使用集合元素的时候必须要用上 instanceof 来检查对象的类型

  2. 这种情况主要出现在还没有泛型的 jdk5 之前的老代码中

  3. 泛型的引入, 是为了解决一个集合之中可能存入多种类型的对象, 从而导致使用起来不方便且容易出错的情况

  4. 为了保持兼容性, JDK5 引入泛型之后采取了下面的措施:

    1. 类型擦除: 编译之后变成该类extend 的类, 或者 Object类, 让字节码可以和旧版JVM兼容. 编译器在编译的时候进行类型检查, 且自动插入类型转换

    2. 原始集合类型: 仍然允许使用原始不带泛型的集合类型, 代价是牺牲了编译时类型安全

    3. 桥接方法: 一个类继承泛型类或者实现泛型接口, 类型擦除会让方法签名在字节码上变化. 为了保持多态性, 编译器在这个类之中会生成一个有原始类型签名的桥接方法, 内部实际调用自己编写的具有泛型签名的方法 下面是例子:

      class Node<T> {
          public T data;
          public void setData(T data) { this.data = data; }
      }
      // 自己实现的方法
      class MyNode extends Node<String> {
          @Override
          public void setData(String data) { // 覆盖了泛型方法
              System.out.println("MyNode setData: " + data);
              super.setData(data);
          }
      }
      

      在编译 MyNode 时,编译器会发现 Node<T> 的 setData(T data) 在擦除后变成 setData(Object data)。而 MyNode 中定义的是 setData(String data)。为了让通过 Node 引用调用 setData 时能够正确调用到 MyNode 的覆盖版本,编译器会为 MyNode 生成一个桥接方法:

      // 由编译器在 MyNode.class 中自动生成的桥接方法(示意)
      public void setData(Object data) {
          setData((String) data); // 调用实际编写的 setData(String) 方法
      }
      

ConcurrentHashMap/HashMap 对于 key 和 value 为 null 的处理

  1. ConcurrentHashMap 的 K 和 V 都不允许为 null. 因为 map 之中 get(K) 结果为 null 会有两种可能: key 本身不存在或者K的值为null. 这样给并发下面的内部状态判断带来了复杂的逻辑, 所以干脆不支持

  2. 多线程下面使用 hashMap 的 containsKey, 可能出现竞态问题, 因为一个线程检查 hashMap 是否含有某个 key 的时候, 另一个线程可能对 Map 已经进行了修改, 导致无法准确判断.

SimpleDateFormat 线程不安全

  1. SimpleDateFormat 为什么是线程不安全的?手册强制规定不能将其定义为 static 变量,如果定义为 static,必须怎么处理?手册推荐使用 JDK8 中的哪个类来代替 SimpleDateFormat?这个类有何特性?
  1. SimpleDateFormat 线程不安全是因为其内部的 Calendar 类线程不安全, 所以当多个线程竞争的时候, 对日期格式化或者日期生成的操作可能因为线程竞争导致数据混乱

  2. 因为 SimpleDateFormat 线程不安全, 所以不能定义为 Static 变量(static变量本身就是类级别的变量, 很容易被多线程共享, 会出问题). 如果必须定义为 static 变量, 必须给对象显性加锁, 但是这样性能会有很大的问题. 或者使用 threadLocal, 对每一个 thread 搞一个自己的 SimpleDateFormat 类

  3. 手册推荐使用 JDK8 的 java.time.format.DateTimeFormatter 来代替 SimpleDateFormat. 因为 DateTimeFormatter 内部是线程安全的.

ThreadLocal 的使用

  1. 关于 ThreadLocal 的补充说明中,个人认为为什么 ThreadLocal 对象必须使用 static final 修饰?这样做有什么好处?手册如何解释不同线程对同一个 ThreadLocal 对象可以取出不同的值?ThreadLocal 能解决共享对象的更新问题吗?

ThreadLocal 本质上就是一个 key ! 按照这个思路去理解, key 一定要全局唯一, 那么 static 能保证一个类之中只有一个 threadLocal, 不会随着对象的生成而增加; final 保证了这个 threadLocal 在生成之后就不可变.

  1. threadLocal 必须用 static final 修饰, 原因:

    1. static 意味着threadLocal 变量是类级别的对象, 且只会被初始化一次, 这样就代表了每个线程对这个类拿到的都是一样的对象.

    2. final: 意味着生成之后就不可重新赋值.

  2. 每个线程是有自己的 threadLocalMap, 那么对于同样的key 取出不同的值就很正常的

  3. threadLocal 不能解决共享对象的更新问题, 因为其实现方式是每一个线程对同样的一个值有自己的副本, 要解决还是得加锁

Lock 上锁规范

  1. 在使用阻塞等待获取锁的方式(如 lock.lock())时,手册强制规定加锁方法必须放在代码块的什么位置?为什么加锁方法和 try 代码块之间不能有任何可能抛出异常的方法调用?如果在 try 块之内调用 lock.lock() 会有什么后果?
  1. 阻塞等待获取锁, 加锁方法必须放在 try 代码块之外

  2. 如果放在try 代码块之内, 有两种情况会造成 IllegalMonitorStateException:

    1. try 代码块之中其他方法抛出异常, 导致 lock.lock() 还未执行就到了 finally, finally 内部对一个还没上锁的 lock 进行解锁, 导致 IllegalMonitorStateException

    2. 上锁过程 lock.lock() 本身就抛出异常, 那么直接去 finally 里面解锁, 也会产生 IllegalMonitorStateException

  3. 加锁方法和 try 代码块之中不能有任何可能抛出异常的调用, 按我看来, 最好是不要有任何调用. 避免上锁之后还未进入 try 代码块就直接抛出异常, 锁永远解不开, 造成死锁