In the world of software development, time is everything. Every developer knows the frustration of waiting for a full application restart just to see a small change take effect. Java developers, in particular, have long dealt with this issue. But what if you didn’t have to stop and restart every time you updated a class? Enter Hot Class Reload (HCR) in Java—a technique that can keep you in the flow, reloading classes on the fly, much like Hot Module Reload (HMR) does in JavaScript.

In this guide, we’ll walk through how to implement HCR and integrate it into your Java development workflow. By the end, you’ll have a powerful new tool to cut down on those long, unproductive restart times.

Understanding Hot Class Reload

Simply put, Hot Class Reload (HCR) allows Java applications to reload classes at runtime. This is incredibly useful in development environments where you’re constantly iterating and tweaking code. Think of it as real-time editing: you change some code, the application picks it up right away—no restart required.

Hot Class Reload (HCR) is a technique that enables Java applications to reload classes at runtime. This approach is particularly useful in development environments where frequent code changes occur. By leveraging Java’s WatchService, custom class loaders using the javax.tools api and JavaCompiler class we can monitor source files for changes, compile them on-the-fly, and reload the updated classes into the running application.

How It Works

The core components of our HCR implementation include:

  1. File Watcher: Utilizes Java’s WatchService to monitor a directory for changes to .java files.
  2. Dynamic Compilation: Uses javax.tools.JavaCompiler to compile modified Java source files.
  3. Custom Class Loader: Loads the newly compiled classes into the JVM, replacing the old versions.

System Flow Diagram

Below is a simplified flow diagram illustrating the HCR process:

HCR Flowchart

  1. Source Changes: Developers modify Java source files in the specified directory.
  2. File Watcher: Detects changes and triggers the compilation process.
  3. Compile & Reload: Compiles the modified files and reloads the classes using a custom class loader.
  4. Custom Class Loader: Loads the newly compiled class into the jvm.
  5. Invoke Main Method: Exectues the main method by executing the bytecode of the reloaded class.

Implementing Hot Class Reload

Here’s a breakdown of the Java program implementing HCR:

Setting Up the Environment

The program initializes by setting up directories for source and compiled classes. It ensures these directories exist and are ready for use.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public HotReload(String sourceDir, String classDir) {
    this.sourceDir = Paths.get(sourceDir).toAbsolutePath();
    this.classDir = Paths.get(classDir).toAbsolutePath();
    this.classLoader = new CustomClassLoader();
    this.compiler = ToolProvider.getSystemJavaCompiler();
    this.fileManager = compiler.getStandardFileManager(null, null, null);

    // Ensure directories exist
    try {
        Files.createDirectories(this.sourceDir);
        Files.createDirectories(this.classDir);
    } catch (IOException e) {
        throw new RuntimeException("Failed to create directories", e);
    }
}

The constructor begins by converting the provided sourceDir and classDir strings into absolute Path objects using Paths.get().toAbsolutePath(). This ensures that the paths are fully qualified and can be used reliably throughout the application. Next, it initializes the classLoader as an instance of the CustomClassLoader class, which is responsible for dynamically loading compiled classes. The compiler is set to the system Java compiler obtained from ToolProvider.getSystemJavaCompiler(), and the fileManager is initialized using the standard file manager from the compiler. This setup allows the class to compile Java source files programmatically. The constructor then attempts to create the directories specified by sourceDir and classDir if they do not already exist, using Files.createDirectories(). If directory creation fails, it throws a RuntimeException to indicate a critical error that prevents the application from proceeding. This ensures that the necessary directory structure is in place for the class to function correctly.

Watching for File Changes

