JVM | 内存管理篇

本篇文章主要介绍Java虚拟机的运行机制、内存布局及其各部分的作用,此外还会简单介绍虚拟机的执行引擎。
建议阅读 [JVM | 快速入门] 后再继续此篇!

目录

  • 内存管理
    • 一、程序计数器
    • 二、Java虚拟机栈
      • (一)栈帧
      • (二)局部变量表
      • (三)操作数栈
      • (四)动态链接
      • (五)方法返回地址
      • (六)附加信息
    • 三、本地方法栈
    • 四、Java堆
    • 五、方法区
    • 六、运行时常量池
    • 七、直接内存
    • 八、对象的创建及访问
  • 执行引擎

内存管理

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
在这里插入图片描述
运行时数据区详细图如下:
在这里插入图片描述
接下来将依次介绍每一部分的作用

一、程序计数器

简介: 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。Java虚拟机的执行引擎就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

是否线程私有:由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

记录的什么:如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

Java虚拟机的pc寄存器的内存足可保存方法的返回地址或本机方法的指针。所以此内存区域是唯一没有内存溢出OutOfMemoryError情况的区域。

为什么需要用PC寄存器存储指令执行地址?
由于CPU需要不停的切换每个线程,切换回来之后需要知道当前线程从哪里继续执行。JVM就是通过改变PC寄存器的值来明确下一条执行那个字节码指令。

二、Java虚拟机栈

简介:虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

常见异常:在Java虚拟机规范中,对这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
    此异常主要是单个线程栈帧过多造成。
  • 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常;
    此异常主要是线程过多(每个线程都有自己的栈)造成。

-Xss 设置栈内存大小
设置栈内存大小

(一)栈帧

