Blog

Paquetes, módulos y JARs en Java sin Maven

Arma un proyecto Java de dos módulos solo con javac, jar y java. Mira cómo encajan los paquetes, los imports, el classpath, los archivos JAR y module-info.java, y qué agregan Maven y Gradle encima.

Hasta ahora, cada programa de esta serie cabía en un solo archivo llamado Main.java. El código Java real se divide en paquetes, se compila a una carpeta de archivos .class, se empaqueta en archivos JAR y se inicia desde un classpath o un module path. Las herramientas de build hacen todo eso por ti, y por eso la mayoría de los desarrolladores nunca lo ha visto hecho a mano.

Este post lo hace a mano. Cubre los paquetes y los imports, el classpath y sus dos errores clásicos, los archivos JAR y el sistema de módulos con module-info.java. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar tú mismo uno de los programas de un solo archivo, guárdalo como Main.java y ejecuta java Main.java. Las sesiones de terminal vienen de un proyecto pequeño de dos módulos, revisado por un script que usa solo el JDK.

Un paquete es un nombre y una carpeta

Un paquete le da a una clase un nombre más largo y único, así que List de java.util nunca choca con una clase List que escribió otra persona. El nombre completo, paquete más clase, es el nombre completamente calificado (fully qualified name), y siempre puedes escribirlo entero. Este programa no usa ningún import. Es una public class Main clásica, porque un archivo fuente compacto importa java.base por ti y escondería lo que queremos mostrar:

public class Main {
    public static void main(String[] args) {
        java.util.List<String> words = java.util.List.of("cat", "sat", "mat");
        IO.println(words.getFirst() + " is one of " + words.size() + " words");
        IO.println(java.util.List.class.getName());
        IO.println(java.util.List.class.getPackageName());
        IO.println("[" + Main.class.getPackageName() + "]");
    }
}

Imprime:

cat is one of 3 words
java.util.List
java.util
[]

java.util.List es el nombre real de la clase. List a secas es una abreviatura. La última línea muestra que Main tiene un nombre de paquete vacío. Vive en el paquete por defecto, también llamado paquete sin nombre, porque el archivo no tiene línea package.

En un proyecto, la primera línea de un archivo fuente nombra su paquete, y las carpetas en disco siguen los mismos puntos. Una clase en package com.example.text; va en com/example/text/. Los nombres de paquete suelen empezar con un dominio que controlas, escrito al revés, para que dos empresas no elijan el mismo.

Un nombre de paquete parece un árbol, pero Java no lo trata como tal. com.example.text y com.example.text.internal son dos paquetes sin relación que resultan compartir un prefijo. Importar uno no importa el otro, y ninguno tiene acceso especial al código del otro.

import e import static

Un import te deja escribir el nombre corto de una clase en lugar de su nombre completamente calificado. No carga nada ni copia código. El compilador solo reescribe List como java.util.List por ti. import static hace lo mismo con los miembros estáticos, como métodos y constantes:

import java.util.ArrayList;
import java.util.List;

import static java.lang.Math.max;
import static java.util.Comparator.reverseOrder;

public class Main {
    public static void main(String[] args) {
        List<Integer> scores = new ArrayList<>(List.of(4, 9, 2));
        scores.sort(reverseOrder());
        IO.println(scores);
        IO.println(max(scores.getFirst(), 10));
    }
}

Imprime:

[9, 4, 2]
10

Sin los imports estáticos, escribirías Math.max y Comparator.reverseOrder(). Úsalos con moderación, o quien lea tendrá que buscar de dónde sale cada nombre suelto.

Nada de java.lang necesita import. Por eso String, Math e IO siempre funcionan.

Un import con comodín (wildcard), import java.util.*;, trae todas las clases de un paquete. Te mete en problemas cuando dos paquetes tienen una clase con el mismo nombre:

import java.util.*;
import java.sql.*;

public class Main {
    public static void main(String[] args) {
        Date today = new Date(0);
        IO.println(today);
    }
}

La compilación falla con:

Main.java:6: error: reference to Date is ambiguous
        Date today = new Date(0);
        ^
  both class java.sql.Date in java.sql and class java.util.Date in java.util match

