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. 需要额外内存记录被回收次数.

Java 字符串常量池

首先看下面一段代码:

public class StringTest {
    public static final String S = "Hello";

    private String s = "Hello";

    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "Hello";
        String s3 = new String("Hello");
        String s4 = new String("Hello");
        String s5 = new String("Hello").intern();
        String s6 = "Hello".intern();

        StringTest st = new StringTest();
        System.out.println(s1 == st.s); // true
        System.out.println(s1 == StringTest.S); // true
        System.out.println(s1 == s2); // true
        System.out.println(s1 == s3); // false
        System.out.println(s3 == s4); // false
        System.out.println(s1 == s5); // true
        System.out.println(s1 == s6); // true

        System.out.println(s1.equals(s2) + " " + s1.equals(s3) + " " + s3.equals(s4) + " " + s1.equals(s5) + " " + s1.equals(s6));
    }
}

运行在 JDK 17 上.
这是除了 StringTest.S 这个静态字段之外的其他是 "Hello" 的字符串在内存的表示. 可以得出:

  1. 除了 s3, s4 其它都指向同一个内存对象.
  2. 所有这些字符串都指向同一个 byte[], 里面存储的是 “Hello” 的 ascii 码.
    stringAddr.png
    静态字段, 静态字段也同其它字面量一样, 都指向同一个.
    class_static.png

这段代码里面, 有声明字面量(literal) 和 String 对象. 所有的字面量(常量或变量)都是指向内存字符串常量池的同一个字符串地址, 所以使用 == 对比也是相等的.

Java 常量池是什么?

Java 的字符串都是 Immutable 的, 也就是更改都会产生新的字符串. 常量池是在内存中开辟出来的一块专门存储字符串的空间. 由 String 类管理(native 代码). 它是一个很大的 HashMap 维护.

Java 常量池存在的意义什么?

所谓池, 就是共享同一份. 因为对很多程序来说, 很多字符串都是反复使用, 只要内存保存一份就好了.

为什么 new String() 不能返回常量池字符串?

常量池大小调节

可以看到如下参数, 默认是 65536. 调的太小, 容易产生hash 碰撞. 如果字符串不多, 调的太大, 占用太多空间.

uintx StringTableSize                          = 65536                                     {product} {default}

记一段shell 脚本遇到的问题

最近写了一段shell 脚本, 执行过程中遇到一些错误, 这里记录一下.

脚本内容

脚本的用意是: 有 pod 在 kubenetes cluster 里面, 要去 profiling 里面 Java 进程. 所以要先 copy profiler 工具上去, 然后解压缩, 之后找到 Java 进程, 最后 profiling.

下面是改正后的脚本:

#!/bin/bash

#set -x
# Set variables
pod="my_pod_1"
ns="my_ns"
cluster_id="37"
local_profiler="~/work/tools/async-profiler-1.7.1-linux-x64.tar.gz"
base_cmd="/tmp/profiler.sh -e itimer -d 30 -o svg -f /mnt/data/profile_eric.log.html"
# -e alloc | -e lock, -t <tid>, --reverse

# Copy profiler to target pod
echo "Copying profiler to target pod"
kubectl cp "${local_profiler}" "${pod}:/tmp/async-profiler-1.7.1-linux-x64.tar.gz" -n "${ns}" --context="${cluster_id}" -c app

# Unzip the profiler
echo "Unzipping the profiler"
kubectl exec -it "${pod}" -n "${ns}" --context="${cluster_id}" -c app -- tar -xf /tmp/async-profiler-1.7.1-linux-x64.tar.gz -C /tmp/

# Get target pod's Java PID
pid=$(kubectl exec "${pod}" -n "${ns}" --context="${cluster_id}" -c app -- pgrep java)
echo "Target pod Java PID is ${pid}"

# Construct the full command
cmd="${base_cmd} ${pid}"
echo "Command is ${cmd}"

# Run profiling
echo "Running profiling"
kubectl exec "${pod}" -n "${ns}" --context="${cluster_id}" -c app -- ${cmd}

遇到的问题

所有的步骤都能被执行, 但是最后一行代码本应该执行 30 秒, 因为在 profiler 的参数里面设置了 -d 30, 可是每次profiling 工具都是开始执行, 立马结束. 打印执行的命令, 把命令单独执行, 也都没有问题.

问题原因

经过 Shell 脚本大师 Peter Deng 的指点, 加了 set -x 参数, 就可以看出到底执行到了哪一步. 发现在 profiler.sh 里面去 kill -0 $PID 的时候, 发现目标进程不存在, 就结束了. 仔细察看代码, 发现传入的进程号竟然是 $25\r, 但其实应该是25.

在进一步排查为什么是$25\r, 发现最初获取进程号的代码是:

pid=$(kubectl exec -it "${pod}" -n "${ns}" --context="${cluster_id}" -c app -- pgrep java)

里面有 -it, 那么它的返回就是 $25\r, 也就是先是一个命令开始符号, 接着是进程号输出, 然后换行. 这正是一般命令行的输出形式. 所以去掉 -it 就只返回进程号了.

另外一个离奇的是: 虽然变量值是 $25\r, 但是当用 echo 打印的时候, 它只显示 25.

验证

使用下面的代码验证:

#!/bin/bash

num="$34\r"
echo "the num is: ${num}"
echo "the num length is ${#num}"

执行输出的结果:

$sh test.sh
the num is: 4
the num length is 3

获得

  1. 使用 set -x 能帮助很快的去debug
  2. 在 $后面的字符很多都有转义, 要小心注意.