栈帧(Stack Frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

栈帧的内部结构如下图所示:
栈帧的内部结构图
接下来将逐一介绍每一部分的功能

(二)局部变量表

1 介绍
局部变量表(Local Variablere Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。如下图所示:
在这里插入图片描述
2 作用
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位。
一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress 这8种类型。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。
下面我们来测试以下这几种类型,编写如下方法:

public void test(){boolean boolean_type = false;byte byte_type = 0;char char_type = 0;short short_type = 0;int int_type = 0;float float_type = 0;String String_type = "";long long_type = 0;double double_type = 0;Object ref_type = new Object();
}

将字节码文件(.class)利用javap命令进行反编译得到如下图局部变量表:
在这里插入图片描述

3 虚拟机如何使用局部变量表
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个Slot,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机将在类加载的校验阶段抛出异常。

注意点
1 对于实例方法(非static的方法),局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
在这里插入图片描述

当对上面的测试代码加上static关键字时:

public static void test(){boolean boolean_type = false;byte byte_type = 0;char char_type = 0;short short_type = 0;int int_type = 0;float float_type = 0;String String_type = "";long long_type = 0;double double_type = 0;Object ref_type = new Object();
}

局部变量表中便没有了关键字"this"的引用
在这里插入图片描述

2 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。

测试代码如下:

public void test(){int value1 = 100;{int value2 = 200;}int value3 = 300;
}

我们可以看到局部变量表中只有value1和value3,而value2的Slot已经被value3所使用,所以在局部变量表中看不到value2.
在这里插入图片描述
JVM的这种设计虽然节省栈帧空间,但是会存在一些副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为(可看完我写的垃圾回收篇之后再来看这一部分内容):
设计如下代码:

public class Main {public static void main(String[] args) {byte[] bbb = new byte[64 * 1024 * 1024];System.gc();}
}

设置虚拟机参数:-XX:+PrintGCDetails
查看垃圾回收行为如下:
在这里插入图片描述
可以看到没有回收bbb所占的64MB内存,因为在执行System.gc()时,变量bbb还处于作用域之内,虚拟机自然不会回收bbb的内存。

如果我们改一下上面的代码:

public class Main {public static void main(String[] args) {{byte[] bbb = new byte[64 * 1024 * 1024];}System.gc();}
}

加入了花括号之后,从代码逻辑上讲,在执行System.gc()的时候,bbb已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,这64MB的内存没有被回收,这又是为什么呢?
在这里插入图片描述
在解释为什么之前,我们先对这段代码进行第二次修改:

public class Main {public static void main(String[] args) {{byte[] bbb = new byte[64 * 1024 * 1024];}int i = 0; // 加上这一行System.gc();}
}

运行一下程序,发现这次内存被正确回收了:
在这里插入图片描述
bbb能否被回收的根本原因是:局部变量表中的Slot是否还存有关于bbb数组对象的引用。第一次修改中,代码虽然已经离开了bbb的作用域,但在此之后,没有任何对局部变量表的读写操作,bbb原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。导致其无法被回收。

(三)操作数栈

1 简介
操作数栈(Operand Stack)是一个后入先出(Last In FirstOut,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
在这里插入图片描述
2 作用
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
操作数栈的作用要结合字节码指令的操作才能更好的理解,读者可以参考我写的字节码指令篇。这里只作简单的演示操作数栈的作用。
设计如下简单的代码:

public class Main {public static void main(String[] args) {int i = 0;}
}

反编译获得其字节码指令为:

0 iconst_0         //   将常量0压入操作数栈
1 istore_1         //   将操作数栈的栈顶元素出栈并赋值给局部变量表中Solt为1的变量
2 return

3 栈顶缓存技术
由于操作数栈是存储在内存中的,因此频繁的读写操作必然会影响执行速度。因此提出了栈顶缓存技术,将栈顶元素全部缓存在CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

4 注意
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
在这里插入图片描述

(四)动态链接

1 简介
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

2 作用
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

那么什么样的方法使用静态,什么样的又使用动态呢?
JVM中有以下五种调用方法的字节码指令:

  • invokestatic: 调用静态方法,解析阶段确定唯一方法版本
  • invokespecial: 调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual: 调用所有虚方法
  • invokeinterface: 调用接口方法
  • invokedynamic: 动态解析(如lambda表达式)出需要调用的方法,然后执行

关于这些方法调用的字节码指令以及方法重写的本质将在后面即将发布的字节码指令篇中介绍,这里不做过多的阐述。

(五)方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复
上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈
中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

(六)附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与程序调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

三、本地方法栈

1 简介
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别就是虚拟机栈为虚拟机执行Java方法,而本地方法栈则为虚拟机使用到的Native方法(该方法是非Java语言实现的)。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

在这里插入图片描述
2 作用

  1. 与Java环境外交互,
  2. 与操作系统进行交互,可以实现Java与底层操作系统进行交互
  3. Java解释器是使用c编写的,一些方法使用c实现的,这就不可避免Java去调用native方法

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存

并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

四、Java堆

1 简介
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

2 作用
Java堆是垃圾收集器管理的主要区域。
内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
在这里插入图片描述

对于内存的分配和回收将在我之后即将推出的垃圾回收篇中着重介绍,可以关注一下。

3 Java堆的常用参数

  • -Xms 设置堆初始内存大小(默认大小为 计算机内存大小/64)
  • -Xmx 设置堆最大内存大小(默认大小为 计算机内存大小/4)
  • -XX:NewSize:设置新生代的大小
  • -XX:NewRatio:设置老年代与新生代的比例
  • -XX:SurviorRatio:新生代中eden区与survivior区的比例

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

五、方法区

简介: 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

Java栈、Java堆、方法区的联系:
在这里插入图片描述
对于一个的对象创建如:Object obj = new Object();
1 new Object() 就代表在堆空间中开辟了一块内存存放这个实例
2 obj 就代表着java栈中的引用,指向对象实例
3 Object 则是代表着对象类型,实例的对象头中会包含指向此类型的指针

Hotspot虚拟机演进过程:
在 jdk7 以前,习惯把方法区称为永久代。从 jdk8 开始,用元空间取代了永久代。从本质上讲,两者并不等价。
当使用永久代(-XX:MaxPermSize)来实现方法区时,是将永久代放到java堆中统一管理的,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。但使用永久代来实现方法区,更容易遇到内存溢出问题。
而使用元空间来实现方法区时,方法区的内存是放到本地内存中实现的,这样就不受java堆内存的限制,不容易出现内存溢出的问题。

方法区大小设置:
jdk7之前:
-XX:PermSize 设置初始分配的空间
-XX:MaxPermSize 设置最大分配的空间
jdk8以及之后:
-XX:MetaspaceSize 设置初始分配的空间
-XX:MaxMetaspaceSize 设置最大分配的空间

方法区的内存回收:
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收是比较难的,尤其是类型的卸载 (关于类的卸载会在我之后发布的篇章中介绍),条件相当苛刻,但是这部分区域的回收又确实是必要的。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

六、运行时常量池

概念: 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
类文件结构图如下:
在这里插入图片描述
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样是通过索引访问的。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址

运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性性。Java语言并不
要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,在运行期间也可能将新的常量放入池中,比如使用String类的intern()方法。

注意事项:方法区与常量池的演进

版本 演进
jdk1.6及之前 有永久代(permanent generation),静态变量存放在 永久代上
jdk1.7 有永久代,但已经逐步"去永久代",字符串常量池、静 态变量移除,保存在堆中
jdk1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆
  • StringTable为什么要调整?
    jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

七、直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样避免了在Java堆和Native堆中来回复制数据,显著的提高了性能。

本机直接分配的内存虽然不会受到Java堆大小的限制,但是还是会受到本机总内存大小的限制。因此当本机内存不足时也会出现OutOfMemoryError异常。

八、对象的创建及访问

在熟悉了Java虚拟机的内存布局后,我们应该知道了虚拟机中内存各个部分的作用,那么不如趁热打铁,学习一下一个对象的创建需要经历什么过程,以及Java虚拟机都要进行那些处理。

1 创建对象的方式

  • new
  • Class的newInstance()
  • Constructor的newInstance(Xxx)
  • 使用clone
  • 反序列化
  • 第三方库Objenesjs

2 对象创建的过程
1) 判断对象所对应的类是否已经加载、链接、初始化
在创建一个对象实例之前,我们肯定需要去判断这个对象所对应的class文件有没有被加载进我们的Java虚拟机的内存之中,也就是判断该类是否被加载、链接、初始化。如果没有,则需要先加载类文件。(注:这是类文件加载的步骤,在之后的类加载篇章将会有详细的介绍)

2) 分配内存
为我们的实例对象分配一块内存(一般是在Java堆中),但是分配内存时我们的内存区域可能并不是完整的,这就产生了内存分配的两种方式。

  • 指针碰撞:听起来很高大上,原理其实就是将所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,这样就能知道下一块空闲的内存在哪,我们就把内存放到到那个区域,再将指针往后移到分界点。

  • 空闲列表:如果不完整的话,就需要维护一个空闲列表,这个空闲列表里面维护着所有空闲内存块的地址,我们可以从其中找出一块区域来存放实例对象。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。(垃圾回收器将在后面篇章介绍)

3) 处理并发安全问题
这一步其实是上一步分配内存时同时需要考虑的问题,当多个线程同时需要分配内存时,假设我们采用的是指针碰撞的方式,线程A分配到一块内存的同时,线程B也需要分配内存,但此时指针的位置可能还没来得及修改,就导致两个线程实际分配的同一块内存。
解决这个问题虚拟机有两种方式:

  • CAS:自旋方式保证更新操作的原子性
  • TLAB:为每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

4) 初始化分配到的空间
将对象的所有属性赋默认值,保证对象实例字段在不赋值时可以正常使用。