A dedicated thread runs a file watcher that listens for modifications to .java files in the source directory.The watchForChanges method is responsible for monitoring the source directory for any modifications to .java files and triggering the compileAndReload method when changes are detected. Here is the code for the watchForChanges method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void watchForChanges() {
    try (WatchService watchService = FileSystems.getDefault()
        .newWatchService()) {
        sourceDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);

        while (true) {
            WatchKey key = watchService.take();
            for (WatchEvent << ? > event : key.pollEvents()) {
                if (event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) {
                    Path changed = sourceDir.resolve((Path) event.context());
                    String fileName = changed.getFileName()
                        .toString();
                    if (fileName.endsWith(".java")) {
                        compileAndReload(fileName);
                    }
                }
            }
            if (!key.reset()) {
                break;
            }
        }
    } catch (Exception e) {
        System.err.println("Error in file watcher: " + e.getMessage());
        e.printStackTrace();
    }
}

Compiling and Reloading Classes

The compileAndReload method is designed to handle the compilation of a Java source file and subsequently reload the resulting class if necessary. Upon invocation, it accepts a fileName parameter, which should be a Java source file (e.g., “MyClass.java”). The method begins by extracting the class name by removing the “.java” extension from the provided file name. It then constructs the paths for both the source file and the compiled class file using sourceDir and classDir, which are assumed to be defined in the broader context of the class.

To determine whether recompilation is required, the method compares the modification times of the source file and the corresponding class file. It retrieves the last known modification time from a lastModified map and obtains the current modification time of the source file. The method checks if the class file exists and whether its modification time is newer than or equal to that of the source file. If these conditions indicate that recompilation is unnecessary, the method exits early.

Conversely, if recompilation is deemed necessary, it invokes a separate compile method (details of which are not provided in this snippet) to compile the source file. Upon successful compilation, it calls a reloadClass method (also not shown) to reload the newly compiled class and updates the lastModified map with the current modification time. A success message is printed to confirm the successful operation. In the case of a compilation failure, an error message is displayed. The entire process is encapsulated within a try-catch block to effectively manage any exceptions that may arise during the compilation or file operations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void compileAndReload(String fileName) {
    try {
        String className = fileName.replace(".java", "");
        Path sourceFile = sourceDir.resolve(fileName);
        Path classFile = classDir.resolve(className + ".class");

        // Check if the file has been modified since last compilation
        long lastModTime = lastModified.getOrDefault(className, 0L);
        long currentModTime = Files.getLastModifiedTime(sourceFile)
            .toMillis();
        if (Files.exists(classFile) && Files.getLastModifiedTime(classFile)
            .toMillis() >= currentModTime && lastModTime >= currentModTime) {
            return;
        }

        // Compile the Java file
        boolean compilationSuccessful = compile(sourceFile);

        if (compilationSuccessful) {
            // Compilation successful, reload the class
            reloadClass(className);
            lastModified.put(className, currentModTime);
            System.out.println("Reloaded: " + className);
        } else {
            System.err.println("Compilation failed for: " + className);
        }
    } catch (Exception e) {
        System.err.println("Error in compile and reload: " + e.getMessage());
        e.printStackTrace();
    }
}

The compile method is designed to compile a Java source file utilizing the Java Compiler API, providing a structured approach to dynamic compilation. The method begins by accepting a Path object that represents the source file intended for compilation. To facilitate error handling and reporting, it creates a DiagnosticCollector, which collects diagnostic information such as errors and warnings encountered during the compilation process.

Next, the method constructs a collection of JavaFileObjects from the source file using a fileManager, likely an instance of StandardJavaFileManager. This setup is crucial for the Java Compiler API to access the source files correctly. The method then specifies compilation options, particularly the output directory for the compiled classes, using the -d option.

A CompilationTask is created using the Java Compiler API, which is configured with several parameters: no custom writer for compiler output (set to null), the file manager, the diagnostic collector, the compilation options, no annotation processors (also null), and the compilation units (the source files). The compilation task is executed by calling task.call(), which returns a boolean indicating whether the compilation was successful.

In the event of a compilation failure, the method iterates through the collected diagnostics, printing detailed error information that includes the line number where the error occurred, the source file URI, and the specific error message. This detailed reporting aids in debugging compilation issues effectively. Finally, the method returns the success status of the compilation, providing a clear indication of the outcome.