La solución es un import de una sola clase, import java.util.Date;. Un import de una sola clase siempre gana sobre un comodín. O escribe el nombre completamente calificado donde lo uses.

El paquete por defecto, y por qué los archivos fuente compactos no tienen otro

El paquete por defecto sirve para un programa de un archivo y es un error para cualquier cosa más grande. Una clase en un paquete con nombre no puede importar de ninguna forma una clase del paquete por defecto, porque no hay nombre que escribir después de import.

Un archivo fuente compacto, la forma void main() que usa esta serie, siempre vive en el paquete por defecto. El compilador lo envuelve en una clase que nunca nombraste, así que no hay nada a lo que otro código pueda referirse, y una línea package se rechaza:

package com.example;

void main() {
    IO.println("hello");
}

La compilación falla con:

Main.java:1: error: compact source file should not have package declaration

Así que los archivos fuente compactos sirven para scripts y para aprender. El código que usan otros va en un paquete con nombre.

import module: todos los paquetes exportados de una vez

Un import de módulo, import module java.base;, importa todas las clases de todos los paquetes que el módulo exporta. Pasó a ser una funcionalidad final en Java 25, y javac --release 24 lo rechaza con module imports are not supported in -source 24.

import module java.base;

public class Main {
    public static void main(String[] args) {
        List<String> words = List.of("b", "a");
        IO.println(words);
    }
}

Imprime:

[b, a]

Esa sola línea cubre java.util, java.io, java.time y el resto de java.base. Es exactamente lo que un archivo fuente compacto recibe sin pedirlo. Los choques de nombres funcionan igual que con los comodines: un import de una sola clase los resuelve.

El proyecto: un contador de palabras en dos módulos

El resto de este post usa un proyecto pequeño: una biblioteca que cuenta palabras y un comando que imprime las tres más frecuentes. Este es el proyecto completo, salvo su .gitignore:

14-wordcount/
├── run-checks.sh
├── sample.txt
├── expected-output.txt
├── checks/
│   └── Peek.java
└── src/
    ├── com.example.text/
    │   ├── module-info.java
    │   └── com/example/text/
    │       ├── WordCount.java
    │       ├── WordCounter.java
    │       └── internal/
    │           └── Tokenizer.java
    └── com.example.app/
        ├── module-info.java
        └── com/example/app/
            └── Main.java

Las carpetas que están directamente bajo src llevan el nombre de módulos, que aparecen más adelante en el post. Debajo de cada una, las carpetas siguen los nombres de paquete.

La biblioteca guarda la separación de palabras en un paquete llamado internal:

package com.example.text.internal;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/** Splits text into lower-case words. Public, but its package is not exported. */
public final class Tokenizer {
    private Tokenizer() {
    }

    public static List<String> words(String text) {
        var words = new ArrayList<String>();
        for (String part : text.split("[^\\p{L}\\p{N}']+")) {
            String word = part.replaceAll("^'+|'+$", "");
            if (!word.isEmpty()) {
                words.add(word.toLowerCase(Locale.ROOT));
            }
        }
        return words;
    }
}

Tokenizer tiene que ser public, porque WordCounter vive en otro paquete y necesita llamarlo. Antes de los módulos, eso significaba que cualquiera podía llamarlo. Guarda esa idea.

La API real de la biblioteca es un record y una clase:

package com.example.text;

/** One word and how many times it appeared. */
public record WordCount(String word, int count) {
}
package com.example.text;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import com.example.text.internal.Tokenizer;

/** Counts how often each word appears in a piece of text. */
public final class WordCounter {
    private WordCounter() {
    }

    /** Returns each word and its count, sorted by word. */
    public static Map<String, Integer> count(String text) {
        var counts = new TreeMap<String, Integer>();
        for (String word : Tokenizer.words(text)) {
            counts.merge(word, 1, Integer::sum);
        }
        return counts;
    }

    /** Returns the n most frequent words. Ties are broken alphabetically. */
    public static List<WordCount> top(Map<String, Integer> counts, int n) {
        return counts.entrySet().stream()
                .map(e -> new WordCount(e.getKey(), e.getValue()))
                .sorted(Comparator.comparingInt(WordCount::count).reversed()
                        .thenComparing(WordCount::word))
                .limit(n)
                .toList();
    }
}

