← 返回博客
2026-08-24 17:29:05

Java 基础学习笔记:看源码前先补的三块——位运算、二进制、内存存储

Java 基础学习笔记:看源码前先补的三块——位运算、二进制、内存存储

第一次打开 HashMap 源码,很多人不是被红黑树劝退的,是被第一屏劝退的:tableSizeFor(int cap) 里五六行全是 |>>>,往下翻到 putVal,又是一句 (n - 1) & hash。这三样看不明白,后面的 put、get、扩容全在云里雾里。

这篇把看 Java 源码前必须先补上的三块底子讲透:位运算、二进制与补码、基本数据类型,外加一个面试高频题——局部变量、实例变量、静态变量到底都存在哪儿。最后回到开头那几行源码,一行行读通它。

一、二进制与补码:先把数的表示搞明白

先记两个硬数字:int 是 32 位,long 是 64 位。源码里所有看不懂的位运算,最后都能拆成一排 0 和 1 来算。

二进制:先把它本身搞懂

二进制就是只用两个数字——0 和 1——来计数。十进制是「逢十进一」,满 10 就往上进一位;二进制是「逢二进一」,满 2 就往上进一位。所以数到 2 的时候,十进制写 2,二进制已经进位成 10 了;十进制 3 是二进制的 11,十进制 4 是二进制的 100。

每一位上的 1 值多少,看它在第几位。十进制从右往左是个位、十位、百位,也就是 10^0、10^1、10^2;二进制从右往左是 2^0、2^1、2^2、2^3,也就是 1、2、4、8。计算机给位编号也从右往左数,最右边那一位叫第 0 位,往左依次是第 1、2、3 位,正好和 2 的次方一一对上。排成一张表:

位编号(从右往左)第 3 位第 2 位第 1 位第 0 位
权值2^3 = 82^2 = 42^1 = 22^0 = 1
二进制 11011101
数字 × 权值1 × 81 × 40 × 21 × 1
贡献8401

算值就是把每一列的「数字 × 权值」加起来,这一步叫按权展开

合计 8 + 4 + 0 + 1 = 13,所以 1101 的值就是 13。这和十进制算 13 是同一个套路——十进制 13 = 1×10 + 3×1,只是把「10 的次方」换成了「2 的次方」。

十进制转二进制:一位一位拆

现在看 13 = 8 + 4 + 1。8、4、1 正好都是 2 的次方(2^3、2^2、2^0),所以把 13 拆成二进制,就是判断「哪些 2 的次方用上了」。从高位往低位一位一位走:

从高到低连起来是 1101。13 的二进制就是这么一步步拆出来的,不是凭空冒出来的。

那代码里的 0b1101 是什么?0b 是 Java 给数字加的前缀,意思是「后面这串按二进制读」,值还是 13,跟直接写 13 一样。前缀只起标注作用,让人一眼看出进制:0b 是二进制,0x 是十六进制,0 开头是八进制。

十六进制:把一长串 0 和 1 压缩一下

int 是 32 位,写成二进制是一长串 0 和 1,太长了。怎么压缩?4 位二进制正好有 16 种组合(0000 到 1111),给每种组合配一个符号,就成了十六进制——一位十六进制对应 4 位二进制,32 位就能压成 8 个字符:

二进制0000000100100011010001010110011110001001101010111100110111101111
十六进制0123456789ABCDEF

对着表查:1101 对应 D,所以 13 还能写成 0xD0xff 是两位十六进制,展开成二进制是 1111 1111,8 个 1,值 255。反过来也一样,十六进制随时能展开回二进制。

源码里大量用十六进制写常量,图的就是短。0x7fffffff 展开成 32 位是 0111 1111 1111 1111 1111 1111 1111 1111,最高位是 0,所以是正数,值正好是 int 最大值 2147483647;0x800000001000 0000 0000 0000 0000 0000 0000 0000,最高位是 1,对应 int 最小值 -2147483648。0x7fffffff 加 1 会溢成 0x80000000,正数最大值直接绕回负数最小值。为什么负数要这么表示、范围为什么不对称,把原码、反码、补码这条进化线走一遍就清楚了。

原码、反码:补码是怎么一步步修出来的