This method serves as a programmatic solution for compiling Java source files, making it particularly useful in scenarios where dynamic compilation is required. Its robust error reporting capabilities enhance the developer’s ability to troubleshoot and resolve compilation errors efficiently.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private boolean compile(Path sourceFile) {
    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
    Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(Collections.singletonList(sourceFile.toFile()));

    List<String> options = Arrays.asList("-d", classDir.toString());

    JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits);

    boolean success = task.call();

    if (!success) {
        for (Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
            System.err.format("Error on line %d in %s%n", diagnostic.getLineNumber(), diagnostic.getSource()
                .toUri());
            System.err.println(diagnostic.getMessage(null));
        }
    }
    
    return success;
}

Custom Class Loader

A custom class loader is used to load the compiled class files into the JVM. The reloading happens after the compilation is successful and then it reloads the class. To reload the class we need to implement a custom class loader that will load the changed class and then execute it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private void reloadClass(String className) {
    try {
        Class << ? > loadedClass = classLoader.loadClass(className);
        Method mainMethod = loadedClass.getMethod("main", String[].class);
        System.out.println("Invoking main method for: " + className);
        mainMethod.invoke(null, (Object) new String[0]);
    } catch (Exception e) {
        System.err.println("Error reloading class " + className + ": " + e.getMessage());
        e.printStackTrace();
    }
}

private class CustomClassLoader extends ClassLoader {

@Override
public Class << ? > loadClass(String name) throws ClassNotFoundException {
    Path classFile = classDir.resolve(name + ".class");
        if (Files.exists(classFile)) {
            try {
                byte[] classBytes = Files.readAllBytes(classFile);
                return defineClass(name, classBytes, 0, classBytes.length);
             } catch (IOException e) {
                throw new ClassNotFoundException("Error loading class " + name, e);
            }
         }
        return super.loadClass(name);
    }
}

Now if we want to execute the Hot Reload program we need to do something like this

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    public static void main(String[] args) {
        Day84 reloader = new Day84("C:\\Users\\mohib\\IdeaProjects\\date-time\\src\\main\\java", "C:\\Users\\mohib\\IdeaProjects\\date-time\\target\\classes");
        reloader.start();

        // Keep the main thread alive and handle user input
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
            System.out.println("Live reloader is running. Press Enter to exit.");
            reader.readLine();
        } catch (IOException e) {
            System.err.println("Error reading user input: " + e.getMessage());
        }
        System.out.println("Exiting live reloader.");
    }

In this main method we can see that we need to provide the path for the src of the code that you want to hot reload and the classes of those java files and after that it then watches for any changes and then compile and reload the class so that we can see the effect immediately.

Total code

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import javax.tools.*;