Y el comando lee la entrada estándar, imprime las tres palabras más frecuentes y luego dice en qué módulo se está ejecutando:

package com.example.app;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import com.example.text.WordCount;
import com.example.text.WordCounter;

public class Main {
    public static void main(String[] args) throws IOException {
        String text = new String(System.in.readAllBytes(), StandardCharsets.UTF_8);
        var counts = WordCounter.count(text);
        for (WordCount wc : WordCounter.top(counts, 3)) {
            IO.println(wc.word() + " " + wc.count());
        }

        Module module = Main.class.getModule();
        IO.println("module: " + (module.isNamed() ? module.getName() : "unnamed"));
    }
}

Esa última línea va a cambiar según cómo inicies el programa, y para eso está.

El classpath: dónde busca clases java

El classpath es una lista de carpetas y archivos JAR donde javac y java buscan clases compiladas. Primero compilas la biblioteca, luego compilas la app con la biblioteca en su classpath, y después ejecutas la app con las dos en el classpath:

$ javac -d classes/lib src/com.example.text/com/example/text/*.java src/com.example.text/com/example/text/internal/*.java
$ javac -cp classes/lib -d classes/app src/com.example.app/com/example/app/Main.java
$ find classes -name "*.class" | sort
classes/app/com/example/app/Main.class
classes/lib/com/example/text/WordCount.class
classes/lib/com/example/text/WordCounter.class
classes/lib/com/example/text/internal/Tokenizer.class
$ java -cp classes/app:classes/lib com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: unnamed

sample.txt contiene la línea the cat sat on the mat and the cat slept. -d fija la carpeta de salida, y javac crea dentro las carpetas de los paquetes por ti. -cp recibe una lista separada por : en Linux y macOS, y por ; en Windows. El comando java nombra una clase por su nombre completamente calificado, no un archivo.

Estos comandos javac se saltaron module-info.java, así que aquí no hay módulos. Todo lo que está en el classpath termina en un gran módulo sin nombre (unnamed module), y eso es lo que dice la última línea.

El classpath falla de dos maneras, y se parecen. Si java no encuentra la clase que le pediste iniciar, obtienes esto:

$ java -cp classes/lib com.example.app.Main < sample.txt
Error: Could not find or load main class com.example.app.Main
Caused by: java.lang.ClassNotFoundException: com.example.app.Main

Si encuentra Main pero no una clase que Main necesita, el programa arranca y luego falla en la primera línea que usa la clase que falta. El stack trace está recortado aquí:

$ java -cp classes/app com.example.app.Main < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
	at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter

ClassNotFoundException significa que se buscó una clase por nombre y no estaba. NoClassDefFoundError significa que falta ahora una clase que existía cuando se compiló el código. Las dos se resuelven igual: pon la carpeta o el JAR que falta en el classpath.

Fíjate en cuándo falló la segunda. Nada revisó el classpath al arrancar. main se ejecutó, y la línea 12 fue donde la JVM necesitó WordCounter por primera vez.

Archivos JAR: un zip con un manifiesto

Un archivo JAR es un archivo zip de archivos .class con un pequeño archivo de texto, el manifiesto, en META-INF/. La herramienta jar crea uno, y --main-class escribe en el manifiesto la clase con la que arrancar:

$ jar --create --file wordcount.jar --main-class com.example.app.Main -C classes/app . -C classes/lib .
$ jar --list --file wordcount.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/app/
com/example/app/Main.class
com/example/text/
com/example/text/WordCount.class
com/example/text/WordCounter.class
com/example/text/internal/
com/example/text/internal/Tokenizer.class
$ unzip -p wordcount.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.4 (Ubuntu)
Main-Class: com.example.app.Main

$ java -jar wordcount.jar < sample.txt
the 3
cat 2
and 1
module: unnamed

-C classes/app . significa “entra a classes/app y agrega todo lo que hay ahí”. Las rutas dentro del JAR son las carpetas de los paquetes, con la misma estructura que en disco. java -jar lee Main-Class del manifiesto, así que no nombras la clase. También ignora cualquier -cp que le pases: el JAR, más lo que liste la línea Class-Path de su manifiesto, es todo el classpath.

Este JAR contiene la app y la biblioteca, así que se ejecuta por sí solo. Las aplicaciones reales dependen de decenas de JARs de bibliotecas, y ahí es donde entran las herramientas de build.

Módulos: module-info.java

Un módulo es un conjunto de paquetes con un nombre y un descriptor, module-info.java, que dice qué módulos necesita y cuáles de sus paquetes pueden usar otros módulos. Los módulos llegaron en Java 9. El descriptor de la biblioteca exporta un paquete y no dice nada de internal:

/** Counts words in text. */
module com.example.text {
    exports com.example.text;
}