补码不是凭空定的,往前还有两个更早的方案。把这条进化路线看一遍,那句「按位取反再加一」就不再是死记的口诀了。

原码是最直白的想法:最左边一位当符号位,0 表示正、1 表示负,剩下的位照抄绝对值。8 位表示 +5 和 -5:

数字原码(符号位 + 绝对值)
+50000 0101
-51000 0101

原码有两个毛病。一是出现了两个 0:0000 0000 是 +0,1000 0000 是 -0,判「是不是 0」得特判。二是符号位参与不了运算:5 + (-3) 直接按位相加会算出 -8 这种错答案,因为符号位的 1 混进了数值位里。CPU 只能先看两个数的符号、再决定该加还是该减,等于为减法单独做一套逻辑。符号位算不了数,是原码最要命的地方。

反码为修减法而生:正数的反码就是自己,负数的反码是符号位不动、其余位逐位取反。于是 -5 的反码是 1111 1010,-3 的反码是 1111 1100。反码让减法变成加法:5 - 3 等于 5 + (-3),把 -3 用反码代进去相加,再把溢出那一位循环加回来,结果正好是 2。但反码还留着两个 0,进位规则也绕。

补码就是为了消灭反码那两个 0 的方案:负数的补码 = 反码再加一。三个方案放一起对比:

数字原码反码补码
+50000 01010000 01010000 0101
-51000 01011111 10101111 1011

对比里能看到,补码的负数正好是反码加一——它为什么这么算、自洽在哪、范围为什么不对称,接着往下看。

负数在计算机里用补码表示,规则一句话:按位取反再加一。以 int 的 -1 为例:1 是 0x00000001,取反得 0xFFFFFFFE,加一得 0xFFFFFFFF,也就是 32 个 1。所以 Integer.toBinaryString(-1) 打印出来是一串 32 个 1,而不是「负号加 1」。

再用 8 位走一遍 -3:3 是 0000 0011,取反得 1111 1100,加一得 1111 1101。所以 -3 在 8 位里就是 1111 1101。可以反向验证:1111 11010000 0011 等于 1 0000 0000,溢出的最高位丢掉,正好归零。负数加上它对应的正数等于 0,这正是补码自洽的地方。

为什么要绕这一圈?因为补码让 CPU 只用加法电路就能做减法:5 - 3 等于 5 + (-3),而 -3 就是用补码算出来的那个数。代价是取值范围不对称:8 位补码最大是 0111 1111 = 127,最小是 1000 0000 = -128。位数换成 32,就是 int 的 2147483647 和 -2147483648。最小值比最大值多 1 个,正是「最小值没有正数对应」的来源,也是后面溢出陷阱的根源。

二、七个位运算符,逐个拆

先看一张全表,下面逐个展开。

int a = 5;   // 0101
int b = 3;   // 0011
System.out.println(a & b);    // 0001 = 1
System.out.println(a | b);    // 0111 = 7
System.out.println(a ^ b);    // 0110 = 6
System.out.println(~a);       // ...1010 = -6
System.out.println(a << 1);   // 1010 = 10
System.out.println(a >> 1);   // 0010 = 2
System.out.println(-1 >>> 1); // 0111...1 = 2147483647

& 按位与:两个位都是 1 结果才是 1,否则是 0。最常用的是判断奇偶:n & 1 为 1 说明 n 是奇数,为 0 是偶数,比 n % 2 更快。它也是后面 HashMap 取下标的核心。

| 按位或:只要有一位是 1 结果就是 1。常用于合并状态位:定义 int READ = 1; int WRITE = 2;int flags = READ | WRITE; 得到 3,之后用 (flags & READ) != 0 判断「是否可读」。JDK 里的权限位、线程池状态都是这套思路。

~ 按位取反:每一位 0 变 1、1 变 0。有个很好记的规律:~x 等于 -x - 1,所以 ~0 = -1~5 = -6

^ 异或:两个位不同结果是 1,相同是 0。三个性质最常用:x ^ 0 = xx ^ x = 0、异或满足交换律,于是有了经典的不用临时变量交换两个数:

