Skip to content

从Java底层原理(JVM、反射)到高并发实战(并发容器、分布式锁)的完整知识链路。


二、Java基础核心

1. 反射在项目中怎么用的?

在实际项目中,反射主要用于解耦动态化

  • 框架配置化: 比如在Spring中,通过XML或注解配置Bean的全限定名,容器利用反射(Class.forName())动态创建对象并注入依赖,而不是在代码里直接new
  • 通用工具类: 比如写一个通用的copyProperties工具,利用反射遍历对象的Getter和Setter方法,实现两个不同对象之间的属性自动拷贝(类似BeanUtils)。
  • 注解处理: 运行时通过反射读取类、方法上的注解,根据注解的元数据来决定执行什么样的逻辑(例如自定义权限校验注解)。

2. 反射在JVM层面的底层实现?

反射的底层实现与JVM的类加载机制紧密相关[[source_group_web_1]]。

  • 入口点: 一切都始于Class对象。当类加载器(ClassLoader)将.class文件加载到方法区后,会在堆中生成一个java.lang.Class对象,作为该类的访问入口[[source_group_web_2]]。
  • Method/Field封装: java.lang.reflect.MethodField等类,在底层其实是对JVM中方法区里存储的**元数据(Metadata)**的封装。
  • 动态调用: 调用Method.invoke()时,JVM会通过Class对象找到对应的方法区元数据,进而进行方法的动态分派和执行。为了安全,反射访问私有成员时会进行访问权限检查(可以通过setAccessible(true)暴力突破)[[source_group_web_3]]。

3. 反射的其他使用场景有哪些?

除了上述项目应用,还有:

  • JDBC数据库连接: 早期的JDBC编程中,通过Class.forName("com.mysql.jdbc.Driver")动态加载数据库驱动类,实现驱动注册[[source_group_web_4]]。
  • 动态代理: JDK动态代理的核心就是利用反射机制,在运行时生成代理类的字节码,并调用目标方法[[source_group_web_5]]。
  • 单元测试: JUnit框架通过反射来获取测试类中的@Test注解方法,并动态调用它们。
  • 逆向工程/调试器: 查看和修改运行时对象的状态。

4. 代码中一般怎么处理异常?

我通常遵循**“早抛晚捕”“分类处理”**的原则:

  • 具体捕获: 尽量捕获具体的异常类型(如FileNotFoundException),而不是笼统的Exception
  • 资源管理: 使用try-with-resources语句自动关闭资源(如IO流),避免资源泄漏。
  • 日志记录: 在捕获异常后,使用日志框架(如SLF4J)记录完整的堆栈信息,便于排查。
  • 包装与向上抛: 在业务层,通常会将底层的技术异常(如DAO异常)包装成自定义的业务异常,向上抛给调用者,或者转换为统一的错误码返回给前端。

5. Java异常如何分类?

Java异常体系的顶层是Throwable,主要分为两大类:

  1. Error(错误): 程序无法处理的严重问题,如OutOfMemoryErrorStackOverflowError[[source_group_web_6]]。一般不建议捕获,程序通常无法恢复。
  2. Exception(异常): 程序本身可以处理的问题。又细分为:
    • 编译时异常(Checked Exception): 必须在代码中显式处理(try-catch或throws),否则编译不通过,例如IOException
    • 运行时异常(Unchecked Exception): 继承自RuntimeException,编译器不强制处理,例如NullPointerException

6. 运行时异常主要有哪些?

常见的运行时异常包括:

  • NullPointerException:空指针引用。
  • ArrayIndexOutOfBoundsException:数组下标越界。
  • ClassCastException:类型转换错误。
  • IllegalArgumentException:非法参数。
  • NumberFormatException:字符串转换为数字格式失败。
  • ArithmeticException:算术异常(如除以0)。

7. JVM的类加载流程是什么?

类加载过程分为五个阶段,按顺序进行:

  1. 加载: 根据类的全限定名获取二进制字节流,在内存中生成一个Class对象作为访问入口[[source_group_web_7]]。
  2. 验证: 确保字节流符合JVM规范,防止危害虚拟机安全(文件格式、元数据、字节码验证等)。
  3. 准备: 为类变量(static)分配内存并设置初始零值(注意:此时不执行赋值逻辑,如static int a = 1,这里只赋0)。
  4. 解析: 将常量池内的符号引用替换为直接引用(内存地址)。
  5. 初始化: 执行类构造器<clinit>()方法,真正给类变量赋代码中指定的值,并执行静态代码块。这是类加载的最后一步[[source_group_web_8]]。

