Appearance
JVM 运行时数据区(下)
本文主要介绍运行时数据区中的堆和方法区,并介绍字符串常量池、对象创建的流程、对象布局、直接内存等相关知识。
1. 堆(Heap)
1.1 堆的概述
在JVM中,堆(Heap)是Java程序在运行时存储对象实例和数组的主要区域。
一个JVM实例只有一个堆内存,堆在JVM启动时创建,在退出时销毁。堆是Java内存最核心区域之一,栈管运行,堆管存储;
JVM堆是线程共享的;
垃圾回收主要发生在堆区域;
堆通常采用分代思想涉及,主要分为新生代和老年代,新生代又分为伊甸园区(Eden Space)、幸存者0区(Survivor 0 Space,也叫From区)和幸存者1区(Survivor 1 Space,也叫to区)。
默认情况下,整个堆空间的初始大小为
物理内存/64,最大为物理内存/4,我们可以通过-Xms或-XX:InitialHeapSize来设置堆的初始大小,通过-Xmx或-XX:MaxHeapSize来设置堆空间的最大值。建议堆的初始值和最大值设置为相同的。默认情况下,新生代占整个堆空间的大小为1/3,我们可以使用虚拟机参数
-XX:NewRatio=n来设置新生代的比例,计算公式为1/1+n。假设-XX:NewRatio=4,表示新生代占堆空间的大小为1/1+4=1/5。默认情况下,伊甸园区和两个幸存者区所占的比例为8:1:1,我们可以使用
-XX:SurvivorRatio=n来调整这个比例
WARNING
上面只是最早的认识,随着垃圾收集器的发展,堆空间的划分也有变化,而且默认值也有变化。
参考资料:https://blog.51cto.com/u_14410880/2824970
在之后的垃圾收集篇再细细探究堆空间。
1.2 对象分配流程
当我们在程序中创建对象时(例如,new Object),我们需要在内存中(此处不说堆,因为存在栈上分配的情况)开辟一块空间,用来存放对象。在哪开辟空间,流程如下:

