JVM 的基本原理和内存分配

JVM (Java Virtual Machine) 即Java虚拟机,是建立在java编译器和操作系统平台之间的虚拟处理器,它与硬件没有直接的交互。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行java的字节码程序。Java的一个非常重要的特点就是与平台的无关性。而使用JVM是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的字节码,就可以在多种平台上不加修改地运行。JVM在执行字节码时,把字节码解释成具体平台上的机器码执行。这就是Java的能够"一次编译,到处运行"的原因。

JVM组成部分

1.类加载器(Class Loader):在JVM启动时或者在类运行时将需要的class文件加载到JVM中。(类加载机制见后文)

2.执行引擎(Execution Engine):负责执行class文件中包含的字节码指令。主要是执行即时编译和垃圾回收。(垃圾回收机制见后文)

3.内存空间(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区,主要可以划分为5个部分:

下面三个属于线程私有区域

  • 程序计数器(PC Register):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证线程在切换或执行完毕后还能恢复到保存的状态,就需要一个独立的计数器,记录之前中断的地址。
  • Java虚拟机栈(JVM Stack):java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈。在这个java栈中又会包含多个栈帧,每运行一个方法就创建一个栈帧,用于存储局部变量表、操作栈、方法返回值等。每一个方法从调用直至执行完成的过程,就对应一个栈帧在java栈中入栈到出栈的过程。
  • 本地方法栈(Native Method Stack):区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

下面两个属于线程共享区域

  • 方法区(Method Area):用于存储类结构信息的地方,包括常量池、静态变量、构造函数等。方法区还包含一个运行时常量池(runtime constant pool)。常量池可以理解为class文件的资源仓库。常量池主要存放两大类常量:字面量(文本字符串、声明为final的常量值等)和符号引用(有三类:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)
  • java堆(Heap):主要是存放对象实例和数组。内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。

4.本地方法接口: 主要是调用C或C++实现的本地方法及返回结果。


编译和执行

javac:负责的是编译的部分,当执行javac时,会启动java的编译器程序。对指定扩展名的.java文件进行编译。 生成了jvm可以识别的字节码文件。也就是class文件,也就是java的运行程序。

java:负责运行的部分.会启动JVM加载运行时所需的类库,并执行class文件,程序起始点就是main方法。在运行时,可以附加几个命令行参数传递给main方法。

JVM执行程序的过程 :

  • 加载.class文件
  • 管理并分配内存
  • 执行垃圾收集

JRE(java运行时环境)包含JVM的java程序的运行环境。而JVM是Java程序运行的容器,同时也是操作系统的一个进程,因此也有自己的生命周期,代码和数据空间。JVM在整个JDK中处于最底层,负责与操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境。操作系统装入JVM是通过JDK中Java.exe来完成,通过下面4步来完成JVM环境。

1.创建JVM装载环境和配置
2.装载JVM.dll
3.初始化JVM.dll并挂接到JNIENV(JNI调用接口)实例
4.调用JNIENV实例装载并处理Class类

主要过程如下:

java源文件 → 编译 → 字节码文件 → JVM → 机器码

内存分配:对象的创建和访问

对象的创建

在JVM遇到new关键字后,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且去检查这个符号引用代表的类是否已被加载,解析和初始化过,如果没有就必须先执行类加载的全过程。在类加载检查后,JVM将会为新生对象在java堆中分配内存,对象所需要的内存大小在类加载的过程中即可完全确定。

在堆中对内存的分配可以分为两种方式,如果堆中的内存时连续的规整的,那么所有使用的内存放在一边,没有使用的内存放在另一边,中间放着一个指针作为分界的指示器,当为对象分配内存的时候,只需将指针移动,划分出一块没有使用的内存即可,这种分配方式成为“指针碰撞”。另一种方式是堆中的内存并不规整,所有的空闲内存都存储在一块空闲列表中,当为对象分配内存时只需更新该列表即可,这种分配方式成为“空闲列表”。选择哪种分配方式由堆的内存是否规整来决定。
在这里还有一点需要注意的是对象的创建时一个非常频繁的操作,当程序处于高并发的状况下时就不能保证线程安全了,例如当为对象A分配内存,当指针还没有来得及修改时,对象B又同时使用了原来的指针来分配内存,当发生这种情况时,我们有两种解决方案:
1)对分配的内存进行同步处理来保证操作的原子性。
2)把内存分配的动作按照线程划分在不同的空间中,即每一个线程在java堆上预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程需要分配内存就现在这个线程的TLAB上分配。只有TLAB用完并且要分配新的TLAB时才需要同步锁定。JVM是否使用TLAB可以通过 -XX: +/-UserTLAB 参数来设定。

对象的内存布局

对象在内存中存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

1)对象头

对象头包括两部分信息:
一部分是用于存储对象自身的运行数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪一个类的实例。当对象是一个java数组的时候,那么对象头还必须有一块用于记录数组长度的数据,因此虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中无法确定数组的大小。

2)实例数据中存储的是对象真正有效的信息。

3)对齐填充这部分并不是必须要存在的,没有特别的含义,在JVM中对象的大小必须是8字节的整数倍,而对象头也是8字节的倍数,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问
1.使用句柄:

如果使用句柄,那么reference中存储的是对象句柄地址,java堆划分一部分内存作为句柄池,句柄中包括了对象实例数据与类型数据各自的具体地址信息。

2.直接指针

直接使用指针,reference存放的就是对象地址,在java堆中就必须考虑如何放置访问实例的类型数据的信息。

使用句柄最大的好处就是当对象被移动的时候只需要改变句柄的地址,无需修改reference。直接指针访问方式速度快,对于频繁需要访问的对象节省了很多开销。