8. JVM常见垃圾回收器介绍一下?

目前主流的垃圾回收器主要针对不同的应用场景:

  • Serial: 新生代单线程回收器,简单高效,适用于单核机器或Client模式。
  • ParNew: Serial的多线程版本,是许多运行在Server模式下的虚拟机首选的新生代收集器。
  • Parallel Scavenge(吞吐量优先): 关注系统的吞吐量(用户代码时间 / (用户代码时间+GC时间)),适合后台计算型任务。
  • CMS(Concurrent Mark Sweep): 老年代收集器,以获取最短回收停顿时间为目标,适合注重响应速度的B/S应用。但对CPU资源敏感,且会产生浮动垃圾。
  • G1: 面向服务端应用的收集器,将堆划分为多个Region,可预测停顿时间,整体上基于“标记-整理”算法,局部上基于“复制”算法。

9. G1垃圾回收器了解吗?

G1是目前JDK 1.9+的默认垃圾回收器,它的核心特点如下:

  • Region化: 将Java堆划分为多个大小相等的独立区域(Region),不再物理隔离新生代和老年代,而是逻辑上的分区。
  • 可预测停顿: 建立在停顿时间模型之上,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
  • 并发标记: 初始标记、并发标记、最终标记、筛选回收。它利用并发线程在应用程序运行时进行标记。
  • 混合回收: 不像老一代收集器那样回收整个老年代,而是根据停顿时间目标,优先回收价值最大的Region(Garbage-First的含义)。

三、MySQL相关

1. InnoDB底层索引是什么数据结构?

InnoDB存储引擎的底层索引主要采用的是 B+树(B+ Tree) 数据结构[[source_group_web_9]]。无论是主键索引(聚簇索引)还是二级索引,都是基于B+树实现的。

2. B+树的结构大概是什么样的?

B+树是一种多路平衡查找树,它的结构很有特点:

  • 非叶子节点: 只存储索引键(Key)和指向子节点的指针,不存储实际的数据行。这使得每个节点能容纳更多的键,树更“矮胖”。
  • 叶子节点: 存储索引键数据记录(聚簇索引)或主键值(二级索引)。
  • 双向链表: 所有的叶子节点通过指针连接成一个有序的双向链表[[source_group_web_10]]。

3. 为什么B+树结构更“矮胖”?

因为B+树是多路平衡查找树

  • 节点容量大: B+树的一个节点通常对应磁盘的一个页(Page,InnoDB默认16KB)。由于非叶子节点只存键和指针,不存数据,所以在同样的页大小限制下,B+树的一个节点能存储的键值数量远多于二叉树或红黑树的节点。
  • 扇出高: 这种“大节点”设计使得B+树的**扇出(子节点数)**非常高,树的高度非常低。通常3-4层的B+树就能支撑上亿的数据量,从而保证查询时磁盘I/O的次数很少[[source_group_web_11]]。

4. B+树为什么支持范围查询?

这得益于其叶子节点的有序链表结构

  • 当我们需要查询一个范围(如WHERE id BETWEEN 10 AND 100)时,首先通过树结构定位到起始值(10)所在的叶子节点。
  • 然后不需要再回溯树节点,只需要沿着叶子节点之间的顺序指针向后遍历,直到遇到结束值(100)或超出范围即可[[source_group_web_12]]。这种顺序扫描的效率非常高。

5. B+树叶子节点之间是怎么关联的?

在InnoDB的B+树实现中,叶子节点之间通过**双向链表(Doubly Linked List)**指针相互关联。

  • 每个叶子节点包含两个指针:一个指向前一个叶子节点,一个指向后一个叶子节点。
  • 这种结构不仅支持向后遍历(用于范围查询),也支持向前遍历(用于反向查询或排序)。

6. 平时怎么排查MySQL问题?

  • 慢查询日志: 开启slow_query_log,利用EXPLAINEXPLAIN FORMAT=JSON分析执行计划,查看是否走索引、扫描行数等[[source_group_web_13]]。
  • Performance Schema: 利用MySQL自带的性能模式监控数据库运行状态。
  • 监控工具: 使用如SHOW PROCESSLIST查看当前连接和执行状态,或者使用Prometheus+Grafana监控QPS、TPS、连接数等指标。
  • 索引优化: 检查是否存在全表扫描,是否需要建立复合索引。

