2024年4月

Java JDPA

JDPA 是一个框架, 它定义了 debugger 和 debuggee 是如何交互的, 两端交互的API 和 协议, 并不包含具体的实现. 它包含3部分.

  1. debugger 端的API: JDI (Java Debug Interface)
  2. debuggee 端的API: JVM TI
  3. 连接两端的协议: JDWP

JDPA.png

jdb

jdb 是 JDK 自带的命令行 debugger 端, 是 JDI 的实现.

  1. 它能连接本地和远程的目标 Java 应用, 执行 debug 命令.
  2. 它能连接本地和远程的 core dump 文件, 并获取信息.
  3. 它能启动本地要debug 的Java 应用, 并且debug.

    jdb -connect sun.jvm.hotspot.jdi.SAPIDAttachingConnector:pid=9302
    jdb -connect com.sun.jdi.SocketAttach:port=12306
    jdb -connect sun.jvm.hotspot.jdi.SACoreAttachingConnector:javaExecutable=$JAVA_HOME/bin/java,core=core.20441
    远程的 core dump 需要 jsadebugd. 

jdb 本地启动一个app 的背后

下面本地启动一个需要debug的app, 然后观察 jdb 是如何跟这个app交互的.

$ jdb FreeMemoryExample # 启动 jdb 
$ stop at FreeMemoryExample:8 # 设置断点
$ run # 启动应用. 

打开另外一个窗口, 然后观察如下:

$ jps #查看当前机器的 java 进程
3790574 TTY
3790626 FreeMemoryExample
3793734 Jps #jps 本身

$ sudo lsof -T -p  3790574 | grep 5598 #查看进程 3790574 里面连接的 tcp, 它的55985连到本地 55888
jdb     3790574 supra    6u  IPv6           37707266       0t0      TCP suprabox:55985->localhost:55888
$ sudo lsof -T -p 3790626 | grep 5598 #查看进程 3790626 里面连接的 tcp, 它的55888连到本地 55985
java    3790626 supra    4u  IPv4           37703541       0t0      TCP localhost:55888->suprabox:55985
$ ps aux | grep 3790626 #查看 3790626 的启动命令
supra    3790626  0.1  0.2 7021672 41712 pts/0   Sl+  00:06   0:01 /usr/lib/jvm/java-11-openjdk-amd64/bin/java -Xdebug -Xrunjdwp:transport=dt_socket,address=suprabox:55985,suspend=y FreeMemoryExample
$ ps aux | grep 3790574 #查看 3790574 的启动命令
supra    3790574  0.2  0.4 7619496 65040 pts/0   Sl+  00:06   0:02 jdb FreeMemoryExample

所以, 当运行 jdb FreeMemoryExample 的时候, 其实它调用java 进程另外启动了需要被 debug 的程序,并且建立了 tcp socket 通信.

被启动的进程使用了参数: -Xdebug -Xrunjdwp:transport=dt_socket,address=suprabox:55985,suspend=y

添加 debug 参数启动应用, 并连接

只要在对应的 Java 进程启动的时候, 添加类似如下参数, 就能启动debug 监听端口, 等待准备被连接:

$ java -agentlib:jdwp=transport=dt_socket,server=y,address=12306 FreeMemoryExample #启动应用端
# 更多参数参看: https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/conninv.html

$ jdb -connect com.sun.jdi.SocketAttach:port=12306 # 启动jdb 并连接
 stop at FreeMemoryExample:8  # 设置断点
 run # 开始运行
 locals #显示本地变量
 where # 显示栈

使用 JDB debug Java 应用程序

写了很多年Java 程序, 很少有机会去使用 JDB debug Java 程序. 因为本地都是使用 IDE 里面的工具, 对于没有桌面的生产环境, 基本都是加入打日志的新代码, 或者使用 Btrace/Bytesman 进行注入. 今天我们将使用 Jdb 在生产环境debug 一段代码.

debug 一段简单的代码

下面是使用 MBean 获取系统剩余空闲内存的代码, 我们将用 jdb 去debug 它.

import java.lang.management.ManagementFactory;
import com.sun.management.OperatingSystemMXBean;

public class FreeMemoryExample {
    @SuppressWarnings("restriction")
    public static void main(String[] args) {
        OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();
        System.out.println("Free Physical Memory Size: " + freePhysicalMemorySize + " bytes");
    }
}

