🎯 一、背景介绍

最近在开发一个定时任务管理系统,使用 Quartz 作为任务调度框架,Spring Boot 作为后端框架。功能包括:任务列表展示、新增任务、编辑任务、删除任务、立即执行、暂停/恢复等。

在开发过程中,遇到了一个非常诡异的 ClassCastException 错误:

java

java.lang.ClassCastException: cn.bosch.com.elab.entity.SchedulerJob cannot be cast to cn.bosch.com.elab.entity.SchedulerJob

注意:类名完全一样,但 JVM 认为它们是不同的类!这篇文章将详细记录问题现象、排查过程、解决方案及底层原理。


🐛 二、问题现象

2.1 错误日志

java

org.quartz.SchedulerException: Job threw an unhandled exception.
Caused by: java.lang.ClassCastException: cn.bosch.com.elab.entity.SchedulerJob cannot be cast to cn.bosch.com.elab.entity.SchedulerJob
    at cn.bosch.com.elab.quartz.QuartzJobExecution.execute(QuartzJobExecution.java:28)
2.2 代码位置

java

@Override
public void execute(JobExecutionContext context) {
    // 第 28 行:这里报错!
    SchedulerJob job = (SchedulerJob) context.getMergedJobDataMap().get(JOB_PARAM_KEY);
    // ...
}
2.3 奇怪之处
  • 类名完全相同

  • 包名完全相同

  • 编译没问题

  • 运行时却报类型转换错误


🔍 三、问题排查过程

3.1 第一步:确认是否是序列化问题

检查 SchedulerJob 类是否实现了 Serializable

java

public class SchedulerJob implements Serializable {
    private static final long serialVersionUID = 1L;
    // ...
}

已经实现了,不是这个问题。

3.2 第二步:对比新增和编辑的请求数据

检查前端发送的数据,发现编辑时多传了 createTime 和 updateTime 字段,但修复后问题依然存在。

3.3 第三步:深入分析堆栈信息

注意到错误发生在 Quartz 的 Worker 线程中,而不是主线程。这暗示可能是类加载器的问题。

3.4 第四步:验证类加载器假设

在代码中添加调试信息:

java

@Override
public void execute(JobExecutionContext context) {
    SchedulerJob job = (SchedulerJob) context.getMergedJobDataMap().get(JOB_PARAM_KEY);
    
    // 打印类加载器信息
    System.out.println("Job ClassLoader: " + job.getClass().getClassLoader());
    System.out.println("This ClassLoader: " + this.getClass().getClassLoader());
}

输出结果:

text

Job ClassLoader: org.quartz.simpl.LoadingLoaderClassLoader@xxx
This ClassLoader: jdk.internal.loader.ClassLoaders$AppClassLoader@xxx

确认了!两个不同的 ClassLoader!


📊 四、问题原因分析

4.1 类加载器层次结构
类加载器 加载范围
Bootstrap JDK 核心类
Extension JDK 扩展类
Application 应用代码、第三方 Jar
Quartz Quartz 内部动态加载
4.2 问题发生的完整流程
4.3 为什么两个 ClassLoader 加载的类不相等?

在 JVM 中,一个类由其全限定名 + ClassLoader共同标识。即使类名完全相同,如果由不同的 ClassLoader 加载,JVM 也会认为是两个不同的类。

java

// 这两个对象不是同一个类型!
Class<?> clazz1 = appClassLoader.loadClass("SchedulerJob");
Class<?> clazz2 = quartzClassLoader.loadClass("SchedulerJob");
clazz1.equals(clazz2);  // false!

✅ 五、解决方案

5.1 方案一:从数据库重新查询(采用)

java

@Override
public void execute(JobExecutionContext context) {
    // ❌ 错误:从 JobDataMap 取对象(有类加载器问题)
    // SchedulerJob job = (SchedulerJob) context.getMergedJobDataMap().get(JOB_PARAM_KEY);
    
    // ✅ 正确:用 jobId 从数据库重新查询
    String jobId = context.getJobDetail().getKey().getName();
    SchedulerJob job = jobMapper.getJobById(Long.parseLong(jobId));
    
    // 执行业务逻辑...
}

优点

  • 简单直接

  • 获取的是最新数据

  • 使用应用 ClassLoader,类型匹配

缺点

  • 多一次数据库查询

5.2 方案二:在 JobDataMap 中只存 ID

java

// 创建任务时
jobDetail.getJobDataMap().put(JOB_PARAM_KEY, job.getId());  // 存 Long,不是对象

// 执行时
Long jobId = (Long) context.getMergedJobDataMap().get(JOB_PARAM_KEY);
SchedulerJob job = jobMapper.getJobById(jobId);

优点

  • 避免序列化整个对象

  • JobDataMap 数据量小

5.3 方案三:配置 Quartz 使用应用 ClassLoader

properties

# 让 Quartz 使用应用的 ClassLoader
org.quartz.scheduler.classLoadHelper.class=org.quartz.simpl.ThreadContextClassLoadHelper

但这个方案不总是有效,因为 Quartz 内部仍有自己的类加载机制。


💡 六、原理总结

阶段 ClassLoader 问题
序列化 Application 正常
反序列化 Quartz ❌ 类型不匹配
数据库查询 Application ✅ 类型匹配

📝 七、最终代码

java

@Slf4j
@Component
public class QuartzJobExecution implements Job {
    
    @Autowired
    private SchedulerJobMapper jobMapper;
    
    private static final String JOB_PARAM_KEY = "JOB_PARAM_KEY";
    
    @Override
    public void execute(JobExecutionContext context) {
        // 从 JobKey 中获取任务 ID
        String jobId = context.getJobDetail().getKey().getName();
        
        // 从数据库重新查询(避免类加载器问题)
        SchedulerJob job = jobMapper.getJobById(Long.parseLong(jobId));
        
        if (job == null) {
            log.error("Job not found: {}", jobId);
            return;
        }
        
        Date startTime = new Date();
        boolean success = true;
        String errorMsg = null;
        
        try {
            log.info("定时任务开始执行: {}", job.getJobName());
            invokeMethod(job);
            log.info("定时任务执行成功: {}", job.getJobName());
        } catch (Exception e) {
            success = false;
            errorMsg = e.toString();
            log.error("定时任务执行失败: {}", job.getJobName(), e);
        } finally {
            saveLog(job, startTime, success, errorMsg);
        }
    }
    
    // 其他方法...
}

✅ 八、经验教训

教训 说明
ClassLoader 很重要 不同 ClassLoader 加载的同名类不相等
Quartz 序列化有坑 尽量避免在 JobDataMap 中存复杂对象
存 ID 是更好的选择 用 ID 查数据库,避免序列化问题
调试要深入 打印 ClassLoader 信息帮助定位问题

🎉 九、总结

Quartz 与 Spring Boot 集成时,由于类加载器不同导致的 ClassCastException 是一个经典问题。

核心原因:序列化时使用应用 ClassLoader,反序列化时 Quartz 使用自己的 ClassLoader。

解决方案:不在 JobDataMap 中存复杂对象,改为存 ID,执行时从数据库重新查询。

一句话记住Quartz JobDataMap 中只存 ID,不存对象!


希望这篇文章能帮助遇到类似问题的开发者!🚀

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