# 基础

1. 谈谈深拷贝和浅拷贝

  • 浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
  • 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

Object 类中,有个 clone() 方法,默认的就是浅拷贝,一个类想要重写 clone() 函数,需要实现 Cloneable 接口,自己实现深拷贝,就可以在 clone 函数中直接 new 就行。序列化也是深拷贝,这里直接给代码:

class DeepClone implements Serializable {
     private static final long serialVersionUID = 1412L;
     public Object deepClone() throws Exception {
          // 序列化,将数据存到 bos 中
          ByteArrayOutputStream bos = new ByteArrayOutputStream();
          ObjectOutputStream oos = new ObjectOutputStream(bos);
          oos.writeObject(this);
          // 反序列化
          ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
          ObjectInputStream ois = new ObjectInputStream(bis);
          return ois.readObject();
     }
}

这里解释一下序列化的两个类:

  • ByteArrayOutputStream :提供了一个输出流的实现,将数据写入一个字节数组。它是 OutputStream 的子类,当您需要将数据写入字节数组而不是文件或其他输出流时, ByteArrayOutputStream 非常有用
  • ObjectOutputStream :它提供了一个输出流的实现,将对象写入一个字节流。它是 OutputStream 的子类,提供了向字节流写入对象和检索字节流内容的方法。 ObjectOutputStream 可以与 ByteArrayOutputStream 一起使用,将对象写入字节数组而不是文件或其他输出流。

# 集合

涉及到 Java 集合部分,也包括线程安全的集合,还是以面试题的形式记录。

1. ArrayListLinkedList 遍历谁更快?

ArrayList 更快, ArrayList 的优势就是内存连续,如果遍历 LinkedList ,随机 IO 可能更多一些

2. 谈谈你对 ArrayList 的理解

从我学习程度来看, ArrayList 需要注意的是:

  • 线程不安全,如果要保证安全,可以使用 Vector 或者使用 Collections.synchronizedList(xx) ,前者在每个操作方法上都加了 synchronized 关键字,后者内部会维护一个排他锁,每次对集合操作时,都需要先竞争锁。
  • 迭代器的 fail-fast 机制:这其实是每个实现了 iterator 集合都需要注意的地方,每次对数组进行增删,都会使得 modCount++ ,而迭代器的 next 函数会检测 modCount 是否和最开始的自己保存的 modCount 相同,也就是检测在迭代的过程中数组是否发生了改变,因为这可能使得迭代过程中漏了某些元素或者重复遍历某些元素
  • ArrayList 内部是使用 Object 数组 -- elementData ,但是该变量没有被 private 修饰,代码注释写的是方便内部类更快的访问该属性,如果被 private 修饰,那么同样的代码反编译后的字节码文件更复杂一些。
  • 扩容时,是 1.5 倍增长,而 Vector 扩容默认两倍扩容。

3. HashMap 了解吗?1.7 版本和 1.8 版本都什么区别

JDK1.7HashMap 使用的是数组桶加链表,如果链表过长,那么时间复杂度会退化为 O (n),而 1.8 使用的引入了红黑树,当链表节点为 8 时,就转为红黑树。至于为什么是 8,估计也是个统计概率值。

1.7 节点插入时使用的是头插法,而 1.8 使用的是尾插法,头插在并发环境下容易出现环导致死循环,具体形成原因我并没有仔细研究,因为我认为 HashMap 本身就是线程不安全的,无论是头插还是尾插,在并发环境下都不能使用 HashMap ,曾经也有人向社区报告 bug 说 1.7 的 HashMap 尾插会在并发环境下出现死循环,但是社区并没有管,而是回复 “ HashMap 本来就不是给你在并发环境用的,想要安全请使用 ConcurrentHashMap ”。

4. HashMap 扩容机制了解过吗,为什么容量大小必须是 2 的指数

HashMap 的容量大小默认是 16,阿里巴巴开发规范插件提示在初始化 HashMap 时尽量指定容量大小(预计存储个数 / 负载因子 + 1),因为没有指定可能会导致多次扩容,这个涉及到重建 Hash 表,链表分拆的操作,比较耗时。如果构造函数传入的容量不是 2 的指数,那么会将容量设置为既是 2 的指数,又是大于传入参数的最小数。

至于为什么是 2 的指数,涉及 hash 公式,也就是 index = hash & (len - 1) ,这个公式其实就相当于用长度取模 index = hash % len ,只不过位运算快很多。扩容机制为两倍扩容,公式为 newCap = 2 * oldCap ,我们假设下标为 i 的那一部分,这个部分的链表的节点都满足 hash / len = y ... i ,也就是说,我们假设商为 y ,那么当商为奇数时( hash & oldCap == 1 ),扩容后再使用 hash 公式得到的结果就是 i+n ,如果商为偶数的话,扩容后再 hash 的结果还是 i 。这就使得每次扩容,都将现有的链表拆为两部分,一部分留在当前下标,另一部分转移到扩容后的 i+n 处,这种机制使用扩容时逻辑简单,操作迅速,还使得数组中节点分布均匀,不会出现那种节点都分布在前半部分或者后半部分。