下面编译这段代码并且进入 debug 运行:

$ javac FreeMemoryExample.java
$ jdb FreeMemoryExample
nitializing jdb ...
> stop at FreeMemoryExample:8
Deferring breakpoint FreeMemoryExample:8.
It will be set after the class is loaded.
> run
run FreeMemoryExample
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: Set deferred breakpoint FreeMemoryExample:8

Breakpoint hit: "thread=main", FreeMemoryExample.main(), line=8 bci=7
8            long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();

上面的运行过程中, 我们把断点设置在 FreeMemoryExample 的第八行. 然后执行 run 命令, 启动程序, 然后程序停在了第8行, 并且打印了该行代码.

执行 list 子命令, 显示当前行的前后几行代码.

main[1] list
4    public class FreeMemoryExample {
5        @SuppressWarnings("restriction")
6        public static void main(String[] args) {
7            OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
8 =>         long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();
9            System.out.println("Free Physical Memory Size: " + freePhysicalMemorySize + " bytes");
10        }
11    }

执行 threads 子命令, 显示所有线程

main[1] threads
Group system:
  (java.lang.ref.Reference$ReferenceHandler)0x16c Reference Handler running
  (java.lang.ref.Finalizer$FinalizerThread)0x16d  Finalizer         cond. waiting
  (java.lang.Thread)0x16e                         Signal Dispatcher running
Group main:
  (java.lang.Thread)0x1                           main              running (at breakpoint)
Group InnocuousThreadGroup:
  (jdk.internal.misc.InnocuousThread)0x198        Common-Cleaner    cond. waiting

执行 locals 子命令, 显示所有本地变量

main[1] locals
Local variable information not available.  Compile with -g to generate variable information

可以看到这次并没有显示本地变量, 是因为在编译的时候没有加 -g 参数. javac 关于 -g 参数的说明如下:

  -g                           Generate all debugging info
  -g:{lines,vars,source}       Generate only some debugging info
  -g:none                      Generate no debugging info

所以 -g 就是添加 debugging 信息的. 所以, 我们重新编译一下, 然后继续上面的 locals 子命令

main[1] locals
Method arguments:
args = instance of java.lang.String[0] (id=831)
Local variables:
osBean = instance of com.sun.management.internal.OperatingSystemImpl(id=832)

这次我们可以看到 main 函数的参数 argsosBean 变量.

添加方法进入断点

我们可以看到下面讲要执行 OperatingSystemImpl.getFreePhysicalMemorySize, 我们进入这个方法的段点, 并通过执行 cont 执行到该断点.

main[1] stop in com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize
Set breakpoint com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize
main[1] cont
>
Breakpoint hit: "thread=main", com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize(), line=243 bci=0

执行 where 查看当前的栈

main[1] where
  [1] com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize (OperatingSystemImpl.java:243)
  [2] FreeMemoryExample.main (FreeMemoryExample.java:8)

通过 help 子命令, 我们可以看到所有的子命令.

如何通过 jdb 连接一个正在运行的程序

上面的例子都是通过 jdb 启动想要调试的程序, 那么如何调试另一个独立的程序呢? 我们先看一下 IDE 是如何做的, 首先, 我们本地使用 Eclipse 打开刚才的程序, 设置断点, 然后启动程序. 这个时候, 应用停在断点处. 我们查看这个应用是如何启动的.

$ jps
57184 FreeMemoryExample
69238 Eclipse
57225 Jps
$ jcmd 57184 VM.command_line
57184:
VM Arguments:
jvm_args: -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:63529 -javaagent:/Users/xiatian/work/tools/eclipse/Eclipse.app/Contents/Eclipse/configuration/org.eclipse.osgi/223/0/.cp/lib/javaagent-shaded.jar -Dfile.encoding=UTF-8
java_command: FreeMemoryExample

可以看到 Eclipse 启动程序的时候, 添加了 -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:63529 这就是启动调试的关键. 它相当于在应用端开启了一个端口在 63529 监听端口并可以完成某些命令, 然后 jdb 通过连接找个端口, 使用 jdwp 协议与之通信, 完成 jdb 的各种命令.

连接器 是Java调试器(JDB)与被调试的Java程序之间通信的接口。它允许JDB与正在运行的Java程序建立连接,并在程序执行时进行交互式的调试操作。

jdbconnectors 子命令能显示当前 jdb 支持的连接器, 如:

