Fuentes, Bytecode, Depuración

Otros idiomas: English 日本語 한국어 Português 中文

Cuando se depuran programas en Java, los desarrolladores a menudo tienen la impresión de que están interactuando directamente con el código fuente. Esto no es sorprendente: las herramientas de Java hacen un trabajo tan excelente en ocultar la complejidad que casi parece que el código fuente existe en tiempo de ejecución.

Si estás comenzando con Java, es probable que recuerdes esos diagramas que muestran cómo el compilador transforma el código fuente en bytecode, que luego es ejecutado por la JVM. También puedes preguntarte: si ese es el caso, ¿por qué examinamos y pasamos a través del código fuente en lugar del bytecode? ¿Cómo sabe la JVM algo sobre nuestras fuentes?

Este artículo es un poco diferente de mis publicaciones anteriores sobre depuración. En lugar de centrarse en cómo depurar un problema específico, como una aplicación que no responde o una fuga de memoria, explora cómo funcionan Java y los depuradores detrás de escena. Mantente cerca, como siempre, se incluyen un par de trucos útiles.

Bytecode

Comencemos con un rápido resumen. Los diagramas que se encuentran en los libros y guías de Java son correctos: la JVM ejecuta bytecode.

Considera la siguiente clase como ejemplo:

package dev.flounder;

public class Calculator {
    int sum(int a, int b) {
        return a + b;
    }
}

Cuando se compila, el método sum() se convierte en el siguiente bytecode:

int sum(int, int);
    descriptor: (II)I
    flags: (0x0000)
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: ireturn
Tip icon

Puedes inspeccionar el bytecode de tus clases usando el comando javap -v incluido en el JDK. Si estás utilizando IntelliJ IDEA, también puedes hacer esto desde la IDE: después de compilar tu proyecto, selecciona una clase, luego haz clic en View | Show Bytecode.

Info icon

Dado que los archivos de clase son binarios, citar su contenido crudo no sería informativo. Para facilitar la lectura, los ejemplos en este artículo siguen el formato de salida de javap -v.

El bytecode consta de una serie de instrucciones compactas e independientes de la plataforma. En el ejemplo anterior:

  1. iload_1 y iload_2 cargan las variables en el stack de operandos
  2. iadd suma el contenido del stack de operandos, dejando un solo valor de resultado en él
  3. ireturn devuelve el valor del stack de operandos

Además de las instrucciones, los archivos de bytecode también incluyen información sobre constantes, el número de parámetros, variables locales y la profundidad del stack de operandos. Esto es todo lo que la JVM necesita para ejecutar un programa escrito en un lenguaje JVM, como Java, Kotlin o Scala.

Información de depuración

Dado que el bytecode se ve completamente diferente a tu código fuente, referirse a él mientras se depura sería ineficiente. Por esta razón, las interfaces de los depuradores de Java, como el JDB (el depurador de consola incluido en el JDK) o el de IntelliJ IDEA, muestran el código fuente en lugar del bytecode. Esto te permite depurar el código que escribiste sin tener que pensar en el bytecode subyacente que se está ejecutando.

Por ejemplo, tu interacción con el JDB podría verse así:

Initializing jdb ...

> stop at dev.flounder.Calculator:5

Deferring breakpoint dev.flounder.Calculator:5.
It will be set after the class is loaded.

> run

run dev/flounder/Main
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
VM Started: Set deferred breakpoint dev.flounder.Calculator:5
Breakpoint hit: "thread=main", dev.flounder.Calculator.sum(), line=5 bci=0

> locals

Method arguments:
a = 1
b = 2

IntelliJ IDEA mostrará la información relacionada con la depuración en el editor y en la ventana de herramientas Debug:

IntelliJ IDEA resalta la línea que se está ejecutando actualmente y proporciona información sobre las variables disponibles IntelliJ IDEA resalta la línea que se está ejecutando actualmente y proporciona información sobre las variables disponibles

Como puedes ver, ambos depuradores utilizan los nombres de las variables correctas y hacen referencia a las líneas válidas de nuestro snippet de código anterior.

Como el tiempo de ejecución no tiene acceso a los archivos fuente, debe recopilar estos datos en otro lugar. Aquí es donde entra en juego la información de depuración. La información de depuración (también denominada símbolos de depuración) es información compacta que vincula el bytecode a las fuentes de la aplicación. Se incluye en los archivos .class durante la compilación.

Hay tres tipos de información de depuración:

En los siguientes capítulos, explicaré brevemente cada tipo de información de depuración y cómo la usa el depurador.

Números de línea

La información de número de línea se almacena en el atributo LineNumberTable dentro del archivo bytecode, y se ve así:

LineNumberTable:
line 5: 0
line 6: 2

La tabla anterior le dice al depurador lo siguiente:

Este tipo de información de depuración ayuda a las herramientas externas, como los depuradores o profilers, a rastrear la línea exacta donde se ejecuta el programa en el código fuente.

