前言
内存管理,是将内存控制权交给JVM。
一、内存划分
Java虚拟机在执行Java程序的过程中,会把它所管理的内存,划分为若干个不同的数据区域。这些区域由各自的用途,以及创建和销毁的时间,由的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
1.1 程序计数器
一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。在字节码解释器工作时,读取其中的数值,来选取需要执行的字节码指令。
生命周期同线程一样,随着线程的创建而创建,随着线程的结束而结束。
起到的作用:
字节码解释器通过改变程序计数器,来选取下一条需要执行的字节码指令,从而实现代码的流程控制,如分支、循环、跳转、异常处理、线程恢复等。
如果线程执行的是一个java方法,记录的是正在执行的JVM字节码指令的地址。
如果线程执行的是一个native方法,计数器则为空。
1.2 虚拟机栈
由一个个栈帧组成,每个栈帧中包括:局部变量表、操作数栈、动态连接、方法出口信息等。
每次的java方法调用,JVM都会创建一个栈帧。
生命周期同线程一样,随着线程的创建而创建,随着线程的结束而结束。
- 局部变量表:存放基本数据类型(boolean、byte、char、short、int、long、float、double)、对象的引用(reference类型,引用指针或句柄)、returnAddress类型(指向一条字节码指令的地址)。
- 操作数栈:存放方法执行过程中,产生的中间计算结果、临时变量。
- 动态链接:将符号引用转换为直接引用,适用于调用其他方法。
1.3 本地方法栈
与虚拟机栈所发挥的作用非常相似,虚拟机栈为执行Java方法服务,本地方法栈为使用的本地(Native)方法服务。
1.4 堆
所有线程共享的内存区域,存放对象实例、静态变量。
细分为新生代(Eden,Survivor)、老年代,便于更好的回收内存,或者更快的分配内存。
1.4.1 字符串常量池
JVM为了提升性能,减少内存消耗,针对字符串专门开辟的一块区域,避免了字符串的重复创建。
其中保存了字符串和字符串对象的引用,之间的映射关系,字符串对象的引用,指向堆中的字符串对象实例。
1.5 方法区(元空间)
当虚拟机要适用一个类时,需要读取并解析Class文件,将获取的信息存入方法区,如类信息、字段信息、方法信息、常量等。
方法区是一个抽象的概念,具体实现为元空间。
若不指定元空间的内存大小,则默认为本机内存大小。
1.5.1 运行时常量池
常量池表,存放编译期生成的各种字面量、符号引用,在类加载后,存放到运行时常量池中。
1.6 直接内存
通过JNI的方式,在本地内存上分配的一块区域,并不是JVM运行时数据区的一部分。
jdk1.4中加入NIO(Non-Blocking I/O),引入一种基于通道(Channel)与缓存区(Buffer)的I/O方式,这样可以使用Native函数库,直接分配堆外内存,java堆可以使用DirectByteBuffer对象,作为堆外内存的引用。避免了java堆和native堆之间来回复制数据。
二、内存分配
2.1 对象的创建
2.1.1 类加载检查
虚拟机遇到一条new指令时,先检查能否在常量池中定位到这个类的符号引用,并且检查这个类是否被类加载、解析、初始化过。如果没有,则先执行类加载过程。
2.1.2 分配内存
在类检查通过后,JVM为新生对象分配内存。对象所需大小,在类加载完成后便可确定。
内存分配方式:
指针碰撞:
所采用的垃圾收集器带有压缩整理功能,java堆是规整的。
用过的内存全部整理到一边,没用过的内存在另一边,中间有一个分界指针,分配时向没用过的方向移动指针。空闲列表:
java堆不是规整的。
JVM维护一个列表,记录哪些内存是可用的,分配时找一块足够大的内存给对象实例,最后更新列表记录。
分配时的线程安全方式:
CAS+失败重试:
CAS为无锁方式,如果没有冲突,则直接更新,如果失败,则不断重试,知道成功为止,保证了操作的原子性。TLAB:
为每一个线程预先在Eden分配一块内存,JVM在给线程中的对象分配内存时,先在TLAB分配,当TLAB不足时,再采用CAS分配。
2.1.3 初始化零值
内存分配后,JVM将分配的内存空间都初始化为零值(不包括对象头),使得对象字段可以不赋值就直接使用。
2.1.4 设置对象头
初始化零值后,JVM对对象进行必要的设置,如对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC对象的GC分代年龄等,保存在对象头中。
2.1.5 执行init方法
上诉步骤都完成后,对于虚拟机来说,对象就创建完成了。对于程序来说,所有的字段都是零值,需要执行自定义的init方法,把对象初始化。
2.2 对象的内存布局
划分为3个部分:对象头、实例数据、对齐填充。
对象头:
一部分存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等),另一部分是类型指针(类的元数据指针)。实例数据:
对象真正的有效信息,即对象定义的各种类型的字段内容。对齐填充:
没有特别的含义,仅仅起占位的作用。
JVM要求对象的起始地址,即对象的大小,必须是8字节的整数倍。对象头已经被设计成8字节的倍数,因此,当实例数据没有对齐时,就需要对齐填充来补全。
2.3 对象的访问定位
使用对象时,是通过栈上的reference指针,来操作堆中的实例数据。
对象的两种访问方式:
-
句柄
java堆中划分一块句柄池,reference指针存储的,就是对象的句柄地址,句柄中包含对象类型数据的指针、对象实例数据的指针。
使用句柄的好处,就是提供稳定的句柄地址,在对象被移动时,只改变句柄中的指针,reference并不修改。
1691133790565.png -
直接指针
reference指针存储的,就是对象的地址。
使用直接指针的好处,就是节省了一次指针定位的开销。
1691133857562.png
2.4 分配策略
- 优先在eden分配
- 大对象直接进入老年代
- 长期存活的对象进入老年代
三、内存回收
程序计数器、虚拟机栈、本地方法栈,这3个区域为线程私有,随着线程的创建而创建,随着线程的销毁而销毁。当方法或线程结束时,内存自然就跟着回收了,这3个区域并不需要考虑回收的问题。
Java堆、方法区,这2个区域有着不确定性。一个接口的多个实现类,需要的内存可能会不一样;一个方法执行不同条件的分支,需要的内存可能会不一样。只有处于运行期间,才能知道要创建哪些对象,创建多少个对象。垃圾收集正是回收这2个区域。
方法区的回收,主要是不在使用的类型。在大量使用反射、动态代理的场景中,都需要JVM具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.1 对象的引用
3.1.1 判断对象无效
引用计数
给对象中添加一个引用计数器,如果有一个地方引用这个对象,计数器就加1,当引用失效,计数器就减1,为0时表示对象不再被使用。
无法解决对象之间的循环引用问题,主流的Java虚拟机并没有使用引用计数算法。可达性分析
从跟对象向下搜索,搜索过程所走过的路径称为引用链,不在引用链上的对象则无效。
可作为跟对象的对象:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中类的静态属性引用的对象、方法区中的常量引用的对象、堆中RSet的跨代引用。
3.1.2 引用类型
强引用
普遍存在的引用关系,如Object object = new Object()。无论任何情况下,只要强引用关系还在,垃圾回收器就不会回收。软引用
描述一些还有用,但非必须的对象。在系统将要发生内存溢出异常前,会把这些对象列入回收范围之中,如果回收过后还是没有足够的内存,才会抛出内存溢出异常。弱引用
也是用来描述非必须对象,但强度比软引用更弱一些,对象只能生存到下一次垃圾回收,无论内存是否足够。虚引用
最弱的一种引用关系,在这个对象被回收时,收到一个系统通知。不影响对象的生存周期,也无法通过虚引用取得一个对象实例。
3.2 垃圾回收
3.2.1 分代收集理论
根据对象的存活周期,将Java堆划分不同的区域,存活率低的对象集中存放,存活率高的对象集中存放,根据不同区域的特点,提出针对性的垃圾回收算法。
一般将存活率低的区域称为新生代,存活率高的区域称为老年代。
对新生代收集时,新生代的对象有可能被老年代引用,如果遍历整个老年代会带来很大负担,为此设计了跨代引用。在新生代上建立一个全局的数据结构-记忆集(Remenbered Set),记录老年代的哪块区域存在跨代引用。这样在young GC时,只需将RSet中记录的老年代的一小块加入GCRoots扫描,不用扫描整个老年代。
3.2.2 垃圾回收算法
新生代对象存活率低,可选择标记复制算法,每次收集只需要复制少量存活对象。
老年代存活率高,可选择标记整理/标记清除算法。
标记清除
分为标记和清除两个阶段,标记出存活的对象,统一回收掉没被标记的对象。
缺点:标记和清除效率都不高,而且会产生大量不连续的内存碎片。标记整理
标记出存活的对象,将存活的对象移动到一端,清理掉端边界以外的内存。
缺点:由于多了整理的步骤,效率低。标记复制
解决了标记清除算法的低效率和内存碎片问题。
将内存分为两部分,每次使用其中的一块。当这一块的内存使用完后,将存活的对象复制到另一块去,然后把这块使用的空间清理掉。
缺点:可用内存缩小。
3.3 垃圾收集器
垃圾回收器是垃圾回收算法的具体实现,其中G1收集器是广泛使用的。
3.3.1 Serial 收集器
单线程,对新生代收集,采用标记复制。
3.3.2 ParNew 收集器
Serial收集器的多线程版本(多条垃圾收集线程并行工作),关注停顿时间。
3.3.3 Parallel Scavenge 收集器
Serial收集器的多线程版本,关注吞吐量。
3.3.4 Serial Old 收集器
Serial收集器的老年代版本,采用标记整理。
3.3.5 Parallel Old 收集器
Parallel Scavenge的老年代版本,关注吞吐量。
3.3.6 CMS 收集器
采用标记清除算法。
并发收集(在收集阶段,用户线程与GC线程同时工作),低停顿。
3.3.7 G1 收集器
设计思路:收集的速度,跟得上对象分配的速度。
特点:可预测停顿,高吞吐量。
Region
G1将内存空间,划分成大小相等的一块块Region,包括eden Region、survivor Region、old Region。可预测停顿
并发标记完成后,就知道了哪些Region的可回收对象多(优先选择),可根据停顿时间,选择回收Region的个数,达到可预测停顿的效果。RSet
每个Region都有一个RSet,记录其他Region指向自己的指针,如果是Young Region,RSet记录了old->young的跨代引用;如果是Old Region,Rset记录了old->old的引用。这样Rset做为根集扫描时,就不用扫描全部的old Region,减少了GC的工作量。Minor GC
eden满时触发。
新生代eden和survivor1的回收,采用标记复制算法,将存活的对象复制到survivor2。-
Mixed GC
回收全部的新生代+可回收对象多的老年代。
初始标记:GCRoots直接关联的对象。
并发标记:GCRoots向下搜索(不需停顿)。
最终标记:STAB算法处理引用变化。
筛选回收:根据停顿时间,选择回收Region的个数(俗称CSet),存活的对象复制到空的Region,多条GC线程并行清除旧Region。
1692688695240.png
3.3.8 ZGC 收集器
JDK15正式使用,追求低停顿10ms,支持TB大小的堆。
使用读屏障、染色指针、内存多重映射,来实现可并发的标记整理算法。