搬运来源: https://github.com/jujunchen/Java-interview-question
1.Java 基础
[TOC]
《Java编程思想》、《疯狂Java:突破程序员基本功的16课.修订版》,这里省略了一些非常基础的知识点
请你解释为什么会出现 4.0 - 3.6=0.40000001 这种现象
二进制小数无法精确表达十进制小数,计算机在计算十进制小数的过程中要先转换为二进制进行计算,这个过程中出现了误差
请你讲讲一个十进制的数在内存中是怎么存的?
以二进制补码形式存储,最高位是符号位,正数的补码是它的原码,负数的补码是它的反码加1,在求反码时符号位不变,其他取反,1表示负数,0正数
接口和抽象类的区别是什么 ?
接口中的所有方法隐含的都是抽象的,而抽象类则可以同时包含抽象和非抽象方法
类可以实现很多个接口,但只能继承一个抽象类
类可以不实现抽象类和接口声明的所有方法,但这种情况下,该类必须声明成抽象的
接口中的成员函数默认是public的。抽象类的成员函数可以是private,protected或者是public。
JDK8后,接口中可以包含default方法,抽象类中不可以
面向对象开发的六个基本原则,在项目中用过哪些原则
-
六个基本原则
-
单一原则
一个类只做它该做的事情(高内聚)。在面向对象中,如果只让一个类完成它该做的事,而不涉及与它无关的领域就是践行了高内聚的原则,这个类就只有单一原则
-
开闭原则
软件实体应当对扩展开发,对修改关闭,要做到开闭有两个要点:
- 抽象是关键,一个系统中如果没有抽象类或接口系统就没有扩展点
- 封装可变性,将系统中的各种可变因素封装到一个继承结构中,如果多个可变因素混杂在一起,系统将变的复杂而繁乱
-
里氏替换原则
任何时候都可以用子类型替换掉父类型,子类一定是增加父类的能力而不是减少父类的能力
-
依赖倒置原则
面向接口编程。高层模块不应该依赖底层模块,两者都应该依赖其抽象,尽可能使用抽象类型而不用具体类型,因为抽象类型可以被它的任何一个子类型所替代。
-
接口隔离原则
类间的依赖关系应该建立在最小的接口上,不能大而全,接口表示能力,一个接口只应该描述一种能力,接口也应该高度内聚
合成聚成复用原则
has a 关联;use a;-
迪米特法则
由叫最少知识原则,一个对象应该对其他对象有尽可能少的了解
-
-
根据自己的项目来说
关于网络方面的问题,面试官应该只会问一题,不会多问
HTTP请求的GET与POST方式的区别
- GET在浏览器回退是无害的,而POST会再次提交请求
- GET请求会被浏览器主动cache,而POST不会,除非手动设置
- GET请求只能进行URL编码,而POST支持多种编码
- GET请求参数会被完整保留在浏览器历史记录中,而POST中的参数不会被保留
- GET请求在URL中传送参数是有大小限制的,不能大于2KB,而POST可以说没有
- GET只接受ASCII字符,而POST没有限制
- GET参数直接暴露在URL上,而POST将数据放在request body中
TCP 三次握手,四次挥手
可见该文:https://blog.csdn.net/qq_38950316/article/details/81087809
TCP 和 UDP区别
-
区别:
UDP是无连接的,即发送数据之前不需要建立连接
UDP使用尽最大努力交付,即不保证可靠交付,同时也不使用拥塞控制
UDP是面向报文的,没有拥塞控制,适合多媒体通信要求
UDP支持一对一,一对多,多对一和多对多的交互通信
UDP首部开销小,只有8个字节
TCP是面向连接的运输层协议
TCP只能一对一连接
TCP提供可靠的交付服务,提供全双工通信
TCP 面向字节流,头部最低20个字节
从输入网址到获取页面的过程
- 查询DNS, 获取域名对应的IP地址
- 浏览器搜索自身的DNS缓存
- 搜索操作系统的DNS缓存
- 读取本地的HOST文件
- 发起一个DNS系统调用(宽带运营服务器查看本身缓存,运营服务器发起一个迭代DNS解析请求)
- 浏览器获得域名对应的IP地址后,发起HTTP三次握手
- TCP/IP建立连接后,浏览器可以向服务器发送HTTP请求了
- 服务器接收到请求后,根据路径参数,经过后端处理将页面返回给浏览器
- 浏览器渲染页面,和外部资源,最终将完整的页面呈现给用户
Session与Cookie区别
Session:
服务器端会为每个访问服务端的请求分配一个会话Session,其数据存储在服务器端,不依赖浏览器端环境,因此高效安全
Cookie:
数据已文件形式存在用户浏览器端,用户可以通过浏览器禁用Cookie,用户可以对Cookie进行查看,修改,和删除
列出自己常用的JDK包
常用的包:
java.lang 包装类,线程等都在该包
java.match 有BigDecimal 精确数字类型
java.util 并发,集合等都在该包内
equals与==的区别
-
equals 比较两个实体值是否相同,可以被覆盖,但需要遵循几个约定:
自反性:对于任何非null的引用值x, x.equals(x)必须返回true
对称性:对于任何非null的引用值x和y,当y.equals(x)返回true时,x.equlas(y)必须返回true
传递性:对于任何非null的引用值x、y、z,如果x.equals(y)返回true,并且y.equals(x)也返回true,那么x.equals(z)也必须返回true
一致性:对于任何非null的引用值x和y,只要比较对象中的所有信息没有被修改,多次调用equals一致返回true,或者false
== 比较两个实体的引用地址是否相等,不能覆盖,如果引用地址相等,那认为两个实体为同一个实体
int a = 1;
Integer b = new Integer(1);
Integer c = new Integer(1);
Integer d = Integer.valueOf(1);
Long e = new Long(1);
Long f = Long.valueOf(1);
assert a == b;
assert b != c;
assert b != d;
assert a == d;
assert a == e;
assert a == f;
assert b.equals(c);
assert !b.equals(e);
hashCode和equals方法的区别与联系
对于覆盖了equals方法的类中,同样也要覆盖hashCode方法。这是JDK规定的结果。
比较两个对象是否相同,hashCode比equals效率更高,所以优先会根据hashCode来比较,但如果不重写hashCode,原本两个对象可以认为是相等,但由于hashCode默认返回表示对象地址的整数,必然不相等,所以需要重写hashCode。
由于hashCode有个问题,可能两个不同的对象会有相同的hashCode,这样还需要通过equals来比较
比如HashMap中,计算key的索引位置,会用到key.hashCode,在确定是否为同一个元素时通过equals比较
什么是Java序列化和反序列化,如何实现Java序列化?或者请解释Serializable 接口的作用
序列化是一种用来处理对象流的机制,也就是将对象的内容转化成二进制流,可以将对象持久化或者网络传输
反序列化是将二进制流还原为对象的过程
实现Java序列化,通过实现Serializable即可
Object类中常见的方法,为什么wait notify会放在Object里边?
因为Java提供的锁是对象级的,每个对象都有对象头,用来存储锁
解释一下extends 和super泛型限定符
<? extends Fruit> 称为 上界限定符,list只能get,不能add(除了add null值),通常用于读
<? super Apple>称为 下界限定符,list只能add,不能get(只能用Object接收),通过用于写
请列举你所知道的Object类的方法并简要说明
Object()默认构造方法
clone()创建并返回此对象的一个副本
equals(Object obj) 当前对象是否与obj对象相同
finalize()当垃圾收集器确定该对象可以回收时,由垃圾收集器调用此方法
getClass返回一个对象的运行时类
hashCode()返回该对象的哈希码值
notify()唤醒此对象监视器上等待的单个线程
notifyAll()唤醒在此对象监视器上等待的所有线程
toString()返回该对象的字符串表示
wait()使当前线程等待,直到其他线程调用此对象的notify()或者notifyAll()方法
wait(long timeout)导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者超过指定的时间量。wait(long timeout, int nanos) 导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者其他某个线程中断当前线程,或者已超过某个实际时间量。
创建线程的方式
- 继承Thread类创建线程,并重写run方法,调用实例对象的start()方法启动线程
- 实现Runnable接口,并实现run方法,将实现Runnable的类传入Thread构造函数中,并调用Thread实例对象的start方法启动线程
- 实现Callable接口,并实现call方法,创建Callable实现类的实例,使用FutureTask包装Callable对象,使用FutureTask对象传入Thread中,调用start方法启动线程,使用FutureTask对象的get方法获取线程的返回值
ArrayList 与 LinkedList 区别
ArrayList 是一种顺序存储的线性表,底层使用数组实现
LinkedList是一种链式存储的线性表,本质是一个双向链表,实现了List、Deque接口,可以当成双向链表、队列、栈使用
自定义注解
-
声明注解的保留期限类型
@Retention(RetentionPolicy.RUNTIME)表示该注解可以在运行期保留
保留期限类型:java.lang.annotation.Retention
SOURCE: 注解信息仅保留在目标类源代码文件中,对应的字节码文件不会保留
CLASS: 注解信息存在于源代码、字节码文件中,但运行期JVM不能获得该注解信息
RUNTIME: 注解信息存在于源代码、字节码文件、运行期JVM中,能够通过反射机制获取注解类信息
-
声明注解可以使用的目标类型
@Target(ElementType.METHOD) 表示这个注解只能在方法上使用
目标类型:java.lang.annotation.ElementType
TYPE: 类、接口、注解类、Enum
FIELD: 类成员变量或常量
METHOD: 方法
PARAMETER: 参数
CONSTRUCTOR: 构造器
LOCAL_VARIABLE: 局部变量
ANNOTATION_TYPE: 注解
PACKAGE: 包
使用@interface 修饰类
-
声明注解成员
成员无入参、不能抛出异常;
可以通过default成员指定默认值
成员类型只能使用基本数据类型、String、Class、enums、注解类型,及上述类型的数组类型。如ForumService value()是非法的
如果注解只有一个成员,则成员名必须取名为value(),再使用时可以忽略成员名和赋值号,如果注解类拥有多个成员时,
对value成员赋值,可以省略value和赋值号,如果是多个成员赋值,必须使用赋值号
ArrayList扩容机制是怎么样的? 详细说一下。
在往ArrayList add元素的时候,如果ArrayList 已有元素数量+1 大于 ArrayList 存储元素的总长度,就会触发扩容。
首先ArrayList会计算新数组的长度,长度为老数组的0.5倍,如果新数组长度还是小于插入元素需要的最小长度,那么新数组长度赋值为最小长度,如果超过ArrayList允许的最大长度Integer.MAX_VALUE(),那么新数组长度为Integer.MAX_VALUE,否则为Integer.MAX_VALUE - 8(为什么要-8?Why the maximum array size of ArrayList is Integer.MAX_VALUE - 8?)
最后将原数组元素拷贝到新数组进行扩容
HashMap 1.7 和 1.8 的区别
1.7,在发生hash冲突的时候,数据结构只有链表;
-
1.8,数据结构有链表和红黑树,使用红黑树是为了能够提高查询效率。在链表长度达到7时(bingCount >= TREEIFY_THRESHOLD - 1),并且hash tab[]数组长度大于等于64时,将链表转换成红黑树,如果数组长度小于64,只是对数组进行扩容
HashMap中的key可以是任何对象或数据类型吗
- 可以是null,但不能是可变对象,如果是可变对象,对象中的属性改变,则对象的HashCode也相应改变,导致下次无法查找到已存在Map中的数据
- 如果要可变对象当着键,必须保证其HashCode在成员属性改变的时候保持不变
HashMap 初始容量 计算方法
如果在new HashMap的时候,没有指定初始initialCapacity,则初始initialCapacity为16,负载因子为0.75,下次扩容阈值为 16*0.75=12
这个初始容量 不一定等于初始化完成后底层数组实际的容量,因为存在阈值的计算,方法如下;也不是初始容量是多少开始就能存多少个元素,因为存在负载因子,在底层数组还没满的时候就会进行扩容
阈值计算方法为:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
该方法计算大于等于输入参数并最接近参数的2的整数次幂的数,如10,返回16
cap -1 ,-1是为了在计算的时候能得到大于等于输入参数的值
在HashMap 进行put方法的时候,如果判断已有的元素大于 阈值就会触发扩容计算,扩容步骤
final Node<K,V>[] resize() {
//原table数组赋值
Node<K,V>[] oldTab = table;
//如果原数组为null,那么原数组长度为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//赋值阈值
int oldThr = threshold;
//newCap 新数组长度
//newThr 下次扩容的阈值
int newCap, newThr = 0;
// 1. 如果原数组长度大于0
if (oldCap > 0) {
//如果大于最大长度1 << 30 = 1073741824,那么阈值赋值为Integer.MAX_VALUE后直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 2. 如果原数组长度的2倍小于最大长度,并且原数组长度大于默认长度16,那么新阈值为原阈值的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 3. 如果原数组长度等于0,但原阈值大于0,那么新的数组长度赋值为原阈值大小
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 4. 如果原数组长度为0,阈值为0,那么新数组长度,新阈值都初始化为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 5.如果新的阈值等于0
if (newThr == 0) {
//计算临时阈值
float ft = (float)newCap * loadFactor;
//新数组长度小于最大长度,临时阈值也小于最大长度,新阈值为临时阈值,否则是Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//计算出来的新阈值赋值给对象的阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//用新计算的数组长度新建一个Node数组,并赋值给对象的table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//后面是copy数组和链表数据逻辑
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结:
如下3种情况,例子需要自己调式,主要关注数组长度(OldCap,newCap)新老变化,扩容阈值(oldThr,newThr)新老变化
//①
Map<String, String> map = new HashMap<>();
map.put("1", "1");
//②
Map<String, String> map1 = new HashMap<>(2);
map1.put("2", "2");
//③
Map<String, String> map2 = new HashMap<>(2, 0.5f);
map2.put("3", "3");
① 没有设置initialCapacity,也没有设置负载因子,第一次put的时候会触发扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
第一次的时候,数组长度为默认值16,阈值为16*0.75=12,走的代码4
逻辑,等到数组长度超过阈值12后,触发第二次扩容,此时table数组,和threshold都不为0,即oldTab、oldCap、oldThr都不为0,先走代码1
,如果oldCap长度的2倍没有超过最大容量,并且oldCap 长度大于等于 默认容量16,那么下次扩容的阈值 变为oldThr大小的两倍即 12 * 2 = 24,newThr = 24,newCap=32
② 设置了initialCapacity,没有设置负载因子,此时hashMap使用默认负载因子0.75,本实例设置的初始容量为2,通过计算阈值为2,第一次put的时候由于还没初始化table数组,因此触发第一次扩容。此时oldCap为0,oldThr为2,走代码3
,确定这次扩容的新数组大小为2,此时还没有确定newThr 下次扩容的大小,于是进入代码5
确定newThr为 2 * 0.75 = 1.5 取整 1 ,及下次扩容阈值为1。当数组已有元素大于阈值及1时,触发第二次扩容,此时oldCap为1,oldThr为1,走代码1
newCap = oldCap << 1 结果为 4 小于最大容量, 但oldCap 小于hashMap默认大小16,结果为false,跳出判断,此时由于newThr等于0,进入代码5
,确定newThr为 4 * 0.75 = 3,下次扩容阈值为3
③ 设置了initialCapacity=2,负载因子为0.5,通过tableSizeFor计算阈值为2,第一次put的时候,进行扩容,此时oldCap为2,oldThr为2,进入代码1
,同实例②,newCap = oldCap << 1 结果为 4 小于最大容量, 但oldCap 小于hashMap默认大小16,结果为false,跳出判断,进入代码5
,确定newThr为 4 * 0.5 = 2,下次扩容阈值为2
面试回答要素
- 回答什么情况下会第一扩容,举例说明,新数组大小,阈值大小
- 以后什么情况下会再次扩容,这次是怎么计算新数组大小,及阈值大小的
HashMap、ConcurrentHashMap初始化阈值为什么要是8,才转为红黑树?
- 当初始阈值为8时,链表的长度达到8的概率变的很小,如果再大概率减小的并不明显
- 树结构查找的时间复杂度是O(log(n)),而链表的时间复杂度是O(n),当阈值为8时,long8 = 3,相比链表更快,但树结构比链表占用的空间更多,所以这是一种时间和空间的平衡
手写HashMap
要点:
- 底层是数组结构
- hash冲突的时候,转换为链表
- 考虑扩容处理
HashMap在并发下会产生什么问题?有什么替代方案?(HashTable, ConcurrentHashMap)。它们两者的实现原理。
HashMap并发下产生问题:由于在发生hash冲突,插入链表的时候,多线程会造成环链,再get的时候变成死循环,Map.size()不准确,数据丢失
https://www.iteye.com/blog/hwl-sz-1897468HashTable: 通过synchronized来修饰,效率低,多线程put的时候,只能有一个线程成功,其他线程都处于阻塞状态
-
ConcurrentHashMap:
1.7 采用锁分段技术提高并发访问率
1.8 数据依旧是分段存储,但锁采用了synchronized,内部采用Node数组+链表+红黑树的结构存储,当单个链表存储数量达到红黑树阈值8时(此时链表已有元素7),并且数组长度大于64时,存储结构转换为红黑树来存储,否则只进行数组的扩容
HashMap 为什么不用平衡树,而用红黑树
红黑树也是一种平衡树,但不是严格平衡,平衡树是左右子树高度差不超过1,红黑树可以是2倍
红黑树在插入、删除的时候旋转的概率比平衡树低很多,效率比平衡树高
查找时间复杂度都维持在O(logN)
关于HashMap的其他文章:https://blog.csdn.net/login_sonata/article/details/76598675
常见异常分为哪两种(Exception,Error),区别是什么,了解受检异常和非受检异常吗
Exception,Error有共同的父类Throwable
Error: 表示程序发生错误,是程序无法处理的,不可恢复的,如OutOfMemoryError
Exception: 表示程序可处理的异常,又分为CheckedException(受检异常)、UncheckedException(非受检异常),受检异常发生在编译期,必须要使用try...catch 或者 throws捕获或者抛出异常,否则编译不通过;非受检异常发生在运行期,具有不确定性,主要由程序的逻辑问题引起的,在程序设计的时候要认真考虑,尽量处理异常
实现一个LRU
可以直接使用LinkedHashMap实现
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private int initialCapacity;
public LRUCache(int initialCapacity) {
//true表示按照访问顺序排序
super(initialCapacity, 0.75f, true);
this.initialCapacity = initialCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
if (size() > initialCapacity) {
return true;
}
return false;
}
}
用过流没有,流怎么实现
Stream流是Java8中引入的新特性,Stream有几个特点:
不存数据,都是通过管道将源数据元素传递给操作;
对Stream的任何修改都不会修改数据源,都是新产生一个流
流的很多操作如filter、map都是延迟执行的,只有到终点才会将操作顺序执行
对于无限流可以通过“短路”操作访问到有限元素后就返回
流的元素只访问一次,如果需要重新访问,需要重新生成一个新的流
Stream中BaseStream规定了流的基本接口,在Stream中使用Stage来描述一个完整的操作,将具有先后顺序的各个Stage连一起,就构成了整个流水线。
AbstractPipeline是流水线的核心,定义了三个AbstractPipeline类型的变量:sourceStage(源阶段)、previousStage(上游pipeline,上一阶段),nexStage(下一阶段)
ReferencePipeline 继承了AbstractPipeline
Head、StatefulOp、StatelessOp继承了ReferencePipeline,分别表示源,无状态操作,有状态操作
比如Collection.stream()方法得到Head也就是stage0,紧接着调用一系列的中间操作,不断产生新的stage。这些stage对象以双向链表的形式组织在一起,构成整个流水线。
由于每个Stage都记录了前一个Stage和本次的操作以及回调函数,依靠这种结构就建立起对数据源的所有操作。