Es importante destacar que la información del número de línea también se utiliza para las referencias de las fuentes en los rastreos de pila de excepciones. En el siguiente ejemplo, compilé el código de mi otro tutorial tanto con como sin información de número de línea. Aquí están los rastreos de pila producidos por los ejecutables resultantes:


Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java)

El ejecutable compilado sin información de número de línea produjo un rastreo de pila que carece de números de línea para las llamadas correspondientes a mi código del proyecto. Las llamadas de la biblioteca estándar y las dependencias todavía incluyen números de línea porque se compilaron por separado y no se vieron afectadas.

Además de los rastreos de pila, puedes encontrar una situación similar donde se involucran números de línea, por ejemplo, en la pestaña Frames de IntelliJ IDEA:

Marcos sin números de línea en el depurador de IntelliJ IDEA Marcos sin números de línea en el depurador de IntelliJ IDEA

Por lo tanto, si ves -1 en lugar de los números de línea reales, y no te gusta, asegúrate de que tu programa esté compilado con información de número de línea.

Crazy icon

Puedes ver el offset de bytecode directamente en la pestaña Frames de IntelliJ IDEA. Para ello, añade la siguiente clave de registro: debugger.stack.frame.show.code.index=true

IntelliJ IDEA muestra el desplazamiento del bytecode justo al lado del número de línea en la pestaña 'Frames' IntelliJ IDEA muestra el desplazamiento del bytecode justo al lado del número de línea en la pestaña 'Frames'

Nombres de variables

Al igual que la información de número de línea, los nombres de las variables se almacenan en archivos de clase. La tabla de variables de nuestro ejemplo se ve así:

LocalVariableTable:
Start  Length  Slot  Name   Signature
    0       4     0  this   Ldev/flounder/Calculator;
    0       4     1     a   I
    0       4     2     b   I

Contiene la siguiente información:

Si faltan variables en la información de depuración, algunas funcionalidades del depurador podrían no funcionar como se espera, y verás slot_1, slot_2, etc. en lugar de los nombres reales de las variables.

IntelliJ IDEA muestra 'slot_1', 'slot_2', etc. en lugar de nombres de variables en el depurador IntelliJ IDEA muestra 'slot_1', 'slot_2', etc. en lugar de nombres de variables en el depurador

Nombres de archivos fuente

Este tipo de información de depuración indica qué archivo fuente se utilizó para compilar la clase. Al igual que la información de número de línea, su presencia en los archivos de clase afecta no solo a las herramientas externas, sino también a los rastreos de pila que genera tu programa.


Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Unknown Source)

Sin los nombres de los archivos fuente, las llamadas correspondientes del rastreo de pila se marcarán como Unknown Source.

Flags del compilador

Como desarrollador, tienes control sobre si incluir información de depuración en tus ejecutables y, si es así, qué tipos incluir. Puedes gestionar esto usando el argumento del compilador -g, de esta manera:

javac -g:lines,vars,source

Aquí está la sintaxis:

CommandResultado
javacCompila la aplicación con números de línea y nombres de archivos fuente (predeterminado para la mayoría de los compiladores)
javac -g

Compila la aplicación con toda la información de depuración disponible: números de línea, variables y nombres de archivos fuente

javac -g:lines,source

Compila la aplicación con los tipos especificados de información de depuración - números de línea y nombres de archivos fuente en este ejemplo

javac -g:noneCompila la aplicación sin la información de depuración
Info icon

Los valores predeterminados pueden variar entre los compiladores. Algunos de ellos excluyen completamente la información de depuración a menos que se instruya lo contrario.

Si estás utilizando un sistema de compilación, como Maven o Gradle, puedes pasar las mismas opciones a través de argumentos del compilador. Por ejemplo:


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <compilerArgs>
            <arg>-g:vars,lines</arg>
        </compilerArgs>
    </configuration>
</plugin>
tasks.compileJava {
    options.compilerArgs.add("-g:vars,lines")
}

¿Por qué eliminar la información de depuración?

Como acabamos de ver, los símbolos de depuración habilitan el proceso de depuración, lo cual es conveniente durante el desarrollo. Por esta razón, los símbolos de depuración generalmente se incluyen en las compilaciones de desarrollo. En las compilaciones de producción, a menudo se excluyen; sin embargo, esto depende en última instancia del tipo de proyecto en el que estés trabajando.

Aquí te dejo un par de cosas que quizás quieras considerar:

Seguridad

Dado que un depurador se puede utilizar para manipular tu programa, incluir información de depuración hace que tu aplicación sea un poco más vulnerable a los ataques y la ingeniería inversa, lo cual puede no ser deseable para algunas aplicaciones.

