- 一. 基础知识复习
- 1.1 对齐填充占用内存
- 1.2 基础数据类型占用内存表
- 1.3 指针压缩
- 二. String类型所占内存
- 2.1 String类型的易错点
我们知道,Java中,对象在内存中的内存布局分为三个部分:
- 对象头。
- 实例数据。
- 对齐填充。
可以复习下相关知识深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常
而对象头又可以分为三个部分:
- Mark Word,64位操作系统下占8字节,32位系统下占用4字节。
- 类型指针:在开启指针压缩的状况下占 4 字节,未开启状况下占 8 字节,默认开启指针压缩,因此占用4字节。
- 数组长度(只有数组对象才会有,本篇文章都是普通对象为例)
然后说下对齐填充。需要注意的是:Java对象的大小默认按照8字节来对齐。即为8字节的整数倍大小。倘若对象大小不足,则由对齐填充部分来补充。
提问:为什么要进行8字节的对齐?
回答:
- CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好也是 L1 缓存行的大小。
- 如果不进行内存对齐,则可能出现跨缓存行的情况,即缓存行污染。 如图:
缓存污染是指操作系统将不常用的数据从内存移到缓存,降低了缓存效率的现象
解释:
- 我们主要访问obj1这个对象,CPU就将对应的L1缓存读取过来。里面包含了obj1和obj2两个对象。
- 在后续对obj1进行修改的时候。倘若CPU在访问obj2对象时。由于obj2所在的缓存行中数据被修改了。因此此时CPU必须将其重新加载到缓存行中。影响程序的执行效率。
- 倘若obj2有自己的L1 Cache空间。那么在修改obj1对象的时候,就不会对obj2产生影响。
因此,采用8字节的对齐填充,是一种用空间换时间的一种方案。
那么总的来说,一个对象所占的内存,记住2点即可:
- 对象内存 = 对象头 + 实例数据 + padding 填充。
- 对象内存为8字节的整数倍。
类型 | 占用空间(B) |
---|---|
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
上文中提到了类型指针的内存占用情况,在开启指针压缩功能的情况下,占用4个字节,否则是8个字节。这个功能在JDK1.6版本之后就开始支持了。JDK1.8里面则是默认开启状态的。
启用 CompressOops 后,会压缩的对象包括:
- 对象的全局静态变量(即类属性)。
- 对象头信息。
- 对象的引用类型:64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。
我们以String为例,做一个小测试。首先我们引入一个pom依赖:
org.openjdk.jol jol-core 0.16
Java代码:
System.out.println(ClassLayout.parseInstance("a").toPrintable())
结果如下:
分析:
首先我们来看下object header,上图一共有2个,即对象头部分总共加起来消耗了12kb。
-
首先我的机器是64位的。使用java -version命令即可查看:
-
其次,由于是64位的机器,因此对象头中的Mark Word部分占用的8个字节大小。对应的是图中的object header: mark部分。而object header: class则指的是类型指针,占用4个字节大小。因此这里一共是12字节。
其次我们来看下这两个部分:
这里我们看下String类中包含了哪些成员变量:
public final class String implements java.io.Serializable, Comparable, CharSequence { private final char value[]; private int hash; // Default to 0 }
分别对应了:
- char[] 数组。用来存储字符串的一个引用。64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。
- int类型的hash变量。根据1.2中基础数据类的内存占用表得知,int类型占用了4个字节。对得上。
到这里为止,总共的大小为12 + 4 + 4 = 20字节。但是其并不是8的整数倍。因此对齐填充会额外占用4个字节的大小,因此一个String类型的字符串占用了24个字节。
以防万一,我在拿一个自定义类当例子:
public class SizeObject { public int size; public double money; public byte[] bytes; }
计算其所占内存:
System.out.println(ClassLayout.parseInstance(new SizeObject()).toPrintable());
结果如下:
- 对象头+类型指针,依旧是固定的8+4=12个字节。
- int类型占用4个字节,double占用8个字节,byte[]数组属于引用类型,4个字节。
- 到这里一共12 + 4 + 8 + 4 = 28个字节。然后并不是8的整数倍,通过对齐填充,再补4个字节。最终得到32字节。
首先,我们依旧用上述的例子:我们增长了这个字符串对象,我们看看它占用了多少的内存。
System.out.println(ClassLayout.parseInstance("adfadsfdsfdsafas").toPrintable());
结果如下:
可见,它还是24个字节大小。为什么我字符内容变长了,这个对象的占用内存还是24B呢?这要说到Java的内存数据结构了,这是我在深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常中贴出的图:
我们看到,有一个运行时常量池和字符串常量池。Java中对于String类型,在实例化字符串的时候做了对应的优化操作:
- 每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。
- 如果字符串不在常量池中,就会实例化该字符串,并将其放在常量池中。
文章提到过,String类型中的char[]数组保存的是这个字符串的引用地址,真正的实例对象则是在堆中另外开辟一块空间来存储的。 因此,无论我这个字符串的内容有多少,并不会改变char[]这个引用数组所占的内存。因此在计算String这个实例所占内存的时候,char[]占用的字节数永远是4个字节。
测试:
String a = "hello"; String b = "world"; String c= "helloworld"; String res = a + b; String res2 = "hello" + "world"; System.out.println(c == res); System.out.println(c == res2); System.out.println(res == res2);
结果如下:
分析:
- 栈中开辟了一块空间引用(String类中的char[]数组),“hello”放入到常量池中,a指向它。
- 栈中又开辟了一块空间引用(String类中的char[]数组),“world”放入到常量池中,b指向它。
- 栈中又又开辟了一块空间引用(String类中的char[]数组),“helloworld”放入到常量池中,c指向它。
- String res = a + b;这段代码本质上调用的是StringBuilder().toString()方法,会返回一个新的String实例,因此此时会在堆中生成一个对象来保存。运行时执行。
- String res2 = "hello" + "world";由于“hello”和“world”都是常量池中的常量,当字符串由多个字符串常量拼接而成的时候,其本身也是字符串常量。
- c==res -->false:res为堆中的一个对象,和常量相比,必定为false。
- c==res2 -->true:因此res2在创建的时候,发现常量池中已经存在同样的字符串helloworld。返回对应的实例。因此两者本质是一个东西。
- res2==res -->false:res为堆中的一个对象,和常量相比,必定为false。同理第六点。