Integrating Java compiler in my application

Learn integrating java compiler in my application with practical examples, diagrams, and best practices. Covers java development techniques with visual explanations.

Integrating the Java Compiler (Javac) into Your Application

Hero image for Integrating Java compiler in my application

Learn how to programmatically invoke the Java compiler (javac) from within your Java applications, enabling dynamic code compilation and execution.

Integrating the Java compiler (javac) directly into your application can unlock powerful capabilities, such as dynamic code generation, on-the-fly compilation of user-defined scripts, or even building custom IDE-like features. While most Java development involves compiling code offline using build tools like Maven or Gradle, the Java Development Kit (JDK) provides APIs to invoke the compiler programmatically. This article will guide you through the process of embedding javac into your Java application, covering the necessary APIs and best practices.

Understanding the Java Compiler API

The core of programmatic compilation in Java lies within the javax.tools package, specifically the JavaCompiler interface. This interface provides a standard way to interact with the Java compiler. You can obtain an instance of the system's default Java compiler using the ToolProvider.getSystemJavaCompiler() method. Once you have a JavaCompiler instance, you can invoke its run() method, passing in streams for input, output, and error, along with compilation arguments and source files.

flowchart TD
    A[Application Start] --> B{Get JavaCompiler Instance}
    B --> C["ToolProvider.getSystemJavaCompiler()"]
    C --> D{Prepare Compilation Arguments}
    D --> E["Source Files, Classpath, Output Dir"]
    E --> F{Invoke Compiler}
    F --> G["compiler.run(in, out, err, args...)"]
    G --> H{Check Compilation Result}
    H -- Success --> I[Load & Execute Compiled Class]
    H -- Failure --> J[Handle Compilation Errors]

Flowchart of programmatic Java compilation within an application.

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.io.StringWriter;
import java.io.Writer;
import java.util.Arrays;

public class DynamicCompiler {

    public static void main(String[] args) {
        // 1. Get the system Java compiler
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        if (compiler == null) {
            System.err.println("JDK not found. Please run with a JDK, not a JRE.");
            return;
        }

        // 2. Prepare source code (e.g., from a String or file)
        String sourceCode = "public class MyDynamicClass {\n" +
                            "    public String getMessage() {\n" +
                            "        return \"Hello from dynamically compiled code!\";\n" +
                            "    }\n" +
                            "}";
        
        // For simplicity, we'll write it to a temporary file
        // In a real application, you might use a custom JavaFileObject
        // to compile directly from memory.
        java.io.File sourceFile = new java.io.File("MyDynamicClass.java");
        try (java.io.FileWriter writer = new java.io.FileWriter(sourceFile)) {
            writer.write(sourceCode);
        } catch (java.io.IOException e) {
            e.printStackTrace();
            return;
        }

        // 3. Prepare compilation arguments
        // -d specifies the output directory for compiled classes
        String outputDir = "."; // Compile to current directory
        Iterable<String> options = Arrays.asList("-d", outputDir);
        Iterable<String> compilationUnits = Arrays.asList(sourceFile.getPath());

        // 4. Capture compiler output/errors
        Writer output = new StringWriter();
        Writer errorOutput = new StringWriter();

        // 5. Invoke the compiler
        System.out.println("Compiling MyDynamicClass.java...");
        int compilationResult = compiler.run(null, output, errorOutput, options, compilationUnits);

        // 6. Check result and print output
        if (compilationResult == 0) {
            System.out.println("Compilation successful!");
            System.out.println("Compiler Output:\n" + output.toString());
            // Clean up source file
            sourceFile.delete();

            // Now, you can load and use the compiled class
            try {
                Class<?> dynamicClass = Class.forName("MyDynamicClass");
                Object instance = dynamicClass.getDeclaredConstructor().newInstance();
                java.lang.reflect.Method method = dynamicClass.getMethod("getMessage");
                String message = (String) method.invoke(instance);
                System.out.println("Invoked dynamic method: " + message);
            } catch (Exception e) {
                System.err.println("Error loading or invoking dynamic class: " + e.getMessage());
                e.printStackTrace();
            }

        } else {
            System.err.println("Compilation failed with exit code: " + compilationResult);
            System.err.println("Compiler Error Output:\n" + errorOutput.toString());
            // Clean up source file even on failure
            sourceFile.delete();
        }
    }
}

Example of dynamically compiling and executing Java code.

Handling Source Code and Class Loading

When compiling code dynamically, you have several options for providing the source. You can write source code to temporary files, as shown in the example, or more efficiently, you can implement custom JavaFileObject and JavaFileManager classes to compile directly from String objects or other in-memory sources. This avoids disk I/O and can be faster for frequent compilations.

After successful compilation, the .class files are generated. To use these classes, you'll need to load them into your application's ClassLoader. The simplest approach is to ensure the compiled classes are in a directory that's already on your application's classpath. For more advanced scenarios, especially when compiling multiple times or from memory, you might need to create a custom URLClassLoader or a specialized InMemoryClassLoader to load the generated bytecode.

Advanced Compilation Options and Error Handling

The JavaCompiler.run() method accepts an Iterable<String> for compiler options, allowing you to specify various javac command-line arguments. Common options include -classpath (to specify dependencies for the compiled code), -source and -target (for Java version compatibility), and -g (to include debugging information). Always capture the compiler's output and error streams to provide meaningful feedback to the user or for debugging purposes.