La app no exporta nada y requiere la biblioteca:

/** A command that prints the most frequent words from standard input. */
module com.example.app {
    requires com.example.text;
}

requires trata de módulos y exports trata de paquetes. Además, todo módulo lee java.base sin pedirlo. javac puede compilar los dos módulos en un solo comando, usando la estructura de carpetas bajo src:

$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.text,com.example.app
$ find out -type f | sort
out/com.example.app/com/example/app/Main.class
out/com.example.app/module-info.class
out/com.example.text/com/example/text/WordCount.class
out/com.example.text/com/example/text/WordCounter.class
out/com.example.text/com/example/text/internal/Tokenizer.class
out/com.example.text/module-info.class
$ java --module-path out -m com.example.app/com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: com.example.app

--module-source-path src le dice a javac que cada carpeta bajo src es un módulo con el nombre de la carpeta. -m elige los módulos que se compilan. javac dedujo el orden a partir de la línea requires.

--module-path out, o -p out, es el module path. java trata cada carpeta bajo out como un módulo con nombre. -m module/class dice qué módulo iniciar y qué clase tiene main. El mismo Main.class ahora reporta module: com.example.app.

módulo com.example.app com.example.app clase Main no exporta nada módulo com.example.text com.example.text WordCounter, WordCount exporta com.example.text com.example.text.internal clase pública Tokenizer no exportado requiere puede usar no visible módulo java.base, leído por todos

com.example.app requiere com.example.text, así que Main puede usar el paquete exportado. El paquete internal está dentro del mismo módulo, y su clase es pública, pero no está exportado, así que nada fuera del módulo puede usarlo.

Encapsulamiento fuerte: public ya no basta

Una clase public en un paquete que su módulo no exporta no se puede usar desde fuera del módulo. Eso se llama encapsulamiento fuerte (strong encapsulation). checks/Peek.java lo intenta de todos modos:

import com.example.text.internal.Tokenizer;

public class Peek {
    public static void main(String[] args) {
        IO.println(Tokenizer.words("reaching inside"));
    }
}

Compílalo contra el module path. --add-modules agrega la biblioteca a la compilación, como lo haría una línea requires:

$ javac -p out --add-modules com.example.text -d peek checks/Peek.java
checks/Peek.java:1: error: package com.example.text.internal is not visible
import com.example.text.internal.Tokenizer;
                       ^
  (package com.example.text.internal is declared in module com.example.text, which does not export it)
1 error

El error nombra la regla y la razón. Tokenizer es público, y da igual. Su módulo no exportó el paquete.

Ahora el mismo archivo, compilado contra el build de classpath de antes:

$ javac -cp classes/lib -d peek checks/Peek.java
$ java -cp peek:classes/lib Peek
[reaching, inside]

Compiló y se ejecutó. En el classpath no interviene ningún module-info.class, así que no hay nada que hacer cumplir. El encapsulamiento fuerte solo existe cuando la biblioteca está en el module path. Eso nos sorprendió la primera vez que lo ejecutamos, y vale la pena recordarlo cada vez que alguien diga que los módulos protegen las partes internas de una biblioteca.

Explicado como si tuvieras diez años

Los paquetes son carpetas en un archivero. Cada carpeta tiene una etiqueta, como com.example.text, y los papeles con la misma etiqueta van en la misma carpeta. La etiqueta es la forma de pedir un papel: “la hoja WordCounter de la carpeta com.example.text“.

Un módulo es un archivero con cerradura. Sus cajones guardan las carpetas. El dueño pega una etiqueta que dice “exportado” en algunos cajones. Cualquiera que venga de otro archivero puede abrir esos. Los cajones sin etiqueta quedan cerrados desde fuera, aunque el papel de adentro diga “público” arriba. Quienes trabajan en ese archivero pueden abrir todos los cajones.

