Proceso de ejecución en Java
[English Version]
Cuando escribimos un programa en un lenguaje de programación que comúnmente tiene un nivel de abstracción considerablemente alto como el de Java, debemos tener en cuenta que el conjunto de instrucciones contenidas en él, así como cualquier tipo de manipulación o proceso que realicemos sobre objetos, en algún momento tendrá que ser descompuesto en una secuencia de operaciones elementales soportadas por el procesador encargado de la ejecución. Así pues, para entender mejor esta necesidad de codificar primero en un lenguaje similar al natural con determinadas restricciones y posteriormente “traducir” el programa a otro lenguaje cuyo contenido pueda ser procesado por una CPU, es conveniente comprender su funcionamiento, aunque su complejidad de implementación únicamente permita captar los fundamentos de todo su potencial.
El procesador (CPU)
El esquema superior muestra lo que se conoce como Máquina Universal de Turing (simplificada), que resulta ser el antecedente teórico básico de la computación clásica desde el siglo XX a posterior. Como se puede observar, sus componentes fundamentales son la memoria y el procesador. Por un lado, la memoria se representa como la lista de celdas en las que cada una contiene un único bit de información. Esta lista tiene la capacidad de ser todo lo extensa que se quiera o necesite (teóricamente), lo que da lugar a postulados en los que se afirma que cualquier proceso computable por una máquina de Turing puede ser resuelto usando una memoria lo suficientemente capaz. Sin embargo, en la práctica existe la limitación material y física que impide tener infinitos bits en memoria.
Por otra parte, puesto que la memoria necesariamente tiene que ser manipulada de alguna manera para completar los procesos correspondientes, la máquina de Turing cuenta con un cabezal (procesador) que apunta hacia un bit en concreto y cuya única función es desplazarse a lo largo de la lista de bits siguiendo una secuencia de instrucciones suministradas por una persona con el fin de cambiar el estado lógico de las respectivas celdas. Así pues, para realizar dicha labor la CPU cuenta con una serie de acciones triviales sustentadas por la implementación física de su propia circuitería. Estas acciones resultan ser muy simples, ya que son del tipo “moverse”, “leer” o “sobreescribir un valor lógico en x posición de memoria”. Especialmente cuando se las compara con la manera de programar en alto nivel, en la que se opera con clases, objetos, estructuras de datos robustas, etc..
Pero, a pesar de su sencillez, una secuencia de ellas lo suficientemente prolongada y ordenada es capaz de generalizar, es decir, llevar a cabo todo el cómputo requerido de alto nivel correctamente representado como conjunto válido de instrucciones elementales.
En la práctica, los procesadores tienen similitudes con el anterior modelo. En primer lugar, todos contienen un Instruction Set análogo al mostrado en la imágen superior que encapsula todas las posibles instrucciones que puede realizar el microprocesador. En el ejemplo de arriba se puede ver ciertas similitudes con el modelo de Turing, como por ejemplo; modificaciones de valor en registros (incrementos y decrementos), desplazamiento a través de la memoria, “saltos” entre sus posiciones y subrutinas, etc…
Asimismo, también se pueden distinguir diversas operaciones adicionales como AND, OR, XOR, Comparación lógica, Push o Pop. La mayoría de estas representan procesos y operaciones lógicas primarias directamente implementadas en la denominada Unidad Aritmético-Lógica (UAL), que mediante el uso de las propiedades y ventajas del álgebra Booleana facilitan el tratamiento de los datos de entrada y salida genéricamente codificados en binario natural. A pesar de la complejidad real de un procesador completo con una UAL extensa, se puede comprender mejor su función usando una simplificación como la de la imágen inferior, en la que se muestra una UAL de 1 solo bit. Por un lado, observamos como existe una zona en la que se agrupan determinadas puertas lógicas destinadas a realizar operaciones lógicas con los respectivos datos de entrada. Contiguamente, se localiza otra sección en la que a diferencia de la anterior, se ocupa de realizar las operaciones aritmétricas correspondientes con las entradas. Nótese que la única operación aritmétrica que puede realizar es la suma, puesto que es la más básica de todas y útil de combinar cuando se trata de generar cómputos más complejos. Aunque, dependiendo de la estructura del procesador puede contar con circuitos especializados en otro tipo de operaciones como cambios de signo o rotaciones de bits.
Además de la UAL, los procesadores están integrados igualmente con una Unidad de Control (UC), que recoge instrucciones almacenadas en memoria, las decodifica y las ejecuta con la ayuda de las demás unidades y registros de datos, direcciones, acumuladores, etc.. A su vez, esta unidad se hace cargo de estar en contacto con la memoria RAM, haciendo necesario el uso de instrucciones anteriormente vistas como Push y Pop, que en pocas palabras añaden o quitan contenido de una estructura de datos llamada Stack, en la que se pueden almacenar más instrucciones codificadas, datos, o cualquier cosa que se requiera poner en cola de procesamiento/ejecución.
Llegados a este punto, podemos concluir con los fundamentos necesarios para comprender el lugar que ocupa el procesador a la hora de ejecutar un programa. No obstante, el campo de la electrónica digital, que estudia a fondo cuestiones relacionadas con la construcción, mantenimiento y optimización de estos dispositivos es mucho más amplio que lo anteriormente explicado. Por tanto, es conveniente seguir investigando a partir de los siguientes recursos para tener una mejor idea acerca de lo que realmente es un procesador:
¿Cómo se ejecuta un programa?
Tras haber codificado un algoritmo en un lenguaje de programación, el siguiente paso si nos encontramos en un editor de código medianamente moderno es pulsar el botón de RUN y ver el posterior resultado, que puede variar dependiendo de lo que estemos programando. Sin embargo, estos editores no han existido siempre, y no todos ofrecen la opción de ejecutar el código con un simple botón. Por esta razón, conviene tener claros los conceptos básicos necesarios para poder llevar a cabo la ejecución de un programa sin la dependencia de un editor específico, programa alternativo, automatización o similar.
Fundamentalmente, el proceso de ejecución de un programa depende del lenguaje en el que esté escrito. Algunos son interpretados; esto es, en la ejecución un programa llamado intérprete recorre el fichero donde se almacena el código y va transformando en tiempo real cada instrucción de alto nivel en una secuencia de instrucciones “bajo nivel” pertenecientes al instruction set del procesador en cuestión. En cambio, otros lenguajes tienen la particularidad de que son compilados. En este caso, existe un programa conocido como compilador que a diferencia de un intérprete, no ejecuta directamente el código escrito, sino que lo compila. Es decir, recorre el archivo análogamente a un intérprete, convierte todas las instrucciones de alto nivel a bajo nivel, y las almacena en un archivo ejecutable, con el fin de que posteriormente el código escrito se ejecute a partir del nuevo archivo creado por el compilador. Algunos ejemplos de ambos tipos de lenguajes son:
- Interpretados: Python, MATLAB, JavaScript, Java
- Compilados: C, C++, Java, Rust
Las diferencias entre ambos procesos de interpretación y compilación tienen sus respectivas ventajas. Principalmente, los lenguajes interpretados como norma general suelen ser más lentos en contraste con los compilados, puesto que tener un fichero con todas las instrucciones listas y optimizadas para que las reconozca un procesador supone una mejora en el tiempo de ejecución final. No obstante, que un lenguaje sea compilado da lugar a una división en el proceso completo de ejecución e implica que “ejecutar” se convierta en una combinación de 2 fases;
- Compilar: El compilador genera un fichero ejecutable (.exe) con instrucciones en “binario” a partir del código escrito inicialmente.
- Ejecutar: Iniciar el archivo ejecutable generado por el compilador y ver los resultados del algoritmo.
Problema de la portabilidad
Existen numerosas diferencias y posibilidades de comparación entre ambos tipos de lenguaje, no solo en términos de velocidad de ejecución o espacio, sino también eficiencia en la gestión de memoria y portabilidad. A pesar de que la varianza de estas características en todos los lenguajes les proveen de utilidad característica para la resolución de ciertos problemas, la portabilidad sobre todo en aquellos que son compilados juega un papel importante a la hora de seleccionar el apropiado para iniciar un proyecto a gran escala, diseñar un algoritmo el cual se prevé que va a ser usado en múltiples plataformas o simplemente crear un producto que se va a usar en diferentes sistemas operativos.
La portabilidad, en este contexto se refiere a la capacidad que tiene un software/lenguaje para ser ejecutado en varias plataformas sin que tenga que ser recompilado o cuya ejecución resulte en errores de procesamiento.
Como se muestra en el esquema superior, si programamos en un lenguaje compilado y necesitamos que el programa se pueda ejecutar en más de un sistema operativo, en este caso Windows, Linux y macOS; debemos tener en cuenta las especificaciones del sistema para cada una de las plataformas, además de las características del procesador como su estructura, socket sobre el que se monta o interacción entre cada uno de sus núcleos de procesamiento. De esta manera, a partir del código que escribamos originalmente, se deben crear las correspondientes versiones adaptadas específicamente para cada plataforma en la que se vaya a usar. Esta especialización necesaria para cada sistema operativo se puede comprender mejor con los mencionados en anterior ejemplo. Así pues, si estuviéramos programando algo directamente relacionado con la organización de archivos y unidades de almacenamiento de cada plataforma, notaríamos que todas tienen ligeras y grandes diferencias que necesariamente tienen que ser consideradas para que el código no devuelva ningún error o peor aún, genere fallos críticos en el sistema. Debido a este problema de portabilidad, en la fase de compilar se le adjunta una de versionado en la que se cubren las necesidades de cada plataforma, lo que en proyectos grandes puede llegar a ser una tarea larga dependiendo del número de sistemas operativos en los que se vaya a utilizar.
Solución de Java
Ante este problema, el equipo de desarrollo de Sun Microsystems creó el lenguaje compilado Java con el objetivo de que tras escribir un programa una sola vez, éste fuera capaz de ser compilado y ejecutado en cualquier plataforma, independientemente del sistema operativo, especificaciones de hardware o similares. Asimismo, estas propiedades están fomentadas por la intención de que Java fuera un lenguaje unificado orientado a internet, es decir, que cualquier máquina conectada a internet pudiera funcionar con código Java.
Así como se muestra en la imágen superior, el fundamento de la solución que aporta Java al problema de la portabilidad reside en el resultado de la compilación del código original de alto nivel escrito una única vez (Write Onece, Run Everywhere), sin ningún tipo de versionado específico para plataformas o hardware, puesto que lo que hace distinto a este lenguaje es la versatilidad con la que se puede integrar en cualquier dispositivo (desde tostadoras hasta parquímetros). En este caso, durante el proceso de compilación, Java transforma el código de alto nivel en otro tipo de lenguaje de “bajo nivel” conocido como Java-bytecode, que no es más que un intermediario entre Java y las instrucciones contenidas en el instruction set de cada procesador. La naturaleza del propio bytecode aquí no es necesariamente relevante, lo importante es que una vez creado por el compilador, este puede ser interpretado por la máquina virtual de Java(JVM) en cualquier plataforma en la que esté correctamente instalada.
Aunque Java pueda parecer ser un lenguaje compilado gracias a la fase en la que el código original se convierte a bytecode, no se puede afirmar completamente que realmente sea compilado, puesto que cuenta con una fase de interpretación en la que la JVM interpreta en la ejecución final el fichero bytecode generado por el compilador. De esta manera, Java aprovecha las ventajas de ambos tipos de lenguaje. Por una parte, la versatilidad de los interpretados, y por otra la optimización temporal de los compilados, a pesar de que en Java este aprovechamiento tiene muchos matices debidos principalmente a la complejidad del proceso.
¿Cómo funciona el compilador?
En esta sección, trataremos de comprender mejor la estructura que sigue Java como lenguaje para lograr compilar un programa y transformar sus clases a Bytecode.
En primer lugar, tras la instalación de Java en un dispositivo desde el que se quiere desarrollar un programa, se cuenta con diversas secciones en las que cada una almacena las herramientas necesarias para crear dicho programa desde la codificación hasta la ejecución final. Por un lado, en el conjunto más grande representado en la imágen anterior se encuentra el Java Development Kit(JDK), que como su nombre indica encapsula una serie de herramientas de desarrollo genéricas como por ejemplo el Debugger(jdb), controladores de rendimiento (javaw), o el compilador (javac). Recordar que es en este último en el que recae la tarea principal de crear el Bytecode y almacenarlo en archivos .class que posteriormente serán procesados por la respectiva máquina virtual.
A continuación, puede surgir la pregunta ¿Cómo funciona internamente el compilador javac?; Ante esta cuestión cabe mencionar que el propio compilador es un software muy complejo que lleva una cantidad considerablemente alta de años en desarrollo, además de haber sufrido cambios y mejoras importantes en su manera de operar. Por tanto, explicar con todo lujo de detalle su funcionamiento resultaría demasiado largo y pesado para el objetivo de saber compilar un programa Java. Así pues, aquí solo se va a ver los pasos fundamentales que sigue para generar correctamente el código Bytecode, aunque para una explicación completa de todos los siguentes pasos existen recursos como Modern Compiler Implementation in Java, entre otros muchos.
1-Análisis léxico:
En este primer paso el compilador toma como entrada el fichero .java con el algoritmo codificado en lenguaje de alto nivel Java. Tras esto, javac realiza una descomposición en “tokens” del código escrito dentro del fichero. En resumidas cuentas, lo que realmente hace es recorrer el código y separar las palabras clave, operadores, separadores, comentarios, identificadores, etc.. de forma análoga al proceso de tokenización en un modelo NLP, con la diferencia de que aquí se tiene muy en cuenta la estructura del lenguaje, así como los símbolos y palabras clave destinadas a realizar determinadas funciones.
Como curiosidad, uno de los programas más ampliamente utilizados para realizar este tipo de tareas de análisis léxico es flex, al que se le puede suministrar una “definición” de la estructura que sigue un determinado lenguaje para generar un analizador que pueda ser reutilizado en un compilador.
2-Análisis sintáctico:
Este paso también es conodico como “Parsing” y consiste en comprobar que la sintaxis del código escrito es correcta, es decir, que todos los puntos y comas estén bien colocados al final de cada instrucción, que no haya llaves o paréntesis sin cerrar o que no haya erratas en las palabras clave.
Para ello, y en consonancia con el anterior paso, se produce una revisión en una estructura de datos que se puede crear entre estos dos primeros pasos llamada Abstract Syntax Tree(AST), cuya función se limita a representar de forma “abstracta” el código de alto nivel para que en futuros pasos se recorra el árbol o parte de él con el fin de añadir las instrucciones bajo nivel correspondientes a cada nodo del grafo.
Ejemplo de un programa que escribe Hello World! en pantalla, representado como AST.
3-Análisis semántico:
En un programa que vaya más allá de un simple HolaMundo, es muy posible que se usen variables, identificadores, métodos y demás técnicas que requieran de un nombre. Es por esto que en este paso lo que se hace es dar sentido a los identificadores extraídos del primer paso, así como determinar su significado y propiedades en la totalidad del código, relacionar su uso con otras variables o en expresiones o comprobar la compatibilidad de tipos.
Asimismo, también se registra cada variable en una tabla de símbolos, con su tipo, identificador, dimensión, dirección en memoria o valor asociado.
4-Generación de árboles de representación intermedia:
El árbol principal generado directamente tras el proceso de tokenización y análisis resulta en una estructura de datos que en programas muy grandes o mal optimizados ocupa un gran espacio en memoria, al mismo tiempo que su anchura y profundidad a lo largo de sus ramas lo hace costoso de recorrer. Así, en este paso que realmente engloba a otros muchos destinados a tareas muy concretas de transformación y optimización, se generan árboles de representación intermedia IR Trees, cuyo principal fin es acercar la forma de representar expresiones y ramas del árbol a las posibles instrucciones pertenecientes al lenguaje máquina (bytecode en este caso). Durante este proceso o conjunto de procesos, se optimiza el orden de ejecución de algunos nodos, mejorando el flujo de datos dentro del grafo.
5-Elección de instrucciones:
Se agrupan los árboles de representación intermedia en clusters que posteriormente deberán ser transformados en instrucciones soportadas por el lenguaje objetivo final, que en cualquier lenguaje compilado sería el instruction set del procesador, sin embargo, en Java se elige el Bytecode por sus ventajas de portabilidad mencionadas en anteriores secciones.
6-Análisis de flujo:
Este paso agrupa 2 procesos denominados análisis de flujo de control y análisis de flujo de datos. En cada uno de estos “subpasos” se ejecutan algoritmos de recorrido en árboles que calculan los posibles recorridos de control que puede seguir el programa al ser ejecutado y se guardan en un grafo de control de flujo.
Asimismo, también se calcula el uso de las variables a lo largo de la ejecución del programa, el flujo de datos que pasa a través de ellas, las expresiones en las que son usadas o las referencias a su espacio de memoria. Todo este análisis tiene como fin optimizar el uso de memoria del código final. Por esa razón, los algoritmos orientados a realizar esta tarea se encargan principalmente de detectar cuando una variable no es útil dentro del ciclo de ejecución, liberando su espacio ocupado modificando los respectivos nodos del árbol.
Como consecuencia de este análisis de flujo de datos, se puede incluir técnicas de optimización más sofisticadas como Constant Folding o Constant Propagation, mediante las que se determina si el valor de una expresión puede ser calculado en el proceso de compilación preferentemente al de ejecución, puesto que al precalcular el valor de una expresión extensa (si es posible) se ahorra el tiempo de cálculo que tendría que dedicar el procesador en la ejecución, mejorando la eficiencia del programa.
7-Emisión de código Java Bytecode:
En este último paso se aisgna un orden de pertenencia a cada variable para que sea contenida dentro de los registros del procesador sin que se sobreescriba erróneamente ningún valor. Finalmente se recorre el árbol resultante tras las numerosas optimizaciones efectuadas en los anteriores pasos y se va añadiendo al fichero bytecode final las instrucciones obtenidas durante la lectura de los datos contenidos en cada nodo del árbol.
Al final de todo el proceso, el compilador javac habrá generado tantos ficheros .class como clases hayamos implementado en el programa, de manera que cada uno de estos ficheros llevará de nombre el identificador de la respectiva clase que contenga.
¿Cómo funciona el intérprete?
Como se mencionó anteriormente, la ejecución de un fichero .class que contiene código Bytecode la realiza la máquina virtual JVM instalada en el sistema operativo. Su arquitectura de funcionamiento se ve representada en la imágen superior, aunque de manera similar a la explicación del compilador se omiten muchos detalles no esenciales para el correcto entendimiento del proceso completo que sigue la JVM para interpretar Bytecode.
1-Class Loader Subsystem:
En el primer bloque nos encontramos con el Class Loader Subsystem, que básicamente es un área en la que se agrupan 3 procesos fundamentales con los que se da comienzo a la interpretación de un fichero .class
Primero, se deben cargan las clases que se van a querer ejecutar. Así, se sigue un proceso de delegación en el que participan diferentes cargadores de clases; BootStrap ClassLoader, Extension ClassLoader y Application ClassLoader en ese orden. Cada una de estas herramientas se dedica a cargar clases desde diferentes lugares del sistema, de forma que si un cargador no logra alcanzar la clase que se requiere, el proceso de carga se delegará hacia el siguiente cargador con menor prioridad, dando lugar a un ClassNotFoundException si la clase no ha sido cargada por ninguno de los anteriores. Finalmente, tras ser correctamente procesada, la clase es transformada en datos codificados directamente en binario y guardada en el Method Area, perteneciente al Runtime Data Area.
En segundo lugar, la JVM verifica que la clase cargada sea válida, es decir, que el compilador no haya cometido ningún tipo de error o que el propio archivo no esté corrupto. Si la verificación falla se devuelve java.lang.VerifyError como excepción al usuario. Si todo va bien, se crea un objeto de tipo clase que es almacenado en el Heap de la memoria. Finalmente y para resumir, en la resolución e inicialización se llevan a cabo tareas de reemplazamiento de referencias simbólicas y gestión de variables estáticas.
-Para más información consultar la documentación!!
2-Runtime Data Area:
Esta zona se encarga de proporcionar la memoria necesaria para guardar todos los objetos, datos y valores necesarios. Tanto bytecode como objetos clase, cómputos intermedios o Threads.
A grandes rasgos, aquí está contenido el área de métodos(Method Area), que es un espacio de memoria reservado comúnmente a objetos clase, con sus correspondientes atributos, métodos y variables estáticas. El Heap, reservado para todo tipo de objetos y variables globales. Y el Stack, que a su vez se divide en un stack reservado para Threads, ejecuciones de métodos, e información de métodos nativos.
3-Execution Engine:
Llegamos a una de las áreas más importantes de todas, ya que aquí se lleva a cabo al interpretación del código Java Bytecode, además de una serie de optimizaciones que aceleran el proceso completo de ejecución.
Como primera instancia, la herramienta Interpreter se ocupa de todo el proceso de interpretación, que consiste en recorrer el Bytecode y ejecutar línea a línea cada instrucción contenida. Sin embargo, existen ciertas desventajas como pueden ser la ejecución de un método varias veces a lo largo de todo el flujo de control del programa. En el caso de que esto ocurra, el intérprete estará recorriendo y procesando todo el código contenido en el método una y otra vez innecesariamente durante las múltiples llamadas que se hayan realizado originalmente, consumiendo memoria y recursos vitales para otros procesos.
Para solucionar este problema, el Execution Engine contiene el Just in Time Compiler(JIT), que transforma en tiempo real el bytecode en código nativo todas las instrucciones y métodos, las almacena en el espacio de memoria nativa correspondiente, y provee al Interpreter de todo este código nativo. De manera que cada vez que se detecta la llamada a un método, no se requiere una reinterpretación completa de su contenido, sino que directamente el Interpreter utiliza el código nativo para ejecutar el método de forma eficiente.
Además, se cuenta con un Garbage Collector, que acumula y elimina todos los espacios de memoria reservados a variables u objetos que nunca son referenciados, optimizando el uso de los recursos disponibles.
4/5-Java Native Interface (JNI) y Native Method Libraries:
Estas dos áreas conforman un conjunto de herramientas que habilitan la creación y correcto funcionamiento del código nativo en hardware específico, relacionando código de Java con librerías y recursos de otros lenguajes como C o C++.
Ejecución práctica (Linux)
En esta sección se detallan todos los pasos a seguir para instalar Java en un dispositivo con un sistema operativo Linux (distribución Debian 11.5.0), crear un programa, compilarlo y ejecutarlo con la ayuda del JDK.
1-Fase de instalación
Esta serie de pasos se pueden omitir si el sistema dispone de un JDK y un JRE(Java Runtime Environment) correctamente instalados en el sistema. No obstante, simepre se ha de comprobar que las versiones del compilador y el intérprete coincidan, de lo contrario se pueden producir errores.
Primero, conviene actualizar el gestor de paquetes apt (advanced packaging tool) con el anterior comando para que las versiones de lo que instalemos sean las más recientes. Al inicio de la línea, se recomienda usar el comando sudo para evitar errores causados por la falta de privilegios del usuario con el que estemos usando la terminal. De esta manera, sudo nos permite acceder temporalmente con permisos de administrador (usuario root).
Tras habernos asegurado que el apt está actualizado, se instala el Java Development Kit con permisos de administrador mediante el comando install y el argumento default-jdk para indicar a la terminal lo que se va a querer instalar.
Posteriormente se hace lo mismo para el Java Runtime Environment.
2-Crear un programa
En Linux se pueden crear y editar programas con cualquier editor instalado, aunque los más utilizados son Vim, Nano o Visual Studio Code. En este caso, se ha utilizado Nano para escribir un programa que muestre en pantalla “Hola Mundo!!”, y guardarlo en el escritorio como HelloWorld.java,( acorde al nombre de la clase principal).
3-Compilar
Una vez creado el código, el comando javac con su argumento principal en el que se escribe la ruta del archivo .java que deseamos compilar, aunque si el archivo está en el mismo directorio que la terminal no es necesario.
Cuando finalize el proceso de compilación (sin ningún error), se habrán creado en la misma ruta que el archivo .java todos los ficheros .class correspondientes a las clases que contenga el código que hemos escrito. En este caso solo hay una clase llamada HelloWorld que resulta ser la principal, es decir, será la que usemos como argumento a la hora de ejecutar, puesto que contiene el método main.
Si tratamos de ver lo que hay dentro del archivo .class, nos encontraremos directamente con código intermediario Bytecode como el que se muestra arriba.
4-Ejecutar/(Interpretar)
El último paso de todos después de haber compilado sin errores es ejecutar el programa con la ayuda de la máquina virtual de Java. Para ello, y análogamente al anterior paso, se usa el comando java seguido de su argumento principal, que esta vez es nombre de la clase principal del programa que hayamos escrito. En este comando, NO hay que escribir la extensión del archivo .class en el que se almacena la clase, ya que el argumento solo busca la clase por el nombre, además es fundamental que el comando se ejecute desde el mismo lugar en el que está guardado el archivo .class
Como se observa en la imágen, si le pasamos como argumento el archivo con su extensión, el intérprete buscará la clase “HelloWorld.class” en vez de “HelloWorld” lo que producirá una excepción del tipo java.lang.ClassNotFoundException.