Connector: com.sun.jdi.ProcessAttach  Transport: local
Connector: com.sun.jdi.RawCommandLineLaunch  Transport: dt_socket
Connector: com.sun.jdi.SocketAttach  Transport: dt_socket

ref:

IEEE 754 浮点表示

IEEE 754 定义了计算机如何表示和存储非整数的数据, 如: 12.084, 0.3, -0.0043. 对于整数, 我们只要把十进制的整数转换成二进制, 并且在最前面设置它的正数/负数的符号, 就很容易的存储.

浮点数转成二进制

浮点数转成二进制分成2部份: 整数部分和小数部份.

  1. 对于整数部分, 直接转成二进制, 比如 12(十进制) = 1100 (二进制).
  2. 对于小数部份, 0.5(十进制) = 0.1(二进制), 0.25(十进制) = 0.01(二进制), 0.125(十进制) = 0.001(二进制), 所以可以看到对于小数部份是通过把1整除的方式获得的. 所以0.375(十进制) = 1/4 + 1/8 = 0.25 + 0.125 = 0.01(二进制) + 0.001(二进制) = 0.011(二进制).

所以:

12.125
   12 = 8 + 4 = 1100
   0.125 = 1/8 = 0.001
12.125 = 1100.001

科学计数法

科学记数法是一种记数的方法。 把一个数表示成a与10的n次幂相乘的形式(1≤|a|<10,a不为分数形式,n为整数),这种记数法叫做科学记数法。
例如:12.125 = 1.2125×10^1, 19971400000000=1.99714×10^13, 0.00345 = 3.45×10^-3.
所以它把一个数分成大于等于1小于10的科学计数部份和指数部分.

对于二进制, 同样适用科学计数法, 只不过二进制表示成科学计数法整数部分除了0之外, 只能有1. 比如:

12.125 = 1100.001 = 1.100001×2^3
0.375 = 0.011 = 1.1×2^-2

IEEE754 浮点表示

IEEE754 规定了单精度(32位)浮点数和双精度(64位)浮点数以及其他浮点数. 我们这里主要看单精度, 其它原理类似.

对于32位的浮点数, 32位分成 1位符号位(正负) + 8位指数 + 23位 科学计数数值.
符号位 0 表示正, 1 表示负.
比如:
12.125 = 1100.001 = 1.100001×2^3, 它的符号位0(正数), 指数部分是3 (二进制11), 科学计数数值是1.100001(二进制).
-0.375 = 0.011 = 1.1×2^-2, 它的符号位数1(负数), 指数部分是-2(-10二进制), 科学计数数值是1.1(二进制).

需要进一步明确的地方:

  1. 8位指数从 00000000 ~ 11111111, 即0 ~ 255, 但是这样对于是负数的指数无法表示, 所以需要调整这个值从0 ~ 255表示成-127 ~ 128. 即0表示 0-127 = -127(指数), 127 - 127 = 0(指数为0), 255 - 127 = 128. 但实际使用的时候, -127128被用作特殊值处理, 实际可能的值只能是 -126 ~ 127.
  2. 对于数值部分, 因为是科学计数法, 所以除了0之外, 其它时候都是1, 所以这个1可以去掉, 所以23位全部用来表示点之后的部份. 即 1.001101 只要使用 001101 它, 1.101 只要使用 101. 0表示成 23个0.

所以:
12.125 = 1100.001 = 1.100001×2^3 -> 符号是0, 指数部分原本是3,转成127+3=130,即二进制 10000010, 科学计数部分去掉点之前的1, 即是100001.
-0.375 = 0.011 = 1.1×2^-2 -> 符号是1, 指数部分是-2, 转成127-2=125, 即二进制 1111101, 科学计数部分去掉点之前的1, 即是 1.