El classpath es lo que pasa cuando vacías todos los archiveros sobre una mesa grande. Todos los papeles quedan ahí, y cualquiera puede tomar el que quiera.

La versión precisa

Un módulo es un conjunto de paquetes con nombre, descrito por module-info.class. Una línea requires hace que un módulo lea a otro. El código del módulo A puede usar un tipo del módulo B solo cuando se cumplen tres cosas: A lee a B, B exporta a A el paquete del tipo, y el tipo mismo es public. Si falta algo, es un error de compilación, y la JVM aplica la misma regla en tiempo de ejecución. La reflexión sobre miembros privados necesita además que el paquete esté abierto.

Todo lo que está en el classpath va al módulo sin nombre. El módulo sin nombre lee todos los módulos que la JVM o el compilador resolvieron, y por eso Peek necesitó --add-modules, pero aun así solo ve sus paquetes exportados. Las clases de JARs en el classpath no tienen fronteras de módulo entre ellas, así que se aplica la regla de siempre: public significa cualquiera.

Dónde falla la analogía: un cajón con cerradura suena a seguridad, y no lo es. Cualquiera que inicie la JVM puede pasar --add-exports o --add-opens en la línea de comandos para abrir un paquete, o mover el JAR al classpath, como acaba de hacer Peek. El encapsulamiento protege a los autores de una biblioteca de dependencias accidentales en sus partes internas. No protege secretos de alguien que controla la línea de comandos.

opens: dejar entrar a la reflexión

exports controla el acceso en tiempo de compilación y las llamadas normales, mientras que opens controla la reflexión profunda, la que lee campos privados. Los frameworks que llenan objetos desde JSON o inyectan dependencias la necesitan. Una línea como opens com.example.text.model; deja que la reflexión en tiempo de ejecución alcance todos los miembros de ese paquete, incluidos los privados, sin exportarlo para la compilación.

Los módulos del propio JDK no abren sus partes internas, y puedes ver el rechazo desde un programa de un archivo:

void main() {
    try {
        var field = String.class.getDeclaredField("value");
        field.setAccessible(true);
        IO.println("opened");
    } catch (NoSuchFieldException | InaccessibleObjectException e) {
        IO.println(e.getClass().getSimpleName());
        String message = e.getMessage();
        // The message ends with " @" and a hash code that changes on every run.
        IO.println(message.substring(0, message.lastIndexOf(" @")));
    }
}

Imprime:

InaccessibleObjectException
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module

getDeclaredField funcionó, así que el campo existe. setAccessible(true) es el paso que falló. De Java 9 a 15, la misma llamada funcionaba con una advertencia, y muchas bibliotecas antiguas dependían de eso. Java 16 hizo que fallara por defecto, y Java 17 quitó la opción que devolvía el comportamiento anterior.

JARs modulares y el module path

Un JAR modular es un JAR normal con module-info.class en su raíz. Creas uno por módulo, y --main-class en el JAR de la app registra la clase con la que arrancar dentro de su descriptor de módulo:

$ mkdir mods
$ jar --create --file mods/com.example.text.jar -C out/com.example.text .
$ jar --create --file mods/com.example.app.jar --main-class com.example.app.Main -C out/com.example.app .
$ java -p mods -m com.example.app < sample.txt
the 3
cat 2
and 1
module: com.example.app

Esta vez -m nombra solo el módulo, porque el JAR ya conoce su clase principal. jar --describe-module imprime el descriptor. Después de una primera línea con el nombre del módulo y la ruta completa del JAR, imprimió esto para cada JAR:

$ jar --describe-module --file mods/com.example.text.jar
exports com.example.text
requires java.base mandated
contains com.example.text.internal
$ jar --describe-module --file mods/com.example.app.jar
requires com.example.text
requires java.base mandated
contains com.example.app
main-class com.example.app.Main

mandated marca el requires java.base que nunca escribiste. contains lista los paquetes que están en el JAR pero no se exportan.

El module path también revisa lo que el classpath no revisaba. Borra el JAR de la biblioteca y ejecuta la app otra vez:

$ rm mods/com.example.text.jar
$ java -p mods -m com.example.app < sample.txt
Error occurred during initialization of boot layer
java.lang.module.FindException: Module com.example.text not found, required by com.example.app

