互联网大厂Java面试全流程大揭秘:核心知识与实战问答
面试官:好了,咱们开始面试。第一轮,先问几个基础的Java核心知识问题。首先,Java 中基本数据类型有哪些?
王铁牛:这我知道,有 byte、short、int、long、float、double、char、boolean。
面试官:不错,回答正确。那自动装箱和拆箱是怎么回事?
王铁牛:就是基本数据类型和对应的包装类可以自动转换,装箱是基本转包装,拆箱是包装转基本。
面试官:嗯,理解得挺到位。最后一个问题,String 类为什么是不可变的?
王铁牛:因为 String 类内部用 final 修饰的 char 数组存储字符,一旦赋值就不能改变。
面试官:很好,第一轮表现不错。接下来第二轮,关于多线程和线程池的问题。说说创建线程有几种方式?
王铁牛:有继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。
面试官:那线程池的核心参数有哪些?
王铁牛:好像有 corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。
面试官:回答得不太准确,corePoolSize 是线程池的核心线程数,当提交的任务数小于 corePoolSize 时,会创建新线程执行任务;maximumPoolSize 是线程池最大线程数,当任务数大于 corePoolSize 且 workQueue 满时,会创建新线程直到线程数达到 maximumPoolSize;keepAliveTime 是线程池中非核心线程的存活时间;unit 是 keepAliveTime 的时间单位;workQueue 是任务队列,用来存储提交的任务;threadFactory 是线程工厂,用于创建线程;handler 是拒绝策略,当线程池满且无法处理新任务时会调用。最后一个问题,多线程中如何保证线程安全?
王铁牛:用 synchronized 关键字或者 Lock。
面试官:回答得比较笼统。synchronized 是 Java 中的关键字,用于实现同步机制,它可以修饰方法、代码块等,保证在同一时刻只有一个线程能访问被 synchronized 修饰的资源。Lock 是一个接口,常用的实现类有 ReentrantLock,它提供了更灵活的锁控制,比如可中断锁、公平锁等。第二轮整体表现一般,继续第三轮。这轮关于 JVM 和 HashMap 的问题。说说 JVM 的内存结构有哪些?
王铁牛:有堆、栈、方法区。
面试官:那堆又分为哪几个区域?
王铁牛:不太清楚。
面试官:堆分为新生代、老年代、永久代(Java8 后为元空间)。新生代又分为 Eden 区和两个 Survivor 区。对象创建时首先在 Eden 区,经过几次 Minor GC 后如果还存活就会进入 Survivor 区,当在 Survivor 区经历一定次数的复制后还存活就会进入老年代。最后一个问题,HashMap 的底层实现原理是什么?
王铁牛:就是一个数组加链表。
面试官:回答得不准确。HashMap 底层是一个数组,数组中的每个元素是一个链表节点(JDK8 后链表长度大于 8 且数组长度大于 64 时会转换为红黑树)。当插入键值对时,会通过 key 的 hash 值计算出在数组中的位置,如果该位置为空则直接插入,如果不为空则遍历链表或红黑树找到相同 key 进行更新,如果没有相同 key 则插入到链表或红黑树末尾。面试结束了,回去等通知吧。
答案:
- Java 中基本数据类型:
- byte:占 1 个字节,表示范围是 -128 到 127。
- short:占 2 个字节,表示范围是 -32768 到 32767。
- int:占 4 个字节,表示范围是 -2147483648 到 2147483647。
- long:占 8 个字节,表示范围是 -9223372036854775808 到 9223372036854775807。
- float:占 4 个字节,是单精度浮点数。
- double:占 8 个字节,是双精度浮点数。
- char:占 2 个字节,表示一个 Unicode 字符。
- boolean:占 1 位,只有 true 和 false 两个值。
- 自动装箱和拆箱:
- 自动装箱是指 Java 自动将基本数据类型转为对应的包装类,比如 int 自动转为 Integer。例如:Integer i = 10; 这里 10 是 int 类型,自动装箱为 Integer 类型。
- 自动拆箱是指 Java 自动将包装类转为基本数据类型,比如 Integer 自动转为 int。例如:int num = new Integer(5); 这里 new Integer(5) 自动拆箱为 int 类型赋值给 num。
- String 类不可变的原因:
- String 类内部有一个用 final 修饰的 char 数组来存储字符。
- 当一个 String 对象被创建后,其内部的 char 数组一旦赋值,内存地址就不会改变,无法再指向其他字符数组。
- 如果要修改 String 对象的值,实际上是创建了一个新的 String 对象,而不是修改原来的对象。例如:String s = "abc"; s = s + "d"; 这里 s 重新指向了一个新的 String 对象 "abcd",原来的 "abc" 对象并没有被修改。
- 创建线程的方式:
- 继承 Thread 类:创建一个类继承 Thread 类,重写 run 方法,然后创建该类的实例调用 start 方法启动线程。例如:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running");
}
}
MyThread thread = new MyThread();
thread.start();
- 实现 Runnable 接口:创建一个类实现 Runnable 接口,重写 run 方法,然后将该类的实例作为参数传递给 Thread 类的构造函数创建线程对象并启动。例如:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable is running");
}
}
Thread thread = new Thread(new MyRunnable());
thread.start();
- 实现 Callable 接口:创建一个类实现 Callable 接口,重写 call 方法,该方法有返回值。然后通过 FutureTask 类来包装 Callable 对象,再将 FutureTask 对象作为参数传递给 Thread 类的构造函数创建线程对象并启动。最后可以通过 FutureTask 的 get 方法获取 call 方法的返回值。例如:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "Callable result";
}
}
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
try {
String result = futureTask.get();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
- 线程池的核心参数:
- corePoolSize:线程池的核心线程数。当提交的任务数小于 corePoolSize 时,线程池会创建新线程来执行任务。
- maximumPoolSize:线程池最大线程数。当任务数大于 corePoolSize 且任务队列 workQueue 已满时,线程池会创建新线程直到线程数达到 maximumPoolSize。
- keepAliveTime:线程池中非核心线程的存活时间。当线程数大于 corePoolSize 时,超过 keepAliveTime 时间的非核心线程会被销毁。
- unit:keepAliveTime 的时间单位,比如 TimeUnit.SECONDS 表示秒。
- workQueue:任务队列,用来存储提交的任务。当任务数大于 corePoolSize 时,新提交的任务会放入 workQueue 中。
- threadFactory:线程工厂,用于创建线程。可以通过自定义 threadFactory 来设置线程的一些属性,比如线程名称、优先级等。
- handler:拒绝策略。当线程池满且无法处理新任务时会调用拒绝策略来处理新任务。常见的拒绝策略有 AbortPolicy(抛出异常)、CallerRunsPolicy(调用者运行任务)、DiscardPolicy(丢弃任务)、DiscardOldestPolicy(丢弃队列中最老的任务)。
- 多线程中保证线程安全的方式:
- synchronized 关键字:
- 修饰方法:当一个方法被 synchronized 修饰时,在同一时刻只有一个线程能访问该方法。例如:
- synchronized 关键字:
public synchronized void synchronizedMethod() {
// 方法体
}
- **修饰代码块**:可以指定锁对象,对特定的代码块进行同步。例如:
Object lock = new Object();
synchronized (lock) {
// 代码块
}
- Lock:
- ReentrantLock:是一个可重入的互斥锁,实现了 Lock 接口。它提供了比 synchronized 更灵活的锁控制。例如:
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
- **可中断锁**:可以在等待锁的过程中响应中断。例如:
try {
lock.lockInterruptibly();
// 临界区代码
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
- **公平锁**:按照线程请求锁的顺序来分配锁,避免线程饥饿。创建 ReentrantLock 时可以传入 true 来创建公平锁,如:ReentrantLock lock = new ReentrantLock(true);
7. JVM 的内存结构:
- 堆:是 JVM 中最大的一块内存区域,用于存储对象实例。
- 新生代:又分为 Eden 区和两个 Survivor 区。对象创建时首先在 Eden 区,经过几次 Minor GC 后如果还存活就会进入 Survivor 区,当在 Survivor 区经历一定次数的复制后还存活就会进入老年代。
- 老年代:存储经过多次垃圾回收后仍然存活的对象。
- 永久代(Java8 后为元空间):用于存储类信息、常量、静态变量等。在 Java8 中,永久代被移除,取而代之的是元空间,元空间使用本地内存,不受 JVM 堆大小限制。
- 栈:每个线程都有自己独立的栈,用于存储局部变量、方法调用等信息。
- 方法区:存储类的元数据信息,如类的结构、方法、字段等。
- HashMap 的底层实现原理:
- HashMap 底层是一个数组,数组中的每个元素是一个链表节点(JDK8 后链表长度大于 8 且数组长度大于 64 时会转换为红黑树)。
- 当插入键值对时,会通过 key 的 hash 值计算出在数组中的位置。计算 hash 值的方法是将 key 的 hashCode 值与自身右移 16 位后的值进行异或运算,这样可以使高位和低位都参与运算,减少 hash 冲突。例如:
static final int hash(Object key) {
int h;
return (key == null)? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 如果该位置为空,则直接插入新的键值对。如果不为空,则遍历链表或红黑树找到相同 key 进行更新,如果没有相同 key 则插入到链表或红黑树末尾。当链表长度大于 8 且数组长度大于 64 时,链表会转换为红黑树,以提高查询效率。当红黑树节点数小于等于 6 时,又会转换回链表。