a = a ^ b;  // 现在 a 存的是「原来的a」异或「原来的b」
b = a ^ b;  // b 变成「原来的a」
a = a ^ b;  // a 变成「原来的b」

HashMap 里对 hashCode 做扰动用的也是它。

异或还有一个很妙的用法:一堆数里只有一个数出现了一次,其余都出现了两次,把它们全部异或起来,剩下的就是那个唯一数——因为 x ^ x = 0,成对的都抵消了。「找出数组里唯一出现一次的数」这道题的标答就是这个思路。

<< 左移:低位补 0,相当于乘 2 的 n 次方。1 << 4 = 16,这正是 HashMap 的默认初始容量。要注意溢出:1 << 31 把 1 顶到了符号位,结果是 -2147483648。

>> 有符号右移:高位补符号位,正数补 0、负数补 1,相当于除以 2 的 n 次方。8 >> 1 = 4-8 >> 1 = -4。这里有个容易踩的坑:Java 的 / 是向零取整,-7 / 2 = -3,而 -7 >> 1 = -4,两者在负数上不一样。

>>> 无符号右移:高位一律补 0,不管正负。-1 >>> 1 = 2147483647,正好是 int 最大值。它是「凑 2 的幂」的关键工具,下面马上用上。

三、源码里它们怎么用:把 HashMap 那几行读通

tableSizeFor:把容量凑成 2 的幂

HashMap 要求容量必须是 2 的幂,原因见下一节。tableSizeFor 干的事就是:给你一个数,返回大于等于它的最小 2 的幂。传 7 返回 8,传 16 还返回 16。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

看懂了位运算,这段就是顺水推舟。以 cap = 7 走一遍:n = 6 = 110n |= n >>> 1110 | 011 = 111n |= n >>> 2111 | 001 = 111,后面移位的倍数越来越大,但 111 已经是「最高位以下的位全是 1」,不会再变。最后 n + 1 得到 8。

所以那 5 行移位的作用只有一句话:把最高位的 1 一路「或」到它后面的每一位,让 n 变成一串连续的 1,再加 1 就是 2 的幂。

开头为什么要 cap - 1?如果 cap 本身就是 2 的幂,比如 16 = 10000,不先减一的话,连续 1 会铺到 31,加一变成 32,白白翻倍。先减一,16 - 1 = 15 = 1111,结果还是 16。

MAXIMUM_CAPACITY1 << 30。为什么是 30 不是 31?因为 1 << 31 已经变成负数(最高位是符号位),1 << 30 是 int 里能表示的最大的 2 的幂,HashMap 把容量封顶在这里。

顺带一个源码和算法题里常见的判断技巧:判断 n 是不是 2 的幂,一行 (n & (n - 1)) == 0。8 = 10008 & 7 等于 0;7 = 01117 & 6 等于 6。因为 2 的幂只有一个位是 1,减一后这个位以下的位全变 1,两者按位与必然为 0。遇到「容量是否合法」「次数是不是二次幂」这类判断,常能看到它。

掩码:把一串位当筛子用

看源码时,还有一个高频惯用法要认识——掩码(bitmask)。掩码就是一段精心设计的二进制串,通常是「连续一串 1」,拿它跟另一个数做位运算,把要的位筛出来、不要的按掉。上面 n & (n - 1) 里那个 n - 1,本质就是一个掩码。

先看最直白的例子。1011 0110(0xB6)想只留低 4 位,就用低 4 位全是 1 的掩码 0000 1111(0x0F)做按位与:

  1011 0110   ← 原数
& 0000 1111   ← 掩码 0x0F,只看低 4 位
----------
  0000 0110   ← 结果,原数的低 4 位被原样取出

高位全被掩码里的 0 按掉了,低 4 位原样留下来。掩码三个基础用法,全是从这一张「筛子」来的:

三种用法本质是一件事:拿一串掩码当筛子,用 &、|、~ 精确控制哪几位保留、哪几位清零、哪几位置一。读到源码里的 & 一串1& ~一串1| 一串1,先认出掩码是哪一段、目的就是筛哪几位,代码就明朗了。下面 (n - 1) & hash 就是掩码第一次实战。

(n - 1) & hash:为什么能代替取模