Aunque la ausencia de símbolos de depuración podría dificultar un poco la interferencia con tu programa utilizando un depurador, no lo protege completamente. La depuración sigue siendo posible incluso con información de depuración parcial o faltante, por lo que esto por sí solo no evitará que una persona determinada acceda a los componentes internos de tu programa. Por lo tanto, si estás preocupado por el riesgo de ingeniería inversa, debes emplear medidas adicionales, como la ofuscación del código fuente.

Tamaño del ejecutable

Cuanta más información contenga un ejecutable, más grande será. Exactamente cuánto más grande depende de varios factores. El tamaño de un archivo de clase en particular podría ser fácilmente dominado por el número de instrucciones y el tamaño del conjunto de constantes, haciendo que sea poco práctico proporcionar una estimación universal. Aún así, para demostrar que la diferencia puede ser sustancial, experimenté con Airports.java, que usamos anteriormente para comparar los rastreos de pila. Los resultados son 4,460 bytes sin información de depuración en comparación con 5,664 bytes con ella.

En la mayoría de los casos, incluir símbolos de depuración no hará daño. Sin embargo, si el tamaño del ejecutable es una preocupación, como suele ser el caso de los sistemas integrados, es posible que desees excluir los símbolos de depuración de tus binarios.

Agregar fuentes para la depuración

Normalmente, las fuentes requeridas residen dentro de tu proyecto, por lo que la IDE no tendrá problemas para encontrarlas. Sin embargo, hay situaciones menos comunes, por ejemplo, cuando el código fuente necesario para la depuración está fuera de tu proyecto, como cuando se entra en una biblioteca utilizada por tu código.

En este caso, necesitas agregar archivos fuente manualmente: ya sea colocándolos debajo de una raíz de fuentes o especificándolas como una dependencia. Durante la depuración, IntelliJ IDEA automáticamente detectará y hará coincidir estos archivos con las clases ejecutadas por la JVM.

Cuando falta el proyecto

En la mayoría de los casos, compilarías, lanzarías y depurarías una aplicación en el mismo IDE, utilizando el proyecto original. ¿Pero qué pasa si solo tienes unos pocos archivos fuente y el proyecto en sí mismo está ausente?

Aquí tienes una configuración de depuración mínima que funcionará:

  1. Crea un proyecto Java vacío
  2. Agrega los archivos fuente bajo una raíz de fuentes o especifica ellos como una dependencia
  3. Lanza la aplicación objetivo con el agente de depuración. En Java, esto se hace típicamente añadiendo una opción de VM, como:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
  1. Crea una configuración de ejecución de Remote JVM Debug con los detalles de conexión correctos. Usa esta configuración de ejecución para adjuntar el depurador a la aplicación objetivo.

Con esta configuración, puedes depurar un programa sin acceder al proyecto original. IntelliJ IDEA hará coincidir las fuentes disponibles con las clases de tiempo de ejecución y te permitirá usarlas en una sesión de depuración. De esta manera, incluso una sola clase de proyecto o biblioteca te da un punto de entrada para la depuración.

Desajuste de fuente

Una situación confusa que podría encontrar durante la depuración es cuando su aplicación parece suspendida en una línea en blanco o los números de línea en la pestaña Frames no coinciden con los del editor:

IntelliJ IDEA resalta una línea vacía como si se hubiera ejecutado IntelliJ IDEA resalta una línea vacía como si se hubiera ejecutado

Esto ocurre al depurar código descompilado (que discutiremos en otro artículo) o cuando el código fuente no coincide completamente con el bytecode que la JVM está ejecutando.

Dado que el único vínculo entre el bytecode y un archivo fuente particular es el nombre del archivo y sus clases, el depurador tiene que confiar en esta información, asistido por algunas heurísticas. Esto funciona bien para la mayoría de las situaciones; sin embargo, la versión del archivo en el disco puede diferir de la que se usó para compilar la aplicación. En el caso de una coincidencia parcial, el depurador identificará las discrepancias e intentará conciliarlas en lugar de fallar rápidamente. Dependiendo de la extensión de las diferencias, esto podría ser útil, por ejemplo, si la única fuente que tiene no es la coincidencia más cercana.

En el afortunado escenario en el que tiene la versión exacta de las fuentes en otro lugar, puede solucionar este problema agregando a ellas al proyecto y volviendo a ejecutar la sesión de depuración.

Conclusión

En este artículo, hemos explorado la conexión entre los archivos fuente, el bytecode y el depurador. Aunque no es estrictamente necesario para la codificación diaria, tener una imagen más clara de lo que sucede bajo la capota puede darle una comprensión más sólida del ecosistema y puede ocasionalmente ayudarte a salir de situaciones no estándar y problemas de configuración. ¡Espero que hayas encontrado útiles la teoría y los consejos!

Todavía hay muchos más temas por venir en esta serie, así que manténganse en sintonía para el próximo. Si hay algo específico que te gustaría ver cubierto, o si tienes ideas y comentarios, ¡me encantaría escuchar de ti!

all posts ->