JAVA内存管理——1

Java内存区域

JAVA内存管理——1

1.java虚拟机管理的内存各个区域

1.1程序计数器

  • 这是一块占比比较小的内存空间,有学习过《操作系统》这门课程的同学应该很熟悉,它是用来记录下一条要执行的指令的地址,在java虚拟机中,字节码解释器就是通过改变这个计数器的数值来选取下一条需要执行的字节码指令(可是后面又说线程如果是正在执行一个java方法,那么计数器记录的是正在执行的方法,如果执行的是本地方法,这个计数器的值就为空,这里给我留下了一个问题,待查明后修改)。
  • 程序计数器是线程私有的,每一条线程都需要独立的程序计数器来记录线程执行的状态。
  • 为什么程序计数器是线程私有的呢?
    答:在多线程并发的情况下,Java虚拟机是通过把处理器的时间分成一片一片分给不同的线程让线程轮流切换执行实现的,其实在同一时刻只有一个线程在执行,但从一段时间(很多个时间片合起来)来讲,就像是有多个线程并行在执行一样。为了让线程切换后能恢复到原来的执行位置,使各个线程相对独立,所以每个线程需要独立拥有一个程序计数器来完成线程恢复的工作。程序计数器除了控制恢复线程,还可以控制分支,循环,跳转,异常处理等

1.2虚拟机栈

  • java虚拟机栈也是线程私有的,一个虚拟机栈对应一个线程,创建线程的时候对应的虚拟机栈也被创建,它的生命周期跟线程一样
  • 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,这个栈帧存储了局部变量表操作数栈、动态连接、方法出口等信息。每个方法被调用和执行结束对应着入栈和出栈
  • 局部变量表:待填
  • 操作数:待填
  • 动态连接:待填
  • 方法出口:待填

1.3本地方法栈

本地方法栈和虚拟机栈非常相似,区别就是虚拟机栈是为虚拟机执行java方法而服务,而本地方法栈是为虚拟机执行本地方法而服务,HotSpot虚拟机将虚拟机栈和本地方法栈合而为一了。
HotSpot虚拟机是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

1.4堆

  • 堆是虚拟机所管理占比最大的一块内存区域,它是所有线程共享的一块内存区域,在虚拟机启动时创建。
  • 此内存的唯一目的就是存放对象的实例(《深入理解java虚拟机——周志明》中提到由于即时编译技术的进步,尤其是逃逸技术分析技术的日渐强大,栈上分配、标量替换优化手段已经导致了一些微妙的变化悄然发生,所以java对象实例都分配在堆上也渐渐变得不是那么绝对了,对这方面有兴趣的读者可以自行深入去研究)
  • 堆是垃圾收集器管理的内存区域,也称为GC堆
  • 从分配内存的角度来看,所有线程共享的java堆中可以划分出多个线程私有的缓冲区TLAB,目的就是为了提升对象分配时的效率。究竟如何分配如何提升效率下篇文章会继续讲解,本文先简单概述各个区域
  • 《java虚拟机规范》规定堆可以处于不连续的内存空间中。但多数虚拟机实现出于实现简单,存储高效考虑,很可能会要求连续的内存空间。
    -《 java虚拟机规范》规定堆的大小可以固定也可扩展,主流java虚拟机都是按照可扩展来实现的(通过设置参数-Xmx和-Xms)

1.5方法区

  • 方法区和堆一样是线程共享的区域,它用于存储被虚拟机加载类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 《java虚拟机规范》把方法区描述成堆的一个逻辑部分,但是它却有一个别名“非堆”,目的就是要把它跟堆区分开
  • 永久代:待填
  • 运行时常量池:它是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 直接内存:这个并不是虚拟机运行时数据区的一部分,也不是《java虚拟机规范》中定义的内存区域,但是这部分内存也被频繁使用,也有可能会导致OutOfMemoryError异常

2.HotSpot虚拟机

