# 基础
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.
ArrayList
和LinkedList
遍历谁更快?
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.7
的 HashMap
使用的是数组桶加链表,如果链表过长,那么时间复杂度会退化为 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. 说说
HashMap
与HashTable
的区别
- 并发:
HashTable
用于并发环境,通过在方法上加入synchronized
关键字保证线程安全,但是并行度太低了,所以大多数都使用ConcurrentHashMap
。而HashMap
是线程不安全的。 - 存储:
HashTable
键值对不允许存储null
,而HashMap
却可以,HashTable
源码中会先判断value
是不是null
,从而抛出空指针异常,并且会直接调用key
的hashCode
方法,计算比较粗暴,而HashMap
统一指定key==null
时hashCode
为 0,至于value
是否为null
完全不重要,因为插入删除过程都不会涉及到value
,只会比较key
。 - 迭代:
HashTable
有两个迭代器,Enumeration
使用的是安全失败机制(fail-safe
),而Iterator
是快速失败机制。HashMap
使用的是快速失败机制。
7. 刚才提到
ConcurrentHashMap
,你知道什么?
参考链接:https://mp.weixin.qq.com/s/My4P_BBXDnAGX1gh630ZKw
这个也要分 1.7
和 1.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
,自然就需要线程安全的容器来缓存。