putVal 里算桶下标就一句:i = (n - 1) & hash,n 是容量。为什么不用 hash % n

因为 n 是 2 的幂时,n - 1 的低位全是 1(16 - 1 = 15 = 1111),正好是上节说的掩码。hash & (n - 1) 相当于只保留 hash 的低 4 位,结果和 hash % 16 完全一样,但按位与比取模便宜得多。这就是「容量必须是 2 的幂」的根本原因——不是玄学,是为了把取模换成位运算。

int hash = 0b100101;  // 37
int n = 16;
int index = (n - 1) & hash; // 37 & 15 = 5,等价于 37 % 16

顺带一提,JDK 8 的 hash(Object key) 在取下标前还会做一次扰动:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

把高 16 位通过异或混到低 16 位,让只有低 16 位参与运算的取模也能用上高位的散列信息。这里 ^>>> 各露了一手。

延伸:默认 hashCode 是怎么来的

刚才那个 hash(key) 里调用了 key.hashCode()。那默认的 hashCode() 到底返回什么?

Object.hashCode() 是个 native 方法,语义上返回的是「身份哈希」(identity hash code)——基于对象本身,跟内容无关。HotSpot 的实现细节值得知道三件事:

和 equals 的关系是这套机制里的关键约束。默认的 Object.equals 就是引用比较:

public boolean equals(Object obj) {
    return (this == obj);  // 默认:引用相等才算相等
}

默认情况下 equals 和 hashCode 是自洽的。但一旦重写 equals 按内容比较(比如「值相同就相等」),就必须同时重写 hashCode:否则值相同的两个对象 hash 不同,会被 HashMap 放进不同桶,出现「明明相等却 get 不到」的 bug。这是所有集合使用者的经典坑。

对比一下,String 和 Integer 的 hashCode 是「值哈希」,有确定算法Integer.hashCode() 直接返回自身;String.hashCode()s[0] 31^(n-1) + s[1] 31^(n-2) + ... + s[n-1],内容相同哈希必相同,所以 String 天然适合当 HashMap 的 key。

验证可以跑一段:同一个对象两次打印 hashCode 相同;把 JVM 重启再跑,同一个 new Object() 的 hashCode 大概率就变了。

ThreadPoolExecutor 的 ctl:一个 int 装两个信息

java.util.concurrent.ThreadPoolExecutor 里有个经典设计:用一个 AtomicInteger ctl 同时装「线程池状态」和「工作线程数」。高 3 位是状态(RUNNING/SHUTDOWN/STOP/TIDYING/TERMINATED),低 29 位是 workerCount。

private static final int COUNT_BITS = Integer.SIZE - 3;      // 29
private static final int CAPACITY   = (1 << COUNT_BITS) - 1; // 2^29 - 1

private static final int RUNNING    = -1 << COUNT_BITS;      // 高3位 111
private static final int SHUTDOWN   =  0 << COUNT_BITS;      // 高3位 000
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

private static int runStateOf(int c)     { return c & ~CAPACITY; } // 取高3位
private static int workerCountOf(int c)  { return c & CAPACITY; }  // 取低29位
private static int ctlOf(int rs, int wc) { return rs | wc; }       // 合并

CAPACITY2^29 - 1,低 29 位全是 1。c & CAPACITY 把高 3 位清掉,取出 workerCount;c & ~CAPACITY 反过来,把低 29 位清掉,取出状态。ctlOf| 把状态和数量拼回去。这样改状态、改线程数可以一次 CAS 完成,不用加锁保护两个字段。这就是 &|<<>> 在一处真实源码里的完整配合。

ReentrantLock 的 state:锁重入次数的计数器

java.util.concurrent.locks.ReentrantLock 复用的是 AQS 里的 volatile int state。state 为 0 表示没人持有锁,每重入一次加一,释放一次减一:

// ReentrantLock 内部(简化):
final boolean nonfairTryAcquire(int acquires) {
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) { ... return true; }
    } else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) throw new Error("Maximum lock count exceeded"); // 溢出保护
        setState(nextc);
        return true;
    }
    return false;
}

