javajarauto-updateurlclassloader

How to use URLClassloader in an auto-update jar launcher?


I've come across many posts about these two topics: Auto-Updating and URLClassloaders. I'll start with the auto updating goal. I found this post here that talks about a 2 jar system. One jar that launches the main app jar: From Stephen C:

The launcher could be a Java application that creates a classloader for the new JAR, loads an entrypoint class and calls some method on it. If you do it this way, you have to watch for classloader storage leaks, but that's not difficult. (You just need to make sure that no objects with classes loaded from the JAR are reachable after you relaunch.)

This is the approach I'm taking, but I'm open to other ideas if they prove easier and/or more reliable. The Coordinator has posted some pretty cool launcher code to which I plan on incorporating some of this reload type code in my launcher, but first I need to get it to work.

My issue is that my main app jar has many other dependencies, and I cannot get some of those classes to load despite the fact that all the jars have been added to the URL's array. This brings up the second topic URLClassloader.

Side Note for future readers: When passing a URL to the URLClassloader that is a directory, a helpful note that would have saved me (an embarrassingly large) amount of time is that the contents of the directory must be .class files! I was originally pointing to my dependent jar directory, no good.

Context for the code below, my launcher jar resides in the same directory as my app jar, which is why I'm using user.dir. I will probably change this, but for now the code works and gets far enough into my app's code to request a connection to a sqlite database before failing.

Launcher:

public class Launcher {

public static void main(String[] args) {
    
    try {
        String userdir = System.getProperty("user.dir");
        File parentDir = new File(userdir);
        ArrayList<URL> urls = getJarURLs(parentDir);
        
        URL[] jarURLs = new URL[urls.size()];
        int index = 0;
        for (URL u : urls) {
            System.out.println(u.toString());
            jarURLs[index] = u;
            index ++;
        }

        URLClassLoader urlCL = new URLClassLoader(jarURLs); 
        Class<?> c = urlCL.loadClass("main.AppStart");
        Object [] args2 = new Object[] {new String[] {}};
        c.getMethod("main", String[].class).invoke(null, args2);
        urlCL.close();
    } catch (Exception e1) {
        e1.printStackTrace();
    } 
}

public static ArrayList<URL> getJarURLs(File parentDir) throws MalformedURLException {
    ArrayList<URL> list = new ArrayList<>();
    for (File f : parentDir.listFiles()) {
        if (f.isDirectory()) {
            list.addAll(getJarURLs(f));
        } else {
            String name = f.getName();
            if (name.endsWith(".jar")) {
                list.add(f.toURI().toURL());
            }
        }
    }
    return list;
}
}

Here's an example of the URL output added to the array:

file:/C:/my/path/to/dependent/jars/sqlite-jdbc-3.32.3.2.jar
file:/C:/my/path/to/main/app.jar
file: ... [10 more]

The URLClassloader seems to work well enough to load my main method in app.jar. The main executes a some startup type stuff, before attempting to load a login screen. When the request is made to get the user info database, my message screen loads and displays (<-this is important for later) the stacktrace containing:

java.sql.SQLException: No suitable driver found for jdbc:sqlite:C:\...\users.db

I understand that this is because that jar is not on the class path, but it's loaded via the class loader, so why can't it find the classes from the jar? From this post JamesB suggested adding Class.forName("org.sqlite.JDBC"); before the connection request. I rebuilt the app jar with this line of code and it worked!

The weird thing that happened next, is that my message screen class can no longer be found even though earlier it loaded and displayed correctly. The message screen is a class inside my main app.jar and not in a dependent jar, which is why I'm baffled. Am I going to have to add Class.forName before every instance of any of my classes? That seems rude..

So what could I be doing wrong with the class loader? Why does it load some classes and not others despite that fact that all the jars have been added to the URL array?

Some other relative info: My app works perfectly as intended when launched from windows command line when the classpath is specified: java -cp "main-app.jar;my/dependent/jar/directory/*" main.AppStart. It's only when I try launching the app via this classloader that I have these issues.