7. 索引失效的原因有哪些?

  • 违反最左前缀原则: 复合索引KEY(a,b,c),查询条件没有用到a,则索引失效。
  • 使用函数或表达式: WHERE YEAR(create_time) = 2023,对字段进行计算或函数操作。
  • 隐式类型转换: 字符串字段在SQL中没有加引号,导致索引失效。
  • 使用!=NOT IN 通常会导致全表扫描。
  • LIKE以通配符开头: LIKE '%abc'无法利用B+树的有序性。
  • OR连接条件: 如果OR两边的字段没有都建立索引,可能导致索引失效。

8. 什么是覆盖索引?覆盖索引的好处是什么?

  • 定义: 如果一个索引包含了满足某个查询语句所需要的所有字段值,那么这个索引就叫做覆盖索引。
  • 好处: 无需回表。通常使用二级索引查询时,如果索引中不包含查询的所有字段,就需要根据主键ID回到聚簇索引(主键索引)中再次查询获取完整数据行(回表)。而覆盖索引直接在二级索引的叶子节点就能拿到所有数据,极大地减少了磁盘I/O,提升了查询性能。

四、并发编程

1. HashMap和ConcurrentHashMap的实现原理(JDK1.7/1.8差异)?

  • HashMap:
    • JDK 1.7: 数组 + 链表。头插法,多线程扩容时容易形成环形链表导致死循环。
    • JDK 1.8: 数组 + 链表 + 红黑树。当链表长度大于8且数组长度大于64时,链表转红黑树;查询时间复杂度由O(n)降为O(log n)。使用尾插法,解决了扩容死循环问题[[source_group_web_14]]。
  • ConcurrentHashMap:
    • JDK 1.7: 分段锁(Segment),将数据分成一段一段的存储,给每一段数据配一把锁,提高并发度。
    • JDK 1.8: 放弃了Segment分段锁,采用 Node数组 + 链表 + 红黑树,并发控制使用 synchronized 关键字锁住链表或红黑树的头节点(粒度更细),配合CAS操作来保证线程安全。

2. ConcurrentHashMap的put流程了解吗?

JDK 1.8的流程如下:

  1. 计算位置: 根据Key的hash值计算在数组中的下标。
  2. 初始化: 如果数组为空,先进行初始化(CAS+自旋)。
  3. CAS插入: 如果该位置为空,使用CAS操作直接插入Node。
  4. 加锁处理: 如果该位置有值(发生碰撞),则使用synchronized锁住该节点(链表头或红黑树根节点)。
  5. 插入或扩容: 在锁内,如果是链表则遍历插入,如果是红黑树则按树的方式插入。插入后检查是否需要转红黑树或扩容。

3. CAS的操作原理是什么?

CAS(Compare and Swap)即比较并交换,是一种无锁的原子操作算法。

  • 原理: 它包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。CAS指令执行时,会比较内存位置V的值是否等于预期值A,如果相等,则将内存位置的值修改为B;如果不相等,说明该值已被其他线程修改,当前操作失败,通常会自旋重试。
  • 底层: 依赖于CPU的cmpxchg指令保证原子性。
  • 问题: 存在ABA问题(可以通过AtomicStampedReference加版本号解决)和自旋消耗问题。

4. 线程池的核心参数有哪些?各参数含义是什么?

参数名含义
corePoolSize核心线程数。线程池中长期保持的线程数量,即使空闲也不会被回收(除非设置核心线程超时)。
maximumPoolSize最大线程数。线程池中允许存在的最大线程数量。
keepAliveTime非核心线程的存活时间。当线程数超过核心数时,空闲线程在等待新任务时的最长等待时间。
unitkeepAliveTime的时间单位(如秒、毫秒)。
workQueue任务队列。用于保存等待执行任务的阻塞队列(如LinkedBlockingQueue, ArrayBlockingQueue)。
threadFactory线程工厂。用于创建新线程,通常用于自定义线程名称或优先级。
handler拒绝策略。当任务队列和线程池都满时,对新提交任务的处理策略[[source_group_web_15]]。

5. 线程池的拒绝策略有哪些?

  • AbortPolicy(默认): 直接抛出RejectedExecutionException异常。
  • CallerRunsPolicy: 由提交任务的线程(调用者)自己来执行这个任务。
  • DiscardOldestPolicy: 丢弃队列中等待时间最久的任务(即队头任务),然后尝试重新提交新任务。
  • DiscardPolicy: 直接丢弃新提交的任务,不抛异常。

