这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战
参考
运行时数据区域
这部分分为5个部分,如下,其中:
- 堆,方法区为共享区域
- 虚拟机栈,本地方法栈,程序计数器是线程私有区域
这些部分构成了运行时所需要的所有动态数据
graph LR
共享区域-->堆
共享区域-->方法区
私有区域-->虚拟机栈
私有区域-->本地方法栈
私有区域-->程序计数器
共享区域
堆
堆放的是程序运行时创建的所有实例对象,不存放基本类型和对象引用。
- 也就是说,这里放的是实际分配给对象的动态内存空间。
- GC主要工作区域就在这里。
由于是共享区域,因此有一个问题:在这上面的工作,是线程不安全的。
- 由于内存有限,因此这里会抛出OOM异常。
java堆区,出于GC以及相关考虑,一般划分如下:
pie
title 堆划分
"新生代-eden" : 26.4
"新生代-S1" : 3.3
"新生代-S2" : 3.3
"老年代" : 66.6
一般来说:
- 新生代和老年代占比是1:2
- 新生代中,eden区,S1(Survivor1),S2(Survivor2)占比是8:1:1
方法区
方法区对照着来说,通俗点来讲的话放的就是会被调用的一些和方法、静态常量等相关信息的地方。
由于:
-
也是为了支持运行时进程中所有线程调用,因此这个区域也是线程不安全的。
-
同样地,因为内存有限且这块区域中的内容可能不能移除,因此这块区域同样地会抛出OOM异常。
方法区存储的是从Class文件加载进来的静态变量、类信息、常量池以及编译器编译后的代码。
对于方法区,我觉得重点应该说一下常量池。常量池可以分为Class文件常量池以及运行时常量池,Java程序运行后,Class文件中的信息被字节码执行引擎加载到了方法区,从而形成了运行时常量池。
另外,说起方法区,可能还有人会把它与永久代、元空间混为一谈。那么他们之间的区别到底是什么?方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。不过Java 8以后就没有永久代这个说法了,元空间取代了永久代。
私有区域
这部分内容就属于线程私有的了,也就是说:
- 这部分内容,应当来说,和线程生命周期是相关的,线程如果结束运行了,这些空间就应当得到释放;
- 同时,随着线程的运行,这块内容应当也是会做出对应修改的。
虚拟机栈
虚拟机栈简单来说,就是线程运行过程中,调用方法的记录。由于调用方法必然是后运行的结果在前面返回,因此这个区域就设计成栈了。
- 因为是线程私有的,因此这里必然是线程安全的
- 线程中方法调用的记录,和其他线程显然没什么关系
- 这里会存放基本数据类型以及对象引用。
- 方法的执行,在个区域的体现是:
- 每个方法执行的时候,都会在VM栈的栈顶创建一个栈帧,等到方法执行完毕,对应的栈帧就会出栈并销毁。
由于栈帧是方法执行抽象出的信息,因此栈帧包括了以下内容:
局部变量表、操作数栈、动态链接、方法出口以及额外附加信息。
显然内存并不是无穷无尽的,因此该区域会抛出以下异常:
- StackOverflowError(栈深度大于虚拟机允许的深度)
- OOM(JVM动态扩展时,无法申请到足够内存)
本地方法栈
和虚拟机栈设计、实现、边界都差不多,只是:
- 本地方法栈的栈帧中,存放的都是 调用本地方法时(native方法)的相关信息。
程序计数器
现代CPU都是多核的,那么程序运行时可能会遇到CPU资源被切换走后再获得的操作。
为了避免切换再获得之后,丢失了前一次运行的信息,因此JVM中设计了该区域来专门记录线程执行位置。
它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。
程序计数器从设计上来看,大部分情况下大小并不会多大
- 如果这块区域占用内存很多,那么说明有很多线程在运行,并且一直没有被销毁,那么显然栈部分,吃掉的内存会远比这个区域大
因此,这个区域并不会抛出OOM。
而且,程序计数器也是线程私有的,天然线程安全。
对象
对象无疑是java中最重要的内容了,前面也提到了:对象中实际的数据,都会存放到堆中。那么:
- 对象的生命周期如何?
- 对象的内容包括哪些?
对象生命周期
对象的创建
在C系列语言中,要创建一个对象实例,我们总需要为其分配空间,因此在java中也不例外。
jvm中,创建对象的流程大致如下:
graph LR
字节码new指令-->到方法去的常量池查找对应类是否被加载,解析,初始化-->de{1}
de-->yes,为新生对象分配内存
de-->no,先加载类信息-->yes,为新生对象分配内存
而分配内存,无非就是把一块内存划出来创建对象,对应的方法有:
-
指针碰撞:(代表GC:serial,parNew,使用标记-整理来回收)
- 预先规划一块内存,并给这块内存分配个指针来标定内存中从哪里往后是可以分配的。
- 每次分配内存,就把指针往后移动需要的位数,并把两次指针位置中间的内存分配给新建的对象。
当然,这个类似顺序IO分配的方法,有一个前提:堆空间相对规整。
-
空闲列表:(代表GC:CMS,使用标记-清除回收)
-
上面的问题就在于回收时的效率低,那么就换个思路:
- 维护一个列表,记录哪些空间可以用于写入
那么,每次新建对象,就到列表中申请一块并且把对应的记录改掉。
-
可以看出来这两个方法,都是可行的。具体选择哪个,就取决于虚拟机的类型了。
注意:上面实际上只是分配了一块JVM可管理的内存给对象,对于程序员而言只是申请到了一块内存,还未开始写入实际的数据(构造方法未执行)
对象的访问
上面的执行完了,java代码拿到了一块内存,现在可以对内存进行各种自己想要的操作了,但这些操作实际上还是需要经过jvm的,因为在写入数据前,我们需要定位这块内存的位置。
虽然我们可以通过绝对的地址来访问对象,但这显然越过了jvm管理,因此在jvm中并不是通过这种绝对的物理地址来访问对象内存空间的。
看了前面的运行时数据区域,我们知道对象的引用是存在虚拟机栈中的,而我们的对象又存在堆中,而对象的类信息又存在方法区中。因此,对象引用的映射路径,在jvm中可以以以下方式进行:
-
使用句柄访问:在堆中划分一块区域作为句柄池,专门存对象的实例数据和类型数据的指针
这种方式如下:
graph LR
vm栈对象引用-->java堆中句柄池内容A
java堆中句柄池内容A-->A中到对象实例数据的指针-->java堆中的实例池中的对象实例数据
java堆中句柄池内容A-->A中到对象类型数据的指针-->方法区中的对象类型数据
-
使用直接指针:在堆中存实例对象以外,再存一份类型指针,而不是专门划个句柄池出来。
这种方式如下:
graph LR vm栈对象引用-->java堆中内容A java堆中内容A-->A中包括了对象实例数据 java堆中内容A-->A中到对象类型数据的指针-->方法区中的对象类型数据
粗一看这个句柄访问有一点多余,比起直接指针,缺点在于:
- 多划分类一块句柄池,要维护这个句柄池内容
- 定位实例信息还得用指针再去查一次
但如果考虑到VM的回收机制,那么句柄池的设计理念就体现出来了:
- 如果对象的绝对内存地址被变更了(也就是说,在垃圾回收中被移动了),那么句柄访问就只需要修改句柄指针的指向,而不需要修改vm栈中的对象引用地址(因为是通过句柄去映射的)
- 而直接指针此时就需要把变更,更新到每个vm栈中,防止这些线程在回收前后读取到了不一致的数据
对象的回收
内存总是有限的,如果我们不再需要一个对象,那么把对象从内存中去掉自然是一个不错的选择,例如在jdk中,我们经常能看到把某个引用置为null的操作,注解上写着help GC。
但虽然我们把引用置为null了,原来引用指向的实例区域还在内存中,我们并没有把这个内存区域给清掉了。
那么,vm是如何将这些我们不要的内容清理掉的呢?
哪些对象应当被回收?
通常来说,判断对象是否被销毁的方法有两种:
-
引用计数算法:
我们给对象添加一个引用计数器,被引用,则计数器+1;引用失效,则计数器-1。
毋庸置疑的是,这个方法够简单,但有个严重的问题。
和Spring中的循环依赖类似,如果存在互相引用,而这两个对象其他地方都不再被引用,那么这俩就都回收不了了。
当然,我们可以再给引用计数上加点料:如果出现了相互引用,我们做额外处理。
- 额外处理:我们将出现相互引用的内容额外维护起来;当这些相互引用的计数变成相互引用时,我们查找整个引用的链路,直到找到相互引用以外的引用;如果找不到,那么清除即可。
graph LR A-->B-->A B-->C-->B C-->...但这种方式显然变得更加复杂了,事实上和下面的可达性分析就很类似。如果要做这种额外处理,为什么不直接采用下面的方法呢?
-
可达性分析
对于上面额外的情况,我们将这个处理翻转过来进行:
- 同样维护引用树,但我们的检查工作,从不会被回收的节点开始,而不是从互相引用的节点开始
- 如果从不会被回收的节点能找到的引用,那么就对象就不会被回收;反之,则被回收。
graph TD GCRoot-->A-->B-->C-->A-->... 无-->1这样的方式很直观:我们应该维护被使用的引用,以及可能使用到的引用(包括被使用的引用引用到的别的引用,可能被使用也有可能尚未被使用到)是存在的。
在上面所提到的不会被回收的节点,称为GCRoot。以下是可能被作为GCRoot的对象:
- 虚拟机栈中被引用的对象,例如线程调用的参数、局部变量、临时变量
- 显然,这些都属于在使用的引用,属于局部的引用,还用着呢就不应该被删掉
- 和上面相同,本地方法栈中所引用的对象,也不应该被删除
- 方法区中:
- 类静态属性引用的对象,比如引用类型的静态变量
- 常量引用的对象
- java虚拟机内部的引用,基本数据类型对应的Class对象,常驻的异常对象
- 被synchronized持有的对象。
对象如何回收?
对象的回收主要有三种算法:
- 标记-清除
- 复制
- 标记-整理
以及一个实现:分代收集。
这部分详见:juejin.cn/post/698435…
分代收集
分代收集的实现,基于两个假设理论:
- 弱分代假说:大多数对象生命存活周期很短 - 也就是说,大部分对象的活动范围,其实在两次GC之间
- 强分代假说:经过越多次垃圾回收的对象,存活时间就越久 - 也就是说,越多次GC没有被清理掉的对象,说明在运行中特别重要,不会轻易地被GC。
根据这两个假说,就把堆分为(大概):
- 新生代(young),1/3
- eden 80%
- S1 10%
- S2 10%
- 老年代(old),2/3
根据堆的空间以及两个假说,GC分为以下几类:
- Minor GC/Young GC:针对新生代
- major GC/Old GC:针对老年代
- Full GC:针对整个java堆和方法区
GC流程如下,注意:
- 每经过一次GC,对象分代年龄都会+1;分代年龄到15后,就会被转移到老年代中。
这里是15的原因是:对象头中用于标记分代年龄的只有4位,最大只能表示到15。
graph LR
对象创建-->eden区
首次MinorGC-->eden区存活对象被转移至S区
再次MinorGC-->id1{分代年龄==15?}-->N,eden区存活对象以及S区存活对象被转移到另一S区
id1-->Y,转移到老年代
大对象分配内存而eden区没有足够空间-->gc-->minorGC-->还没有足够空间,分配到老年代
标记-清除算法
标记清除算法,会对无效的对象进行标记,随后清除。
这个算法并不会在清除后做合并操作,因此有一个很大的问题:
- 如果出现了不规整的情况,那么如果给大对象做分配,找不到足够的连续空间,就会再触发一次GC。
并且,由于算法是标记后再回收,因此如果存在着大量的垃圾对象,那么回收必然需要进行大量的标记以及清除,造成回收效率降低。
复制算法
复制算法是一个更简单的算法,步骤如下:
-
预先分配两块大小相等的内存,一块用于写,一块什么也不做
-
开始GC的时候,把写的那块中的所有存活对象,规整地写到空白的那一块里,然后把原来的那块清空。
这个算法的缺点在于:我们划分了两块内存,然而使用中只用到了一块,堆空间的利用率下降了。
现在大部分vm在年轻代使用的都是标记-复制算法,这也就是上面提到的S1、S2中的实现依据。
这种算法的好处显而易见:速度快,我们不需要做删除操作了而是复制再整个区域删除。
复制算法不需要标记
标记-整理算法
标记整理算法可以说是标记清除算法的一个分支。
根据对于标记-清除算法的描述,我们知道标记清除算法的问题就在于:会产生大量的碎片。
那么,我们把标记-清除算法中清理完毕之后的空间,再整理一下:
- 把存活对象移动到一端
那么,在另一端就可以获得更为规整的内存空间了。
当然,这个整理算法也是有缺点的:
-
运行速度是三种方法中最慢的
-
如果以该方式进行GC,那么进行过程中就得暂停所有用户线程
还记得之前说的:这个区域是线程不安全的吗?所以就得上锁
标记-整理算法通常在老年代中使用。
对象内容
在创建对象后,我们的对象就在堆里给自己找了一块地方住了。
当然,由于对象也是托付给jvm来管理的,那么必然在内存中,除了自身的信息,还要额外存一些信息,方便jvm管理。
堆中对象的内容,可以分为以下三个部分:
-
对象头
- 这部分就属于给jvm管理方便的了,包括以下两类:
- 存储对象自身运行时数据,例如:hash码、GC分代年龄、锁状态标志
- 指针类型,让jvm可以确定对象对应的类
- 这部分就属于给jvm管理方便的了,包括以下两类:
-
实例数据
- 这部分就属于对象自身的信息了
-
对齐填充
- vm中可能存在对于内存地址的要求,例如:hotSpot中就要求对象起始地址必须是8字节的整数倍。
那么,如果上面这两个加起来又不能是8字节的整数倍,就得补一些空数据上去,这部分数据就称为对齐填充。