5) 设置对象的对象头
对象头中存放着如对Class实例的指针、对象的哈希码、GC分代年龄、锁状态信息等,都是在这一步初始化。

6) 执行<init>方法进行初始化
init方法其实可以看做是对象的构造方法加上一些普通代码块(非静态)中的代码。

补充: 从字节码角度看对象创建过程
见如下代码:

public class Main {public static void main(String[] args) {Object obj = new Object();}
}

main方法的字节码如下,创建过程可看我的注释解释:

new #2 <java/lang/Object>      // 在Java堆空间中开辟一块空间,这块空间存的是Object类型的对象
dup     // 复制堆中这个实例对象的内存到操作数栈
invokespecial #1 <java/lang/Object.<init>>   // 调用Object类的<init>方法,完成对象的初始化
astore_1  // 将操作数栈顶元素出栈,赋值给局部变量表中索引为1的变量,也就是上面代码中的 obj
return

3 对象的内存布局
一个Java对象在内存中的布局如下图所示,对象实例和数组实例有所区别。
在这里插入图片描述
第一部分:对象头
1)_mark的布局

32位虚拟机
在这里插入图片描述
64位虚拟机
markword

2)类型指针
指向这个对象的Class实例,32位计算机下占4个字节,64位计算机下未开启指针压缩占8个字节,开启指针压缩则一样占4个字节(hotspot默认开启)。