No se ejecutó ni una línea de main. La JVM leyó cada requires antes de arrancar, encontró uno que no podía satisfacer y se detuvo. Compáralo con el NoClassDefFoundError de la línea 12 de antes.

Una sorpresa más. Con los dos JARs de vuelta en mods, java -jar sobre el JAR modular de la app falla:

$ java -jar mods/com.example.app.jar < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
	at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter

java -jar siempre pone el JAR en el classpath. El module-info.class de adentro se ignora, así que la línea requires no significa nada, y la biblioteca no está en el classpath. Para ejecutar un JAR modular como módulo, usa -p y -m.

java --list-modules, y cuándo valen la pena los módulos

El propio JDK está dividido en módulos, y java --list-modules los imprime con sus versiones. En esta máquina listó 69, y empiezan así:

$ java --list-modules | head -4
java.base@25.0.4
java.compiler@25.0.4
java.datatransfer@25.0.4
java.desktop@25.0.4

Agrega -p mods y tus propios módulos aparecen al final de la lista, cada uno con el archivo del que vino.

Ahora la parte honesta. La mayoría de las aplicaciones Java de hoy todavía se ejecutan en el classpath, y funcionan bien. Muchos frameworks y bibliotecas populares se crearon antes de los módulos, y algunos dependen de la reflexión de maneras que hacen incómodo el module path. Si escribes una aplicación, puedes ignorar module-info.java y perder muy poco.

Los módulos se ganan su lugar en dos casos:

  • Bibliotecas. Un paquete no exportado te deja cambiar tus partes internas sin romperles nada a quienes usan tu biblioteca, siempre que estén en el module path. Es la idea de la carpeta internal, con el compilador haciéndola cumplir.
  • Runtimes a medida. jlink arma un JDK recortado que contiene solo los módulos que tu programa necesita. Tiene que conocer esos módulos, así que tu código debe ser modular. La parte sobre probar y distribuir sin herramienta de build lo usa.

Qué agregan Maven y Gradle

Todo lo anterior usó tres herramientas del JDK, y para dos módulos sin dependencias fue suficiente. El script que revisa este proyecto lista a mano las carpetas de código fuente y los pasos del build. Maven y Gradle reemplazan eso con un proyecto declarado. Listas tus dependencias por nombre y versión, y la herramienta las descarga de un repositorio como Maven Central, junto con todo lo que esas dependencias necesitan, y resuelve los conflictos de versión entre ellas. Luego arma el classpath o el module path por ti, compila en el orden correcto, ejecuta las pruebas y empaqueta los JARs. Las mismas versiones declaradas te dan el mismo build en cada máquina, que es justo lo primero que un script escrito a mano hace mal. Nada de eso es magia: por debajo, las herramientas siguen llamando a javac con un -cp o un -p, y a jar, más o menos como hizo este post.

Qué recordar

  • Un paquete es un espacio de nombres y una carpeta. import es solo una abreviatura del nombre completamente calificado, import static hace lo mismo con los miembros estáticos, y un import de una sola clase le gana a un comodín.
  • Los archivos fuente compactos viven en el paquete por defecto y no pueden declarar uno. import module java.base; es final en Java 25 y es lo que esos archivos reciben automáticamente.
  • El classpath es una lista de carpetas y JARs. Si falta la clase de inicio, obtienes ClassNotFoundException, y una clase que falta más adelante da NoClassDefFoundError solo cuando se ejecuta esa línea.
  • Un JAR es un zip con un manifiesto. --main-class fija Main-Class, y java -jar siempre lo ejecuta en el classpath, aunque sea un JAR modular.
  • module-info.java usa requires para módulos, exports para el acceso en tiempo de compilación a paquetes y opens para la reflexión profunda. Una clase public en un paquete no exportado es invisible fuera de su módulo.
  • El encapsulamiento y las revisiones al arrancar solo existen en el module path. En el classpath, todo es un solo módulo sin nombre.
  • A la mayoría de las aplicaciones les basta el classpath. Los módulos importan sobre todo para bibliotecas y para jlink.

Los paquetes nombran tu código, los JARs lo distribuyen y los módulos deciden quién de fuera puede usarlo.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.