By the way, is this java command universal? Will it work on all operating systems with java installed? If so, could I not just scrap this launcher, and use a process builder to execute the above command? Bonus points for someone who can tell me how to execute the command from a jre packaged with my app, as that's what I plan on doing so the user does not have to download Java.


Solution

  • EDIT I figured out one of the answers to one of the questions below. Turns out, I didn't need to do any of the code below. My main method loads a login screen but after it's loaded it returns back to the AppLauncher code, thus closing the URLClassLoader! Of course, at that point any requested class will not be found as the loader has been closed! What an oof! Hopefully I will save someone a headache in the future...

    Original

    Well, after more time, effort, research, and effective use of Eclipse's debugging tool, I was able to figure out what I needed to do to resolve my issues.

    So the first issue was my JDBC driver was never registered when passing the jars to the URLClassloader. This is the part I sorta don't understand, so advisement would be welcomed, but there is a static block in the JDBC class that registers the driver so it can be used by DriverManager see code below. Loading the class is what executes that static block, hence why calling Class.forName works.

    static {
        try {
            DriverManager.registerDriver(new JDBC());
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    

    What I don't understand, is how class loading works if jars are specified via the class path. The URLClassLoader doesn't load any of those classes until they are called, and I never directly work with the JDBC class, thus no suitable driver exception, but are all the classes specified via the classpath loaded initially? Seems that way for static blocks to execute.

    Anyhow, to resolve my other issue with some of my app's classes not being found I had to implement my own classloader. I get what I did and how it works well, but still don't understand why I had to do it. All of my jars were loaded to the original URLClassloader so if I could find them and the files within, why couldn't it do it?

    Basically, I had to override the findClass and findResource methods to return jarEntry information that I had to store. I hope this code helps someone!

    public class SBURLClassLoader extends URLClassLoader {
    
        private HashMap<String, Storage> map;
    
        public SBURLClassLoader(URL[] urls) {
            super(urls);
            map = new HashMap<>();
            try {
                storeClasses(urls);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
        }
    
        private void storeClasses(URL[] urls) throws ClassNotFoundException {
        
            for (URL u : urls) {
                try {
                    JarFile jarFile = new JarFile(new File(u.getFile()));
                    Enumeration<JarEntry> e = jarFile.entries();
            
                    while (e.hasMoreElements()) {
                        JarEntry jar = e.nextElement();
                        String entryName = jar.getName();
                    
                        if (jar.isDirectory()) continue;
                        if (!entryName.endsWith(".class")) {
                            //still need to store these non-class files as resources
                            //let code continue to store entry un-altered
                        } else {
                            entryName = entryName.replace(".class", "");
                            entryName = entryName.replace("/", ".");
                        }
                    
                        map.put(entryName, new Storage(jarFile, jar));
                    
                        System.out.println(entryName);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            Class<?> c = null;
            try {
                c = super.findClass(name);
            } catch (ClassNotFoundException e) {
            
                Storage s = map.get(name);
                try {
                    InputStream in = s.jf.getInputStream(s.je);
                    int len = in.available();
                    c = defineClass(name, in.readAllBytes(), 0, len);
                    resolveClass(c);
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            
                if (c == null) throw e; 
            }
            return c;
        }
    
        @Override
        public URL findResource(String name) {
            URL url = super.findResource(name);
        
            if (url == null) {
                Storage s = map.get(name);
                if (s != null) {
                    try {
                        url = new URL("jar:"+s.base.toString() + "!/" + name);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
            return url; 
        }
    
        private class Storage {
        
            public JarFile jf;
            public JarEntry je;
            public URL base;
        
            public Storage(JarFile jf, JarEntry je) {
                this.jf = jf;
                this.je = je;
                try {
                    base = Path.of(jf.getName()).toUri().toURL();
                } catch (MalformedURLException e) {
                    e.printStackTrace();
                }
            }
        }
    }