Java 引用是4个字节还是8个字节?

问题:

  1. Java引用和C的指针有啥区别?
  2. Java 引用是4个字节还是8个字节?

一、内存寻址

1.1、寻址空间

1.1.1 数据范围

  • 4字节一共32(4 * 8)个 bit,每个 bit 可以有2种取值(0, 1),所以4字节可以表示0~232 这个范围内的任何一个数;
  • 8字节同理可以表示0~264 这个范围内的任何一个数。

1.1.2 内存地址

将1.1.1中表示的数据范围用16进制表示:

字节数 bit数 10进制范围 16进制范围
4 32 0~232 0x 00 00 00 00 ~ 0x FF FF FF FF
8 64 0~264 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字节的寻址范围

  • 232 Byte = 22 * 210 * 210 * 210 = 4GB;
  • 264 Byte = 24 * 210 * 210 * 210 * 210 * 210 * 210 = 224TB = 16EB
最大内存地址 字节数 bit数 10进制范围 16进制范围 备注
4GB 4 32 0~232 0x 00 00 00 00 ~ 0x FF FF FF FF
16EB 8 64 0~264 0x 00 00 00 00 00 00 00 00 ~ 0x FF FF FF FF FF FF FF FF 实际使用时,64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 248大小,能够表示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组,内存地址为 211( 211 = 256 * 8)
    • 4个字节最大可以标识232 组,堆内存地址为 32GB(232 * 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; // decode
  • Ref = 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(235 = 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, 236 = 64GB)。
  • 调整引用(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 无需指针压缩
相关推荐
wdfk_prog5 分钟前
lely-canopen-rtt:为什么推荐在 RT-Thread 上使用 Lely CANopen 构建主站
java·运维·缓存·docker·容器
Token掘金室11 分钟前
JSON模式结构化输出报错
java·服务器·json
秋饼17 分钟前
Spring AI 2.0 接入 DeepSeek V4.1 Flash 生产级实战
java·ai·技术分享·后端开发
IT枫斗者枫哥17 分钟前
Java 分批导出仍然 OOM?用 32 MiB 堆复现三种 CSV 写法
java
shehuiyuelaiyuehao25 分钟前
算法43,外观数列,模拟算法+双指针
java·算法
瀚高PG实验室43 分钟前
瀚高数据库V9.0.5数据脱敏完整配置步骤
jvm·数据库·postgresql·瀚高数据库
lhldsg1 小时前
多商户团购系统源码:技术架构选型与二次开发实战指南
java·小程序·架构
亦暖筑序1 小时前
AgentScope Java 实战:@Tool 方法里到底该不该写业务逻辑?
java·agent·ai编程
脉动数据行情11 小时前
Java SpringBoot 国际期货批量采集实践 美原油 / 黄金 / 指数期货定时落库
java·开发语言·spring boot
Zane19941 小时前
写Stream时踩过的坑:中间操作不会真正执行,直到你调用这一个方法
java·后端