import java.io.*;
import java.lang.reflect.Method;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class Day84 {

    private final Path sourceDir;
    private final Path classDir;
    private final Map<String, Long> lastModified = new ConcurrentHashMap<>();
    private final CustomClassLoader classLoader;
    private final JavaCompiler compiler;
    private final StandardJavaFileManager fileManager;

    public Day84(String sourceDir, String classDir) {
        this.sourceDir = Paths.get(sourceDir)
            .toAbsolutePath();
        this.classDir = Paths.get(classDir)
            .toAbsolutePath();
        this.classLoader = new CustomClassLoader();
        this.compiler = ToolProvider.getSystemJavaCompiler();
        this.fileManager = compiler.getStandardFileManager(null, null, null);

        // Ensure directories exist
        try {
            Files.createDirectories(this.sourceDir);
            Files.createDirectories(this.classDir);
        } catch (IOException e) {
            throw new RuntimeException("Failed to create directories", e);
        }
    }

    public void start() {
        Thread watchThread = new Thread(this::watchForChanges, "FileWatcherThread");
        watchThread.setDaemon(true);
        watchThread.start();
        System.out.println("Live reloader started. Watching directory: " + sourceDir);
    }

    private void watchForChanges() {
        try (WatchService watchService = FileSystems.getDefault()
            .newWatchService()) {
            sourceDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);

            while (true) {
                WatchKey key = watchService.take();
                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) {
                        Path changed = sourceDir.resolve((Path) event.context());
                        String fileName = changed.getFileName()
                            .toString();
                        if (fileName.endsWith(".java")) {
                            compileAndReload(fileName);
                        }
                    }
                }
                if (!key.reset()) {
                    break;
                }
            }
        } catch (Exception e) {
            System.err.println("Error in file watcher: " + e.getMessage());
            e.printStackTrace();
        }
    }

    private void compileAndReload(String fileName) {
        try {
            String className = fileName.replace(".java", "");
            Path sourceFile = sourceDir.resolve(fileName);
            Path classFile = classDir.resolve(className + ".class");

            // Check if the file has been modified since last compilation
            long lastModTime = lastModified.getOrDefault(className, 0L);
            long currentModTime = Files.getLastModifiedTime(sourceFile)
                .toMillis();
            if (Files.exists(classFile) && Files.getLastModifiedTime(classFile)
                .toMillis() >= currentModTime && lastModTime >= currentModTime) {
                return;
            }

            // Compile the Java file
            boolean compilationSuccessful = compile(sourceFile);

            if (compilationSuccessful) {
                // Compilation successful, reload the class
                reloadClass(className);
                lastModified.put(className, currentModTime);
                System.out.println("Reloaded: " + className);
            } else {
                System.err.println("Compilation failed for: " + className);
            }
        } catch (Exception e) {
            System.err.println("Error in compile and reload: " + e.getMessage());
            e.printStackTrace();
        }
    }

    private boolean compile(Path sourceFile) {
        DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
        Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(Collections.singletonList(sourceFile.toFile()));

        List<String> options = Arrays.asList("-d", classDir.toString());

        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits);

        boolean success = task.call();

        if (!success) {
            for (Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
                System.err.format("Error on line %d in %s%n", diagnostic.getLineNumber(), diagnostic.getSource()
                    .toUri());
                System.err.println(diagnostic.getMessage(null));
            }
        }

        return success;
    }

    private void reloadClass(String className) {
        try {
            Class<?> loadedClass = classLoader.loadClass(className);
            Method mainMethod = loadedClass.getMethod("main", String[].class);
            System.out.println("Invoking main method for: " + className);
            mainMethod.invoke(null, (Object) new String[0]);
        } catch (Exception e) {
            System.err.println("Error reloading class " + className + ": " + e.getMessage());
            e.printStackTrace();
        }
    }

    private class CustomClassLoader extends ClassLoader {

        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            Path classFile = classDir.resolve(name + ".class");
            if (Files.exists(classFile)) {
                try {
                    byte[] classBytes = Files.readAllBytes(classFile);
                    return defineClass(name, classBytes, 0, classBytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException("Error loading class " + name, e);
                }
            }
            return super.loadClass(name);
        }
    }

    public static void main(String[] args) {
        Day84 reloader = new Day84("C:\\Users\\mohib\\IdeaProjects\\date-time\\src\\main\\java", "C:\\Users\\mohib\\IdeaProjects\\date-time\\target\\classes");
        reloader.start();

        // Keep the main thread alive and handle user input
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
            System.out.println("Live reloader is running. Press Enter to exit.");
            reader.readLine();
        } catch (IOException e) {
            System.err.println("Error reading user input: " + e.getMessage());
        }
        System.out.println("Exiting live reloader.");
    }
}

Conclusion

Hot Class Reload in Java offers a powerful way to enhance development productivity by reducing the need for full application restarts. By integrating file watching, dynamic compilation, and custom class loading, developers can achieve a seamless development experience akin to JavaScript’s Hot Module Reload.

Implementing HCR can significantly speed up the development cycle, allowing for rapid testing and iteration. As you integrate this technique into your workflow, you’ll find it an invaluable tool for efficient Java development. There are some libraries like JRebel which provides this feature more on that in the next post.