6. ThreadLocal在项目中怎么用的?

  • 用户会话信息: 在Web应用中,拦截器中解析Token后,将用户信息(如userId, userName)存入ThreadLocal,后续业务逻辑可随时获取,避免了参数层层传递。
  • 数据库连接管理: 在事务管理中,将Connection存入ThreadLocal,保证同一个线程获取的是同一个数据库连接,从而实现事务的一致性。
  • 时间上下文: 用于存储请求开始时间,用于统计接口耗时。

7. ThreadLocal的底层实现(内部Map的Key是什么)?

ThreadLocal的底层实现主要依赖于Thread类和ThreadLocalMap

  • Key: 每个Thread对象内部都持有一个ThreadLocal.ThreadLocalMap类型的变量(threadLocals)。
  • 结构: 这个Map的KeyThreadLocal 实例本身(this),Value 是我们要存储的值。
  • 原理: 当调用threadLocal.set(value)时,实际上是获取当前线程的ThreadLocalMap,然后以this threadLocal为键,存入值。

8. ThreadLocal使用不当会导致什么问题?

  • 内存泄漏(Memory Leak): 这是最主要的问题。ThreadLocalMap中的Entry继承自WeakReference<ThreadLocal>,Key是弱引用,但Value是强引用。如果ThreadLocal实例被置为null,Key会被回收,但Value由于线程还在(如线程池中的线程),且没有强引用链可达,导致Value无法被回收,造成内存泄漏。
  • 解决: 每次使用完ThreadLocal后,必须调用其remove()方法,手动删除对应的Entry。

五、设计模式

1. 常用的设计模式有哪些?

除了你提到的单例、模板方法、工厂,常用的还有:

  • 代理模式: Spring AOP的核心实现。
  • 策略模式: 用于消除复杂的if-else逻辑(如不同支付方式的实现)。
  • 观察者模式: 事件监听机制。
  • 建造者模式: 用于构建复杂的对象(如构造HTTP请求、复杂配置对象)。

2. 单例模式的懒汉式和饿汉式区别?

特性饿汉式懒汉式
创建时机类加载时就立即创建实例。第一次调用getInstance()方法时才创建。
线程安全天然线程安全(由JVM类加载机制保证)。需要额外的同步机制(如双重检查锁、synchronized)来保证线程安全。
资源利用可能造成资源浪费(即使不用也会被创建)。延迟加载,按需创建,节约资源。
性能获取实例速度快(直接返回)。获取实例时可能需要同步,稍微慢一点。

3. 工厂模式和模板方法模式的实现思路?

  • 工厂模式:
    • 思路: 定义一个创建对象的接口,但让实现这个接口的类来决定实例化哪个类。将对象的实例化推迟到子类。
    • 应用: 简单工厂(根据传入参数返回不同产品)、工厂方法(每个产品对应一个工厂子类)、抽象工厂(创建一系列相关或依赖对象的接口)。
  • 模板方法模式:
    • 思路: 定义一个操作中的算法骨架,而将一些步骤延迟到子类中。使得子类可以在不改变算法结构的情况下,重新定义算法的某些特定步骤。
    • 应用: 比如一个“做菜”的流程(洗菜 -> 炒菜 -> 调味),父类定义好流程,子类(如西红柿炒蛋、红烧肉)去实现具体的“炒菜”逻辑。

六、Redis与分布式

1. 项目中Redis分布式锁怎么用的(避免超卖和数据不一致)?

通常使用 SET key value NX EX 命令来实现。

  • 防超卖: 在秒杀场景中,扣减库存前,先尝试获取分布式锁(Key为商品ID,Value为唯一请求ID)。
  • 原子性: 使用SET命令的NX(不存在则设置)和EX(设置过期时间)参数保证加锁的原子性。
  • 业务执行: 获取锁成功后,查询Redis中的库存,如果大于0则执行DECR扣减,并处理订单逻辑。
  • 释放锁: 使用Lua脚本保证“判断Value是否为自己”和“DEL删除锁”这两个操作的原子性,防止误删别人的锁。

2. 如何自己实现可重入的分布式锁?

基于Redis,可以通过以下方式实现可重入:

  • 数据结构: 使用Hash结构。Key为锁名称,Field为线程唯一标识(如host:port:threadId),Value为重入次数。
  • 加锁逻辑: 使用Lua脚本。如果锁不存在,直接设置Hash并设置过期时间;如果锁存在且Field是当前线程,则Value(重入计数)加1;否则返回失败。