问题:
Java
引用和C
的指针有啥区别?- Java 引用是4个字节还是8个字节?
一、内存寻址
1.1、寻址空间
1.1.1 数据范围
- 4字节一共32(4 * 8)个 bit,每个 bit 可以有2种取值(0, 1),所以4字节可以表示0~2^32^ 这个范围内的任何一个数;
- 8字节同理可以表示0~2^64^ 这个范围内的任何一个数。
1.1.2 内存地址
将1.1.1中表示的数据范围用16进制表示:
字节数 | bit数 | 10进制范围 | 16进制范围 |
---|---|---|---|
4 | 32 | 0~2^32^ | 0x 00 00 00 00 ~ 0x FF FF FF FF |
8 | 64 | 0~2^64^ | 0x 00 00 00 00 00 00 00 00 ~ 0x FF FF FF FF FF FF FF FF |
注意,上面的数字并未标注单位。
问:数字256
代表啥 ?
答:数字256
没有确切含义,可以说256元,256米,256吨,256比特,256字节......
对于计算机内存地址而言,我们明确下它的单位,定为字节。即256
代表第256个字节。
现在看下我们上面提到的4字节和8字节的寻址范围:
- 2^32^ Byte = 2^2^ * 2^10^ * 2^10^ * 2^10^ = 4GB;
- 2^64^ Byte = 2^4^ * 2^10^ * 2^10^ * 2^10^ * 2^10^ * 2^10^ * 2^10^ = 2^24^TB = 16EB
最大内存地址 | 字节数 | bit数 | 10进制范围 | 16进制范围 | 备注 |
---|---|---|---|---|---|
4GB | 4 | 32 | 0~2^32^ | 0x 00 00 00 00 ~ 0x FF FF FF FF |
|
16EB | 8 | 64 | 0~2^64^ | 0x 00 00 00 00 00 00 00 00 ~ 0x FF FF FF FF FF FF FF FF |
实际使用时,64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2^48^大小,能够表示256T大小的内存,其中低 128T 的空间划分为用户空间,高 128T 划分为内核空间,可以说是非常大了。 |
java
内存地址 32bit 内存地址(16进制) 64bit 内存地址(16进制)
0 +---------------+ 0x 00 00 00 00 +---------------+ 0x 00 00 00 00 00 00 00 00
| byte | | byte |
1 +---------------+ 0x 00 00 00 01 +---------------+ 0x 00 00 00 00 00 00 00 01
| byte | | byte |
2 +---------------+ 0x 00 00 00 02 +---------------+ 0x 00 00 00 00 00 00 00 02
| byte | | byte |
3 +---------------+ 0x 00 00 00 03 +---------------+ 0x 00 00 00 00 00 00 00 03
...... ...... ...... ......
2^32 +---------------+ 0x FF FF FF FF +---------------+ 0x 00 00 00 00 FF FF FF FF
| byte | | byte |
+---------------+ +---------------+ 0x 00 00 00 01 00 00 00 00
...... ......
2^64 +---------------+ 0x FF FF FF FF FF FF FF FF
| byte |
+---------------+
图1.1- 物理内存示意图
附:
- 1 Byte = 8 Bits
- 1 Kilobyte (KB) = 1024 Bytes
- 1 Megabyte (MB) = 1024 KB
- 1 Gigabyte (GB) = 1024 MB
- 1 Terabyte (TB) = 1024 GB
- 1 Petabyte (PB) = 1024 TB
- 1 Exabyte (EB) = 1024 PB
- 1 Zettabyte (ZB) = 1024 EB
- 1 Yottabyte (YB) = 1024 ZB
1.2、JAVA堆内存
1.2.1、堆内存怎么分配
堆内存是在物理内存上分配的。我们假设物理内存无限大,可以满足堆内存任何诉求 ;
- 当堆内存小于
4G
空间时,用4个字节(32bit)即可满足寻址; - 当堆内存超过
4G
空间时,用4个字节(32bit)貌似无法满足寻址;- 我们换个角度,之前小节中我们提到了数字的单位,我们约定了计算机内存地址的寻址单位为字节;同理,我们再次约定堆内存的分配单位为组,每组约定为 8 个字节;
- 那么数字
256
表示啥?表示第256
组,内存地址为 2^11^( 2^11^ = 256 * 8) - 4个字节最大可以标识2^32^ 组,堆内存地址为 32GB(2^32^ * 8),也就是说我们可以用4个字节标识的实际物理内存空间达到了
32GB
。
java
堆内存编号 32bit 堆内存编号(16进制)
0 +---------------+ 0x 00 00 00 00
| 8 byte |
1 +---------------+ 0x 00 00 00 01
| 8 byte |
2 +---------------+ 0x 00 00 00 02
| 8 byte |
3 +---------------+ 0x 00 00 00 03
...... ...... ......
2^32 +---------------+ 0x FF FF FF FF
| 8 byte |
+---------------+
图1.2- 堆内存4字节示意图
1.2.2、堆内存怎么映射到物理内存地址
问题来了,我们上面约定都是在堆内存上的约定。而堆内存是在物理内存上分配的,怎么把堆内存编号映射到真实的物理内存地址上呢?我们把真实的物理地址标出来看看,如下图所示。
java
堆内存编号 32bit 堆内存编号(16进制) 物理内存地址(16进制)
0 +---------------+ 0x 00 00 00 00 0x 00 00 00 F8 00 00 00 00
| 8 byte |
1 +---------------+ 0x 00 00 00 01 0x 00 00 00 F8 00 00 00 08
| 8 byte |
2 +---------------+ 0x 00 00 00 02 0x 00 00 00 F8 00 00 00 10
| 8 byte |
3 +---------------+ 0x 00 00 00 03 0x 00 00 00 F8 00 00 00 18
...... ...... ...... ......
2^32 +---------------+ 0x FF FF FF FF 0x 00 00 00 F9 00 00 00 00
| 8 byte |
+---------------+ ......
图1.3- 堆内存真实的物理地址4字节示意图
上图中不难发现,我们可以基于堆内存在物理内存的起始地址 (记为Base )结合偏移量(堆内存编号)、偏移单位(上文提到的组,即偏移8字节)计算,即
堆内存真实的物理地址 = 堆内存起始物理内存地址 + 堆内存编号 * 偏移单位
以上图为例:
堆内存编号为 2,堆内存在物理内存地址的起始地址为0x 00 00 00 F8 00 00 00 00
,偏移量为8字节,则对应的时间物理地址为:
0x 00 00 00 F8 00 00 00 10 = 0x 00 00 00 F8 00 00 00 00 + 2 * 8
这样,我们4字节存储堆编号,另外保存堆空间的起始物理地址即可。
至此我们可以用 4字节 来保存内存0 ~ 32GB
的地址空间的任一地址。
1.2.3、java堆内存怎么分配
java堆内存(通过jvm启动参数-Xms4g -Xmx4g
来调整java堆内存空间大小)可以按照我们前面提到的方式分配映射吗?当然可以,但是java堆内存对4字节的存储内容(前面我们让4字节存储的是堆内存编号)做了优化。
java
32bit 物理内存地址(16进制) 物理内存地址(2进制)
+-----------+ 0x 00 00 00 F8 00 00 00 00 0b 0000 0000 0000 0000 0000 0000 1111 1000 0000 0000 0000 0000 0000 0000 0000 0000
| 8 byte |
+-----------+ 0x 00 00 00 F8 00 00 00 08 0b 0000 0000 0000 0000 0000 0000 1111 1000 0000 0000 0000 0000 0000 0000 0000 1000
| 8 byte |
+-----------+ 0x 00 00 00 F8 00 00 00 10 0b 0000 0000 0000 0000 0000 0000 1111 1000 0000 0000 0000 0000 0000 0000 0001 0000
| 8 byte |
+-----------+ 0x 00 00 00 F8 00 00 00 18 0b 0000 0000 0000 0000 0000 0000 1111 1000 0000 0000 0000 0000 0000 0000 0001 1000
...... ...... ......
+-----------+
| 8 byte |
+-----------+ 0x 00 00 00 F9 00 00 00 00 0b 0000 0000 0000 0000 0000 0000 1111 1001 0000 0000 0000 0000 0000 0000 0000 0000
......
图1.4- java堆内存真实的物理地址4字节示意图
我们观察图1.4中物理内存地址,会发现物理地址的最后3 位bit位均是0
。
- 这得益于我们前面的约定组 (一组为8个字节),jvm规范约定java对象默认8字节对齐(可以手动调整该参数),方便寻址,详见Java对象的内存分布(一)。
- 对齐8字节这个参数可以通过JVM参数
-XX:ObjectAlignmentInBytes
来改变(默认值为8)。当然这个数值的必须是2的次幂,数值范围需要在8 - 256之间。
既然这个 32GB 堆内存空间的物理地址最后3 bit 均为0,那就没有什么意义,我们可以直接丢弃掉这3个bit,即将物理内存地址右移3位后将其低32 bit 用4字节存储,神奇的事情发生了,我们用32 bit 存储了35个 bit 的实际内容 。我们先给这4个字节的存储单元一个名字,方便我们后面使用,暂且称其为引用(记为Ref)。
这样处理后,我们实际的物理地址计算会变成:
实际物理地址(记为ptr)= 堆内存的物理起始地址(记为base) + 引用 左移3位,这个过程我们称之为解码(decode);相应的,
引用(记为Ref)= 实际物理地址 右移3位,这个过程我们称之为编码(encode)。
即 :
ptr = base + Ref << 3;
// decodeRef = ptr >> 3
// encode
如:
0x00 00 00 F8 00 00 00 10 >> 3 = 0x00 00 00 1F 00 00 00 02
Ref =0x00 00 00 02
;ptr = 0x 00 00 00 F8 00 00 00 00 + 0x00 00 00 02 << 3 = 0x 00 00 00 F8 00 00 00 10;
上述过程,就是java中指针压缩(Compressed OOPs, OOPs为Ordinary Object Pointers)。
小节:
32bit 可以最大寻址4GByte,要想寻址大于4GB的内存地址,比如32GB,则地址需要35bit才可以做到。java堆内存通过指针压缩技术(32bit 存储 35 bit 信息)将寻址空间扩大到32GByte(2^35^ = 32GB)。
注意这里有个重要的前提:java对象默认对齐8字节。
1.2.4、java堆内存分配优化
看到这里,不知道你是否有疑问,java引用 直接存储堆内存编号(偏移量) (忘了的话,回去看1.2.2、堆内存怎么映射到物理内存地址 )不也是可以吗?通过所谓的指针压缩做了一大堆优化,看起来没有啥差别啊?
堆内存编号寻址 | 指针压缩寻址 | 备注 | |
---|---|---|---|
计算方法 | 物理地址 = 起始物理内存地址 + 堆内存编号 * 偏移单位; 引用:直接存储堆编号; | 物理地址:解码计算; 引用:编码计算; | |
公式 | ptr = base + Ref << 3; Ref |
ptr = base + Ref << 3; Ref = ptr >> 3 |
ptr = base + Ref * 8; 等价于 ptr = base + Ref << 3; |
通用 | ptr = base + Ref << shift; Ref |
ptr = base + Ref << shift; Ref = ptr >> shift |
发现了吗?ptr 计算的公式竟然一致了。所以,引用(Ref)里最终存储的就是堆内存编号,即存储堆内存的偏移量 |
可以看到对比后,甚至觉得堆编号寻址的引用都不需要运算可以支直接储存起来,更快呢?
我们思考下,指针压缩存储推倒公式时,为啥会加起始地址base
?那是因为我们假设了我们的物理内存无限大,实际当中物理内存是有限的,假设物理内存上限32GB,即一共35个bit可以表示的地址,那么指针压缩后,32 bit 刚好可以表示35 bit 信息 ,那我们就不需要起始地址base
了,这样我们就可以去掉加法运算,即
ptr = Ref << shift;
Ref = ptr >> shift
位运算要比加法运算快多了,这样对比下来,指针压缩的优势就明显了。这个优化在Hotspot 中称之为Compressed Oops mode: Zero based(基于零的压缩普通对象指针)
实际使用时,我们不需要限制物理内存为32GB的上限,只需要保证我们的堆内存分配在物理内存0~32GB
的地址空间即可。
如果堆内存分配在物理内存32GB以上的位置,我们的内存起始地址base
是不可以省略的。即base
需要参与实际的编解码工作。此时,在Hotspot中称之为Compressed Oops mode: Non-zero based(基于非零的压缩普通对象指针)
指针压缩是为了解决超过4GB的内存地址空间访问,换言之当分配的堆内存在0~4GB
的容量时,4字节引用(Ref)都不需要指针压缩技术,直接存储堆偏移量即可。
1.2.5、java堆内存分配指针压缩
目前为止,我们讨论的一直是引用为4字节的堆内存分配,java采用了指针压缩技术来保证引用(Ref)为4字节时,堆内存上限可以分配到32GByte。如果超过32GB的堆内存呢?
- 我们可以调整 java 对象对齐字节(默认为8),这样可以有更多的低位bit空闲出来。即
shift
可以比3大了。- 可以通过JVM参数
-XX:ObjectAlignmentInBytes
来改变(默认值为8)。当然这个数值的必须是2的次幂,数值范围需要在8 - 256之间。但放大这个值意味着会有更多的空间浪费掉,详见Java对象的内存分布(一)。 - 例如对齐16字节,则可以表示64GB空间(shift = 4, 2^36^ = 64GB)。
- 可以通过JVM参数
- 调整引用(Ref) 为8字节。这样有64bit,寻址范围就大多了。
- 更大的引用使得的指针(ptr)在主内存和缓存器(例如LLC, L1等)之间移动数据的时候,会占用更多的带宽。
- 引用扩大了1倍,意味着对象变大了,堆内存一定的前提下,可以分配的对象数会变少;(事实上当内存到达40-50GB的时候,有效内存才相当于使用Compressed OOPS技术时候的32GB内存)
- 对象变大了,堆内存很快就满了,频繁触发GC;
如果堆内存分配超过32GB ,我们也没有调整扩大java 对象对齐字节的值,那么就只能用8字节的引用(Ref)了,换言之,指针压缩就失效了。
从Java7开始,当maximum heap size小于32G的时候,压缩指针是默认开启的。但是当maximum heap size大于32G的时候,压缩指针就会关闭。
这也是为什么很多Java服务在运行中,官方都建议单个运行实例的内存设置不要超过32GB的根本原因。
二、代码实测
2.1 JOL测试 jvm
java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class Reference {
Object ref = new Object();
public static void main(String[] args) {
System.out.println(VM.current().details());
final Reference reference = new Reference();
System.out.println(ClassLayout.parseInstance(reference).toPrintable());
}
}
2.2 执行结果
2.2.1 jvm启动开启指针压缩
分析:
- 如图所示,已开启指针压缩,引用
Reference.ref
占用4字节;java为8字节对齐方式, 3个bit shift; - 对象大小为16个字节。
2.2.2 jvm启动关闭指针压缩
添加-XX:-UseCompressedOops
即:
-Xms5g -Xmx5g -Xlog:gc+heap+coops -XX:-PrintGCDetails -XX:-UseCompressedOops
分析:
- 如图所示,已关闭指针压缩,引用
Reference.ref
占用8字节。java为8字节对齐方式; - 对象大小为24个字节。意味着关闭指针压缩后对象变大了。
三、总结
- 大部分 JVM 实现将 Java 引用(Ref)转换为机器指针(ptr);
- 设置java堆内存范围为
0~4GB
时,java 引用为4字节即可保证正确寻址; - 设置java堆内存范围为
4GB~32GB
时, java通过指针压缩技术 来保证4字节引用的正确寻址; - 设置java堆内存超过
32GB
时java通过8字节引用来保证正确寻址。 - 指针压缩开启时,寻址对比如下:
编号 | 寻址公式 | 前提条件 | 备注 |
---|---|---|---|
1 | ptr = base + Ref << 3 | 4GB <= Heap_Size < 32GB | Heap_Size为堆内存分配大小 |
2 | ptr = Ref << 3 | Heap_High_Ptr < 32GB | 在物理内存0~32GB上分配堆内存 |
3 | ptr = base + Ref | Heap_Size < 4GB | 无需指针压缩 |