Error handling is crucial. A non-zero return code from compiler.run() indicates compilation failure. The detailed error messages will be written to the error stream you provide. Parsing these messages can help pinpoint syntax errors, missing imports, or other issues in the dynamically generated code.

import javax.tools.*;
import java.io.*;
import java.net.URI;
import java.util.*;

// Custom JavaFileObject to hold source code in memory
class StringJavaFileObject extends SimpleJavaFileObject {
    private final String code;

    protected StringJavaFileObject(String name, String code) {
        super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
        this.code = code;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
        return code;
    }
}

// Custom JavaFileManager to capture compiled bytecode in memory
class InMemoryJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> {
    private final Map<String, ByteArrayOutputStream> compiledClasses = new HashMap<>();

    protected InMemoryJavaFileManager(JavaFileManager fileManager) {
        super(fileManager);
    }

    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException {
        if (kind == JavaFileObject.Kind.CLASS) {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            compiledClasses.put(className, bos);
            return new SimpleJavaFileObject(URI.create("mem://" + className.replace('.', '/') + kind.extension), kind) {
                @Override
                public OutputStream openOutputStream() throws IOException {
                    return bos;
                }
            };
        }
        return super.getJavaFileForOutput(location, className, kind, sibling);
    }

    public byte[] getCompiledClass(String className) {
        ByteArrayOutputStream bos = compiledClasses.get(className);
        return bos != null ? bos.toByteArray() : null;
    }
}

// Custom ClassLoader to load bytecode from memory
class InMemoryClassLoader extends ClassLoader {
    private final Map<String, byte[]> classBytes;

    public InMemoryClassLoader(ClassLoader parent, Map<String, byte[]> classBytes) {
        super(parent);
        this.classBytes = classBytes;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = classBytes.get(name);
        if (bytes == null) {
            return super.findClass(name);
        }
        return defineClass(name, bytes, 0, bytes.length);
    }
}

public class DynamicCompilerInMemory {

    public static void main(String[] args) throws Exception {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        if (compiler == null) {
            System.err.println("JDK not found. Please run with a JDK, not a JRE.");
            return;
        }

        String className = "MyInMemoryClass";
        String sourceCode = "public class " + className + " {\n" +
                            "    public String getMessage() {\n" +
                            "        return \"Hello from in-memory compiled code!\";\n" +
                            "    }\n" +
                            "}";

        JavaFileObject sourceFile = new StringJavaFileObject(className, sourceCode);
        List<JavaFileObject> compilationUnits = Collections.singletonList(sourceFile);

        StringWriter output = new StringWriter();
        StringWriter errorOutput = new StringWriter();

        InMemoryJavaFileManager fileManager = new InMemoryJavaFileManager(compiler.getStandardFileManager(null, null, null));

        JavaCompiler.CompilationTask task = compiler.getTask(
                output,
                fileManager,
                null, // DiagnosticListener
                null, // Options
                null, // Classes for annotation processing
                compilationUnits
        );

        System.out.println("Compiling " + className + " in memory...");
        boolean success = task.call();

        if (success) {
            System.out.println("Compilation successful!");
            System.out.println("Compiler Output:\n" + output.toString());

            byte[] classBytes = fileManager.getCompiledClass(className);
            if (classBytes != null) {
                Map<String, byte[]> compiledClassesMap = new HashMap<>();
                compiledClassesMap.put(className, classBytes);
                InMemoryClassLoader classLoader = new InMemoryClassLoader(DynamicCompilerInMemory.class.getClassLoader(), compiledClassesMap);

                Class<?> dynamicClass = classLoader.loadClass(className);
                Object instance = dynamicClass.getDeclaredConstructor().newInstance();
                java.lang.reflect.Method method = dynamicClass.getMethod("getMessage");
                String message = (String) method.invoke(instance);
                System.out.println("Invoked dynamic method: " + message);
            } else {
                System.err.println("Error: Compiled class bytes not found.");
            }
        } else {
            System.err.println("Compilation failed.");
            System.err.println("Compiler Error Output:\n" + errorOutput.toString());
        }
    }
}

Advanced example: Compiling Java code from a String and loading it from memory.

1. Step 1: Obtain JavaCompiler Instance

Use ToolProvider.getSystemJavaCompiler() to get the compiler. Ensure your application is running on a JDK, not just a JRE, as the compiler is part of the JDK.

2. Step 2: Prepare Source Code

Decide whether to write source code to temporary files or use custom JavaFileObject implementations to compile directly from memory (e.g., Strings). For simple cases, temporary files are easier.

3. Step 3: Define Compilation Arguments

Create a list of Strings for compiler options (e.g., output directory -d, classpath -classpath). These are the same arguments you'd pass to javac from the command line.

4. Step 4: Invoke the Compiler

Call compiler.run(InputStream in, OutputStream out, OutputStream err, String... arguments). Provide StringWriter instances for out and err to capture compiler messages.

5. Step 5: Handle Results and Load Classes

Check the integer return code (0 for success). If successful, load the compiled .class files using a ClassLoader (e.g., URLClassLoader for file-based compilation or a custom InMemoryClassLoader for in-memory compilation).