Shopee Java 一面,问了整整一个小时没停过
整体考察范围很广,Java 基础、JVM、MySQL、Redis、Spring、并发、算法都有涉及,每道题不会问得特别深,但覆盖面很全,感觉是在快速扫描你的知识面。算法题有两道,一道链表一道树,难度中等,给的时间不多。
总时长刚好一个小时,面试官最后说感觉还不错,让等通知。
1. Java 中 synchronized 和 ReentrantLock 的区别是什么,分别适合什么场景?
答:synchronized 是 Java 内置的关键字级别的锁,使用简单,加锁和释放锁由 JVM 自动管理,不需要手动释放,不会因为忘记释放导致死锁。但功能比较基础,不支持超时获取锁、不支持中断等待、不支持公平锁,也无法知道锁的状态。
ReentrantLock 是 java.util.concurrent 包里的显式锁,功能更丰富。支持 tryLock 尝试获取锁,可以设置超时时间,获取不到直接返回而不是一直阻塞。支持 lockInterruptibly,等待锁的过程中可以响应中断。支持公平锁模式,按照等待顺序分配锁,避免线程饥饿。还可以绑定多个 Condition,实现更精细的等待通知机制。
两者都是可重入锁,同一个线程可以多次获取同一把锁不会死锁。
场景选择:简单的同步场景用 synchronized,代码简洁,JVM 对它有专门优化(锁升级机制)。需要超时、中断、公平性、多条件等高级功能时用 ReentrantLock,但要记得在 finally 块里释放锁。
2. JVM 的垃圾回收算法有哪些,G1 收集器的工作原理是什么?
答:基础垃圾回收算法有三种。标记清除算法,先标记所有存活对象,再清除未标记的对象,简单但会产生大量内存碎片。标记整理算法,标记后把存活对象移动到一端,消除碎片,但移动对象有开销。复制算法,把内存分成两半,存活对象复制到另一半,原来那半全部清空,没有碎片,但内存利用率只有一半。
现代 JVM 通常把堆分为新生代和老年代,新生代用复制算法(Eden 区和两个 Survivor 区),老年代用标记整理。
G1 收集器的工作原理:G1 把堆划分成大量固定大小的 Region(默认 1-32MB),每个 Region 可以扮演 Eden、Survivor 或 Old 的角色,不再是物理上连续的分代。
G1 的核心思想是优先回收垃圾最多的 Region,也就是 Garbage First 的含义。它会维护每个 Region 的垃圾量统计,回收时优先选垃圾最多的 Region,在用户设定的停顿时间目标内尽量多回收。
G1 的回收过程分几个阶段:年轻代 GC(只回收 Eden 和 Survivor Region)、并发标记(和应用线程并发执行,标记老年代存活对象)、混合 GC(同时回收年轻代和部分老年代 Region)。大对象(超过 Region 大小一半)直接分配到 Humongous Region。
G1 适合大堆(4GB 以上)、对停顿时间有要求的场景,是 JDK 9 之后的默认收集器。
3. HashMap 在 Java 8 之后做了哪些优化,链表转红黑树的条件是什么?
答:Java 8 对 HashMap 最重要的优化是引入了红黑树。当一个桶里的链表长度超过 8,并且整个 HashMap 的容量超过 64 时,这个链表会转换为红黑树,把查找时间从 O(n) 降到 O(log n)。当红黑树节点数量减少到 6 以下时,会退化回链表。
另一个优化是扩容时的 rehash 策略。Java 7 扩容时需要重新计算每个元素的哈希值和新位置,Java 8 利用了一个规律:扩容是容量翻倍,新容量是旧容量的 2 倍,元素在新数组里的位置要么和原来一样,要么是原位置加上旧容量。判断方法是看哈希值在新增的那一位是 0 还是 1,不需要重新计算哈希,效率更高。
还有一个细节是 Java 8 的链表插入改为尾插法,Java 7 是头插法。头插法在多线程扩容时可能形成环形链表导致死循环,尾插法避免了这个问题,但 HashMap 本身仍然不是线程安全的,多线程场景要用 ConcurrentHashMap。
4. ConcurrentHashMap 是怎么实现线程安全的,Java 7 和 Java 8 的实现有什么区别?
答:Java 7 的 ConcurrentHashMap 用分段锁(Segment)实现,把整个 Map 分成若干个 Segment,每个 Segment 是一个独立的小 HashMap,有自己的锁。不同 Segment 的操作可以并发执行,锁的粒度是 Segment 级别,默认 16 个 Segment,最多支持 16 个线程并发写。
Java 8 完全重写了,放弃了分段锁,改用 CAS + synchronized 的组合,锁的粒度细化到单个桶(数组槽位)。
具体实现:数组用 volatile 修饰保证可见性。插入时,如果目标桶为空,用 CAS 操作直接插入,不需要加锁。如果桶不为空,用 synchronized 锁住这个桶的头节点,然后在链表或红黑树上操作。扩容时多个线程可以协作迁移数据,每个线程负责一段区间,提高扩容效率。
size 的计算用了 LongAdder 类似的思路,用多个 CounterCell 分散计数,减少竞争,最后汇总得到总数。
Java 8 的实现锁粒度更细,并发度更高,而且不需要预先指定并发级别,更灵活。
5. MySQL 的事务隔离级别有哪些,可重复读是怎么实现的,幻读是什么,InnoDB 是怎么解决的?
答:四个隔离级别从低到高:读未提交、读已提交、可重复读、串行化。MySQL InnoDB 默认是可重复读。
可重复读的实现依赖 MVCC(多版本并发控制)。InnoDB 给每行数据维护两个隐藏字段:事务 ID 和回滚指针。每次修改数据时,旧版本数据通过回滚指针链接成版本链存在 undo log 里。
事务开始时创建一个 ReadView,记录当前活跃的事务 ID 列表。读数据时,根据 ReadView 判断哪个版本的数据对当前事务可见:如果数据的事务 ID 小于 ReadView 里最小活跃事务 ID,说明这个版本在事务开始前已经提交,可见;如果大于最大事务 ID,说明是事务开始后才开始的,不可见;在范围内的,看是否在活跃列表里,在的话不可见,不在的话可见。通过这个机制,同一个事务里多次读同一行数据,看到的是同一个版本,实现了可重复读。
幻读是指同一个事务里,两次执行同样的范围查询,第二次多出了新行,这是因为其他事务插入了新数据。MVCC 可以解决普通 SELECT 的幻读,因为快照读看的是固定版本。但当前读(SELECT FOR UPDATE、UPDATE、DELETE)会读最新数据,MVCC 解决不了。
InnoDB 用间隙锁(Gap Lock)解决当前读的幻读问题。间隙锁锁住的不是某一行,而是两行之间的间隙,防止其他事务在这个范围内插入新行。间隙锁和行锁组合成 Next-Key Lock,锁住一个左开右闭的区间,既防止修改已有行,也防止插入新行。
6. Redis 的持久化方式有哪些,RDB 和 AOF 各自的优缺点是什么,实际怎么选择?
答:RDB 是快照持久化,把某个时间点的内存数据全部写入一个二进制文件。可以手动触发(SAVE/BGSAVE)或者配置自动触发(比如 900 秒内有 1 次写操作就触发)。BGSAVE 会 fork 一个子进程来写文件,主进程继续处理请求,利用 COW(写时复制)机制,子进程看到的是 fork 时刻的内存快照。
RDB 的优点:文件紧凑,恢复速度快,适合备份和灾难恢复。缺点:两次快照之间的数据可能丢失,数据量大时 fork 操作本身有开销,可能导致短暂的性能抖动。
AOF 是追加日志,把每条写命令追加到文件末尾,重启时重放所有命令恢复数据。有三种刷盘策略:always(每条命令都 fsync,最安全但最慢)、everysec(每秒 fsync,最多丢 1 秒数据,推荐)、no(由 OS 决定,性能最好但不可控)。
AOF 的优点:数据更完整,最多丢 1 秒数据。缺点:文件比 RDB 大,恢复速度慢,需要定期 rewrite 压缩文件。
实际选择:对数据安全性要求高的场景,两者都开,RDB 用于快速恢复,AOF 保证数据完整性。对性能要求极高、可以接受少量数据丢失的场景,只用 RDB。纯缓存场景,两者都不开,重启后从数据库重新加载。
7. Spring 的 Bean 生命周期是什么,@PostConstruct 和 InitializingBean 的执行顺序是怎样的?
答:Spring Bean 的生命周期大致分这几个阶段:
实例化:Spring 通过反射调用构造函数创建
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
Java面试圣经,带你练透java圣经
查看12道真题和解析