- 首先判断是否可以进行栈上分配,如果是,则将对象保存在栈上,结束对象创建;
- 然后再判断是否为大对象,如果是大对象,则直接在老年代空间中开辟空间,如果空间不够,则会进行一次
Full GC,如果垃圾收集后空间还不够,则会报内存不足错误; - 如果不是大对象,则尝试在当前线程的 TLAB 中分配对象;
- 如果 TLAB 空间不足,尝试在新生代的 Eden 区中分配对象,如果Eden区空间不足,则会触发
Minor GC; - 如果
Minor GC后, Eden 区仍然没有足够的空间,则尝试将对象分配到老年代;对象进老年代之前也会判断一下空间是否充足,不足则进行Full GC;
总结:在分配对象之前,先判断剩余空间是否充足。如果Eden区剩余空间不足,则触发Minor GC,如果老年代空间不足,则触发Full GC。
1.3 垃圾收集概述
所谓垃圾,就是在程序中不再使用的对象,这些对象存放在堆空间中,如果不进行清理,那么会造成内存不足错误,影响程序运行。
在JVM中,根据回收的目标区域和触发时机的不同,通常将 GC 分为以下几种类型:
- Minor GC:主要针对 新生代(Young Generation)进行垃圾回收。新生代通常包含 Eden 区和两个 Survivor 区(S0 和 S1)。
- Major GC:主要针对 老年代(Old Generation)进行垃圾回收。老年代中存放的是经过多次 Minor GC 仍然存活下来的对象,这些对象通常生命周期较长。
- Full GC:对 整个堆内存(包括新生代、老年代,以及方法区,取决于 JVM 版本和垃圾回收器 进行全面的垃圾回收。
关于三种GC的特性对比表如下:
| 特性 | Minor GC (Young GC) | Major GC (Old GC) | Full GC |
|---|---|---|---|
| 目标区域 | 新生代 | 老年代 | 整个堆(包括方法区/元空间) |
| 触发时机 | Eden 区满 | 老年代满等 | 老年代满、System.gc()等 |
| 频率 | 较高 | 较低 | 最低 |
| 速度 | 较快 | 较慢 | 最慢 |
| 暂停时间 | 较短 | 较长 | 最长 |
| 影响 | 较小 | 较大 | 最大 |
注意:Major GC 和 Full GC 的概念有时会混淆。 在某些语境下,"Major GC" 可能特指对老年代的回收,而在另一些语境下,它可能被用作 "Full GC" 的同义词,指清理整个堆。为了避免混淆,最好根据具体的垃圾回收器和语境来理解。
1.4 TLAB
参考资料:https://zhuanlan.zhihu.com/p/349173209
TLAB介绍
TLAB,全称Thread Local Allocation Buffer,译为线程本地分配缓存,是一块线程专用的内存分配区域。TLAB占用的是Eden区的空间,在TLAB启用的情况下(默认开启),JVM会为每个线程分配一块私有缓存区域,即为TLAB内存区域。
可以把TLAB理解为线程独享的堆内存,这里说的线程独享,只是在“内存分配”这个动作上是线程独享,至于在读取、垃圾回收等动作上都是线程共享的。即其他线程可以在这个区域读取、操作数据,但是无法在这个区域分配内存。
TLAB工作过程
预先分配:当一个新线程被创建时,JVM会从Eden区(新生代的一部分)为该线程分配一块称为TLAB的内存区域。
本地分配:当线程需要创建新的对象时,会首先尝试在自己的TLAB中分配内存。如果TLAB中有足够的空间,对象可以直接在TLAB中分配,无需任何同步。
慢分配:如果线程的TLAB空间不足以容纳新对象,或者要分配的对象过大,JVM会采取慢分配。这通常涉及到在共享的Eden区进行分配,并且可能需要同步机制来保证线程安全。
为什么叫慢分配,是因为在共享的Eden区分配内存,是需要线程同步机制的,采用CAS或者加锁,而在TLAB空间内分配内存,是不需要线程同步机制的,所以也叫快分配。
- 重新申请TLAB空间:当一个线程的TLAB用完后或剩余空间小于允许浪费的空间,JVM会尝试为该线程分配一个新的TLAB。这个过程也可能涉及到同步操作。
TLAB相关配置
TLAB通常是默认启用的。可以通过JVM参数进行配置,例如:
-XX:+UseTLAB: 显式启用TLAB(通常是默认开启的)。-XX:-UseTLAB: 显式禁用TLAB。-XX:+ResizeTLAB: 自动调整TLAB的大小。默认启用。-XX:TLABSize: 设置TLAB的初始大小,单位为字节。一般不指定,JVM采用EMA算法计算。-XX:MinTLABSize: TLAB最小值,默认值为2048字节。-XX:TLABWasteTargetPercent: 初始TLAB占用Eden的百分比,默认值为1,即TLAB初始大小为Eden区大小的1%。-XX:TLABRefillWasteFraction: 设置当TLAB剩余空间小于这个比例时,不再尝试在该TLAB中分配对象,而是重新分配一个更大的TLAB。默认值为64,即允许剩余空间为TLAB空间大小的1/64时。-XX:-PrintTLAB: 是否跟踪TLAB信息,默认false。
TLAB优势
提高性能: 通过减少或消除多线程并发分配对象时的锁竞争,显著提升了应用程序的性能,尤其是在高并发场景下。
更好的内存访问模式: 分配在同一个TLAB中的对象往往具有更好的局部性,这有助于提高CPU缓存的命中率,从而进一步提升性能。
1.5 代码优化
代码优化的前提是开启了逃逸分析,逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸;
当一个对象在方法中被定义后,它会被外部所引用,则认为发生了逃逸。例如对象作为方法返回值、对象作为方法参数等。
示例代码:
javapublic class EscapeAnalysisDemo { private Object obj; public void createObject() { this.obj = new Object(); // new Object()新创建的对象发生了逃逸 } public Object getObj() { return new Object(); // new Object()新创建的对象发生了逃逸 } public void createNewObject(Object obj2){ obj2 = new Object(); // new Object()新创建的对象发生了逃逸 } public void createNewObject2(){ Object o = new Object(); // new Object()新创建的对象未发生逃逸 } }总之,就是看新创建的对象,在方法结束后,还能不能引用到。如果能引用到,那么就发生了逃逸,如果不能引用到,那么就没有发生逃逸。
在Hotspot虚拟机中,默认开启了逃逸分析,可以通过虚拟机参数:
-XX:-DoEscapeAnalsis关闭逃逸分析。
由于堆空间存在垃圾收集,为了减少垃圾收集的影响,有以下方式优化对象创建。
栈上分配:如果某个对象没有发生逃逸,可以将该对象分配到栈上;
同步省略:如果一个对象被发现只能从一个线程被访问到,把么对于这个对象的操作可以不考虑同步;
标量替换:JIT编译器将原本需要在堆上分配的小对象分解成若干个独立的标量值(例如基本数据类型),并将标量值直接存储在栈中,而不是在堆上分配完整的对象;在Hotspot虚拟机中,参数
-XX:EliminateAllocations开启标量替换(默认开启)。标量:标量是指无法再分解为更小单位的数据,在Java中,基本数据类型就是标量;
聚合量:聚合量就是还可以分解为更小单位的数据,在Java中,对象就是聚合量;
例如:
javapublic class ScalarReplacementDemo { public void showPoint(){ Point point = new Point(10,20); System.out.println("x:"+point.x+" y:"+point.y); } } class Point{ public int x; public int y; public Point(int x, int y){ this.x = x; this.y = y; } }在经过JIT优化后,
showPoint()方法可能优化成如下:javapublic void showPoint(){ int x = 10; int y = 20; System.out.println("x:"+x+" y:"+y); }将
Point对象分为了两个标量,消除了对象分配过程。
2. 方法区(Method Area)
方法区(Method Area)是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、JIT编译器编译后的代码缓存等数据。
在JVM规范中,方法区在逻辑上属于堆的一部分,但是对于Hotspot来说,方法区不属于堆,方法区还有一个别名叫做非堆(Non- Heap),就是要把方法区和堆分开。
在方法区中存储的内容主要有以下两类:
- 类信息:类信息包括类的元信息、域信息、方法信息、静态变量、运行时常量池
- 类元信息:类的名称、包名、访问修饰符、父类、实现的接口等基本信息,描述了类的整体结构;
- 域信息:每个字段的名称、类型、修饰符等,描述了类的成员变量;
- 方法信息:每个方法的名称、返回类型、参数、访问修饰符、字节码、异常表、局部变量表大小、操作数栈大小等,描述了方法的具体实现细节;
- 运行时常量池:当一个类或接口被 JVM 加载到内存中时,它的 Class 文件中的常量池(Constant Pool Table)会被转换成运行时常量池,并存储在方法区中;常量池中存储的内容包括字面量值、类引用、接口引用、方法引用、字段引用等;
- 代码缓存:代码缓存是指JIT编译后的代码缓存。为了提高执行效率,HotSpot VM 会将热点代码编译成本地机器码,并将这些编译后的代码存储在方法区中。这使得后续执行相同的代码时可以直接使用高性能的本地代码,而无需再次解释执行。
方法区在Hotspot中的实现有如下演进:
- 在JDK 1.6 之前,方法区在实现上又叫永久代(使用虚拟机内存),里面包含了所有的方法区内容;
- 在JDK 1.7时,方法区由永久代+堆实现,但是字符串常量、静态变量已迁移至堆中,其他仍然在方法区;
- 在JDK 1.8时,方法区改为元空间(使用本地内存)+堆实现,字符串常量、静态变量仍然在堆中,其他在元空间中;

在方法区中有几个常量池的概念容易混淆,这里做如下区分:
- class常量池:也叫静态常量池,主要存放编译期生成的各种字面量和符号引用。
- 运行时常量池:当类或接口加载到内存中时,class常量池中的内容存放到运行时常量池。符号引用经过解析后变为直接引用。在运行期间,可以动态放入新的常量。
- 字符串常量池:在逻辑上属于运行时常量池,主要存放字符串。在JDK 1.7及之后,字符串常量池存放在堆空间中。
下表显示了类中各变量值的存储位置:

我们可以使用以下的参数(JDK 1.8及之后)设置方法区:
-XX:MetaspaceSize:元空间初始大小,默认值约为2MB。-XX:MaxMetaspaceSize:元空间最大大小,默认没有限制。下面的代码演示了元空间溢出的情况:
java// -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m public class MetaspaceOutofMemoryDemo extends ClassLoader{ public static void main(String[] args) { MetaspaceOutofMemoryDemo cl = new MetaspaceOutofMemoryDemo(); int i = 0; ClassWriter classWriter = new ClassWriter(0); while (true){ classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); byte[] bytes = classWriter.toByteArray(); cl.defineClass("Class" + i, bytes, 0, bytes.length); i++; } } }
3. 字符串常量池
3.1 字符串常量池概述
在Java中,由于会大量地使用字符串常量,如果每一次声明一个字符串常量都创建一个String对象,那将会造成极大地空间资源浪费。所以Java提出了字符串常量池的概念,在堆内存中(JDK 1.7+)开辟了一块存储空间来存放字符串常量。如果一个字符串常量已经存在,那么就不会创建新的字符串对象,而是会返回已经存在了的字符串引用。
字符串常量池通过固定大小的HashTable实现,通过哈希值来判断是否有相同的字符串,如果哈希表大小太小,就会导致有多个字符串具有相同的哈希值,就会以链表的形式存放字符串,如果链表过长会导致效率过低。
字符串常量池特点:
- 字符串常量池中不会存储相同内容的字符串,称为字符串常量的唯一性;
-XX:StringTableSize参数可以设置字符串常量池的大小:- 在JDK 1.6中,默认值是1009;
- 在JDK 1.7+中,默认值是60013;
-XX:+PrintStringTableStatistics:在JVM进程退出时,打印出字符串常量池的统计信息;- 字符串常量池存在垃圾回收;
下面的代码演示字符串常量池大小对效率的影响:
java
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
List<String> stringList = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
String s = ("String" + i).intern(); // intern() 方法是将字符串放入常量池中
stringList.add(s);
}
long endTime = System.currentTimeMillis();
System.out.println("耗费时间:" + (endTime - startTime) / 1000.0 + "秒");
}通过改变虚拟机参数来测试:
第一组参数(设置字符串常量池大小为1009):-Xms2g -Xmx2g -XX:StringTableSize=1009,测试结果:耗费时间:6.053秒
第二组参数(设置字符串常量池大小为60013):-Xms2g -Xmx2g -XX:StringTableSize=60013,测试结果:耗费时间:0.187秒
可见字符串常量池大小太小会影响字符串效率。
当我们在虚拟机参数上加上-XX:+PrintStringTableStatistics时,会打印如下内容:
txt
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13607 = 326568 bytes, avg 24.000
Number of literals : 13607 = 524736 bytes, avg 38.564
Total footprint : = 1011392 bytes
Average bucket size : 0.680
Variance of bucket size : 0.686
Std. dev. of bucket size: 0.828
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1000995 = 24023880 bytes, avg 24.000
Number of literals : 1000995 = 64066496 bytes, avg 64.003
Total footprint : = 88570480 bytes
Average bucket size : 16.680
Variance of bucket size : 9.706
Std. dev. of bucket size: 3.116
Maximum bucket size : 273.2 字符串的不可变性
在Java中,字符串对象被设置为不可变的,即当一个字符串对象创建好之后,就不能在原字符串对象上进行增加、删除、修改操作来改变原字符串,当一些需要改变原字符串的操作执行时,原对象保持不变,返回一个新的字符串对象。
Java为什么把字符串对象设计为不可变的?
1. 安全性 (Security):
- 防止恶意修改敏感信息: 字符串经常用于存储敏感信息,例如用户名、密码、文件路径、网络连接信息等。如果字符串是可变的,那么在传递给某个方法或组件后,可能会被意外或恶意地修改,导致安全漏洞。例如,一个方法接收到包含密码的字符串,如果该字符串可变,另一个线程或方法可能会在验证之前修改它。
- 防止反射攻击: 不可变性使得通过反射修改字符串内部状态变得更加困难,从而增强了程序的安全性。
2. 并发性 (Concurrency):
- 天然的线程安全: 由于字符串对象一旦创建就不能被修改,多个线程可以安全地共享同一个字符串对象,而无需额外的同步措施。这简化了并发编程,避免了潜在的竞态条件和数据不一致问题。
- 简化多线程环境下的数据共享: 在多线程环境中,如果多个线程需要访问同一个字符串数据,不可变性保证了它们看到的数据始终是一致的,无需担心数据被意外修改。
3. 性能 (Performance):
- 字符串常量池 (String Pool) 的实现基础: Java 中有一个特殊的内存区域称为字符串常量池,用于存储字符串字面量。当创建一个字符串字面量时,JVM 会首先检查池中是否已经存在相同值的字符串,如果存在则直接返回池中的引用,否则创建一个新的字符串对象并放入池中。这个机制极大地节省了内存,并提升了性能。如果字符串是可变的,那么字符串常量池就无法实现,因为一个引用上的修改会影响到所有指向同一个字符串字面量的其他引用。
- 哈希码 (HashCode) 的缓存: 不可变性使得字符串对象的哈希码在创建后就不会改变。因此,哈希码可以被缓存起来,在需要时直接使用,而无需重新计算。这对于将字符串作为
HashMap或HashSet的键时非常重要,因为哈希码的稳定性和计算效率直接影响到这些数据结构的性能。
4. 缓存 (Caching):
- 方便进行缓存: 由于字符串的内容不会改变,因此可以安全地对其进行缓存。例如,可以将经常使用的字符串缓存起来,避免重复创建和计算。
5. 作为其他类的基础 (Foundation for Other Classes):
- 作为 Map 和 Set 的键: 不可变性是作为
HashMap和HashSet等基于哈希的数据结构的键的关键要求。如果键是可变的,那么当键的值发生变化时,其哈希码也会改变,导致在数据结构中无法正确地找到对应的元素。 - 作为类加载器的命名空间: 字符串常用于表示类名、方法名等,这些信息在类加载过程中至关重要。不可变性保证了这些信息的稳定性和一致性。
虽然正常使用字符串,字符串对象是不可变的,但是我们也可以利用反射来修改字符串对象,破坏字符串的不可变性:
java
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String s = "hello, world";
Class<String> stringClass = String.class;
Field value = stringClass.getDeclaredField("value");
value.setAccessible(true);
char[] charArr = (char[])value.get(s);
charArr[0] = 'H';
System.out.println(s);
}结果如下:
txt
Hello, world注意,在JDK 9之后,字符串中的字符数组
char[]改为了字节数组byte[]
3.3 字符串创建
字符串的创建主要分为两种方式:
通过字面量的方式创建,即通过双引号
""的方式创建,例如:String a = "hello"。通过这种方式创建的字符串会存放在字符串常量池中。
通过
new关键字的方式创建。通过这种方式创建的字符串会存放在堆中。
分为两种情况:
拷贝数组的方式,例如
s:javachar[] chars = {'a','b','c'}; String s = new String(chars);通过查看源码可以知道这种方式就是拷贝字符串数组来创建字符串。
javapublic String(char value[]) { this.value = Arrays.copyOf(value, value.length); }值引用的方式,例如
a:javaString a = new String("abc");通过查看源码可以知道这种方式就是通过值引用来创建字符串。
javapublic String(String original) { this.value = original.value; this.hash = original.hash; }
3.4 字符串拼接
字符串拼接有如下方式:
| 序号 | 拼接方式 | 例子 | 说明 |
|---|---|---|---|
| 1 | +号拼接 | String a = "a"; String b = "b"; String r1 = a+b; | 会转换成StringBuilder的方式 |
| 2 | concat() | String r2 = "a".concat("b"); | 通过拷贝字符数组,然后进行拼接 |
| 3 | StringBuilder | StringBuilder sb = new StringBuilder(); String r3 = sb.append("a").append("b").toString(); | 采用动态字符数组的方式,本质上还是拷贝字符数组 |
| StringBuffer | StringBuffer sb = new StringBuffer(); String r4 = sb.append("a").append("b").toString(); | 在3的基础上增加了线程安全 |
第一种方式代码示例:
java
public void test01(){
String a = "a";
String b = "b";
String r1 = a + b;
}我们查看编译后的字节码:
txt
0 ldc #2 <a>
2 astore_1
3 ldc #3 <b>
5 astore_2
6 new #4 <java/lang/StringBuilder>
9 dup
10 invokespecial #5 <java/lang/StringBuilder.<init> : ()V>
13 aload_1
14 invokevirtual #6 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
17 aload_2
18 invokevirtual #6 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
21 invokevirtual #7 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
24 astore_3
25 return可以看到在字节码层面,+号拼接字符串的方式会转换为StringBuilder的方式。
第二种方式concat()方法的源码:
java
public String concat(String str) {
if (str.isEmpty()) {
return this;
}
int len = value.length;
int otherLen = str.length();
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
void getChars(char dst[], int dstBegin) {
System.arraycopy(value, 0, dst, dstBegin, value.length);
}本质就是复制字符数组的方式创建一个新的字符串对象。
第三种方式append()方法的源码:
java
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}也是复制字符数组的方式。
综合示例:
java
public static void main(String[] args) {
String a1 = "a1";
String a2 = "a2";
String a3 = "a1a2";
String a4 = "a1" + "a2";
String a5 = "a1" + a2;
String a6 = a1 + a2;
String a7 = "a1".concat("a2");
String a8 = new StringBuilder().append("a1").append("a2").toString();
String a9 = new StringBuffer().append("a1").append("a2").toString();
System.out.println(a3 == a4);
System.out.println(a3 == a5);
System.out.println(a3 == a6);
System.out.println(a3 == a7);
System.out.println(a3 == a8);
System.out.println(a3 == a9);
}结果如下:
txt
true
false
false
false
false
false注意,String a4 = "a1" + "a2";这行代码,在编译器优化后,结果为:String a4 = "a1a2";,所以a3和a4指向的都是字符串常量池中同样的字符,结果为true。
a5到a9都是在堆上创建新的字符串。
3.5 intern()方法
我们可以使用String提供的intern()方法(本地方法),来将字符串放入常量池,并返回常量池中该字符串的引用,如果常量池中已经有该字符串了,则直接返回池中的引用地址。
该方法在不同的JDK 版本有不同的实现:
- JDK 1.6:
- 如果字符串常量池中有,则不会放入,直接返回该字符串在常量池中的地址;
- 如果没有,则会将该对象复制一份,将新的字符串对象放入常量池中,返回常量池中的地址;
- JDK 1.7+:
- 如果字符串常量池中有,则不会放入,直接返回该字符串在常量池中的地址;
- 如果没有,则会将该对象的引用地址复制一份,将新的引用地址放入常量池中,返回常量池中的地址;
代码示例:
java
public static void main(String[] args) {
String s1 = new String("a") + new String("b");
String s2 = s1.intern();
String s3 = "ab";
System.out.println("s1 == s2: " + (s1 == s2)); // JDK1.6: false, JDK 1.7+: true
System.out.println("s1 == s3: " + (s1 == s3)); // JDK1.6: false, JDK 1.7+: true
System.out.println("s2 == s3: " + (s2 == s3)); // JDK1.6: true, JDK 1.7+: true
}

