2022-Java 后端工程师面试指南 -(Java 基础篇)
Tips
面试指南系列,很多情况下不会去深挖细节,是小六六以被面试者的角色去回顾知识的一种方式,所以我默认大部分的东西,作为面试官的你,肯定是懂的。
https://www.processon.com/view/link/600ed9e9637689349038b0e4
上面的是脑图地址
叨絮
可能大家觉得有点老生常谈了,确实也是。面试题,面试宝典,随便一搜,根本看不完,也看不过来,那我写这个的意义又何在呢?其实嘛我写这个的有以下的目的
第一就是通过一个体系的复习,让自己前面的写的文章再重新的过一遍,总结升华嘛
第二就是通过写文章帮助大家建立一个复习体系,我会将大部分会问的的知识点以点带面的形式给大家做一个导论
然后下面是前面的文章汇总
最后就是以面试题的形式来回顾所有的知识点,会整理一些比较常见的面试题和自己实际开发碰到的问题等题目。
Java 基本功
Java 字符型常量和字符串常量的区别?
我们在开发的过程中,用的比较多的应该是字符串,所以要熟悉下字符常量,我们可以回答
形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的 0 个或若干个字符
含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)
占内存大小 字符常量只占 2 个字节; 字符串常量占若干个字节
String 和 StringBuilder、StringBuffer 的区别?
Java 平台提供了两种类型的字符串:String 和 StringBuffer/StringBuilder,它们可以储存和操作字符串。
其中 String 是只读字符串,也就意味着 String 引用的字符串内容是不能被改变的。
而 StringBuffer/StringBuilder 类表示的字符串对象可以直接进行修改。StringBuilder 是 Java 5 中引入的,它和 StringBuffer 的方法完全相同,区别在于它是在单线程环境下使用的,因为它的所有方面都没有被 synchronized 修饰,因此它的效率也比 StringBuffer 要高。
小六六多嘴下,其实也是在告诫自己,其实我相信这个题目的答案大部分的人都是会的,也背的滚瓜烂熟,但是我们真正在开发的过程中确是没有遵守的,比如有时候我们处理一些逻辑的时候,需要拼接些字段的时候我们会习惯的用+,不知道有没有同款开发。哈哈,小六六和大家一起尽量养成好的开发习惯哈
说说反射的用途及实现
Java 反射机制是一个非常强大的功能,在很多的项目比如 Spring,MyBatis 都都可以看到反射的身影。通过反射机制,我们可以在运行期间获取对象的类型信息。利用这一点我们可以实现工厂模式和代理模式等设计模式,同时也可以解决 Java 泛型擦除等令人苦恼的问题。
获取一个对象对应的反射类,在 Java 中有下列方法可以获取一个对象的反射类
new 一个对象,然后对象.getClass()方法
通过 Class.forName() 方法
使用 类.class
说说有没有碰到 BigDecimal 的坑,或者说需要注意的点
我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的 BigDecimal(String) 构造方法来创建对象。其实就是想知道我们的实际开发是否有注意到这些点,
说说你平时是怎么把一个逗号隔开的字符串变成一个集合的,类似于("1,2,3")这种
List myList = Arrays.stream(myArray).collect(Collectors.toList()),建议使用这种方式,而不是 List myList = Arrays.asList(1, 2, 3);
至于原因就是 Arrays.asList()将数组转换为集合后,底层其实还是数组
说说 String s="abc"和 String s=new String("abc")区别;
相信大家对这道题并不陌生,答案也是众所周知的,1 个或 2 个。
首先在堆中(不是常量池)创建一个指定的对象"abc",并让 str 引用指向该对象
在字符串常量池中查看,是否存在内容为"abc"字符串对象
若存在,则将 new 出来的字符串对象与字符串常量池中的对象联系起来
若不存在,则在字符串常量池中创建一个内容为"abc"的字符串对象,并将堆中的对象与之联系起来
聊聊 Java 中的 SPI
系统设计的各个抽象,往往有很多不同的实现方案,在面向的对象的设计里,一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。Java SPI 就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似 IOC 的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。所以 SPI 的核心思想就是解耦。
聊聊 Java 8 有哪些特性
函数式接口
接口可以有实现方法,而且不需要实现类去实现其方法。
lambda 表达式
stream 流
日期时间 API LocalDateTime 年月日十分秒;LocalDate 日期;LocalTime 时间
Optional 类虽然说目前最新的版本已经是 15 了,但是大部分企业还是用的 8,所以就聊聊这个拉。
说说成员变量与局部变量的区别有哪些?
成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
从变量在内存中的存储方式来看:如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存
从变量在内存中的生存时间上看:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。
成员变量如果没有被赋初值:则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
说说构造方法有哪些特性?
名字与类名相同。
没有返回值,但不能用 void 声明构造函数。
生成类的对象时自动执行,无需调用。
说说==和 equals
== : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象(基本数据类型==比较的是值,引用数据类型==比较的是内存地址)。
equals() : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况:
情况 1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。
情况 2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来比较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。
聊聊 Java 的四种引用,强弱软虚
小六六这边说下,其实我们作为一个 crud 仔,平时真正接触到的也许就是强引用了,但是也不是说其他的引用方式没有用,存在即合理,我们来看看他们具体的作用吧!感觉应该放到 JVM 模块的,算了,就这个吧
强引用:最普遍的一种引用方式,如 String s = "abc",变量 s 就是字符串“abc”的强引用,只要强引用存在,则垃圾回收器就不会回收这个对象。
软引用:用于描述还有用但非必须的对象,如果内存足够,不回收,如果内存不足,则回收。一般用于实现内存敏感的高速缓存(自定义内存 cached 的时候用),软引用可以和引用队列 ReferenceQueue 联合使用,如果软引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中。
弱引用:弱引用和软引用大致相同,弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。(这个就比较垃圾了)
虚引用:虚引用不会改变对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样。(其实小六六自己对于虚引用的理解还是因为 ThreadLocal,防止我们忘记 remove)
Java 容器集合
聊聊数组的特点呗
一致性:数组只能保存相同数据类型元素,元素的数据类型可以是任何相同的数据类型。
有序性:数组中的元素是有序的,通过下标访问。
不可变性:数组一旦初始化,则长度(数组中元素的个数)不可变。 数组是一种连续存储线性结构,元素类型相同,大小相等
说说 Java 常见的集合有哪些
嗯,我觉得这题会经常问,算是一个对集合考查的引入吧
Map 接口和 Collection 接口是所有集合框架的父接口
Collection 接口的子接口包括:Set 接口和 List 接口
Map 接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等
Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet 等
List 接口的实现类主要有:ArrayList、LinkedList、Stack 以及 Vector 等
简单聊聊 Set、List、Map 的区别
List
可以允许重复的对象。
可以插入多个 null 元素。
是一个有序容器,保持了每个元素的插入顺序,输出的顺序就是插入的顺序。
Set
不允许重复对象
无序容器,你无法保证每个元素的存储顺序,TreeSet 通过 Comparator 或者 Comparable 维护了一个排序顺序。
只允许一个 null 元素
Map
Map 不是 collection 的子接口或者实现类。Map 是一个接口。
Map 的 每个 Entry 都持有两个对象,也就是一个键一个值,Map 可能会持有相同的值对象但键对象必须是唯一的。
Map 里你可以拥有随意个 null 值但最多只能有一个 null 键。
他们的使用场景
如果你经常会使用索引来对容器中的元素进行访问,那么 List 是你的正确的选择。如果你已经知道索引了的话,那么 List 的实现类比如 ArrayList 可以提供更快速的访问,如果经常添加删除元素的,那么肯定要选择 LinkedList。
如果你想容器中的元素能够按照它们插入的次序进行有序存储,那么还是 List,因为 List 是一个有序容器,它按照插入顺序进行存储。
如果你想保证插入元素的唯一性,也就是你不想有重复值的出现,那么可以选择一个 Set 的实现类,比如 HashSet、LinkedHashSet 或者 TreeSet。所有 Set 的实现类都遵循了统一约束比如唯一性,而且还提供了额外的特性比如 TreeSet 还是一个 SortedSet,所有存储于 TreeSet 中的元素可以使用 Java 里的 Comparator 或者 Comparable 进行排序。LinkedHashSet 也按照元素的插入顺序对它们进行存储。
如果你以键和值的形式进行数据存储那么 Map 是你正确的选择。你可以根据你的后续需要从 Hashtable、HashMap、TreeMap 中进行选择。 `
来聊聊 ArrayList 和 LinkedList 区别
ArrayList 是实现了基于动态数组的数据结构,LinkedList 基于链表的数据结构。
对于随机访问 get 和 set,ArrayList 觉得优于 LinkedList,因为 LinkedList 要移动指针。
对于新增和删除操作 add 和 remove,LinedList 比较占优势,因为 ArrayList 要移动数据。
说说 Vector 和 ArrayList 区别:
Vector 的方法都是同步的(Synchronized),是线程安全的(thread-safe),而 ArrayList 的方法不是,由于线程的同步必然要影响性能,因此,ArrayList 的性能比 Vector 好。
当 Vector 或 ArrayList 中的元素超过它的初始大小时,Vector 会将它的容量翻倍,而 ArrayList 只增加 50%的大小,这样,ArrayList 就有利于节约内存空间。
我们也可以用 Collections.synchronizedList 来生成一个线程安全的 List
说说 HashSet 和 TreeSet 的区别:
TreeSet 是二叉树实现的,Treeset 中的数据是自动排好序的,不允许放入 null 值。
HashSet 是哈希表实现的,HashSet 中的数据是无序的,可以放入 null,但只能放入一个 null,两者中的值都不能重复,就如数据库中唯一约束。
HashSet 要求放入的对象必须实现 HashCode()方法,放入的对象,是以 hashcode 码作为标识的,而具有相同内容的 String 对象,hashcode 是一样,所以放入的内容不能重复。但是同一个类的对象可以放入不同的实例 。
说说 HashMap 和 HashTable 的区别:
HashMap 是非线程安全的,Hashtable 是线程安全的,所以 Hashtable 重量级一些,因为使用了 synchronized 关键字来保证线程安全。
HashMap 允许 key 和 value 都为 null,而 Hashtable 都不能为 null。
HashMap 的迭代器(Iterator)是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。所以当有其它线程改变了 HashMap 的结构(增加或者移除元素),将会抛出 ConcurrentModificationException,但迭代器本身的 remove()方法移除元素则不会抛出 ConcurrentModificationException 异常
上面的是我们集合的基础,下面来看看一些进阶面试题
能不能深度的总结下 ArrayList
默认的无参构造的数组大小为 10
ArrayList 用 for 循环遍历比 iterator 迭代器遍历快
一起来总结下它的扩容过程,第一步判断是否需要扩容(就是通过计算当前我数组的长度加上我要添加到数组的长度的和 minCapacity 去和当前容量去比较,如果需要的话,那就进行第一次扩容,第一次扩容是的容量大小是原来的 1.5 倍,扩容之后再把扩容之后的值和前面的那个 minCapacity 和比较 如果还小的话,就进行第二次扩容,把容量扩成 minCapacity,如果 minCapacity 大于最大容量,则就扩容为最大容量 21 亿左右
为什么 ArrayList 的 elementData 是用 transient 修饰的
ArrayList 实现了 Serializable 接口,这意味着 ArrayList 是可以被序列化的,用 transient 修饰 elementData 意味着我不希望 elementData 数组被序列化
序列化 ArrayList 的时候,ArrayList 里面的 elementData 未必是满的,比方说 elementData 有 10 的大小,但是我只用了其中的 3 个,那么是否有必要序列化整个 elementData 呢?显然没有这个必要,因此 ArrayList 中重写了 writeObject 方法。
你重写过 hashcode 和 equals 么,为什么重写 equals 时必须重写 hashCode 方法?
如果两个对象相等,则 hashcode 一定也是相同的
两个对象相等,对两个对象分别调用 equals 方法都返回 true
两个对象有相同的 hashcode 值,它们也不一定是相等的
因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
聊聊 HashSet 如何检查重复
当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
说说 HashMap 的底层实现
JDK1.8 之前 JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
JDK1.8 之后相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
那么你可以聊聊红黑树吗
首先我们知道红黑数是一个二叉查找树, 它有以下的特点
左子树上所有结点的值均小于或等于它的根结点的值。
右子树上所有结点的值均大于或等于它的根结点的值。
左、右子树也分别为二叉排序树。但是一般的二叉树在极端情况下,可能变成线性查找了,那么就失去它的查找特性意义了,而红黑树是一个自平衡树,它最重要的是增加了下面 3 个规则来确保它的自平衡
每个叶子节点都是黑色的空节点(NIL 节点)。
每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
这里小六六说下,红黑树还是很复杂的,所以一般往下不会问了,如果变态点,就还会问问自平衡的过程,这边我就不一一解释了,大家自行去找。它的变色,它的左旋,右旋,哈哈确实掉头发。。
HashMap 的长度为什么是 2 的幂次方
首先考虑奇数行不行,在计算 hash 的时候,确定落在数组的位置的时候,计算方法是(n - 1) & hash ,奇数 n-1 为偶数,偶数 2 进制的结尾都是 0,经过 &运算末尾都是 0,会增加 hash 冲突,并且有一半的空间不会有数据
为啥要是 2 的幂,不能是 2 的倍数么,比如 6,10?
hashmap 结构是数组,每个数组里面的结构是 node(链表或红黑树),正常情况下,如果你想放数据到不同的位置,肯定会想到取余数确定放在那个数据里, 计算公式:hash % n,这个是十进制计算。在计算机中, (n - 1) & hash,当 n 为 2 次幂时,会满足一个公式:(n - 1) & hash = hash % n,计算更加高效。
只有是 2 的幂数的数字经过 n-1 之后,二进制肯定是 ...11111111 这样的格式,这种格式计算的位置的时候(&),完全是由产生的 hash 值类决定,而不受 n-1(组数长度的二进制) 影响。你可能会想,受影响不是更好么,又计算了一下,类似于扰动函数,hash 冲突可能更低了,这里要考虑到扩容了,2 的幂次方*2,在二进制中比如 4 和 8,代表 2 的 2 次方和 3 次方,他们的 2 进制结构相 似,比如 4 和 8 00000100 0000 1000 只是高位向前移了一位,这样扩容的时候,只需要判断高位 hash,移动到之前位置的倍数就可以了,免去了重新计算位置的运算。
HashMap 的扩容伐值为什么是 0.75
当负载因子是 1.0 的时候,也就意味着,只有当数组的 8 个值(这个图表示了 8 个)全部填充了,才会发生扩容。这就带来了很大的问题,因为 Hash 冲突时避免不了的。当负载因子是 1.0 的时候,意味着会出现大量的 Hash 的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
负载因子是 0.5 的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash 冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。
基本上为什么是 0.75 的答案也就出来了,这是时间和空间的权衡。
HashMap 树化的条件
这个小六六说下,如果没看过源码,肯定印象没有那么深刻
第一个肯定是发生了 hash 碰撞
并且链表的长度大于 8 就会树化,小于 6 之后会退化
还有一个条件就是整个 hashmap 的容量要大于 64
JDK1.7 版本的 hashmap 死循环问题知道吗
小六六也大致说下,就是 1.7 在多线程的情况下,扩容的时候,假设 2 个线程同时扩容导致的我们链表的相互引用,导致的死循环,也就是我们所说的链表尾插,其实这也不算 bug,因为官方明确说了 hashmap 不应该在多线程的环境下使用,具体大家自行百度
说说 hashmap 的 put 操作
第一步当然是先计算 key 的 hash 值(有过处理的 (h = key.hashCode()) ^ (h >>> 16))
第二步调用 putval 方法,然后判断是否容器中全部为空,如果是的话,就把容器的容量扩容。
第三步,把最大容量和 hash 值求 &值(i = (n - 1) & hash),判断这个数组下标是否有数据,如果没有就把它放进去。还要判断 key 的 equals 方法,看是否需要覆盖。
第四步,如果有,说明发生了碰撞,那么继续遍历判断链表的长度是否大于 8,如果大于 8,就继续把当前链表变成红黑树结构。
第五步,如果没有到 8,那么就直接把数据存在链表的尾部
第六步,最后将容器的容量+1。
说说 hashmap 的 resize 的操作
如果到达最大容量,那么返回当前的桶,并不再进行扩容操作,否则的话扩容为原来的两倍,返回扩容后的桶;
根据扩容后的桶,修改其他的成员变量的属性值;
根据新的容量创建新的扩建后的桶,并更新桶的引用;
如果原来的桶里面有元素就需要进行元素的转移;
在进行元素转移的时候需要考虑到元素碰撞和转红黑树操作;
在扩容的过程中,按次从原来的桶中取出链表头节点,并对该链表上的所有元素重新计算 hash 值进行分配;
在发生碰撞的时候,将新加入的元素添加到末尾;
在元素复制的时候需要同时对低位和高位进行操作。
聊聊 ConcurrentHashMap 1.7 和 1.8 的比较
ConcurrentHashMap 是 conccurrent 家族中的一个类,由于它可以高效地支持并发操作,以及被广泛使用,经典的开源框架 Spring 的底层数据结构就是使用 ConcurrentHashMap 实现的。与同是线程安全的老大哥 HashTable 相比,它已经更胜一筹,因此它的锁更加细化,而不是像 HashTable 一样为几乎每个方法都添加了 synchronized 锁,这样的锁无疑会影响到性能。
1.7 和 1.8 实现线程安全的思想也已经完全变了其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。它沿用了与它同时期的 HashMap 版本的思想,底层依然由“数组”+链表+红黑树的方式思想。
不采用 segment 而采用 node,锁住 node 来实现减小锁粒度。
设计了 MOVED 状态 当 resize 的中过程中 线程 2 还在 put 数据,线程 2 会帮助 resize。
使用 3 个 CAS 操作来确保 node 的一些操作的原子性,这种方式代替了锁。
sizeCtl 的不同值来代表不同含义,起到了控制的作用。
聊聊 ConcurrentHashMap 的 sizeCtl
负数代表正在进行初始化或扩容操作
-1 代表正在初始化
-N 表示有 N-1 个线程正在进行扩容操作
正数或 0 代表 hash 表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。还后面可以看到,它的值始终是当前 ConcurrentHashMap 容量的 0.75 倍,这与 loadfactor 是对应的。
说说 ConcurrentHashMap 的 put 方法
第一步,一进去肯定判断 key value 是否为 null 如果为 null 抛出异常
第二步,当添加一对键值对的时候,首先会去判断保存这些键值对的数组是不是初始化了,如果没有就初始化数组。
第三步, 通过计算 hash 值来确定放在数组的哪个位置如果这个位置为空则直接添加(CAS 的加锁方式),如果不为空的话,则取出这个节点来
第四步,如果取出来的节点的 hash 值是 MOVED(-1)的话,则表示当前正在对这个数组进行扩容,复制到新的数组,则当前线程也去帮助复制
第五步,如果这个节点,不为空,也不在扩容,则通过 synchronized 来加锁,进行添加操作
第六步,如果是链表的话,则遍历整个链表,直到取出来的节点的 key 来个要放的 key 进行比较,如果 key 相等,并且 key 的 hash 值也相等的话,则说明是同一个 key,则覆盖掉 value,否则的话则添加到链表的末尾
第七步,如果是树的话,则调用 putTreeVal 方法把这个元素添加到树中去
第八步,最后在添加完成之后,会判断在该节点处共有多少个节点(注意是添加前的个数),如果达到 8 个以上了的话,则调用 treeifyBin 方法来尝试将处的链表转为树,或者扩容数
结尾
这个就是一些基础的面试题,当然还是很多不一定那么全,但是如果把这些都能回答出来,其实 Java 基础这块也算是蛮扎实了。
日常求赞
好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是真粉。
创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见
微信 搜 "六脉神剑的程序人生" 回复 888 有我找的许多的资料送给大家
评论