你好,我是 Guide。很久没有分享凉经了,今天来分享一位西北工业大学的读者面试快手,一面就直接秒挂的面经。
快手一面主要会问一些基础问题,也就是比较简单且容易准备的常规八股,通常不会问项目或者问的比较少。到了二面,会开始问项目,各种问题也挖掘的更深一些。
很多同学觉得这种基础问题的考查意义不大,实际上还是很有意义的,这种基础性的知识在日常开发中也会需要经常用到。例如,线程池这块的拒绝策略、核心参数配置什么的,如果你不了解,实际项目中使用线程池可能就用的不是很明白,容易出现问题。而且,其实这种基础性的问题是最容易准备的,像各种底层原理、系统设计、场景题以及深挖你的项目这类才是最难的!
下面是正文。
一面没有问项目,就是一些很基础的八股。面试官说我的基础知识太薄弱,很多面试题回答的像是在硬背,没有自己的理解。另外,他建议我提高一下自己的表达能力,吐词尽量要清晰一些。
1、Java 异常类分为哪两种?有什么区别?
Java 异常类层次结构图概览:
在 Java 中,所有的异常都有一个共同的祖先 java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类:
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。Error
:Error
属于程序无法处理的错误 ,我们没办法通过catch
来进行捕获不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
2、try-with-resources 怎么使用?
- 适用范围(资源的定义): 任何实现
java.lang.AutoCloseable
或者java.io.Closeable
的对象 - 关闭资源和 finally 块的执行顺序: 在
try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行
《Effective Java》中明确指出:
面对必须要关闭的资源,我们总是应该优先使用
try-with-resources
而不是try-finally
。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources
语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally
则几乎做不到这点。
Java 中类似于InputStream
、OutputStream
、Scanner
、PrintWriter
等的资源都需要我们调用close()
方法来手动关闭,一般情况下我们都是通过try-catch-finally
语句来实现这个需求,如下:
java
//读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}
使用 Java 7 之后的 try-with-resources
语句改造上面的代码:
java
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}
当然多个资源需要关闭的时候,使用 try-with-resources
实现起来也非常简单,如果你还是用try-catch-finally
可能会带来很多问题。
通过使用分号分隔,可以在try-with-resources
块中声明多个资源。
java
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
更多 Java 基础相关的面试题,可以参考这几篇文章:
3、HashMap 为什么不是线程安全的?
JDK1.7 及之前版本,在多线程环境下,HashMap
扩容时会造成死循环和数据丢失的问题。
数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。
JDK 1.8 后,在 HashMap
中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap
的 put
操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
- 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 判断是否出现 hash 碰撞
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
// ...
}
还有一种情况是这两个线程同时 put
操作导致 size
的值不正确,进而导致数据覆盖的问题:
- 线程 1 执行
if(++size > threshold)
判断时,假设获得size
的值为 10,由于时间片耗尽挂起。 - 线程 2 也执行
if(++size > threshold)
判断,获得size
的值也为 10,并将元素插入到该桶位中,并将size
的值更新为 11。 - 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
- 线程 1、2 都执行了一次
put
操作,但是size
的值只增加了 1,也就导致实际上只有一个元素被添加到了HashMap
中。
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
4、那 HashMap 线程安全的替代品是?
我们知道,HashMap
是线程不安全的,如果在并发场景下使用,一种常见的解决方式是通过 Collections.synchronizedMap()
方法对 HashMap
进行包装,使其变为线程安全。不过,这种方式是通过一个全局锁来同步不同线程间的并发访问,会导致严重的性能瓶颈,尤其是在高并发场景下。
为了解决这一问题,ConcurrentHashMap
应运而生,作为 HashMap
的线程安全版本,它提供了更高效的并发处理能力。
在 JDK1.7 的时候,ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
到了 JDK1.8 的时候,ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
更多 Java 集合相关的面试题,可以参考这几篇文章:
5、可重入锁指的是?
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
6、synchronized 是可重入锁吗?
JDK 提供的所有现成的 Lock
实现类,包括 synchronized
关键字锁都是可重入的。
在下面的代码中,method1()
和 method2()
都被 synchronized
关键字修饰,method1()
调用了method2()
。
java
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}
public synchronized void method2() {
System.out.println("方法2");
}
}
由于 synchronized
锁是可重入的,同一个线程在调用method1()
时可以直接获得当前对象的锁,执行 method2()
的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized
是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()
时获取锁失败,会出现死锁问题。
更多 Java 并发相关的面试题,可以参考这几篇文章:
7、堆内存的结构是?
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。
8、对象会被分配都哪个区域?
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
9、JDK 默认垃圾回收器是?
- JDK 1.8 默认垃圾回收器:Parallel Scanvenge(新生代)+ Parallel Old(老年代)。 这个组合也被称为 Parallel GC 或 Throughput GC,侧重于吞吐量。
- JDK 1.9 及以后默认垃圾回收器:G1 GC (Garbage-First Garbage Collector)。 G1 GC 是一个更现代化的垃圾回收器,旨在平衡吞吐量和停顿时间,尤其适用于堆内存较大的应用。
更多 JVM 相关的面试题,可以参考这几篇文章:
10、你知道哪些 Java 性能优化和问题排查工具?
JDK 自带的可视化分析工具:
- JConsole :基于 JMX 的可视化监视、管理工具,可以用于查看应用程序的运行概况、内存、线程、类、VM 概括、MBean 等信息。
- VisualVM:基于 NetBeans 平台开发,具备了插件扩展功能的特性。利用它不仅能够监控服务的 CPU、内存、线程、类等信息,还可以捕获有关 JVM 软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享。根据《深入理解 Java 虚拟机》介绍:"VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的"。
JDK 自带的命令行工具:
jps
(JVM Process Status): 类似 UNIX 的ps
命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;jstat
(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;jinfo
(Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;jmap
(Memory Map for Java) : 生成堆转储快照;jhat
(JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。JDK9 移除了 jhat;jstack
(Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。
第三方工具:
- MAT :一款功能强大的 Java 堆内存分析器,可以用于查找内存泄漏以及查看内存消耗情况,用户可以利用 VisualVM 或者是
jmap
命令生产堆文件,然后导入工具中进行分析。 - GCeasy :一款在线的 GC 日志分析器,使用起来非常方便,用户可以通过它的 Web 网站导入 GC 日志,实时进行内存泄漏检测、GC 暂停原因分析、JVM 配置建议优化等功能。网站地址:https://gceasy.io/ 。
- GCViewer:一款非常强大的 GC 日志可视化分析工具,功能强大而且完全免费。
- JProfiler:一款商用的性能分析利器,功能强大,但需要付费使用。 它提供更深入的性能分析功能,例如方法调用分析、内存分配分析等。
- Arthas:阿里开源的一款线上监控诊断工具,可以查看应用负载、内存、gc、线程等信息。