第二部分:对象实例
也就是对象的各种属性,每一种占多少字节相信学过Java基础的同学都知道,我这里不再赘述。

第三部分:对齐填充
这一部分是为了保证对象的总字节数是8的整数倍,以便于虚拟机的读取。

看完内存布局我们可以思考一下,一个空对象(没有任何属性)在内存中占多少字节?

4 对象的访问定位
Java程序是如何通过栈上的引用来找到Java堆上具体对象的呢?目前主要有句柄访问和直接指针两种。
1)句柄访问
在这里插入图片描述

2)直接指针(hotspot默认采用)
在这里插入图片描述

方式 优点 缺点
句柄访问 栈中的引用存储的是句柄地址,在堆中的对象被移动时只会改变句柄中的实例数据指针,而栈中的引用本身不需要修改 会有额外一块内存开销并且有额外的时间开销
直接指针 速度更快,它节省了一次指针定位的时间开销 对象移动需要去改变栈中的引用地址

执行引擎

简介

执行引擎也就是执行我们字节码的工具,Java语言的执行就是将我们编写的Java代码编译(javac编译)为JVM的字节码,执行引擎再去通过把字节码解释或者编译成机器指令(计算机能够识别的指令)进行执行。这样我们的代码就运行起来了。

正是因为字节码和执行引擎的存在,就消灭了不同计算机机器语言的差别,使得我们的Java代码可以在不同的平台上运行了。这也就是为什么Java是跨平台的语言

此外不同的语言只要能够编译为JVM的字节码,就可以在Java虚拟机上运行。这也就是为什么Java虚拟机是跨语言的平台

两种执行方式
字节码执行引擎有解释执行与编译执行两种情况,这也正是Java为什么被称为半编译半解释型语言(非初学时理解的先编译为字节码文件再解释运行)
在这里插入图片描述
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。然后再可以选择走绿色的解释执行的方式还是蓝色编译执行的方式。

  • 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • JIT(JustIn Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

运行流程如下:
在这里插入图片描述
热点代码
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR(On Stack Replacement)编译。

执行引擎中有一个计数器用于统计方法被调用的次数,它的默认阈值在 Client 模式下是1500次在Server模式下是10000次。超过这个阈值,就会触发ITT编译。当然超过时间也会进行衰减,如过一段时间之后会将调用次数衰减一半。这样计数器就可以统计一定时间内方法调用的频率了,频率高的才是热点代码。

这个阙值可以通过虚拟机参数-xx:CompileThreshold来人为设定。
可以使用虚拟机参数 -xx:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
另外,可以使用-xx:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
在这里插入图片描述
如上图所示:当一个方法被调用时,会先检查该方法是不存在被1编译过的版木,如果存在,则优先使用编还后的本地代码来拍行。如果不在在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

方式的选择
缺省情况下HotSpotVM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
  • -Xmixed: 采用解释器+即时编译器的混合模式共同执行程序

注意:在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Sen Compiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定Java虚拟机运行在Client模式下,并使用C1编译器 。
    C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
  • -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。
    C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高

64为操作系统默认为-server模式。

C1和C2编逢器不同的优化策略:
C1编译器上主要有方法内联,去虚拟化、冗余消除。

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数佳递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指synchronized

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注