最近想整理一下GC相关的知识和经验,在整理之前先整理一下jvm的内存结构,后续会持续更新。
jvm内存结构重要由两部分组成:线程共享区域与线程私有区域,如下图所示:
其中方法区和堆为线程共享区域,栈与程序计数器为线程私有区域。与操作系统定义的堆栈类似,栈用来存储方法调用时产生的临时变量以及寄存器值,函数的调用伴随着栈帧的开辟及销毁。而堆则是一块较大的内存区域由各线程共享,像对象、常量等jvm进程拥有的资源在堆中由各线程共享。
方法区
方法区也是线程共享区,用于存储虚拟机加载的类信息(instanceOopKlass,即类在jvm中的数据结构),常量,静态变量,即时编译器编译后的代码等数据。
在逻辑上方法区其实属于堆的一部分,但是为了与堆进行区分,方法区也叫“非堆”。
HotSpot虚拟机使用永久代来实现方法区,使得HotSpot虚拟机的垃圾收集器可以像管理堆内存一样来管理这部分内存,能省去专门为方法区编写内存管理代码工作。所以开发者喜欢将方法区称为永久代,本质上两者并不等价,对于其他虚拟机来说不存在永久代的概念。jdk1.8之后,HotSpot虚拟机放弃了永久代的概念,转而使用元空间来代替永久代。下面我们会解释一下方法区与永久代的关系,以及元空间的概念。
方法区可选择不实现垃圾收集,一般来说,这个区域对内存回收的条件较为苛刻。因为类一旦被加载,会有多种被使用的方式,判断该类是否还在被使用并不是那么容易,不合理的垃圾回收策略很容易导致运行时异常。但是这部分区域的回收确实是必要的,不进行回收的代价便是会时常出现内存溢出问题。
当方法区无法满足内存分配需求时,将会抛OutOfMemoryError异常。
方法区与永久代的关系
在Java虚拟机规范中,方法区在虚拟机启动的时候创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择不在方法区实现垃圾回收与压缩。这个版本的虚拟机规范也不限定实现方法区的内存位置和编译代码的管理策略。所以不同的JVM厂商,针对自己的JVM可能有不同的方法区实现方式。
在HotSpot中,设计者将方法区纳入GC分代收集。HotSpot虚拟机堆内存被分为新生代和老年代,对堆内存进行分代管理,所以HotSpot虚拟机使用者更愿意将方法区称为老年代。
方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。我们知道在HotSpot虚拟机中存在三种垃圾回收现象,minor GC、major GC和full GC。对新生代进行垃圾回收叫做minor GC,对老年代进行垃圾回收叫做major GC,同时对新生代、老年代和永久代进行垃圾回收叫做full GC。许多major GC是由minor GC触发的,所以很难将这两种垃圾回收区分开。major GC和full GC通常是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是major GC。
上面说过,HotSpot虚拟机在1.8之后已经取消了永久代,改为元空间,类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域,即堆外内存。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。
这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。为了减少内存碎片,永久代中的元数据的位置也会随着一次full GC发生移动,比较消耗虚拟机性能。同时,HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。将元数据从永久代剥离出来,不仅实现了对元空间的无缝管理,还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。
运行时常量池
运行时常量池是方法区的一部分。jdk1.8之前常量池是使用永久代来实现的,相比类的多少,常量池的大小更容易引起内存溢出异常,好在元空间的引入替代了永久代,HotSpot 虚拟机在1.8版本之后使用了堆外内存也就是元空间来实现常量池,大大缩减了该类风险。
class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后加入方法区的运行时常量池中存放。
运行时常量池相于class文件中的常量池所不同的是其具备了动态性。class文件中常量池中的常量在编译期间就已经定义好了,而运行时常量池在程序运行期间也可以将常量放入该常量池中,最常见的做法就是调用String类的intern()方法。
堆
堆是JVM管理的最大的一块内存区域,存放着对象的实例,由该jvm进程中的所有线程共享。同时堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”。
JAVA堆的分类:
从内存回收的角度上看,可分为新生代(Eden空间,From Survivor空间、To Survivor空间)及老年代(Tenured Gen)。
从内存分配的角度上看,为了解决分配内存时的线程安全性问题,线程共享的JAVA堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
JAVA堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
可通过参数 -Xmx -Xms 来指定运行时堆内存的大小,堆内存空间不足也会抛OutOfMemoryError异常。值得注意的是(个人经验,未必正确),适当情况下,应当在jvm启动前充分测试确定合理的堆的大小,将堆的初始化大小与最大大小设为一致,避免jvm在运行时频繁的动态扩容堆的大小,对性能造成影响。
程序计数器
程序计数器是一块较小的空间,它可以看作是当前线程所执行的字节码的行号指示器。
如果线程执行的是java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是native方法,这个计数器的值为undefined。
JVM的多线程是通过线程轮流切换并分配CPU执行时间片的方式来实现的,任何一个时刻,一个CPU都只会执行一条线程中的指令。为了保证线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程间的程序计数器独立存储,互不影响。
此区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域,因为程序计数器是由虚拟机内部维护的,不需要开发者进行操作。
虚拟机栈
每个线程有一个私有的栈,随着线程的创建而创建,生命周期与线程相同。栈中以栈帧为单位进行分配和释放,每调用一个方法,便为方法开辟一个新的栈帧。栈帧中存放了该次方法调用的局部变量表、操作数栈、动态链接、方法出口等信息。
对于递归调用,每次调用时便会为该次调用开辟新的栈帧,这也是递归函数容易引起爆栈的原因。许多语言对尾递归进行了优化,尾递归是指递归调用在函数的最后一行且递归调用的返回值不会被用来计算的递归函数。因为调用在最底层,递归调用时需要的临时变量会在调用时传入新的调用,且返回值不参与计算,所以当函数执行到递归调用时已经没有临时变量需要存储。许多语言便针对此特性进行了优化,进行尾递归调用时不开辟新的栈帧而直接清空并复用之前一次调用的栈帧,这样无论调用多少层,只需要一个栈帧就够了,这就避免了爆栈的风险。
但遗憾的是java并未对尾递归进行优化,按官方文档的说明是目前看来不需要优化,之后的版本如有需要会对这一特性进行补充。
这样我们必须对递归函数进行改造,比如用递推模拟回归过程(如动态规划),或借助栈结构进行递推(如二叉树的深度优先遍历)。
本地方法栈
和虚拟机栈类似,两者的区别就是虚拟机栈是为虚拟机执行java方法服务,本地方法栈为虚拟机执行native方法服务 。
HotSpot虚拟机不区分虚拟机栈和本地方法栈,两者是一块的。
与虚拟机栈一样,本地方法栈也会抛StackOverflowError和OutOfMemoryError异常。
对象的创建
下面我们看一下程序员脱单的基本操作,new一个对象的过程。
创建一个对象的过程为:
检查对象所属的类是否已被加载------>
在堆内为对象分配空间,对象所需的空间大小在类加载时便可完全确定------>
初始化对象内存空间,对于基础数据类型来说便是赋予初始值,例如int型初始值为0。但对象头不会被初始化,对象头(instanceOopDesc对象)记录着对象的一些信息,比如年龄分代、指向instanceKlass的元数据指针、锁标志位等------>
设置对象头中的相关信息------>执行对象的init()方法。
创建对象的实例化方法
init()方法是jvm自动生成的实例构造器,其执行过程包含了构造方法的执行。
init方法(实例构造器)
.Java文件在编译后会在字节码文件中生成init方法,该方法被称之为实例构造器。init方法是在对象实例化时执行的。该方法中的操作及其顺序为
1.父类变量初始化 2.父类语句块 3.父类构造函数 4.子类变量初始化 5.子类语句块 6.子类构造函数
clinit方法(类构造器)
.java文件在编译后会在字节码文件中生成clinit方法,该方法被称之为类构造器。该方法中的操作及其顺序为:
1.父类静态变量初始化 2.父类静态语句块 3.子类静态变量初始化 4.子类静态语句块 (若父类为接口,则不会调用父类的clinit方法,一个类可以没有clinit方法)
clinit一定比init先执行,因为clinit是在类加载过程中执行的,而init是在对象实例化时执行的。整个执行顺序为:
1.父类静态变量初始化 2.父类静态语句块 3.子类静态变量初始化 4.子类静态语句块 5.父类变量初始化 6.父类语句块 7.父类构造函数 8.子类变量初始化 9.子类语句块 10.子类构造函数
上面所说的顺序需要理解并掌握,对静态语句块以及语句块的作用、子类父类构造方法的关系等的理解大有裨益,可以写个简单的Demo(如下图)测试一下。
如果父类有无参的构造方法,子类创建对象时会先调用父类的构造方法再调用本身的构造方法;如果父类没有无参的构造方法则子类需要通过super()显式的调用父类有参的构造方法。在子类的构造方法中,第一行默认是super(),因为它继承了某些父类成员的使用,必须调用父类的构造方法来初始化这些成员。
如果类中没有定义任何一个构造方法,则java会为该类生成一个默认的构造方法,无参数且方法体为空。如果类中显式定义了一个或多个构造方法,则不再提供默认的构造方法。
创建对象的内存分配
在为对象分配内存时,按照所需内存是否为连续内存来分有两种分配方式。虚拟机按何种方式分配内存是由JAVA堆中的空间是否规整来决定的,而堆的规整与否要看所选的GC策略是否带有压缩整理功能。对于规整的堆内存,jvm采用指针碰撞的方式为对象分配内存。而对于不规整的堆,jvm采用空闲列表的方式为对象分配内存。
指针碰撞:要求堆中内存绝对规整,所有用过的内存都放一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅只是将该指针向空闲空间那边挪动一段与对象大小相等的距离。
空闲列表:针对的是堆中内存不规整的情况,虚拟机维护着一个列表,记录哪些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
在对象的分配过程中,我们需要考虑多核心多线程的同步问题。
一方面,对象的创建往往伴随着对象的引用,从引用层面看,对象的创建过程是这样的:
1.在堆内存开辟对象所需的内存空间
2.实例化对象中的各个参数
3.把对象的引用指向堆内存空间
需要注意的是,第二步与第三步是可能乱序执行的,多核多线程的情况下,引用的指向并不一定对其它线程可见,这会导致对象的重复创建。这也是我们在用DCL单例模式时需要将引用volatile修饰的原因。
另一方面,对象在分配内存时伴随着内存指针的移动(如指针碰撞)。可能A线程a对象已经分配了内存,但未来得及修改指针指向,B线程使用原指针指向分配的内存覆盖了A线程中为a对象分配的内存。这是jvm层面需要解决的问题,目前有两种方式:
1.对分配内存空间的动作进行同步处理,保证更新操作的原子性(采用CAS + 失败重试机制保障原原子性),但效率较低。
2.使用本地线程分配缓冲(TLAB),哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并需要分配新的TLAB时,才需要同步锁定(可通过-XX:+/-UseTLAB参数来设定虚拟机启用TLAB)。
对象的内存布局
Java对象由三部分构成:对象头、实例数据、对齐补充。
对象头
第一部分是与对象在运行时状态相关的信息,长度通过与操作系统的位数保持一致。包括对象的哈希值、GC分代年龄、锁状态以及偏向线程的ID等。由于对象头信息是与对象所定义的信息无关的数据,所以使用了非固定的数据结构,以便存储更多的信息,实现空间复用。因此对象在不同的状态下对象头的存储信息有所差别。另一部分是类型指针,即指向该对象所属类元数据的指针,虚拟机通常通过这个指针来确定该对象所属的类型(但并不是唯一方式)。另外,如果对象是一个数组,在对象头中还应该有一块记录数组长度的数据,因为JVM可以通过对象的元数据确定对象的大小,但不能通过元数据确定数组的长度。
实例数据
实例数据存储的是真正的有效数据,即各个字段的值。无论是子类中定义的,还是从父类继承下来的都需要记录。这部分数据的存储顺序受到虚拟机的分配策略以及字段在类中的定义顺序的影响。
对齐补充
这部分数据不是必然存在的,因为对象的大小总是8字节的整数倍,该数据仅用于补齐实例数据部分不足整数倍的部分,充当占位符的作用。