这里没有位运算,但它是「用一个 int 状态字段承载业务语义」的另一个例子。读源码时遇到这种 statestatus 字段,先想清楚每个取值代表什么、怎么变化,代码就顺了。

验证工具:Integer.toBinaryString

读源码想验证某段位运算,本地跑一段就行:

System.out.println(Integer.toBinaryString(16));       // 10000
System.out.println(Integer.toBinaryString(-1));        // 11111111111111111111111111111111
System.out.println(Integer.toBinaryString(37 & 15));   // 101,即 37 % 16 的结果,正数不带前导0

Integer.toBinaryString 打印的是补码形式,正好用来对照上面的推导。

java.lang.Integer 里还自带一批位运算工具方法:highestOneBitlowestOneBitnumberOfLeadingZerosnumberOfTrailingZerosbitCountrotateLeftrotateRight。看源码遇到它们,直接查 javadoc 就能知道用途,不用重新发明轮子。

四、基本数据类型:字节数、取值范围、溢出陷阱

8 种基本类型,记一张表:

类型位数字节数最小值最大值
byte8 位1-128127
short16 位2-3276832767
int32 位4-21474836482147483647
long64 位8-92233720368547758089223372036854775807
char16 位2065535
float32 位4约 -3.4e38约 3.4e38
double64 位8约 -1.8e308约 1.8e308
boolean未定义未定义true / falsetrue / false

位数就是字节数乘 8,一个 32 位的 int 一共是 0 和 1 组成的 32 个二进制位。规律就是第一节讲的补码:n 位有符号类型,最大值是 2^(n-1) - 1,最小值是 -2^(n-1)。int 是 32 位,所以 2^31 - 1 = 2147483647-2^31 = -2147483648,byte/short/long 依次类推。char 特殊,它是 16 位无符号,范围是 0 到 2^16 - 1。boolean 是唯一没定死大小的——Java 语言规范不规定它占几个字节(也不规定位数),HotSpot 里字段和数组元素一般按 1 字节处理。

溢出是源码和面试里都爱考的坑:

System.out.println(Integer.MAX_VALUE + 1);       // -2147483648,绕回最小值
System.out.println(Math.abs(Integer.MIN_VALUE));  // 还是 -2147483648

Math.absInteger.MIN_VALUE 失效,因为它的绝对值是 2 的 31 次方,在 int 里根本没有正数对应,溢出绕回自身。写工具类、算差值时碰到极端值要留个心眼。

另一个和取值范围直接相关的规则是自动提升:byte、short 参与算术运算时会先提升成 int 再算。所以 short a = 1; short b = 2; short c = a + b; 会编译报错(a + b 是 int),必须写成 (short) (a + b)。看源码时看到短整型相加还带强制转换,多半就是在应付这条规则。

float 和 double 的精度坑:为什么不能用 == 比浮点

上面那张表里 float/double 只标了数量级,没讲内部怎么存。浮点走的是 IEEE 754:一个数拆成「符号位 + 指数位 + 尾数位」,用二进制科学计数法表示。问题在于绝大多数十进制小数在二进制里是无限循环的——就像 1/3 在十进制里写不完,0.1 在二进制里也写不完,只能按尾数位数截断。double 尾数 52 位,截断后误差落在小数点后 16 位左右,float 尾数只有 23 位,误差更大。

所以下面这段(本机实测)是每个 Java 新手都会撞的墙:

System.out.println(0.1 + 0.2);                    // 0.30000000000000004
double a = 0.1, b = 0.2, c = 0.3;
System.out.println((a + b) == c);                 // false!0.1+0.2 不等于 0.3
System.out.println(Math.abs((a + b) - c) < 1e-9); // true:用误差范围比

浮点相等只能用「差的绝对值小于一个很小的数」判断,不能 ==。更彻底的方案是钱和需要精确的数一律用 BigDecimal——它把数按十进制小数逐位存,用字符串构造(new BigDecimal("0.1"),不是 new BigDecimal(0.1),后者又把 double 的误差带进来了)。金额、费率、评分这类字段在 Java 里都用 BigDecimal,是业务代码的基本纪律。

自动装箱与 Integer 缓存:== 陷阱第二弹

