在Ubuntu中编译和调试OpenJDK

 

构建编译环境

安装GCC编译器:

sudo apt install build-essential

安装OpenJDK依赖库:

工具 库名称 安装命令
FreeType The FreeType Project sudo apt install libfreetype6-dev
CUPS Common UNIX Printing System sudo apt install libcups2-dev
X11 X Window System sudo apt install libx11-dev libxext-dev libxrender-dev libxrandr-dev libxtst-dev libxt-dev
ALSA Advanced Linux Sound Architecture sudo apt install libasound2-dev
libffi Portable Foreign Function Interface sudo apt install libffi-dev
Autoconf Extensible Package of M4 Macros sudo apt install autoconf
zip/unzip unzip sudo apt install zip unzip
fontconfig fontconfig sudo apt install libfontconfig1-dev

假设要编译大版本号为N的JDK,我们还要安装一个大版本号至少为N-1的、已经编译好的JDK作为“Bootstrap JDK”:

sudo apt install openjdk-11-jdk

获取源码

可以直接访问准备下载的JDK版本的仓库页面(譬如本例中OpenJDK 11的页面为https://hg.openjdk.java.net/jdk-updates/jdk11u/),然后点击左边菜单中的“Browse”,再点击左边的“zip”链接即可下载当前版本打包好的源码,到本地直接解压即可。

也可以从Github的镜像Repositories中获取(https://github.com/openjdk),进入所需版本的JDK的页面,点击Clone按钮下的Download ZIP按钮下载打包好的源码,到本地直接解压即可。

进行编译

首先进入解压后的源代码目录,本例解压到的目录为~/openjdk/

cd ~/openjdk

要想带着调试、定制化的目的去编译,就要使用OpenJDK提供的编译参数,可以使用bash configure --help查看. 本例要编译SlowDebug版、仅含Server模式的HotSpot虚拟机,同时我们还可以禁止压缩生成的调试符号信息,方便gdb调试获取当前正在执行的源代码和行号等调试信息. 对应命令如下:

bash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info

对于版本较低的OpenJDK,编译过程中可能会出现了源码deprecated的错误,这是因为>=2.24版本的glibc中 ,readdir_r等方法被标记为deprecated。若读者也出现了该问题,请在configure命令加上--disable-warnings-as-errors参数,如下:

bash configure --with-debug-level=slowdebug --with-jvm-variants=server --disable-zip-debug-info --disable-warnings-as-errors

此外,若要重新编译,请先执行make dist-clean

执行make命令进行编译:

make

生成的JDK在build/配置名称/jdk中,测试一下,如:

cd build/linux-x86_64-normal-server-slowdebug/jdk/bin
./java -version

生成Compilation Database

CLion可以通过Compilation Database来导入项目. 在OpenJDK 11u及之后版本中,OpenJDK官方提供了对于IDE的支持,可以使用make compile-commands命令生成Compilation Database

make compile-commands

对于版本较低的OpenJDK,可以使用一些工具来生成Compilation Database,比如:

然后检查一下build/配置名称/下是否生成了compile_commands.json.

cd build/linux-x86_64-normal-server-slowdebug
ls -l

导入项目至CLion

优化CLion索引速度

提高Inotify监视文件句柄上限,以优化CLion索引速度:

  1. /etc/sysctl.conf中或 /etc/sysctl.d/目录下新建一个*.conf文件,添加以下内容:

    fs.inotify.max_user_watches = 524288
    
  2. 应用更改:

    sudo sysctl -p --system
    
  3. 重新启动CLion

导入项目

打开CLion,选择Open Or Import,选择上文生成的build/配置名称/compile_commands.json文件,弹出框选择Open as Project,等待文件索引完成.

接着,修改项目的根目录,通过Tools -> Compilation Database -> Change Project Root功能,选中你的源码目录.

为了减少CLion索引文件数,提高CLion效率,建议将非必要的文件夹排除:Mark Directory as -> Excluded. 大部分情况下,我们只需要索引以下文件夹下的源码:

  • src/hotspot
  • src/java.base

配置调试选项

创建自定义Build Target

点击File菜单栏,Settings -> Build, Execution, Deployment -> Custom Build Targets,点击+新建一个Target,配置如下:

  • NameTarget的名字,之后在创建Run/Debug配置的时候会看到这个名字

  • 点击Build或者Clean右边的三点,弹出框中点击+新建两个External Tool配置如下:

    # 第一个配置如下,用来指定构建指令
    # Program 和 Arguments 共同构成了所要执行的命令 "make"
    Name: make
    Program: make
    Arguments:
    Working directory: {项目的根目录}
      
    # 第二个配置如下,用来清理构建输出
    # Program 和 Arguments 共同构成了所要执行的命令 "make clean"
    Name: make clean
    Program: make
    Arguments: clean
    Working directory: {项目的根目录}
    
  • ToolChain选择DefaultBuild选择make(上面创建的第一个External Tool);Clean选择make clean(上面创建的第二个External Tool

创建自定义的Run/Debug configuration

点击Run菜单栏,Edit Configurations, 点击+,选择Custom Build Application,配置如下:

# Executable 和 Program arguments 可以根据需要调试的信息自行选择

# Name:Configure 的名称
Name: OpenJDK
# Target:选择上一步创建的 “Custom Build Target”
Target: {上一步创建的 “Custom Build Target”}
# Executable:程序执行入口,也就是需要调试的程序
Executable: 这里我们调试`java`,选择`{source_root}/build/{build_name}/jdk/bin/java`。
# Program arguments: 与 “Executable” 配合使用,指定其参数
Program arguments: 这里我们选择`-version`,简单打印一下`java`版本。
# Before luanch:这个下面的Build可去可不去,去掉就不会每次执行都去Build,节省时间,但其实OpenJDK增量编译的方式,每次Build都很快,所以就看个人选择了。

配置GDB

由于HotSpot JVM内部使用了SEGV等信号来实现一些功能(如NullPointerExceptionsafepoints等),所以调试过程中,GDB可能会误报Signal: SIGSEGV (Segmentation fault). 解决办法是,在用户目录下创建.gdbinit,让GDB捕获SEGV等信号:

vi ~/.gdbinit

将以下内容追加到文件中并保存:

handle SIGSEGV nostop noprint pass

开始调试

使用CLion调试C++层面的代码

完成以上配置之后,一个可修改、编译、调试的HotSpot工程就完全建立起来了。HotSpot虚拟机启动器的执行入口是${source_root}/src/java.base/share/native/libjli/java.cJavaMain()方法,读者可以设置断点后点击Debug可开始调试.

使用GDB调试汇编层面的代码

这里提供两个方法,一个是使用-XX:StopInterpreterAt=<n>虚拟机参数来实现中断,缺点是需要找到你所感兴趣的字节码在程序中的序号;第二个方法是直接去寻找记录生成的机器指令的入口(EntryPoint)的表,即Interpreter::_normal_table,在对应的字节码入口地址打断点,但是这需要读者对模板解释器有一定了解。

使用虚拟机参数进行中断

对于汇编级别的调试,我们可以手动使用GDB进行调试:

gdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java

由于目前HotSpot在主流的操作系统上,都采用模板解释器来执行字节码,它与即时编译器一样,最终执行的汇编代码都是运行期间产生的,无法直接设置断点,所以HotSpot增加了一些参数来方便开发人员调试解释器。

我们可以先使用参数-XX:+TraceBytecodes,打印并找出你所感兴趣的字节码位置,中途可以使用Ctrl + C退出:

set args -XX:+TraceBytecodes
run

然后,再使用参数-XX:StopInterpreterAt=<n>,当遇到程序的第n条字节码指令时,便会进入${source_root}/src/os/linux/vm/os_linux.cpp中的空函数breakpoint()

set args -XX:+TraceBytecodes -XX:StopInterpreterAt=<n>

再通过GDB在${source_root}/src/hotspot/os/linux/os_linux.cpp中的breakpoint()函数上打上断点:

break breakpoint

为什么要将断点打在这里?

去看${source_root}/src/hotspot/share/interpreter/templateInterpreterGenerator.cpp里,函数TemplateInterpreterGenerator::generate_and_dispatch中对stop_interpreter_at()的调用就知道了.

接着我们开始运行hotspot:

run

当命中断点时,我们再跳出breakpoint()函数:

finish

这样就会返回到真正的字节码的执行了。

不过,我们还要跳过函数TemplateInterpreterGenerator::generate_and_dispatch中插入到字节码真正逻辑前的一些用于debug的逻辑:

if (PrintBytecodeHistogram)                                    histogram_bytecode(t);
// debugging code
if (CountBytecodes || TraceBytecodes || StopInterpreterAt > 0) count_bytecode();
if (PrintBytecodePairHistogram)                                histogram_bytecode_pair(t);
if (TraceBytecodes)                                            trace_bytecode(t);
if (StopInterpreterAt > 0)                                     stop_interpreter_at();

比如开启了参数-XX:+TraceBytecodes-XX:StopInterpreterAt=<n>,应该跳过的指令如下:

# count_bytecode()对应指令:
0x7fffe07e8261:	incl   0x16901039(%rip)        # 0x7ffff70e92a0 <BytecodeCounter::_counter_value>
# trace_bytecode(t)对应指令:
0x7fffe07e8267:	mov    %rsp,%r12
0x7fffe07e826a:	and    $0xfffffffffffffff0,%rsp
0x7fffe07e826e:	callq  0x7fffe07c5edf
0x7fffe07e8273:	mov    %r12,%rsp
0x7fffe07e8276:	xor    %r12,%r12
# stop_interpreter_at()对应指令:
0x7fffe07e8279:	cmpl   $0x66,0x1690101d(%rip)        # 0x7ffff70e92a0 <BytecodeCounter::_counter_value>
0x7fffe07e8283:	jne    0x7fffe07e828e
0x7fffe07e8289:	callq  0x7ffff606281a <os::breakpoint()>

#	.........................
#	......真正的字节码逻辑......
#	.........................

# dispatch_epilog(tos_out, step)对应指令,用来取下一条指令执行...

进入真正的字节码逻辑后,我们就可以使用指令级别的stepi, nexti命令来进行跟踪调试了。(由于汇编代码都是运行期产生的,GDB中没有与源代码的对应符号信息,所以不能用C++源码行级命令step以及next

寻找字节码机器指令的入口手动打断点

关于模板解释器相关知识,可以阅读:JVM之模板解释器.

还是一样,运行GDB:

gdb build/linux-x86_64-normal-server-slowdebug/jdk/bin/java
start
break JavaMain
continue

我们先在${source_root}/src/hotspot/share/interpreter/templateInterpreter.cppDispatchTable::set_entry(...)函数上打条件断点,条件是函数实参i == <字节码对应十六进制>,字节码对应的十六进制见:${source_root}/src/hotspot/share/interpreter/bytecodes.hppBytecodes::Code.

break DispatchTable::set_entry if i==<字节码对应十六进制>

然后继续运行

continue

命中断点后,查看函数实参entry所指向的内存地址

print entry

在这个地址上打断点。

break *<内存地址>

然后继续运行

continue

命中断点后,就跟前一个方法一样可以直接使用指令级别的stepi, nexti命令来进行跟踪调试了。

配置IDEA

为项目的绑定JDK源码路径

打开IDEA,新建一个项目。然后选择File -> Project Structure,选到SDKs选项,新添加上自己刚刚编译生成的JDK,JDK home path${source_root}/build/配置名称/jdk. 然后在Sourcepath下移除原本的源码路径(如果有),并添加为前面的源代码,如${source_root}/src/java.base/share/classes等. 这样以来,我们就可以在IDEA中编辑JDK的JAVA代码,添加自己的注释了。

重新编译JDK的JAVA代码

在添加中文注释后,再编译JDK时会报错:

error: unmappable character (0x??) for encoding ascii

我们可以在${source_root}/make/common/SetupJavaCompilers.gmk中,修改两处编码方式的设置,替换原内容:

-encoding ascii

为:

-encoding utf-8

这样编译就不会报错了。

而且,如果我们只修改了JAVA代码,无需使用make命令重新编译整个OpenJDK,而只需要使用以下命令仅编译JAVA模块:

make java

使用IDEA的Step Into跟踪调试源码

我们发现,在IDEA调试JDK源码时,无法使用Step Into(F7)跟进JDK中的相关函数,这是因为IDEA默认设置不步入这些内置的源码。可以在File -> Settings -> Build, Execution, Deployment -> Debugger -> Stepping中,取消勾选Do not step into the classes来取消限制。

参考文章