运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

image-20200505142316196

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。它在线程隔离的数据区,属于线程私有的内存。

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

Java虚拟机栈

Java虚拟机栈是线程私有的,它的生命周期和线程相同。

image-20200514224901767

每个方法被执行的时候,Java虚拟机都会同步的创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。当方法返回后,任何由它所分配的局部存储空间被释放。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用类型和returnAddress类型(指向一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的。

如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverFlowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够内存时抛出OutOfMemoryError异常。

本地方法栈

本地方法栈与Java虚拟机栈作用类似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈则是为虚拟机用到的本地方法服务。

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的是存放对象实例。

Java堆是垃圾收集器管理的内存区域,可以处于物理上不连续的内存空间中,但在逻辑上被视为连续的。

Java堆既可以被实现成固定大小的,也可以是扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的。如果Java堆没有内存完成实例分配,并且堆无法扩展时,会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

在jdk8以前,很多人把方法区称为永久代,但两者不同,当初是hotspot虚拟机设计团队选择使用永久代来实现方法区,这样就可以让hotspot的垃圾收集器可以像管理Java堆一样管理方法区。但这样设计让Java应用更容易遇到内存溢出问题(永久代有-XX:MaxPermSize上限),在jdk8时,完全废弃了永久代的概念,改为在本地内存实现的元空间。

如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池具有动态性,不要求常量一定只有编译期才产生,比如String类的intern方法。

常量池无法申请到内存时会抛出OutOfMemory异常。

直接内存

直接内存(Direct Memory)就是Java堆外内存。直接内存不是虚拟机运行时数据区的一部分,但这部分内存经常被使用,也有可能导致OutOfMemory异常。

在jdk1.4中新加入了NIO类,引入了基于通道和缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样可以在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

直接内存会受到本机总内存大小以及处理器寻址空间的限制,不当处理回出现OutOfMemory异常。

偶然看见个比较好的图:

img

HotSpot虚拟机的对象

关于HotSpot虚拟机在Java堆中对象(不包括数组和Class对象)分配、布局和访问的全过程。

对象的创建

在虚拟机遇见new指令时,先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个引用代表的类是否被加载、解析和初始化过,没有,那就先执行类加载。

类加载检查通过后,接下来虚拟机为对象分配内存。如果Java堆中内存是绝对规整的,就是使用过的内存在一边,没用过的在另一边,那分配内存就是把指针向空闲方移动一段,称位‘指针碰撞’。如果不规整,那虚拟机就维护一个列表,记录内存是否可用,这种方式是‘空闲列表‘。一般Java堆采用有空间压缩整理能力的GC时可以用指针碰撞。

对象分配很频繁,只是指针碰撞的话,它不是线程安全的。目前有两种方式解决:一是采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按线程划分在不同空间进行,即每个线程在Java堆先分配一小块内存,称为本地线程分配缓冲(TLAB),如果使用TLAB的话,可以在TLAB分配时顺便进行内存初始化,否则只能在分配完毕后初始化为零值。然后对对象头进行设置,这样从虚拟机角度看,一个对象就产生了。但从程序员角度看,构造函数还没执行,然后执行构造函数。

对象的内存布局

在HotSpot虚拟机里,对象在堆中的存储可以分为三部分:对象头、实例数据和对齐填充。

对象头主要有两类信息:

  • 一类是存储对象自身的数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,称为Mark Word。考虑虚拟机空间效率,Mark Word被设计成有动态定义的数据结构。

  • 另一类是类型指针,即对象指向它的类型元数据的指针,虚拟机通过这个指针确定对象是那个类的实例。但查找对象的元数据信息并不一定经过对象本身。如果对象是一个Java数组,对象头还有一块用于记录数组长度的数据。

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面定义的各种类型的字段内容,无论父类继承还是子类中定义的都记录下来。

对齐填充就是起到占位符的作用。虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象大小是8字节的整数倍。实例数据没有填充够8字节的整数倍的话,就要对齐填充来补全。

对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式主要有句柄和直接指针两种。

句柄:Java堆会划分出一块内存用来作为句柄池,reference中存储的就是对象的句柄地址,句柄包含对象实例数据和类型数据各自具体的地址信息。

优点是reference存储的是稳定的句柄地址,对象移动时只会改变句柄的实例数据指针,reference本身不修改。

缺点是增加了一次指针定位的时间开销。

3130736-b43450a00c0a9866

直接指针:reference存储的就是对象地址,只访问对象本身,不需要多一次间接访问的开销。速度更快

3130736-38e0a509ec16d1b0

HotSpot虚拟机主要是使用直接指针来进行对象访问。

一个有趣的实验

1
2
3
4
5
6
7
8
9
public class StaticTest {

public static void main(String[] args) {
String s1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(s1.intern() == s1);
String s2 = new StringBuilder("ja").append("va").toString();
System.out.println(s2.intern() == s2);
}
}

这段代码在jdk6运行会得到两个false,在jdk7中运行会得到一个true和一个false。

原因是在jdk6中,intern方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代的字符串实例的引用,但由于stringBuilder创建的在Java堆上,所以false。

而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

Java这个字符串在执行main时已经加载到了字符串常量池中,所以是false。

intern作用:intern()的本质是改变字符串引用的方向,让对等价字符串对象的引用都指向同一个字符串对象,使得多余的等价字符串对象可以被回收,防止在堆中new出多个相同的字符串都有引用无法被回收的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String s1 = new String("计s机软件");
//String s1 = new String("计s机软件");等价于String s = "计s机软件"; String s1 = new String(s);
//所以常量池已经有"计s机软件",s1就会在堆里创建一个对象,而s1.intern()是之前加载到常量池的对象,所以是false
System.out.println(s1.intern() == s1);//f

String s2 = new StringBuilder("ja").append("va").toString();
//这个是因为java字符串在jvm加载时就已经进入常量池,s2是新创建在堆里的对象,所以不等
System.out.println(s2.intern() == s2);//f

String s3 = new StringBuilder("计算机").append("软件").toString();
//字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用
//s3字符串是首次出现的,s3.intern实际上还是s3的实例引用,所以相等。
System.out.println(s3.intern() == s3);//t

String s5 = "计算机软件";
//因为字符串常量池记录的是首次出现在堆里的字符串的实例引用,所以s3是堆的实例引用,s5是常量池里记录的堆的实例引用,所以相等。
System.out.println(s5 == s3);//t