5. 为什么重写 equals 方法的同时建议重写 hashCode方法 ,能用 HashMap 举个例子吗?

我们假设一个类为 People ,只有一个属性 name ,那么现实情况下,两个对象相等,要么他们内存地址相同,要么 equals 比较后相同,这里也就是 name 相同。如果不重写 hashCode 方法,也就是使用 Object 内置的方法。现在有两个内存地址不同的 People 对象, name 相同使得 equals 相同,但是 hashCode 却不相同,那么当这两个对象作为 Key 加入到 HashMap 时,我们希望的是后加入的对象会覆盖前面加入的,因为两个对象是相同的,但是因为 hashCode 不同,他们两个甚至连映射出来的数组下标都不同,是无法覆盖的。这也就导致 HashMap 中有两个相同的 key ,这明显不符合哈希表的定义。

再来看 HashMap 的源码,为了实现覆盖操作,首先就要使得 equals 相同的对象 hashCode 也相同才一定能映射到同样的下标,然后顺着链表向下查找,如果 HashCode 相同,同时 equals 也相同就会覆盖,反之如果遍历了整个链表都没有这样的节点,就算做新增节点,尾插到链表中。

6. 说说 HashMapHashTable 的区别

  • 并发: HashTable 用于并发环境,通过在方法上加入 synchronized 关键字保证线程安全,但是并行度太低了,所以大多数都使用 ConcurrentHashMap 。而 HashMap 是线程不安全的。
  • 存储: HashTable 键值对不允许存储 null ,而 HashMap 却可以, HashTable 源码中会先判断 value 是不是 null ,从而抛出空指针异常,并且会直接调用 keyhashCode 方法,计算比较粗暴,而 HashMap 统一指定 key==nullhashCode 为 0,至于 value 是否为 null 完全不重要,因为插入删除过程都不会涉及到 value ,只会比较 key
  • 迭代: HashTable 有两个迭代器, Enumeration 使用的是安全失败机制( fail-safe ),而 Iterator 是快速失败机制。 HashMap 使用的是快速失败机制。

7. 刚才提到 ConcurrentHashMap ,你知道什么?

参考链接:https://mp.weixin.qq.com/s/My4P_BBXDnAGX1gh630ZKw

这个也要分 1.71.8 两个版本:

  • 1.7 使用的是数组桶 + 链表,分段,段数决定并行度。通俗来讲, 1.7 版本维护了一个数组,数组中每个元素都是一个 HashMap ,上锁就是对每个段上锁。所以并行度并不高
  • 1.8 使用的是数组桶 + 链表(升级红黑树),同时锁的粒度更小了,使用 CAS+synchronized 来实现并发安全。维护的数组和 HashMap 的数组是一致的,只不过每次上锁都是对要修改的下标单个元素进行上锁。由于 Synchronized 的性能采用锁升级的方式优化后, ConcurrentHashMap 的性能也随之上升。

至于 CAS 操作,是对数组元素进行修改,也就是链表的表头

只要上了锁,保证了线程安全,其他的都和 HashMap 没有太多区别,也就有些细节不同,比如 HashMap 再加入一个键值对就要扩容了,它会先插入再扩容,而 ConcurrenHashMap 是先扩容再插入。

8. 这么了解 ConcurrentHashMap ,你实际用过吗,或者你看源码有什么地方用过吗?

在日志框架中,日志门面 slf4j-api 自带了一个实现,叫做 slf4j-simple ,它在实现 LoggerFactory 时就用到了 ConcurrenMap ,键值对是 <String,Logger> ,这里的 String 对应的就是 name 。当外界传给 LoggerFactory 一个 name 时,就会从 ConcurrentHashMap 中找,如果有就返回,没有就新建一个,缓存起来再返回。

public class SimpleLoggerFactory implements ILoggerFactory {
    ConcurrentMap<String, Logger> loggerMap = new ConcurrentHashMap();
    public SimpleLoggerFactory() {
        SimpleLogger.lazyInit();
    }
    public Logger getLogger(String name) {
        Logger simpleLogger = (Logger)this.loggerMap.get(name);
        if (simpleLogger != null) {
            return simpleLogger;
        } else {
            Logger newInstance = new SimpleLogger(name);
            Logger oldInstance = (Logger)this.loggerMap.putIfAbsent(name, newInstance);
            return (Logger)(oldInstance == null ? newInstance : oldInstance);
        }
    }
    void reset() {
        this.loggerMap.clear();
    }
}

其实日志中使用 ConcurrentHashMap 很正常,因为日志打印本身就经常处于并发环境中,有些甚至会专门分配线程去处理日志。有时类专门有一个 Logger ,其对应键值就是类的全类限定名,多个线程可能都会请求这个 logger ,自然就需要线程安全的容器来缓存。

更新于