对于指数部分不够8位只要前面补0, 对于科学计数部分, 由于是小数点后的, 所以后面补0. 于是:
12.125 = 1100.001 = 1.100001×2^3 => 0 10000010 10000100000000000000000`.
-0.375 = 0.11 = 1.1×2^-1 => 1 01111101 1000000000000000000000

验证

让AI写了一段 C 代码, 来验证一把:

#include <stdio.h>

int main() {
    float num1 = 12.125f;
    float num2 = -0.375f;

    // 将浮点数的二进制表示转换为字节表示
    unsigned char *bytePtr1 = (unsigned char *)&num1;
    unsigned char *bytePtr2 = (unsigned char *)&num2;

    printf("12.125 的二进制表示为:\n");
    for (int i = sizeof(float) - 1; i >= 0; i--) {
        for (int j = 7; j >= 0; j--) {
            printf("%d", (bytePtr1[i] >> j) & 1);
        }
        printf(" ");
    }
    printf("\n");

    printf("-0.375 的二进制表示为:\n");
    for (int i = sizeof(float) - 1; i >= 0; i--) {
        for (int j = 7; j >= 0; j--) {
            printf("%d", (bytePtr2[i] >> j) & 1);
        }
        printf(" ");
    }
    printf("\n");

    return 0;
}

跑一台机器

12.125 的二进制表示为:
01000001 01000010 00000000 00000000
-0.375 的二进制表示为:
10111110 11000000 00000000 00000000

Java 通过 OperatingSystemMXBean 获得 getFreePhysicalMemorySize()

最近有个同事找我来看一个Java 应用的内存问题: 他的一个Java应用运行在一个8G内存的机器上, 配置的 Java heap 最大值是2G, 然而这个应用还有个页面能看到系统剩余空闲内存, 上面显示只有500M多的剩余. 他们想给这个应用加大 heap, 却看到只有500M剩余, 不敢加了.

初步看了下机器的内存, 确实是 free 内存是500多M, 然而那是绝对剩余的内存, 没有去掉随时可以回收的 buffer/cache 占用的内存. 如果去掉 buffer/cache 可以释放的内存, 剩余有近5G.

类似如下图:
freeMem.png

那么它看到的500M是哪里来的呢? 经过他们翻找源代码, 发现来自于 MBean:

OperatingSystemMXBean.getFreePhysicalMemorySize()

这个 OperatingSystemMXBean 是根据操作系统不同有不同的实现, 但是根据这个测试类 GetFreePhysicalMemorySize 我们可以发现, 它其实取之于: cat /proc/meminfoMemFree, 由此推断, 它就是 free -m 里面的 free 列, 并不是 available 还可以通过释放 buffer/cache 去释放很多.

buffer/cache 就是操作系统为了最大化内存的使用价值而做的: buffer - 其实需要直接写到磁盘, 但是现在直接扔到内存, 等需要的时候, 再写到磁盘. cache - 不用直接从磁盘去取了, 而是把常用的放在内存随时快速使用.

找出源代码

写一段使用 OperatingSystemMXBean 获取空闲内存的代码:

import java.lang.management.ManagementFactory;
import com.sun.management.OperatingSystemMXBean;

public class FreeMemoryExample {
    @SuppressWarnings("restriction")
    public static void main(String[] args) {
        OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();
        System.out.println("Free Physical Memory Size: " + freePhysicalMemorySize + " bytes");
    }
}

编译并用 jdb 执行:

$ javac -g FreeMemoryExample.java

$ jdb FreeMemoryExample
Initializing jdb ...
> stop at FreeMemoryExample:8
Deferring breakpoint FreeMemoryExample:8.
It will be set after the class is loaded.
> run
run FreeMemoryExample
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: Set deferred breakpoint FreeMemoryExample:8

Breakpoint hit: "thread=main", FreeMemoryExample.main(), line=8 bci=7
8            long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();

main[1] locals
Method arguments:
args = instance of java.lang.String[0] (id=831)
Local variables:
osBean = instance of com.sun.management.internal.OperatingSystemImpl(id=832)

main[1] methods com.sun.management.internal.OperatingSystemImpl
** methods list **
com.sun.management.internal.OperatingSystemImpl <init>(sun.management.VMManagement)
com.sun.management.internal.OperatingSystemImpl getCommittedVirtualMemorySize()
com.sun.management.internal.OperatingSystemImpl getFreePhysicalMemorySize()
com.sun.management.internal.OperatingSystemImpl getTotalPhysicalMemorySize()
com.sun.management.internal.OperatingSystemImpl getCommittedVirtualMemorySize0()
com.sun.management.internal.OperatingSystemImpl getFreePhysicalMemorySize0()
com.sun.management.internal.OperatingSystemImpl getFreeSwapSpaceSize0()
...

可以看到 osBean 的类是: com.sun.management.internal.OperatingSystemImpl. 查找 JDK 可以看到有 windowsunix 版本. 其中 unix 版本核心的代码是:

#else // solaris / linux
    jlong num_avail_physical_pages = sysconf(_SC_AVPHYS_PAGES);
    return (num_avail_physical_pages * page_size);

AI 对这2行代码的解释:

这段C代码用于获取系统中可用的物理页面数,并计算可用物理内存的总大小。

  1. jlong num_avail_physical_pages = sysconf(_SC_AVPHYS_PAGES);:

    1. sysconf() 是一个用于获取系统配置信息的函数,位于 <unistd.h> 头文件中。
    2. _SC_AVPHYS_PAGES 是一个参数,用于指示我们要获取系统中可用的物理页面数。这个参数是一个宏定义,表示系统中可用的物理内存页面的数量。
    3. sysconf(_SC_AVPHYS_PAGES) 调用返回的值将被赋给 num_avail_physical_pages 变量,该变量的类型为 jlong,通常在Java Native Interface (JNI) 中用于表示Java中的 long 类型。
  2. return (num_avail_physical_pages * page_size);:

    1. page_size 变量应该是一个代表页面大小的值。在大多数情况下,页面大小是一个固定的值,通常在操作系统的头文件中定义。例如,在Linux系统中,PAGE_SIZE 宏定义了页面的大小。
    2. num_avail_physical_pages * page_size 计算了可用物理内存的总大小,即可用的物理页面数乘以页面大小。
    3. 最后,这个总大小被作为函数的返回值返回。

    综上所述,这段代码的主要作用是通过查询系统配置信息获取可用的物理页面数,然后计算可用物理内存的总大小,并将其作为函数的返回值返回。

所以,可以看到这里只是查看绝对空闲内存的数量, 然后再乘以每个页面大小.

使用 UseStringDeduplication 来减少 Java String 的内存占用量

我们分析 Java heap dump 的时候, 经常发现里面包含很多 java.lang.String, 可是让我们回想到底哪里用了这么多 String 的时候, 确实很难列举. 如此多的 String 肯定占用了很多宝贵的内存空间, 那么有什么办法能减少 String 的空间占用呢?

下面一个 heap dump 的 object histogram 的截图, 可以看到 String 的数量仅次于 byte[], 位居第二.
str_obj_histogram.png

如果你去研究为啥这么多 byte[], 最终你会发现, 其实是 String 太多, 每个 String 对象的背后也是一个byte[].

String 也是 immutable 的对象, 也就是说你对 String 的任何修改会直接创建另外一个新的对象.

另外, 我们会发现, 其实我们的内存里面有很多重复的 String. 通过 MAT 的 “Java Basics” -> "Group by value", 我们对 java.lang.String 进行分组, 可以看到很多重复的字符串.

如下图, 在一个只有 158 MB的 heap 里面, https 这个 String 竟然有 70397 个实例对象.
str_group_by_value.png

如果我们添加 -XX:+UseStringDeduplication, 经过一段时间的稳定运行后, 我们可以看到, 虽然 String 还是那么多, 但是 byte[] 已经大幅减少:
string_after_dedup.png

对比上图我们发现:

  1. String 数量还是差不多, 但是 byte[] 明显减少.
  2. String 对象 retained size 明显减少, 就是因为它们引用的 bytes[] 很多都合并了.

我们以 https 这个字符串为例, 可以看到他们引用的 byte[] 都是一个:
string_sample.png

对比 intern()

使用 intern()方法, 返回的字符串常量都是字符串常量池里面的同一个.
使用 UseStringDeduplication, 一开始是在EDEN 区域分配的时候, 每个String 都是新的, byte[] 也是不一样的, 当被GC 回收次数达到 StringDeduplicationAgeThreshold 的时候, 会有后台线程处理, 把 bytes[] 指向常量池里的那个字符串. 但是 String 对象本身还是之前的.

uint StringDeduplicationAgeThreshold          = 3                                         {product} {default}
bool UseStringDeduplication                   = false                                     {product} {default}
bool PrintStringTableStatistics               = false                                     {product} {default}

为什么 UseStringDeduplication 默认是关闭的

  1. String 的 byte[] 一开始还少正常每个都分配空间的, 等被回收次数到达 StringDeduplicationAgeThreshold 后, 才会有后台线程更改 byte[], 所以需要在 GC 后额外占用时间.
  2. 更改这些指针还需要额外CPU.
  3. 需要额外内存记录被回收次数.