Dynamic Class Loading and Unloading for Data Governance Tasks in Spring Boot with XXL‑Job
By implementing a plug‑in architecture that uses a custom URLClassLoader to load and unload JAR‑packaged governance tasks at runtime, the Spring Boot application can dynamically register Spring beans and XXL‑Job handlers, remove them cleanly, update configuration via YAML or Nacos, and package the solution with Maven Shade without restarting services.
Overview: In data‑governance services each task change currently requires a service restart, which disrupts other tasks. The objective is to enable dynamic start, stop, upgrade and addition of governance tasks without affecting the rest of the system.
Solution: Implement a plug‑in architecture where each governance task is packaged as a JAR and loaded at runtime. Tasks are registered as XXL‑Job jobs and managed through Spring, allowing hot‑plug capability.
Custom ClassLoader: A subclass of URLClassLoader that keeps a Map<String, Class<?>> of loaded classes and provides an unload() method to release resources and clear the class cache.
package cn.jy.sjzl.util;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 自定义类加载器
* @author lijianyu
* @date 2023/04/03 17:54
*/
public class MyClassLoader extends URLClassLoader {
private Map
> loadedClasses = new ConcurrentHashMap<>();
public Map
> getLoadedClasses() { return loadedClasses; }
public MyClassLoader(URL[] urls, ClassLoader parent) { super(urls, parent); }
@Override
protected Class
findClass(String name) throws ClassNotFoundException {
Class
clazz = loadedClasses.get(name);
if (clazz != null) { return clazz; }
try {
clazz = super.findClass(name);
loadedClasses.put(name, clazz);
return clazz;
} catch (ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
public void unload() {
try {
for (Map.Entry
> entry : loadedClasses.entrySet()) {
String className = entry.getKey();
loadedClasses.remove(className);
try {
Method destory = entry.getValue().getDeclaredMethod("destory");
destory.invoke(entry.getValue());
} catch (Exception e) { }
}
close();
} catch (Exception e) { e.printStackTrace(); }
}
}DynamicLoad component: Reads a JAR file, creates a MyClassLoader , registers Spring beans for classes that carry Spring core annotations, and registers methods annotated with @XxlJob as XXL‑Job handlers. It also supports optional registration of jobs during the first load.
package com.jy.dynamicLoad;
import com.jy.annotation.XxlJobCron;
import com.jy.classLoader.MyClassLoader;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import com.xxl.job.core.handler.annotation.XxlJob;
import com.xxl.job.core.handler.impl.MethodJobHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.support.AbstractBeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
@Component
public class DynamicLoad {
private static Logger logger = LoggerFactory.getLogger(DynamicLoad.class);
@Autowired
private ApplicationContext applicationContext;
private Map
myClassLoaderCenter = new ConcurrentHashMap<>();
@Value("${dynamicLoad.path}")
private String path;
public void loadJar(String path, String fileName, Boolean isRegistXxlJob) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
File file = new File(path + "/" + fileName);
Map
jobPar = new ConcurrentHashMap<>();
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
try {
URL url = new URL("jar:file:" + file.getAbsolutePath() + "!/");
URLConnection urlConnection = url.openConnection();
JarURLConnection jarURLConnection = (JarURLConnection) urlConnection;
JarFile jarFile = jarURLConnection.getJarFile();
Enumeration
entries = jarFile.entries();
MyClassLoader myClassloader = new MyClassLoader(new URL[]{ url }, ClassLoader.getSystemClassLoader());
myClassLoaderCenter.put(fileName, myClassloader);
Set
initBeanClass = new HashSet<>(jarFile.size());
while (entries.hasMoreElements()) {
JarEntry jarEntry = entries.nextElement();
if (jarEntry.getName().endsWith(".class")) {
String className = jarEntry.getName().replace('/', '.').substring(0, jarEntry.getName().length() - 6);
myClassloader.loadClass(className);
}
}
Map
> loadedClasses = myClassloader.getLoadedClasses();
XxlJobSpringExecutor xxlJobExecutor = new XxlJobSpringExecutor();
for (Map.Entry
> entry : loadedClasses.entrySet()) {
String className = entry.getKey();
Class
clazz = entry.getValue();
Boolean flag = SpringAnnotationUtils.hasSpringAnnotation(clazz);
if (flag) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(clazz);
AbstractBeanDefinition beanDefinition = builder.getBeanDefinition();
String packageName = className.substring(0, className.lastIndexOf(".") + 1);
String beanName = className.substring(className.lastIndexOf(".") + 1);
beanName = packageName + beanName.substring(0, 1).toLowerCase() + beanName.substring(1);
beanFactory.registerBeanDefinition(beanName, beanDefinition);
beanFactory.autowireBean(clazz);
beanFactory.initializeBean(clazz, beanName);
initBeanClass.add(clazz);
}
Map
annotatedMethods = null;
try {
annotatedMethods = MethodIntrospector.selectMethods(clazz, method -> AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class));
} catch (Throwable ex) { }
for (Map.Entry
methodXxlJobEntry : annotatedMethods.entrySet()) {
Method executeMethod = methodXxlJobEntry.getKey();
XxlJobCron xxlJobCron = executeMethod.getAnnotation(XxlJobCron.class);
if (xxlJobCron == null) { throw new CustomException("500", executeMethod.getName() + "(),没有添加@XxlJobCron注解配置定时策略"); }
if (!CronExpression.isValidExpression(xxlJobCron.value())) { throw new CustomException("500", executeMethod.getName() + "(),@XxlJobCron参数内容错误"); }
XxlJob xxlJob = methodXxlJobEntry.getValue();
jobPar.put(xxlJob.value(), xxlJobCron.value());
if (isRegistXxlJob) {
executeMethod.setAccessible(true);
xxlJobExecutor.registJobHandler(xxlJob.value(), new CustomerMethodJobHandler(clazz, executeMethod, null, null));
}
}
}
initBeanClass.forEach(beanFactory::getBean);
} catch (IOException e) {
logger.error("读取{} 文件异常", fileName);
e.printStackTrace();
throw new RuntimeException("读取jar文件异常: " + fileName);
}
}
}Utility SpringAnnotationUtils determines whether a class carries a core Spring annotation (Component, Service, Repository, Controller, Configuration) and should therefore be registered in the Spring container.
public class SpringAnnotationUtils {
private static Logger logger = LoggerFactory.getLogger(SpringAnnotationUtils.class);
/** 判断一个类是否有 Spring 核心注解 */
public static boolean hasSpringAnnotation(Class
clazz) {
if (clazz == null) return false;
if (clazz.isInterface()) return false;
if (Modifier.isAbstract(clazz.getModifiers())) return false;
try {
if (clazz.getAnnotation(Component.class) != null ||
clazz.getAnnotation(Repository.class) != null ||
clazz.getAnnotation(Service.class) != null ||
clazz.getAnnotation(Controller.class) != null ||
clazz.getAnnotation(Configuration.class) != null) {
return true;
}
} catch (Exception e) {
logger.error("出现异常:{}", e.getMessage());
}
return false;
}
}Dynamic unloading ( unloadJar ) removes the job handlers from XXL‑Job, destroys the corresponding Spring beans, deletes bean definitions, clears the classloader’s internal class cache and finally discards the custom classloader.
public void unloadJar(String fileName) throws IllegalAccessException, NoSuchFieldException {
MyClassLoader myClassLoader = myClassLoaderCenter.get(fileName);
Field privateField = XxlJobExecutor.class.getDeclaredField("jobHandlerRepository");
privateField.setAccessible(true);
XxlJobExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
Map
jobHandlerRepository = (ConcurrentHashMap
) privateField.get(xxlJobSpringExecutor);
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
Map
> loadedClasses = myClassLoader.getLoadedClasses();
Set
beanNames = new HashSet<>();
for (Map.Entry
> entry : loadedClasses.entrySet()) {
String key = entry.getKey();
String packageName = key.substring(0, key.lastIndexOf(".") + 1);
String beanName = key.substring(key.lastIndexOf(".") + 1);
beanName = packageName + beanName.substring(0, 1).toLowerCase() + beanName.substring(1);
Object bean = null;
try { bean = applicationContext.getBean(beanName); } catch (Exception e) { continue; }
Map
annotatedMethods = null;
try {
annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(), method -> AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class));
} catch (Throwable ex) { }
for (Map.Entry
methodXxlJobEntry : annotatedMethods.entrySet()) {
XxlJob xxlJob = methodXxlJobEntry.getValue();
jobHandlerRepository.remove(xxlJob.value());
}
beanNames.add(beanName);
beanFactory.destroyBean(beanName, bean);
}
Field mergedBeanDefinitions = beanFactory.getClass().getSuperclass().getSuperclass().getDeclaredField("mergedBeanDefinitions");
mergedBeanDefinitions.setAccessible(true);
Map
rootBeanDefinitionMap = (Map
) mergedBeanDefinitions.get(beanFactory);
for (String beanName : beanNames) {
beanFactory.removeBeanDefinition(beanName);
rootBeanDefinitionMap.remove(beanName);
}
jobHandlerRepository.remove(fileName);
try {
Field field = ClassLoader.class.getDeclaredField("classes");
field.setAccessible(true);
Vector
> classes = (Vector
>) field.get(myClassLoader);
classes.removeAllElements();
myClassLoaderCenter.remove(fileName);
myClassLoader.unload();
} catch (NoSuchFieldException | IllegalAccessException e) {
logger.error("动态卸载的类,从类加载器中卸载失败");
e.printStackTrace();
}
logger.error("{} 动态卸载成功", fileName);
}Dynamic configuration: After a successful load or unload, the system updates configuration either by editing a local bootstrap.yml (using SnakeYAML) or by publishing a new YAML document to Nacos.
// Example: modify local YAML
Yaml yaml = new Yaml();
InputStream is = new FileInputStream(new File("src/main/resources/bootstrap.yml"));
Map
obj = yaml.load(is);
is.close();
obj.put("loadjars", jarNames);
FileWriter writer = new FileWriter(new File("src/main/resources/bootstrap.yml"));
DumperOptions options = new DumperOptions();
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
options.setPrettyFlow(true);
Yaml yamlWriter = new Yaml(options);
yamlWriter.dump(obj, writer);
// Example: publish to Nacos
ConfigService configService = nacosConfig.configService();
String content = configService.getConfig(dataId, group, 5000);
YAMLMapper yamlMapper = new YAMLMapper();
Object yamlObject = yamlMapper.readValue(content, Object.class);
String jsonString = new ObjectMapper().writeValueAsString(yamlObject);
JSONObject jsonObject = JSONObject.parseObject(jsonString);
List
loadjars = jsonObject.containsKey("loadjars") ? (List
) jsonObject.get("loadjars") : new ArrayList<>();
if (!loadjars.contains(jarName)) loadjars.add(jarName);
jsonObject.put("loadjars", loadjars);
String newYaml = yamlMapper.writeValueAsString(yamlMapper.readValue(new ObjectMapper().writeValueAsString(jsonObject), Object.class));
configService.publishConfig(dataId, group, newYaml);Packaging: The Maven Shade plugin can be configured to produce a shaded JAR that includes only the required packages, simplifying deployment.
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<includes>
<include>com/jy/job/demo/**</include>
</includes>
</filter>
</filters>
<finalName>demoJob</finalName>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>Java Tech Enthusiast
Sharing computer programming language knowledge, focusing on Java fundamentals, data structures, related tools, Spring Cloud, IntelliJ IDEA... Book giveaways, red‑packet rewards and other perks await!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.