intern()的使用场景:

4. 基本对象缓存
Byte、Short、Integer、Long、Character、Boolean都是具有缓存机制的类。缓存工作是在类生命周期的初始化阶段执行的。- 默认情况下,
Byte、Short、Integer、Long缓存范围是-128到127,Character缓存范围是0到127。 Integer可以通过JVM参数指定缓存范围,其他类都不行。Integer的缓存上界可以通过参数-XX:AutoBoxCacheMax=size指定,取值定值与127的最大值并且不超过Integer的表示范围,下界不能指定,只能为-128。
查看Integer缓存的源码:
java
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}当我们将原始值赋值给Integer对象时,Java会自动执行装箱,字节码指令是执行valueOf()方法,例如:
java
Integer i = 10;txt
0 bipush 10
2 invokestatic #18 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
5 astore_1
6 returnvalueOf()源码:
java
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}即当原始值处于缓存范围内,则会从缓存数组中取。
测试代码:
java
public static void main(String[] args) {
Integer a1 = 10;
Integer a2 = 10;
Integer b1 = 128;
Integer b2 = 128;
System.out.println(a1 == a2); // true
System.out.println(b1 == b2); // false
}5. 创建对象相关
5.1 创建对象的方式
创建对象有以下方式:
- new语句:最常用的方式,高效率,静态方式;
- Class的
newInstance()方法:采用反射的方式创建对象,需要创建对象的类有无参构造方法,效率低; - Constructor的
newInstance(x)方法:采用反射的方式创建对象,构造方法可以有参数,效率低; - clone方式:实现
Cloneable接口,调用对象的clone()方法实现,本质是基于内存拷贝的方式; - 反序列化:用于特殊场景,比如json与class对象之间的转换;
5.2 创建对象的流程
创建对象主要分为以下步骤:
- 类加载检查:Java虚拟机在读取创建对象指令时,首先检查对应的类是否被加载、解析和初始化,如果没有,则会先执行相应的类加载过程;
- 内存分配:在通过类加载后,开始为对象在堆中分配内存,这一步需要线程同步机制。对象所需的内存大小在类加载完成后便可确定。内存管理主要有以下方式:
- 指针碰撞:在堆内存规整的情况下(堆内存规整就是用过的内存在一边,没有用过的内存在另外一边,中间利用一个分界脂针进行分界),在开辟内存空间时,将分界脂针往没用过的内存方向移动相应大小位置即可。将堆内存这样划分的GC收集器算法有:Serial,ParNew。
- 空闲列表:在堆内存不规整的情况下(虚拟机维护一个可以记录空闲内存的列表),在开辟内存空间时,找到一块足够大的内存分配给该对象即可,同时更新空闲内存列表。将堆内存这样划分的GC收集器算法有:CMS。
- TLAB机制:虚拟机为每个线程分配了不同的空间,这样每个线程在分配内存时只是在自己的空间,尽量避免上述方式中的同步锁。
- 初始化默认值:分配完内存后,需要对对象的字段进行零值初始化(赋默认值);
- 设置对象头:对创建出来的对象,进行信息标记,包括是否为新生代/老年代、哈希值、元数据信息等;
- 执行初始化方法:包含成员变量显式赋值、构造代码块的初始化、构造器中的初始化,前两者按照声明的顺序执行;
- 对象引用赋值(对外可用): 对象创建完成,其引用可以被其他地方访问;
下面以代码演示对象字段赋值顺序:
java
class TestObject{
{
i = 1;
}
private int i = 2;
public TestObject(){
i = 3;
}
}我们查看字节码,内容如下:
txt
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 aload_0
5 iconst_1
6 putfield #2 <data/area/obj/TestObject.i : I>
9 aload_0
10 iconst_2
11 putfield #2 <data/area/obj/TestObject.i : I>
14 aload_0
15 iconst_3
16 putfield #2 <data/area/obj/TestObject.i : I>
19 return可以看到执行顺序为i=1->i=2->i=3;
我们改变顺序如下:
java
class TestObject{
public TestObject(){
i = 3;
}
private int i = 2;
{
i = 1;
}
}再次查看字节码:
txt
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 aload_0
5 iconst_2
6 putfield #2 <data/area/obj/TestObject.i : I>
9 aload_0
10 iconst_1
11 putfield #2 <data/area/obj/TestObject.i : I>
14 aload_0
15 iconst_3
16 putfield #2 <data/area/obj/TestObject.i : I>
19 return执行顺序为i=2->i=1->i=3。
综上,对象初始化顺序:
- 成员变量显式赋值/构造代码块的初始化,按照声明的顺序执行
- 构造器中的初始化
5.3 对象内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding):

