下面我将分享一位同学在「美团后端一面的面试经历」,对于这次面试,「他的评价是,很有难度,你试试呢」? 【提醒】通过这次面试经验,你将可以复习到以下知识点: 「面试官」: 你好,首先我们来做一道算法题,LeetCode124,你解决过这道题吗? 「求职者」: 是的,我记得这道题的解题思路。 「面试官」: 很好,那你能简单描述一下这道题的解题思路吗? 「求职者」: 这道题是求一棵二叉树中的最大路径和,我是采用了深度优先搜索的方法,通过后序遍历的方式,对每一个节点,分别计算包含该节点的最大路径和,并更新全局最大路径和。 「面试官」: 明白了。那你能写出解决这道题的代码吗? 「求职者」: 当然,这里是我写的代码: 「面试官」: 很好。现在我们来讨论一下集合,你能告诉我String、StringBuilder和StringBuffer的区别吗? 「求职者」: 当然可以。String,StringBuilder和StringBuffer三者在Java中都用来存储和操作字符串,但是他们在性能和使用场景上有所不同。首先,String是不可变的,也就是说一旦一个String对象被创建,我们不能改变它的内容。这是因为String的实现中,值被存储在一个final的字符数组中。而StringBuilder和StringBuffer是可变的,他们可以添加,删除,插入等操作来改变其内容。他们的值被存储在一个字符数组中,当数组容量不足时,会自动扩容。最主要的区别在于,StringBuilder是线程不安全的,而StringBuffer是线程安全的,因为StringBuffer的大部分方法都是synchronized的。 「面试官」: 那么,你能解释一下哈希冲突的解决办法吗? 「求职者」: 哈希冲突是指当两个不同的键的哈希值相同时,会发生的情况。解决哈希冲突的常见办法有两种,一种是拉链法,另一种是开放寻址法。拉链法是将哈希到同一位置的元素链接在一起,形成一个链表。当发生冲突时,就在链表的末尾添加元素。而开放寻址法则是当哈希位置已经被占用时,通过探测其他位置来找到一个未被占用的位置。 「面试官」: 明白了。那你能详细介绍一下HashMap的底层原理吗? 「求职者」: 当然。HashMap是Java中最常用的哈希表实现。它由一个Entry数组和一些链表组成。每个Entry存储了键值对。当我们向HashMap中插入一个键值对时,首先会计算键的哈希值,然后使用这个哈希值来决定该键值对应该存储在数组的哪个位置。如果该位置已经有其他键值对,那么就在链表的末尾添加新的键值对。至于扩容,当HashMap中的键值对数量超过数组长度和加载因子的乘积时,就会对数组进行扩容。扩容后的数组长度是原数组长度的两倍。扩容操作会重新计算每个键的哈希值,并将键值对放在新的位置上。 「面试官」: 很好。那么,如果有100个数据,怎么确定hashmap的容量,使得它不用扩容? 「求职者」: 确定HashMap的容量需要考虑到负载因子。负载因子是一个浮点数,其值表示了当HashMap被填充的程度。默认的负载因子是0.75。所以,如果有100个数据,为了使得HashMap不用扩容,我们需要先确定一个初步的容量,就是100除以负载因子,也就是100/0.75=133。但是,HashMap的容量总是2的幂次方,所以我们需要选择一个大于等于133的最小的2的幂次方,也就是256。所以,对于这个问题,我们应该将HashMap的初始容量设为256。 「面试官」: 接下来我们来讨论一下Java并发包,你是否了解如何保证共享变量的可见性? 「求职者」: 是的,Java中通过使用volatile关键字来保证共享变量的可见性。当一个共享变量被volatile修饰后,它会保证每次读变量的时候都从内存中读,跳过CPU缓存这一步。同时每次写变量的时候,都会立即将变量的新值刷新到主内存中,这样就能保证在多线程环境下,所有线程都能看到共享变量的最新值。 「面试官」: 那么,你能解释一下volatile的原理吗? 「求职者」: volatile关键字的原理主要是通过内存屏障和禁止指令重排序来实现的。内存屏障是一种处理器指令,它能确保特定操作的执行顺序,并且能够保证某些变量的内存可见性。通过插入内存屏障,我们可以禁止特定类型的处理器重排序。对于volatile修饰的变量,编译器和运行时都会插入内存屏障来禁止指令重排序,确保执行顺序符合代码的书写顺序。 「面试官」: 很好。你是否使用过线程池?能否讲一下它的工作原理? 「求职者」: 线程池主要是为了减少在创建和销毁线程上所花的时间以及系统资源的消耗,达到重用已创建的线程的目的。在Java中,我们可以通过Executor框架在Java.util.concurrent包中的ThreadPoolExecutor类来创建线程池。线程池主要包含一个线程池和一个任务队列。当新任务来临时,首先会尝试将其添加到队列中。如果队列已满,那么会尝试创建新的线程处理任务。如果线程数量达到线程池的最大值,那么会根据饱和策略来处理无法处理的任务。 「面试官」: 你能详细介绍一下线程池的参数配置吗? 「求职者」: 当然。线程池主要有以下几个参数: 「面试官」: 了解了。那你是否了解ReentrantLock,它是如何实现可重入的? 「求职者」: ReentrantLock是一种可重入的互斥锁,也就是说,一个线程可以多次获取同一个锁。在ReentrantLock中,有一个状态变量state,当一个线程首次获取锁时,会将state设置为1。如果这个线程再次获取锁,那么会将state加1。每次释放锁,都会将state减1。只有当state变为0时,锁才真正被释放。 「面试官」: 那么,ReentrantLock是公平锁吗? 「求职者」: ReentrantLock可以是公平锁,也可以是非公平锁,这取决于我们在创建ReentrantLock时传入的参数。如果我们在创建ReentrantLock时传入的参数为true,那么它就是一个公平锁。在公平锁模式下,线程会按照请求锁的顺序获得锁。如果我们在创建ReentrantLock时传入的参数为false或者不传入参数,那么它就是一个非公平锁。在非公平锁模式下,当一个线程请求锁时,如果锁当前没有被其他线程持有,那么这个线程会直接获得锁,不管是否有其他线程正在等待这个锁。 「面试官」: 你是否使用过ThreadLocal,它的原理是什么? 「求职者」: 是的,我使用过ThreadLocal。ThreadLocal是Java中用来实现线程局部变量的类,也就是说,每个线程都可以有一个自己的局部变量,而这个局部变量对其他线程是不可见的。ThreadLocal的实现原理是,每个Thread维护一个ThreadLocalMap,这个Map的键是ThreadLocal对象,值是真正的局部变量。当我们通过ThreadLocal的get或set方法访问局部变量时,实际上是通过当前线程的ThreadLocalMap来存取的。 「面试官」: 那么,ThreadLocal是否可能引起内存泄漏,如果会,应该如何避免? 「求职者」: 是的,ThreadLocal可能会引起内存泄漏。这是因为,ThreadLocalMap的生命周期和Thread是一样的,如果ThreadLocal没有被外部强引用持有,那么这个ThreadLocal是可以被垃圾回收器回收的,但是如果我们忘记了从ThreadLocalMap中移除对应的entry,那么即使ThreadLocal对象已经被回收,由于Thread一直在运行,它的ThreadLocalMap及其entry的值(也就是我们的局部变量)会一直占用内存,这就会导致内存泄漏。为了避免这种情况,我们需要在不再使用局部变量时,显式地调用ThreadLocal的remove方法来移除当前线程ThreadLocalMap中的entry。 「面试官」: 很好,这部分你回答得很详尽。现在让我们转到JVM,你提到了内存区域划分,能详细说明一下吗? 「求职者」: 当然可以。在JVM中,内存主要被分为几个区域:堆(Heap),方法区(Method Area),程序计数器(Program Counter Register),虚拟机栈(JVM Stack),和本地方法栈(Native Method Stack)。堆是用来存储对象实例和数组的地方,是垃圾收集器进行垃圾回收的主要区域。方法区用来存储已被虚拟机加载的类信息、常量、静态变量等数据。程序计数器存储当前线程所执行的字节码的行号指示器。虚拟机栈存储局部变量表、操作数栈、动态链接、方法出入口等信息。本地方法栈则服务于本地方法调用。 「面试官」: 你提到了GC,GC是在什么时候触发的? 「求职者」: GC主要在两个场景下触发:一是当堆内存不足时,JVM会触发GC来回收空间,二是System.gc()方法被调用时,JVM同样会尝试进行垃圾回收,尽管调用System.gc()并不保证GC一定会执行。GC会根据不同的垃圾收集器和堆的区域(如Young Generation和Old Generation)以不同的方式进行。 「面试官」: 那么,JVM是如何判断一个对象是否已经死亡? 「求职者」: JVM主要通过可达性分析来判断对象是否存活。在可达性分析中,一系列的称为"GC Roots"的对象被选定,然后JVM从这些节点开始向下搜索,如果一个对象到GC Roots没有任何引用链相连(即不可达),那么这个对象就被认为是死亡的,可以被垃圾回收器回收。除此之外,也有其他的方法,例如引用计数法,但是由于它不能解决对象之间的循环引用问题,所以在Java的垃圾回收器中并不采用。 「面试官」: 明白了。你对MySQL、Redis和Spring哪个更熟悉? 「求职者」: 我对MySQL和Redis比较熟悉,对Spring也有一定的了解。 「面试官」: 好的,让我们继续深入MySQL。你能告诉我MySQL主从一致性是如何保持的吗? 「求职者」: MySQL主从一致性主要是通过binlog(二进制日志)来保持的。在主服务器上,所有的更改(比如INSERT、UPDATE和DELETE操作)都会记录在binlog中。然后,从服务器会从主服务器上读取这些binlog,并在自己上面重放这些操作,以此来保证数据的一致性。 「面试官」: 那么,如果有大量数据,你会如何进行分表? 「求职者」: 当单一表中的数据量过大时,会对数据库性能产生影响。此时,我们可以采用分表的策略,即将一个大表分成多个小表来管理。分表可以根据不同的需求采取不同的策略,比如根据时间范围进行分表,这种情况下可以进行冷热数据分离;或者根据某个字段的值进行水平分表,可以使用一致性哈希等算法来分配数据到不同的表中,以保持负载均衡。 「面试官」: 很好,那你对binlog的具体流程了解吗? 「求职者」: binlog的具体流程主要分为几个步骤。首先,在主服务器上执行的数据变更操作会被记录到binlog中。然后,从服务器上的IO线程会连接到主服务器,并请求从上次断开的位置开始复制binlog。复制到从服务器的binlog会被写入到中继日志(relay log)中。接着,从服务器的SQL线程会读取中继日志,并执行其中的操作来更新从服务器上的数据。 「面试官」: 明白了。你能比较一下MySQL不同的存储引擎吗?例如InnoDB和MyISAM。 「求职者」: 当然可以。InnoDB和MyISAM是MySQL中两种常见的存储引擎。InnoDB支持事务处理,具有ACID(原子性、一致性、隔离性、持久性)特性,支持外键,适合处理大量的短小的、需要频繁更新的查询。而MyISAM不支持事务处理,也不支持外键,但是在查询性能上通常比InnoDB要快,适合读取操作远多于写入操作的场景。另外,InnoDB支持行级锁定,而MyISAM只支持表级锁定,这使得InnoDB在并发操作上有更好的性能。 「面试官」: 最后一个问题,为什么MySQL的索引通常使用B+树而不是其他数据结构,比如二叉搜索树或红黑树? 「求职者」: B+树作为MySQL索引的数据结构,它在磁盘读写方面有优势。二叉搜索树虽然在理论上查找效率是对数级别的,但是由于树的深度可能会很大,这在实际的磁盘IO操作中会导致大量的随机读取,效率不高。而红黑树作为一种平衡二叉搜索树,虽然可以保证最坏情况下的效率,但是它的填充因子低,也就是说,树的节点没有充分利用磁盘的页。相比之下,B+树有较高的填充因子,可以减少IO次数,并且B+树的所有值都存在叶子节点,并且叶子节点之间是相互链接的,这对于范围查询非常有效。 「面试官」: 好了,今天的面试结束,回去等通知吧。 本文使用 mdnice 排版class Solution {
int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxGain(root);
return maxSum; } private int maxGain(TreeNode node) { if (node == null) { return 0; } int leftGain = Math.max(maxGain(node.left), 0); int rightGain = Math.max(maxGain(node.right), 0); int priceNewpath = node.val + leftGain + rightGain; maxSum = Math.max(maxSum, priceNewpath); return node.val + Math.max(leftGain, rightGain); } }