大家好,我是小林。
阿里巴巴上周开启了 25 届的实习招聘,阿里是Java 一线大厂,到底面试难度如何呢?同学们准备好了吗?
今天分享一位校招同学阿里的 Java 后端开发的面经,阿里的风格会比较关注 Java 和后端组件,而网络和系统考察通常都比较少的,所以准备面阿里的同学 Java 和后端组件这两方面的要准备好。
这次的 Java 后端开发的面经,考察的知识点, 针对八股文的涉及的内容,我帮大家列了一下:
- Java 基础:面向对象、多态、重载
- Java 集合:HashMap 连环问、红黑树
- Java并发:volatile、Synchronized、ReentrantLock
- Java 线程池:线程池参数、核心线程数设置
- MySQL:索引结构、建立索引规则、explain、联合索引
八股这块一共问了 30 分钟,其余时间问了项目方面的内容。
Java基础
讲一下Java面向对象的特点
封装、继承、多态是Java面向对象编程的三大特点。
- 封装(Encapsulation):封装是面向对象编程的基本特点之一,它将数据和方法封装在对象内部,隐藏对象的内部实现细节,只暴露必要的接口供外部访问。通过封装,可以实现信息的隐藏和保护,提高代码的安全性和可靠性。
- 继承(Inheritance):继承是面向对象编程的重要特点,它允许一个类(子类)继承另一个类(父类)的属性和方法。子类可以重用父类的代码,并可以通过扩展和重写来增加新的功能或修改现有功能。继承提高了代码的复用性和可维护性,同时也体现了类与类之间的关系。
- 多态(Polymorphism):多态是面向对象编程的核心概念之一,它允许不同对象对同一消息作出不同的响应。在Java中,多态性通过方法重载和方法重写来实现。方法重载是指在同一个类中可以定义多个同名方法,但参数列表不同;方法重写是指子类可以重写父类的方法,实现不同的行为。多态性提高了代码的灵活性和扩展性,使得程序更易于理解和维护。
用过“多态”吗?举一个具体例子
一个具体的例子是,假设有一个动物类(Animal)和它的两个子类:狗类(Dog)和猫类(Cat)。它们都有一个名为“makeSound”的方法,但是每种动物发出的声音是不同的。
class Animal {
public void makeSound() {
System.out.println("Some sound");
}
}
class Dog extends Animal {
public void makeSound() {
System.out.println("Woof");
}
}
class Cat extends Animal {
public void makeSound() {
System.out.println("Meow");
}
}
public class PolymorphismExample {
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
dog.makeSound(); // 输出:Woof
cat.makeSound(); // 输出:Meow
}
}
通过多态性,可以创建一个Animal类型的引用指向一个具体的Dog或Cat对象。当调用这个引用的“makeSound”方法时,根据实际指向的对象类型,会执行相应子类的方法,从而实现不同动物发出不同声音的效果。这样就体现了多态的特性,同一个方法调用可以产生不同的行为,提高了代码的灵活性和可扩展性。
多态和重载有什么关系?
重载是一种编译时多态,而多态是一种运行时多态。两者都是实现多态性的方式,但发生的时间点和机制不同。
- 重载是指在同一个类中,方法名相同但参数列表不同的情况,通过参数个数、类型或顺序的不同来区分不同的方法。重载是静态绑定的概念,编译器在编译期间根据方法的参数列表来确定调用哪个方法。
- 多态是指同一个方法名可以在不同的类中有不同的实现,不同的子类可以重写父类的方法,通过父类引用指向子类对象时,根据实际对象的类型来确定调用哪个方法。多态是动态绑定的概念,运行时根据对象的实际类型来确定调用哪个方法。
Java集合
Java中的HashMap了解吗?
了解的,HashMap是Java中常用的一种数据结构,它基于哈希表实现,用于存储键值对
聊聊HashMap的底层结构
在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。
所以在 JDK 1.8 版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。
图片
为什么要引入红黑树,而不用其他树?
- 为什么不使用二叉排序树?问题主要出现在二叉排序树在添加元素的时候极端情况下会出现线性结构。比如由于二叉排序树左子树所有节点的值均小于根节点的特点,如果我们添加的元素都比根节点小,会导致左子树线性增长,这样就失去了用树型结构替换链表的初衷,导致查询时间增长。所以这是不用二叉查找树的原因。
- 为什么不使用平衡二叉树呢?红黑树不追求"完全平衡",而而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。红黑树读取略逊于AVL,维护强于AVL,空间开销与AVL类似,内容极多时略优于AVL,维护优于AVL。
基本上主要的几种平衡树看来,红黑树有着良好的稳定性和完整的功能,性能表现也很不错,综合实力强,在诸如STL的场景中需要稳定表现。
HashMap会出现红黑树一直增高变成无限高的情况吗?
不能无限增长。当集合中的节点数超过了阈值,HashMap会进行扩容,这时原始的红黑树节点会被打散,可能会退化成链表结构。
HashMap读和写的时间复杂度是多少?
HashMap的读取(查找)和写入(插入、更新、删除)操作的时间复杂度均为O(1),即常数时间复杂度。
这是因为HashMap内部使用哈希表来存储键值对,通过计算键的哈希值可以直接定位到对应的存储位置,从而实现快速的读取和写入操作。
在理想情况下,HashMap可以在常数时间内完成查找和插入操作,具有高效的性能表现。
HashMap是线程安全的吗?怎么解决?
不是线程安全的。
- JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。
- JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
解决的方式:
- 使用ConcurrentHashMap:ConcurrentHashMap是Java提供的线程安全的哈希表实现,它通过分段锁(Segment)和CAS操作来保证线程安全性,适用于高并发环境。
- 使用Collections.synchronizedMap:可以通过Collections工具类中的synchronizedMap方法来创建一个线程安全的HashMap,该方法会返回一个同步的Map对象,但性能可能不如ConcurrentHashMap。
解决线程安全问题还有哪些办法?
- 使用同步关键字synchronized:可以通过在方法或代码块上使用synchronized关键字来实现线程安全,确保同一时刻只有一个线程可以访问共享资源。
- 使用volatile关键字:可以使用volatile关键字修饰变量,保证变量的可见性,即一个线程修改了变量的值,其他线程能立即看到最新值,从而避免数据不一致的问题。
- 使用线程安全的工具类:Java中提供了诸如AtomicInteger、AtomicLong、CountDownLatch等线程安全的工具类,可以帮助解决并发场景下的线程安全性问题。
- 使用并发容器:Java中提供了多种线程安全的并发容器,如ConcurrentLinkedQueue、CopyOnWriteArrayList等,可以替代传统的非线程安全容器来解决多线程并发访问问题。
Java并发
volatile关键字是如何保证内存可见性的?底层是怎么实现的?
volatile关键字通过两种机制来保证内存可见性:
- 禁止指令重排序:在程序运行时,为了提高性能,编译器和处理器可能会对指令进行重排序,这可能会导致变量的更新操作被延迟执行或者乱序执行,从而使得其他线程无法立即看到最新的值。使用volatile关键字修饰的变量会禁止指令重排序,保证变量的更新操作按照代码顺序执行。
- 内存屏障(Memory Barrier):在多核处理器架构下,每个线程都有自己的缓存,volatile关键字会在写操作后插入写屏障(Write Barrier),在读操作前插入读屏障(Read Barrier),确保变量的更新能够立即被其他线程看到,保证内存可见性。
通过禁止指令重排序和插入内存屏障,volatile关键字能够保证被修饰变量的更新操作对其他线程是可见的,从而有效解决了多线程环境下的内存可见性问题。
为什么需要保证内存可见性?
如果不保证内存可见性,就会出现数据脏读,一个线程修改了共享变量的值,但其他线程无法立即看到最新值,导致其他线程读取到了过期数据,从而产生错误的结果。
通过保证内存可见性,避免数据不一致性和并发访问带来的问题,保证程序的正确性和稳定性。
volatile为什么要禁止指令重排,能举一个具体的指令重排出现问题的例子吗
禁止指令重排是为了确保程序的执行顺序与代码编写顺序一致,特别是在多线程环境下,避免出现意外的结果。具体来说,如果不禁止指令重排,可能会导致以下问题:
举例来说,假设有如下代码片段:
int a = 0;
boolean flag = false;
// 线程1
a = 1;
flag = true;
// 线程2
if (flag) {
System.out.println(a);
}
如果发生指令重排,可能会导致线程2在判断flag时先于a的赋值操作,那么线程2就会打印出0,而不是预期的1。这种情况下,禁止指令重排可以确保线程2在看到flag为true时,也能看到a被正确赋值为1,避免出现问题。
因此,通过禁止指令重排,可以保证程序的执行顺序符合代码逻辑,避免出现意外的行为,特别是在涉及多线程并发的情况下更为重要。
Synchronized的底层原理是什么?锁升级的过程了解吗?
- 底层实现:Synchronized关键字底层是使用monitor对象锁实现的,每一个对象关联一个monitor对象,而monitor对象可以看成是一个对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获取这个对 象锁时会被阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区的代码。
- 锁升级:是指JVM根据锁的竞争情况和对象的状态,将对象的锁从偏向锁、轻量级锁升级为重量级锁的过程。偏向锁是指针对无竞争的情况下,锁会偏向于第一个获取锁的线程;轻量级锁是指针对短时间内只有一个线程竞争锁的情况下,使用CAS操作来避免阻塞;重量级锁是指多个线程竞争同一个锁时,通过操作系统的互斥量来实现线程阻塞和唤醒。锁升级的过程是为了提高多线程并发访问的效率和性能。
线程是怎么确定拿到锁的?
线程确定拿到锁的过程:是通过检查锁的状态并尝试获取锁来实现的。在JVM中,锁信息具体是存放在Java对象头中的。
当一个线程尝试进入synchronized代码块或方法时,JVM会检查对应对象的锁状态。如果对象的锁未被其他线程持有,即锁状态为可获取,那么该线程将成功获取锁并进入临界区执行代码。
锁信息具体放到哪的?
锁的状态信息是Java对象头中的 Mark Word 字段,这个字段对象关于锁的信息、垃圾回收信息等。
图片
Java 对象在内存中的表示
JVM通过操作对象的头部信息来实现锁的获取、释放以及等待队列的管理。当线程成功获取锁后,对象的头部信息会被更新为当前线程的标识,表示该线程拥有了这个锁。
其他线程在尝试获取同一个锁时,会检查对象的头部信息,如果锁已经被其他线程持有,它们将会被阻塞直到锁被释放。
Synchronized加锁和ReentrantLock加锁有什么区别?
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:
- 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
- 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
- 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
Java 线程池
线程池了解过吗?有哪些核心参数?
了解过的,线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗。
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
图片
线程池的构造函数有7个参数:
图片
- corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。
- maximumPoolSize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。
- keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。
- unit:就是keepAliveTime时间的单位。
- workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。
- threadFactory:线程工厂。可以用来给线程取名字等等
- handler:拒绝策略。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。
为什么核心线程满了之后是先加入阻塞队列而不是直接加到总线程?
- 线程池创建线程需要获取mainlock这个全局锁,会影响并发效率,所以使用阻塞队列把第一步创建核心线程与第三步创建最大线程隔离开来,起一个缓冲的作用。
- 引入阻塞队列,是为了在执行execute()方法时,尽可能的避免获取全局锁。
核心线程数一般设置为多少?
假设机器有N个CPU:
- 如果是CPU密集型应用,则线程池大小设置为N+1,线程的应用场景:主要是复杂算法
- 如果是IO密集型应用,则线程池大小设置为2N+1,线程的应用场景:主要是:数据库数据的交互,文件上传下载,网络数据传输等等
对于同时有计算工作和IO工作的任务,应该考虑使用两个线程池,一个处理计算任务,一个处理IO任务,分别对两个线程池按照计算密集型和IO密集型来设置线程数。
IO密集型的线程数为什么一般设置为2N+1?
在IO密集型任务中,线程通常会因为IO操作而阻塞,此时可以让其他线程继续执行,充分利用CPU资源。设置为2N+1可以保证在有多个线程阻塞时,仍有足够的线程可以继续执行。
MySQL
聊聊MySQL的索引结构,为什么使用B+树而不用B树?
MySQL 默认的存储引擎 InnoDB 采用的是 B+ 作为索引的数据结构。
图片
原因有:
- B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少。
- B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化;
- B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。
你是怎么建立索引的?一般是建立哪些字段的索引呢?
索引最大的好处是提高查询速度,我经常针对下面场景来建立索引:
- 字段有唯一性限制的,比如商品编码;
- 经常用于 WHERE 查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。
- 经常用于 GROUP BY 和 ORDER BY 的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。
怎么确定语句是否走了索引?
可以通过 explian查看执行计划来确认。
图片
img
对于执行计划,参数有:
- possible_keys 字段表示可能用到的索引;
- key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引;
- key_len 表示索引的长度;
- rows 表示扫描的数据行数。
- type 表示数据扫描类型,我们需要重点看这个。
如果 typy=all,代表没有走索引,进行了全表扫描。如果 key 不为 null,说明用到了索引。
如果要建立联合索引,字段的顺序有什么需要注意吗?
- 最左匹配原则:联合索引遵循最左匹配原则,即在查询时,只有按照索引字段的顺序从最左边开始连续使用索引字段,索引才会被使用。因此,根据最常用作查询条件的字段放在联合索引的最左边,可以提高索引的利用率。
- 区分度高的字段放在前面:将区分度高的字段放在联合索引的前面,可以减少索引的扫描范围,提高查询效率。