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~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; // 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(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)。
  • 调整引用(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 无需指针压缩
相关推荐
俎树振12 分钟前
深入理解与优化Java二维数组:从定义到性能提升的全面指南
java·算法
DARLING Zero two♡21 分钟前
【优选算法】Sliding-Chakra:滑动窗口的算法流(上)
java·开发语言·数据结构·c++·算法
love静思冥想26 分钟前
Apache Commons ThreadUtils 的使用与优化
java·线程池优化
君败红颜27 分钟前
Apache Commons Pool2—Java对象池的利器
java·开发语言·apache
意疏36 分钟前
JDK动态代理、Cglib动态代理及Spring AOP
java·开发语言·spring
小王努力学编程38 分钟前
【C++篇】AVL树的实现
java·开发语言·c++
爽口泡菜39 分钟前
垃圾回收算法
jvm
找了一圈尾巴1 小时前
Wend看源码-Java-集合学习(List)
java·学习
逊嘘1 小时前
【Java数据结构】链表相关的算法
java·数据结构·链表
爱编程的小新☆1 小时前
不良人系列-复兴数据结构(二叉树)
java·数据结构·学习·二叉树