对齐填充
现代 CPU 通常以固定大小的块(如 4 字节-32位、8 字节-64位) 读取内存。这就意味着每次读取内存时,内存起始地址是4的倍数(32位)或8的倍数(64位)。
例如,现在有两个对象在内存中紧密排列,当程序读取B对象时:

- 如果不对齐,则CPU先一次性读取8字节(从地址0开始),此时只读取到B对象一部分,再读取8字节(从地址8开始),然后将两次读取到的内容进行拼接得到B对象。总共需要读取两次内存。
- 如果对齐,则CPU从地址8开始读取8字节,此时已全部读取到B对象的数据,总共只需要读取一次内存。
对齐大小,可以通过参数-XX:ObjectAlignmentInBytes(默认是8)进行配置,一般不建议修改;
指针压缩
指针压缩目的:在64位平台下,一个内存的起始位置(对象引用指针)是8字节,CPU和内存消耗过大。指针压缩是指在64位平台下,利用32位大小,获得超过4G(2的32次方)的内存寻址空间,这种方法不消耗更多CPU和内存。
指针压缩实现原理:由于堆中的对象的起始地址均是对齐至8的倍数,所以对象引用地址的后三位始终是0。我们可以利用存储0的这3个bit来存储有意义的地址,这样就多出了3个bit的寻址空间,即我们可以寻址的内存空间从4G变为了32G(
一些说明:
- JDK 1.6 update 14开始,在64位平台下,如果JVM的内存不超过32G时,默认支持指针压缩;
- 可以通过参数
-XX:-UseCompressedOops禁用指针压缩; - 如果堆内存小于4G时,无需压缩;
- 如果对内存大于4G小于32G时,自动开启指针压缩,指针压缩比非压缩效率高;
- 如果堆内存大于32G时,指针压缩会失效,JVM会强制使用64位来对Java对象寻址;
5.4 对象大小
本小节利用代码探索对象的内存布局与大小。首先准备工具:
xml
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>然后编写如下测试代码:
java
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());控制台输出如下:
txt
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000f28
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total可以看到有三部分内容:
- 对象头中的mark部分:占8字节;
- 对象头中的class部分(指向方法区中的类模板):占4字节;
- 对齐填充:占4字节;
由于Object对象没有实例数据,所以实例数据没有输出。
我们改变虚拟机参数,禁用指针压缩-XX:-UseCompressedOops,再次运行测试代码,输出如下:
txt
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 8 (object header: class) 0x00000001157f1c38
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total可以看到对象头中的class部分变为了8字节,并且对齐填充没有了。
编写如下测试类,查看对象属性的大小:
java
class TestObject2{
private boolean a;
private byte b;
private short c;
private char d;
private int e;
private long f;
private float g;
private double h;
private Object o;
}编写如下测试代码(开启了指针压缩的情况下),查看对象大小:
java
TestObject2 testObject2 = new TestObject2();
System.out.println(ClassLayout.parseInstance(testObject2).toPrintable());测试结果如下:
txt
data.area.obj.TestObject2 object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00060a18
12 4 int TestObject2.e 0
16 8 long TestObject2.f 0
24 8 double TestObject2.h 0.0
32 4 float TestObject2.g 0.0
36 2 short TestObject2.c 0
38 2 char TestObject2.d �
40 1 boolean TestObject2.a false
41 1 byte TestObject2.b 0
42 2 (alignment/padding gap)
44 4 java.lang.Object TestObject2.o null
Instance size: 48 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total如果我们不开启指针压缩,再次测试,结果如下:
txt
data.area.obj.TestObject2 object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 8 (object header: class) 0x00000001139353c8
16 8 long TestObject2.f 0
24 8 double TestObject2.h 0.0
32 4 int TestObject2.e 0
36 4 float TestObject2.g 0.0
40 2 short TestObject2.c 0
42 2 char TestObject2.d �
44 1 boolean TestObject2.a false
45 1 byte TestObject2.b 0
46 2 (alignment/padding gap)
48 8 java.lang.Object TestObject2.o null
Instance size: 56 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total通过上面的结果,我们可以得出下面明显的结论:
- byte和boolean数据类型占据1个字节大小,char和short数据类型占据2个字节大小,int和float数据类型占据4个字节大小,long和double占据8个字节数据大小;
- 在开启了压缩指针的情况下,引用数据类型占据4个字节大小;关闭压缩指针的情况下,引用数据类型占据8个字节大小;
- 在分配完基本数据类型的空间后,会先进行一次内部填充,然后再分配引用数据类型的空间,如果此时还没有对齐,则再进行外部对齐;
关于各属性的分配顺序,可以通过-XX:FieldsAllocationStyle指定:
-XX:FieldsAllocationStyle=0:表示先分配对象,然后再按照double/long、float/int、char/short、byte/boolean的顺序分配其他字段,相同宽度的字段尽可能被分配在一起,而相同宽度字段的顺序则是它们在class文件中声明的顺序;Details
txtdata.area.obj.TestObject2 object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 8 (object header: class) 0x000000011550d3c8 16 8 java.lang.Object TestObject2.o null 24 8 long TestObject2.f 0 32 8 double TestObject2.h 0.0 40 4 int TestObject2.e 0 44 4 float TestObject2.g 0.0 48 2 short TestObject2.c 0 50 2 char TestObject2.d � 52 1 boolean TestObject2.a false 53 1 byte TestObject2.b 0 54 2 (object alignment gap) Instance size: 56 bytes Space losses: 0 bytes internal + 2 bytes external = 2 bytes total-XX:FieldsAllocationStyle=1(默认值):表示先分配基本数据类型,按照double/long、float/int、char/short、byte/boolean的顺序分配字段,然后再分配引用类型;可是在上面开启了指针压缩的测试结果中,是int数据类型字段排在了long和double数据类型前面,这是为什么呢?
在JVM中,有一个参数
-XX:CompactFields会将int/float、short/char、byte/boolean这些小字段填充到对象头信息与字段起始偏移位置的间隙中去。如果关闭:-XX:+UseCompressedOops -XX:FieldsAllocationStyle=1 -XX:-CompactFields测试结果如下(可以看到对象头与对象数据之间的间隙也被填充了):
Details
txtdata.area.obj.TestObject2 object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x00060a18 12 4 (alignment/padding gap) 16 8 long TestObject2.f 0 24 8 double TestObject2.h 0.0 32 4 int TestObject2.e 0 36 4 float TestObject2.g 0.0 40 2 short TestObject2.c 0 42 2 char TestObject2.d � 44 1 boolean TestObject2.a false 45 1 byte TestObject2.b 0 46 2 (alignment/padding gap) 48 4 java.lang.Object TestObject2.o null 52 4 (object alignment gap) Instance size: 56 bytes Space losses: 6 bytes internal + 4 bytes external = 10 bytes total-XX:FieldsAllocationStyle=2:父类中的引用类型与子类中的引用类型放在一起,此时父类采用策略1,子类采用策略0;测试代码见附录1。
5.5 数组对象的大小
如果数组元素是基本数据类型,占用空间大小如下:
java
long[] longArr = new long[10];
System.out.println(ClassLayout.parseInstance(longArr).toPrintable());结果如下:
txt
[J object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000d48
12 4 (array length) 10
16 80 long [J.<elements> N/A
Instance size: 96 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total可以看到,在对象头之后,还多了一部分数组长度,用4个字节存储,之后是数据空间,一个long占据8个字节,10个long则占据80字节,总共占据96个字节。
如果数组元素是引用数据类型,占用空间大小如下:
java
Object[] objArr = new Object[5];
System.out.println(ClassLayout.parseInstance(objArr).toPrintable());结果如下:
txt
[Ljava.lang.Object; object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00011840
12 4 (array length) 5
16 20 java.lang.Object Object;.<elements> N/A
36 4 (object alignment gap)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total同样是在对象头之后,多了4字节的数组长度,然后是数据空间,一个引用占据4个字节(在开启指针压缩的情况下),5个引用则占据20个字节大小,再加上4字节的填充,总共占据40个字节。
5.6 对象访问定位
对象访问可以分为句柄访问和直接内存访问,hotspot采用直接内存访问方式。
句柄访问:

直接内存访问:

6. 直接内存
- 直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域;
- 直接内存是在Java堆外、直接向系统申请的内存区间;
- 来源于NIO,通过存在堆外的DirectByteBuffer操作直接内存;
- 访问直接内存的速度会优于Java堆,即读写性能好。在读写频繁的场合可以考虑吧使用直接内存。
附录1-字段分配策略测试
首先准备如下类:
java
class TestParent{
private Object o;
private int i;
private long j;
private byte k;
}java
class TestChild extends TestParent{
private boolean a;
private int b;
private long l;
private Object obj;
}然后编写测试代码:
java
TestChild testChild = new TestChild();
System.out.println(ClassLayout.parseInstance(testChild).toPrintable());在开启了指针压缩、字段压缩与默认字段顺序的参数下:
txt
-XX:+UseCompressedOops -XX:FieldsAllocationStyle=1 -XX:+CompactFields结果如下:
txt
data.area.obj.TestChild object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00060c10
12 4 int TestParent.i 0
16 8 long TestParent.j 0
24 1 byte TestParent.k 0
25 3 (alignment/padding gap)
28 4 java.lang.Object TestParent.o null
32 8 long TestChild.l 0
40 4 int TestChild.b 0
44 1 boolean TestChild.a false
45 3 (alignment/padding gap)
48 4 java.lang.Object TestChild.obj null
52 4 (object alignment gap)
Instance size: 56 bytes
Space losses: 6 bytes internal + 4 bytes external = 10 bytes total默认情况下,是先分配父类字段空间,然后再分配子类字段空间,父类和子类字段分配顺序都是先基本数据类型,再引用类型。
当我们把-XX:FieldsAllocationStyle=2后,结果如下:
txt
data.area.obj.TestChild object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00060c10
12 4 int TestParent.i 0
16 8 long TestParent.j 0
24 1 byte TestParent.k 0
25 3 (alignment/padding gap)
28 4 java.lang.Object TestParent.o null
32 4 java.lang.Object TestChild.obj null
36 4 int TestChild.b 0
40 8 long TestChild.l 0
48 1 boolean TestChild.a false
49 7 (object alignment gap)
Instance size: 56 bytes
Space losses: 3 bytes internal + 7 bytes external = 10 bytes total仍然是先分配父类空间,再分配子类空间,但是父类字段是先基本数据类型,再引用数据类型,子类字段是先引用数据类型,再基本数据类型,这样父类和子类的引用数据类型就放在一起了。