Dynamic Loading and Unloading of Java Governance Tasks with Custom ClassLoader and XXL‑Job Integration
The article presents a Java Spring solution that uses a custom URLClassLoader to dynamically load, register, and unload governance task JARs as Spring beans and XXL‑Job handlers at runtime, with configuration persistence via YAML or Nacos and Maven Shade packaging for seamless updates without service restarts.
The article describes a solution for dynamically managing data‑governance tasks in a Java Spring application without restarting the whole service. It outlines the goals, the design, and the concrete implementation, including custom class loading, registration with the XXL‑Job scheduler, dynamic configuration updates, and packaging.
Goals
Start or stop any governance task at runtime.
Add or upgrade tasks dynamically.
Ensure that task changes do not affect other running tasks.
Solution Overview
To achieve loose coupling, the business logic is packaged as separate JARs and loaded at runtime via a custom URLClassLoader . The loaded classes are registered with Spring and XXL‑Job so that they can be scheduled and invoked like regular beans.
1. Custom ClassLoader
A subclass of URLClassLoader called MyClassLoader keeps a map of already loaded classes and provides an unload() method that attempts to invoke a destory (sic) method on each class and then closes the loader.
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;
/**
* Custom class loader for dynamic JAR loading.
*/
public class MyClassLoader extends URLClassLoader {
private Map
> loadedClasses = new ConcurrentHashMap<>();
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 ignored) {}
}
close();
} catch (Exception e) {
e.printStackTrace();
}
}
}2. Dynamic Loading Process
The DynamicLoad component reads a JAR file, creates a MyClassLoader , loads all .class files, registers Spring‑annotated classes as beans, and registers methods annotated with @XxlJob as XXL‑Job handlers.
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* Handles dynamic loading of JARs and registration with Spring & XXL‑Job.
*/
@Component
public class DynamicLoad {
private static final 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 HashMap<>();
// Obtain Spring bean factory
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
try {
URL url = new URL("jar:file:" + file.getAbsolutePath() + "!/");
JarURLConnection jarURLConnection = (JarURLConnection) url.openConnection();
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();
// Register Spring beans
if (SpringAnnotationUtils.hasSpringAnnotation(clazz)) {
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);
}
// Register XXL‑Job handlers
Map
annotatedMethods = MethodIntrospector.selectMethods(clazz, (MethodIntrospector.MetadataLookup
) method ->
AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class));
for (Map.Entry
methodXxlJobEntry : annotatedMethods.entrySet()) {
Method executeMethod = methodXxlJobEntry.getKey();
XxlJob xxlJob = methodXxlJobEntry.getValue();
XxlJobCron xxlJobCron = executeMethod.getAnnotation(XxlJobCron.class);
if (xxlJobCron == null) {
throw new RuntimeException(executeMethod.getName() + "() missing @XxlJobCron");
}
jobPar.put(xxlJob.value(), xxlJobCron.value());
if (isRegistXxlJob) {
executeMethod.setAccessible(true);
xxlJobExecutor.registJobHandler(xxlJob.value(), new CustomerMethodJobHandler(clazz, executeMethod, null, null));
}
}
}
// Initialize Spring beans
initBeanClass.forEach(beanFactory::getBean);
} catch (IOException e) {
logger.error("Read {} file error", fileName);
throw new RuntimeException("Read jar file error: " + fileName, e);
}
}
}3. Dynamic Unloading
The unloadJar method reverses the loading steps: it removes the job handlers from XXL‑Job, destroys the Spring beans, clears the class loader’s internal caches, and finally discards the custom class loader.
public void unloadJar(String fileName) throws IllegalAccessException, NoSuchFieldException {
MyClassLoader myClassLoader = myClassLoaderCenter.get(fileName);
// Remove job handlers from XXL‑Job
Field privateField = XxlJobExecutor.class.getDeclaredField("jobHandlerRepository");
privateField.setAccessible(true);
XxlJobExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
Map
jobHandlerRepository = (ConcurrentHashMap
) privateField.get(xxlJobSpringExecutor);
// Remove Spring beans
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 ignored) { continue; }
// Remove job handlers
Map
annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(), (MethodIntrospector.MetadataLookup
) method ->
AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class));
for (Map.Entry
methodXxlJobEntry : annotatedMethods.entrySet()) {
jobHandlerRepository.remove(methodXxlJobEntry.getValue().value());
}
beanNames.add(beanName);
beanFactory.destroyBean(beanName, bean);
}
// Remove bean definitions
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);
}
// Remove class loader references
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 (Exception e) {
logger.error("Failed to unload class loader for {}", fileName);
e.printStackTrace();
}
logger.info("{} dynamic unload succeeded", fileName);
}4. Dynamic Configuration Updates
Two approaches are provided to keep the list of loaded JARs persistent after a restart:
Local YAML modification : uses snakeyaml to read and rewrite bootstrap.yml with a new loadjars list.
Nacos configuration : reads the existing sjzl‑loadjars.yml from Nacos, updates the loadjars array, and publishes the new content via the Nacos ConfigService .
YAML helper example
package com.jy.util;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Map;
public class ConfigUpdater {
public void updateLoadJars(List
jarNames) throws IOException {
Yaml yaml = new Yaml();
FileInputStream inputStream = new FileInputStream(new File("src/main/resources/bootstrap.yml"));
Map
obj = yaml.load(inputStream);
inputStream.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);
}
}Nacos helper example
package cn.jy.sjzl.util;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.exception.NacosException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.alibaba.fastjson.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class NacosConfigUtil {
private static final Logger logger = LoggerFactory.getLogger(NacosConfigUtil.class);
@Autowired
private NacosConfig nacosConfig;
private String dataId = "sjzl-loadjars.yml";
@Value("${spring.cloud.nacos.config.group}")
private String group;
public void addJarName(String jarName) throws Exception {
ConfigService configService = nacosConfig.configService();
String content = configService.getConfig(dataId, group, 5000);
YAMLMapper yamlMapper = new YAMLMapper();
ObjectMapper jsonMapper = new ObjectMapper();
Object yamlObject = yamlMapper.readValue(content, Object.class);
String jsonString = jsonMapper.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);
Object yaml = yamlMapper.readValue(jsonMapper.writeValueAsString(jsonObject), Object.class);
String newYamlString = yamlMapper.writeValueAsString(yaml);
boolean success = configService.publishConfig(dataId, group, newYamlString);
logger.info(success ? "nacos configuration update succeeded" : "nacos configuration update failed");
}
}5. Packaging
When the JARs are built separately, the Maven Shade plugin can be used to create an uber‑JAR that contains only the required packages (e.g., com/jy/job/demo/** ) and sets a custom final name.
<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>By following these steps, developers can add, update, or remove governance tasks on‑the‑fly, keep the service continuously available, and persist the configuration across restarts.
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.