基本类型和包装类之间能自动转(装箱拆箱),代价是包装类 == 比较的是引用不是值。更阴的是 Integer 有个缓存:JVM 启动时把 -128~127 的 Integer 对象预先造好放在缓存里,Integer.valueOf(自动装箱走的就是它)在这个范围内直接返回缓存对象,超过才 new。于是出现这种看似诡异的结果(本机实测):

Integer x = 127, y = 127;    // 命中缓存,同一个对象
Integer m = 128, n = 128;    // 超过上限,各 new 一个
Integer p = -128, q = -128;  // 命中缓存
Integer r = -129, t = -129;  // 低于下限,各 new 一个
System.out.println(x == y);  // true
System.out.println(m == n);  // false
System.out.println(p == q);  // true
System.out.println(r == t);  // false

缓存上下限 -XX:AutoBoxCacheMax 可调,默认 128。判断包装类相等请用 equals(或 Objects.equals);== 只在你确定在缓存范围内才可靠。这套「值相等但 == 结果看对象身份」的坑,和上面补码里「最小值没有正数对应」是同一类教训:先想清楚你在比值还是在比对象

再说回为什么集合喜欢 2 的幂:不只 HashMap,容量是 2 的幂才能用 (n - 1) & hash 代替取模。看到 1 << k(n - 1) & x 这类代码,第一反应就该是「这是为了把取模换成位运算」。

五、堆、栈、方法区:变量到底存在哪

这是面试必问的存储问题。先分清三个区域:

虚拟机栈(栈):每个线程一个,用来放方法调用的栈帧。栈帧里有局部变量表、操作数栈、方法返回地址。局部变量就存在这里——基本类型直接存值,引用类型存的是指向堆的地址。方法一返回,栈帧弹出,局部变量随之消失。栈大小有限,默认约 1MB(Linux x64,可用 -Xss 调),递归太深会抛 StackOverflowError

:所有 new 出来的对象都在这里,所有线程共享,垃圾回收的主战场。实例变量跟着对象走,每个对象一份,存在堆里。堆不够用抛 OutOfMemoryError

方法区:存类元数据(类结构、方法字节码)和运行时常量池,静态变量按 JVM 规范也归这里管。JDK 8 起它改叫 Metaspace,用的是本地内存,不在堆里。字符串常量池是另一个概念——JDK 7 起从方法区挪到了堆上。

JVM 内存布局:栈、堆、方法区

把「静态/非静态」对应进去:

public class Counter {
    static int total;   // 静态变量:类加载时初始化,所有实例共享一份
    int count;          // 实例变量:每个对象一份,存在堆上
    void inc(int step) {
        int i = count;  // step、i 是局部变量,存在栈帧里
        count = i + step;
        total++;
    }
}

三句话记住:

还有一个和存储位置绑定的老考点:Java 只有值传递。基本类型传的是值本身,引用类型传的是引用的副本——也就是地址值。方法里改对象的内容,外面能看到;方法里给引用重新赋值(obj = new X()),外面的引用不受影响,因为两个引用是栈上两个独立的地址变量。这和「引用存在栈、对象存在堆」是同一套事实的两面。

想亲眼验证栈和堆的边界,做两个小实验:写一个无限递归的方法,会抛 StackOverflowError,证明栈有大小上限;再写一个 List.add 死循环配合 -Xmx10m -Xms10m,会抛 OutOfMemoryError,证明堆有上限。

结尾:回到开头那几行

现在回头看 tableSizeFor,是不是通顺了:cap - 1 防止 2 的幂被翻倍,>>> 逐级把最高位的 1 铺满低位,+ 1 得到 2 的幂,1 << 30 封顶。putVal 里的 (n - 1) & hash 是拿容量减一的掩码取下标,等价于 hash % n

核心收获就一句:位运算、补码、数据类型不是零散知识点,它们是「容量为什么是 2 的幂」这条设计链的地基。下一步可以做的:把上面 Integer.toBinaryString 的例子跑一遍,对着打印结果手算 tableSizeFor 的三个输入(7、16、33);然后就可以去读单元 1 的 HashMap 源码文章,那里会用到这里的全部基础。

本文关键词:位运算、原码反码补码、掩码、HashMap、数据类型、JVM 内存