2.1创建对象

  • 当java虚拟机遇到一条字节码new指令时,1.检查这个指令的参数是否能在常量池中定位到一个类的符号引用,2.检查这个符号引用代表的类是否已经被加载、解析、初始化过。如果没有那必须先执行相应的类加载过程(以后的文章再详细探讨类如何加载)
  • 类加载通过后,接下来虚拟机将为新生对象从中划分一块确定大小的内存。分配多大的内存在类被加载之后就可以确定(怎么确定呢?)
  • 指针碰撞:如果堆的内存是绝对规整的(一边是已用的,一边是空闲的,中间放一个指针作为分界点),那么分配内存就只要把指针往空闲区域的内存移动,这种分配方式就称为“指针碰撞”
  • 空闲列表:如果堆的内存不是绝对规整的,那么就需要维护一张表记录哪些是空闲区域,分配内存的时候先查空闲列表,找到一块足够大的空间划分给对象的实例,并更新这张空闲列表,这种方式就称为“空闲列表”
  • 选择哪种方式由堆是否规整决定,而堆是否规整又由采用的垃圾收集器空间压缩整理能力决定
  • 由于创建对象在虚拟机是非常非常频繁的行为,在并发环境下会出现很多线程安全的问题。比如可能出现正在给A分配内存,A的指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。
    解决这个问题有两种解决方案
  1. 分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  2. 还记得上面讨论堆的时候提到“线程私有的TLAB”吗,他的中文名称叫“本地线程缓冲(Thread Local Allocation Buffer)”,每个线程会预先在堆中分配一块很小的线程私有内存,这块内存就是TLAB,哪个线程需要分配内存就先在自己的TLAB中分配,当TLAB不足,分配新的内存才需要同步锁定。虚拟机是否使用TLAB可以通过-XX:+/–UseTLAB参数来设定
  • 分配得到的内存再次划分成如下图结构
    JAVA内存管理——1
    将分配到的内存中的示例数据块初始化为零,如果使用TLAB可以在TLAB分配时顺便进行。这步操作使得对象的实例字段在JAVA代码中可以不赋初始值就直接使用,使得程序能访问到这些字段的数据类型对应的零值
  • 接下来java虚拟机还要对对象设置这个对象是属于哪个类的实例,如何才能找到类的元数据信息,对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算),对象的GC分代年龄等信息。这些信息存放在对象头中,关于对象头的具体内容后面再继续详细探讨。
  • 上面的工作完成之后,对于虚拟机来说一个新的对象就创建完成了,但是,从java程序的视角来说,对象的创建才刚刚开始,因为构造函数还没有执行,所有的字段都还是默认零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好,按照程序员的意愿(也就是构造函数)对对象进行初始化之后,一个真正可用的对象才完全的被创建出来。

2.2对象的内存布局

在HotSpot虚拟机里,对象在堆内存中存储的布局可用划分为三个部分:对象头,实例数据,对齐填充。如上节图所示。

  • 对象头包括两类信息(数组多一类用于记录数组长度的数据,因为虚拟机可用通过普通对象的元数据信息确定java对象的大小,但是数组的长度是不确定的,无法从元数据推断数组的大小)
  1. 用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称为“MarkWord”
  2. 类型指针,即对象指向它类型元数据的指针,通过这个指针来确定这个实例是属于哪个类,但是并不是所有的虚拟机都会在对象数据上保留类型指针,也就是说不一定要通过对象本身去查找对象的元数据
  • 实例数据部分,这部分是对象真正存储的有效信息,即我们在代码中定义的各种类型的字段内容,无论是从父类继承还是子类定义的都必须记录起来,这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义的顺序影响。HotSpot虚拟机设定的默认顺序是long/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
  • 对齐填充,这部分不是必然存在的,也没特别的含义,仅仅作为占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说创建对象分配的内存大小是8字节的整数倍,由于对象头部分已经被精心设计成8个字节的整数倍,因此数据部分没有对齐的话就要通过对齐填充来实现对齐。

2.3对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《java虚拟机规范》值规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方法区定位访问堆中的对象,所以对象的访问方式也是由虚拟机定的,主流的访问方式主要有句柄和直接指针两种。

  • 句柄,java堆中划出一块内存作为句柄池,reference存储就是对象的句柄的地址,句柄包含了实例对象数据和类型数据各自的地址信息,如下图所示。
    JAVA内存管理——1
  • 直接指针,reference中直接存储对象的地址,对象的类型信息存储在对象所分配的内存中,如上节介绍的对象头的第二部分中。如下图所示。
    JAVA内存管理——1
    这两种方式各有各的优势,句柄最大的好处就是reference中存储的是稳定的句柄地址,对象移动(垃圾收集时移动对象是非常频繁的行为)的时候只会改变句柄的实例指针,而reference本身不需要修改,直接指针最大的好处就是速度更快,节省了一次指针定位的时间。HotSpot主要使用的是直接指针方式(有例外情况,比如使用了Shenandoah收集器的话也会有一次额外的转发,具体下篇文章讨论)