前言
Java 文件编译后的二进制 Class 文件被类加载器加载到内存中,那么JVM 将内存区域分成了五大块,即堆、方法区、java栈、本地栈、程序计数器等,这五个区域都有什么用途呢?
本文进行一一拆解。
一、概述
JVM 详细内存布局如下。
运行时数据区分为
- 本地方法栈、虚拟机栈、程序计数器,这些都是线程私有的;
- 堆、方法区是线程共有的。
栈是以栈帧为基本单位,栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息。
方法区分为常量池,方法元信息,klass类元信息。
一些概念不明白也没关系,后面在研究。
二、程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,其中 Register 的命名源于 CPU 的寄存器。
它可以看作是当前线程所执行的字节码行号指示器,字节码解析器工作时就是改变这个计数器来获取下一条要执行的字节码指令,分支、循环、跳转、溢出处理等基础功能都要依赖这个计数器完成。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在“Java虚拟机规范”中没有规定任何OutOfMemoryError情况的区域。程序计数器既没有垃圾回收也没有内存溢出。
程序计数器是线程私有的,各线程之间程序计数器互不干扰。
三、虚拟机栈
3.1 概述
Java 虚拟机栈也是线程私有的,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接,方法出口等信息,每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。
在 Java 虚拟机规范中,对这个区域规定了两种异常状态:
-
1、如果采用固定大小的Java栈,那每个线程如果请求分配的栈容量超过允许的最大容量,就会抛出 StackOverflowError 异常
-
2、如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够内存,那 JVM 会抛出 OutOfMemoryError(OOM)异常
3.2 栈的存储单位
每个线程都有自己的栈,栈中的数据都是以栈帧的形式存在。
在这个线程上正在执行的每个方法都各自对应一个栈帧,一个时间点只有一个活动的栈帧,当前栈帧对应的方法就是当前方法,当前方法所在的类就是当前类。
栈遵从先进后出的原则,每个栈帧内部包含一个方法运行时的环境数据,分为局部变量表、操作数栈、动态链接,方法返回地址,额外附加信息等。(简称 巨吵东方)
3.3 局部变量表
局部变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference)以及 returnAddress 类型。
- 对于基本数据类型,就直接存储它的值
- 对于引用类型,则存储对象的引用
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
可以看到局部变量最大槽数是1.
局部变量表的基本存储单位是 slot(变量槽),其中64位长度的long 和double类型的数据只占用1个。JVM 会为局部变量表中每一个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上。如果需要访问局部变量表中一个64位的局部变量值,只需要使用该变量占用的两个slot中的第一个slot的索引即可。比如,访问long类型或double类型变量,如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。栈帧中的局部变量表中的slot是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的slot,从而达到节省资源的目的。
注意:我们前面学习类加载的时候,发现类变量 静态变量可以通过初始化进行值赋值,并没有提到局部变量,这意味着局部变量表必须手动初始化,否则无法使用。
局部变量表中直接或间接引用的对象都不会被垃圾回收。
3.4 操作数栈
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称为表达式栈(Expression Stack)。
操作数栈也是栈帧中重要的内容之一,它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈在方法执行过程中,根据字节码指令往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数从栈中取出,比如,执行复制、交换、求和等操作。
使用它们后再把结果压入栈,如图所示,2和3分别出栈,经过iadd指令执行后再入栈。操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
同局部变量表一样,每个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保持在方法的 Code 属性中的 Maximum stack size 数据项中。
回顾下前面的字节码验证阶段。再来看看这个操作数栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,它在执行指令的时候,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,让两个栈帧出现一部分重叠。
这样在进行方法调用的时候就可以共用一部分数据,无须额外的参数复制过程。
由于操作数是存储在内存中的,因此频繁地执行内存读、写操作必然会影响执行速度。为了提升性能,HotSpot虚拟机的设计者提出了栈顶缓存( Top-of-Stack Cashing,ToS)技术。所谓栈顶缓存技术就是当一个栈的栈顶或栈顶附近元素被频繁访问,就会将栈顶或栈顶附近的元素缓存到物理CPU的寄存器中,将原本应该在内存中的读、写操作分别变成了寄存器中的读、写操作,从而降低对内存的读、写次数,提升执行引擎的执行效率。
3.5 动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。
比如,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。
动态链接的目的就是在JVM加载了字节码文件,将类数据加载到内存以后,当前栈帧能够清楚记录此方法的来源。将字节码文件中记录的符号引用转换为调用方法的直接引用,直接引用就是程序运行时方法在内存中的具体地址。
3.6 方法返回地址
方法返回地址存储的是调用该方法的程序计数器的值。
一个方法的结束有两种可能,分别是正常执行完成结束和出现异常导致非正常结束。
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
- 方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
- 通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
方法返回地址中存储的是调用该方法的程序计数器的值,当字节码指令执行到ireturn、lreturn、freturn、dreturn、areturn、return时该方法执行结束
如果方法执行过程中抛出异常时,使用try-catch语句或者try-finally语句处理异常,异常处理会存储在一个异常表中。
四、本地方法栈
Java虚拟机实现可能会使用到传统的栈(通常称为C Stack)来支持本地方法(使用Java语言以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。
本地方法栈是线程私有的。本地方法栈的大小允许被实现成固定大小的或者是可动态扩展的。在内存溢出方面,它与Java虚拟机栈也是相同的。
与Java栈类似的:
-
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
-
如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。
五、堆
栈是解决程序的运行问题,程序如何处理数据,栈中处理的数据主要来源于堆(Heap),堆解决的是数据存储的问题,即数据怎么放,放在哪儿。堆是 JVM 所管理的内存中最大的一块区域。
堆和栈的关系如下:
5.1 JVM 堆内存
方法结束后,栈帧弹出,但是堆中的对象不会马上被移除,仅仅在垃圾回收的时候才会移除。
堆内存是分代划分区域的,分为新生代和老年代。
- 新生代:Eden区和两个 Survivor 区,简写 s0 和 s1。
- 老年代
Java堆区用于存储Java对象实例,堆的大小在JVM启动时就已经设定好了,可以通过JVM参数“-Xms”和“-Xmx”来进行设置
- “-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize。
- “-Xmx”用于表示堆区的最大内存,等价于-XX:MaxHeapSize。
初始内存大小占据物理内存大小的1/64。最大内存大小占据物理内存大小的1/4。
注意:通常会将“-Xms”和“-Xmx”两个参数配置相同的值。否则,服务器在运行过程中,堆空间会不断地扩容与回缩,势必形成不必要的系统压力。所以在线上生产环境中,JVM的Xms和Xmx设置成同样大小,避免在GC后调整堆大小时带来的额外压力
JDK8默认使用UseParallelGC垃圾回收器,该垃圾回收器默认启动参数AdaptiveSizePolicy,该参数会根据垃圾收集的情况自动计算Eden区和两个Survivor区的大小,因此,Eden区和两个Survivor区默认所占的比例为6:1:1。
但在大多数情况下,Eden 区和两个 S区默认所占的比例是 8:1:1。
扩展:堆空间的常用几个参数设置:
-XX:+PrintFlagsInitial:查看所有的参数的默认初始值。
-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)。
-Xms:初始堆空间内存(默认为物理内存的1/64)。
-Xmx:最大堆空间内存(默认为物理内存的1/4)。
-Xmn:设置新生代的大小(初始值及最大值)。
-XX:NewRatio:配置新生代与老年代在堆结构的占比。
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例。
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄。
-XX:+PrintGCDetails:输出详细的GC处理日志。打印GC简要信息:① -XX:+PrintGC;② -verbose:gc。
-XX:HandlePromotionFailure:是否设置空间分配担保(true 是开启,false 不开启直接full gc),在发生Minor GC之前,JVM会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,则此次Minor GC是安全的;如果小于,则进行 full gc
5.2 Java 对象分配过程
Java 对象在堆中的分配过程,大概如下流程图。
1、当 new 对象的时候,先放入到 Eden 区;
2、如果 eden 区放不下,就进行 YGC ,再把对象放入内存(旧对象进入S区);如果Eden 区还是放不下,就放入 Old 区,如果 Old 区还是放不下,就进行 Full GC,再放入 Old 区,经过 Full GC 后,还是放不下,就爆出 OOM 异常。
3、当 Eden 区触发GC 时,旧对象会被放入到 S0 区,同时 S0 的年龄指数加 1;
4、当 Eden 区再次触发 GC 时,Eden 区的对象 和 S0 区的对象都复制到 S1 区,Eden 区过来的对象年龄为1,S0区过来的对象年龄+1,为2;
5、S0 区 和 S1 区的对象不断转移,每次转移一次年龄就加1,直到达到设定的最大年龄阈值15,就进入老年代。
6、老年代经过Full GC 后,仍然存不下对象,就爆出 OOM 异常。
再来总结下对象分配策略:
-
1、优先分配到 Eden 区;
-
2、大对象直接分配到老年代,在开发过程中避免出现过多的大对象;
-
3、长期存活的对象分配到老年代;
-
4、通过动态对象年龄判断,如果S区中相同年龄的所有对象的大小总和大于S区的一半,年龄大于等于该年龄的对象可以直接进入老年代,无需等到阈值年龄。
-
5、空间分配担保:使用参数-XX:HandlePromotionFailure来设置空间分配担保是否开启,只要老年代的连续空间小于新生代对象总大小或者历次晋升的平均大小,触发Full GC。
5.3 Minor GC、Major GC、Full GC
GC 也是分类型的。
1)Minor GC : 就是 YGC,只是新生代(Eden、S0、S1区)的垃圾收集;
触发机制:
-
1、这里的新生代空间不足指的是 Eden 区的空间不足,S区空间不足不会引发 GC;
-
2、Minor GC 发生非常频繁,会引发 STW(暂停用户线程),但是过程很短。
2)Major GC:老年代的垃圾收集,很多时候经常与 Full GC 混淆,目前只有 CMS GC 会单独收集老年代的行为。
触发机制:
-
1、对象从老年代消失时,就会触发Major GC或Full GC
-
2、在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC。
-
3、Major GC的STW时间更长。
3)Full GC:指对整个 Java 堆和方法区的垃圾收集,针对整个新生代和老年代。
触发机制:
- 1、调用 System.gc() ,但并非必然执行;
- 2、老年代空间不足;
- 3、方法区空间不足;
- 4、老年代的最大可用连续空间小于历次晋升到老年代对象的平均大小触发;
- 5、由 Eden 区、S0 区向 S1 复制时,如果对象大小小于 S1 区可用内存,则把对象转存到老年代,但这个时候,老年代的可用内存小于改对象大小,就触发Full GC。
5.4 逃逸分析
说到堆,就不得不提下逃逸分析的概念。
很多时候,我们认为对象是一定存储在堆上的,但是由于即时编译技术的进步,对象有可能在栈上分配,这样就无需在堆中分配内存,无需进行垃圾回收,随着栈的出栈而消灭,以达到提升JVM GC 的效率。
对象逃逸分析,有可能把对象分配到栈上,逃逸分析是一种算法,通过这个算法的分析结果,可以得出一个新对象引用的使用范围,从而决定是否将这个对象分配到堆上。
-
当一个对象在方法中被定义后,若对象只在方法内部使用,则认为没有发生逃逸。
-
当一个对象在方法中被定义后,若它被外部方法引用,则认为发生了逃逸分析,例如作为调用参数传递到其他地方中。
例子1:下面这个案例中,student 对象的作用域只在 test 方法中,则认为没有发生逃逸,在栈上分配内存。
public void test(){
Student xiaolei = new Student();
}
例子2:下面这个例子返回了 student 对象,则认为发生了逃逸分析,在堆上分配内存。
public Student test(){
Student xiaolei = new Student();
return xiaolei;
}
开启逃逸分析,编译器可以对程序做如下优化:
(1)栈上分配:对象分配在栈上,减少GC的压力,提升性能。
(2)同步省略:如果一个对象只被一个线程访问,那这个对象不考虑同步。
(3)标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存中,而是存储在栈中。
栈上分配我们已经可以理解,同步省略是什么呢?
以下面例子为例
public class SyncTest {
public void test(){
Object xiaolei = new Object();
synchronized (xiaolei){
System.out.println(xiaolei);
}
}
}
Xiaolei 对象在 test 方法中,并不会被其他线程访问,因为 xiaolei 对象是当前线程所创建的,因此,其实这个写法本身就有问题,代码就会被优化成下面这种
public class SyncTest {
public void test(){
Object xiaolei = new Object();
System.out.println(xiaolei);
}
}
最后,来到第三个--标量替换。
何为标量(Scalar)是一个无法再分解成更小数据的数据。Java 中的原始数据类型就是标量。可以被分解的数据称为聚合量(Aggregate),Java 对象就是聚合量。
在 JIT 编译器的编译阶段,如果经过逃逸分析,发现一个对象不被外界访问的话,就可以经过优化,将这个对象拆解成若干个成员变量,这个过程称为标量替换。
编译阶段,则针对 static 静态方法,
public static void main(String args[]) {
alloc();
}
class Point {
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
经过标量替换之后的代码如下
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
标量替换,就不用在堆中创建对象了,因为这个对象不被外界访问,就可以进行标量替换,达到节省在堆中创建对象的目的。
六、方法区
6.1 概述
方法区与堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区在逻辑上属于堆的一部分,但是可以看作是独立于堆的内存空间。
栈、堆、方法区的内存结构关系图如下
JDK8 及以后方法区相关设置如下。
元空间大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代JDK7中的永久代的初始值和最大值。
默认值依赖于具体的系统平台,取值范围是12~20MB。
例如在Windows平台下,-XX:MetaspaceSize 默认大约是20MB,如果-XX:MaxMetaspaceSize 的值是 -1,表示没有空间限制。与永久代不同,如果不指定大小,在默认情况下,虚拟机会耗尽所有的可用系统内存。如果元空间发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace。
6.2 方法区的内部结构
Java 源代码编译之后生成的 class 文件,经过类加载器把 class 文件的一部分信息加载到方法区(比如类的class、接口、枚举、注解、运行时常量池等类型信息)。
6.2.1 类型信息
对每个加载的类型,jvm 必须在方法区中存储以下信息。
-
完整的全类名,包括包名和类名
-
直接父类的完整有效名
-
修饰符(public、abstract)
-
方法信息包含方法名称,方法修饰符,返回类型等
6.2.2 类变量和常量
Static 修饰的成员变量为类变量,随着类的加载而加载,即使没有类实例也可以访问它。
被 statci final 修饰的成员变量称为静态常量,静态常量在编译的时候就已经赋值了,在讲解class 文件的时候已经介绍了。
6.2.3 常量池
方法区内部包含了运行时常量池。class文件中有个constant pool,翻译过来就是常量池。当class文件被加载到内存中之后,方法区中会存放class文件的constant pool相关信息,这时候就成为了运行时常量池。所以要弄清楚方法区的运行时常量池,需要理解class文件中的常量池。
常量池内存储的数据类型包括数量值、字符串值、类引用、字段引用以及方法引用,可以把常量池看作一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
6.2.4 运行时常量池
常量池是 class 文件的一部分,用于存储编译期间生成的各种字面量与符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。
虚拟机加载类或接口后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
在JDK 1.7之前,方法区位于堆内存的永久代中,运行时常量池作为方法区的一部分,也处于永久代中。
因为使用永久代实现方法区可能导致内存泄露问题,所以,从JDK1.7开始,JVM尝试解决这一问题,在1.7中,将原本位于永久代中的运行时常量池移动到堆内存中。(永久代在JDK 1.7并没有完全移除,只是原来方法区中的运行时常量池、类的静态变量等移动到了堆内存中。)
在JDK 1.8中,彻底移除了永久代,方法区通过元空间的方式实现。随之,运行时常量池也在元空间中实现。
不同版本的方法区变化如下:
为什么 JDK8 中用元空间替换永久代?
-
1、为永久代设置空间大小很难确定,jdk8中原空间存储本地内存;
-
2、简化Full GC,不用暂停线程就可以回收直接内存的数据。
辨析:
- 常量池:字节码编译时的常量存储表
- 运行时常量池:字节码加载时,将常量池中的对象加载到运行时常量池
- 字符串常量池:加载字节码过程中,如果是字符串,就被装到字符串常量池。
6.3 字符串常量池
字符串常量池是 Java 程序中使用最多的对象了,字符串对象具有不可变性,一旦创建,内容和长度是固定的,因此,字符串对象是完全可以共享的, StringTable 叫做字符串常量池,当存在相等字符串的时候,就可以达到字符串对象复用的目的。
Intern 方法:
public native String intern();
1、使用双引号声明的字符串对象会保存在字符串常量池中。
2、使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象,如果找到了,就直接在堆中创建字符串对象。
String s = new String("xiaolei")
3、针对没有使用双引号声明的字符串对象来说,如果想把 s 的内容也放入字符串常量池的话,就可以使用 intern() 方法。
Intern 是一种手动将字符串加入常量池中的方法。当调用 intern 方法时,如果池中已经包含一个等于此 String 对象的字符串,则返回池中的字符串。
否则,将此 String 对象添加到池中,并返回此对象的引用。
在 JDK7 中,
-
- 如果字符串常量池中已经存在该字符串对象,则返回池中此字符串对象的引用。
-
- 如果堆中已经有这个字符串对象,则把此字符串对象的引用添加到字符串常量池中并返回该引用;
-
- 如果堆中没有此字符串对象,则先在堆中创建字符串对象,再返回其引用
// 下面这条语句会创建两个对象,一份存在字符串常量池中,一份存在堆中
String s = new String("xiaolei");
// 检查常量池中是否存在字符串xiaolei,对应上面的1,s1指向常量池的字符串
String s1 = s.intern();
String s2 = "xiaolei";
System.out.println(s == s2); // false System.out.println(s1 == s2); // true
String s3 = new String("xiao") + new String("lei");
String s4 = s3.intern();
System.out.println(s2 == s4); // true
七、对象实例化内存布局与访问定位
这节学习下对象的实例化过程,包括对象在内存中是如何布局的,以及对象的访问定位方式。
7.1 对象实例化
创建对象的方式有很多种,例如 new 关键字,Class 的newInstance 方法,clone 方法,反序列化等。
创建对象的步骤如下:
1、判断对象对应的类是否加载、链接、初始化。
- 遇到 new 指令时,首先会检查这个类的符号引用所代表的类是否被加载,解析和初始化,如果没加载就进行加载;
2、为对象分配内存
- 首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。
- 如果内存规整-->使用指针碰撞为对象分配内存。分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了
- 如果内存不规整,-->使用空闲列表,那么 JVM 会维护一个可用内存空间的列表用于分配。
3、处理并发问题
- 创建对象要保证 new 对象的线程安全性,有两种方式解决并发问题
- CAS:是一种用于在多线程环境下实现同步功能的机制。CAS操作包含三个操作数,内存位置、预期数值和新值。CAS的实现逻辑是将内存位置处的数值与预期数值相比较,若相等,则将内存位置处的值替换为新值;若不相等,则不做任何操作。
- TLAB:本地线程分配缓冲,JVM 被每个线程分配一块空间,每个线程在自己的空间中创建对象
4、初始化分配到的空间
内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
5、设置对象的对象头
为对象设置对象头信息,对象头信息包含如下内容:类元信息、对象哈希码、对象年龄、锁状态标志等
- 对象头 MarkWork 字段(32 位)
- 对象头中的类型指针(Klass Pointer)
类型指针用于指向元空间当前类的类元信息。比如调用类中的方法,通过类型指针找到元空间中的该类,再找到相应的方法。
开启指针压缩后,类型指针只用 4个字节存储,否则需要 8个字节存储。
- 指针压缩
过大的对象地址,会占用更大的带宽和增加 GC 的压力。
对象中指向其他对象所使用的指针:8字节被压缩成 4 字节。
6、执行 init()方法进行初始化
调用对象的构造函数进行初始化
调用了 init 方法之后,这个对象才真正能使用。
整个流程步骤如下:
7.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中的布局可以分成对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)三部分。
对象头: 包含Mark Wrod 和 类型指针。类型指针确定该对象所属的类型。
实例数据:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段,无论是父类继承的,还是子类中定义的字段都必须记录下来。
对其填充:占位符的存在,保证任何对象的大小都必须是8字节的整数倍。
7.3 对象的访问定位
对象访问方式是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图
如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型的相关信息。reference 中存储的就直接是对象地址,如果只是访问对象本身的话,就少了一次间接的访问开销。