[读书笔记]深入理解Java虚拟机——内存自动管理
内存模型
-
程序计数器
Program Counter Register 线程私有,较小的内存空间,当前线程所执行的字节码的行号指示器。
-
Java虚拟机栈
Java Virtual Machine Stack 线程私有,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表:
存储基本数据类型、对象引用(指向对象起始地址的指针or代表对象的句柄or与对象相关的位置)、returnAddress类型(一个字节码指令的地址)。
存储空间以局部变量槽(Slot)分割,64位的long和double用2个槽,其他用一个。
所需的内存空间均在编译期间分配。
线程请求的栈深度超过虚拟机允许,抛出StackOverflowError异常;如果栈可以动态扩展,则会在无法申请到足够内存时抛出OutOfMemoryError异常。
-
本地方法栈
Native Method Stacks 与JVM Stack相似,只不过时为本地方法服务。
-
Java堆
Java Heap 是虚拟机所管理的内存中最大的,被所有线程共享,作用是存放对象实例。“几乎”所有对象实例都在此存放。
Java堆是垃圾收集器管理的内存区域,因此被称为GC(Garbage Collected Heap)。
堆物理可以不连续,但逻辑上应该被视为是连续的。
当前主流堆都是可扩展的,当堆没有内存可以分配且不能扩展时,抛出OutOfMemoryError异常。
-
方法区
Method Area 被所有线程共享,存储已被加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
垃圾回收比较少见,主要针对常量池的回收和类型的卸载。
运行时常量池:Runtime Constant Pool,存放编译期生成的各种字面量和符号引用、由符号引用翻译出来的直接引用。
对象的内存布局
在HotSpot虚拟机中,对象存储在堆中可以划分为:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
-
对象头:包括
-
存储对象自身的运行时数据Mark Word,例如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;考虑到虚拟机的空间效率Mark Word被设计为一个有动态定义的数据结构。
-
类型指针,对象指向它类型元数据的指针,虚拟机通过这个指针来确定该对象是哪个对象的实例。
-
如果对象是一个数组,还需要记录数组的长度。
-
-
实例数据:在代码中定义的各种类型的字段内容。
-
对齐填充:HotSpot要求对象起始地址是8字节的整数倍。
对象的访问定位
Java程序通过栈上的reference数据来操作堆上的具体对象。方法有句柄和直接指针两种:
-
句柄:在Java堆中划出一块内存存储句柄,reference指向句柄。好处是在对象被移动时(垃圾回收时普遍发送)只需要修改句柄中指针,而不修改reference本身。
-
直接指针:reference直接指向实例数据,此时对象内部需要存储访问类型数据的相关信息。好处是节约了一次指针定位的开销。
垃圾收集器与内存分配策略
程序计数器、虚拟机栈、本地方法栈在方法结束或线程结束时,内存自然回收;
JAVA堆和方法区,在运行期间动态分配和回收内存。
判断方法
-
引用计数法
Reference Counting 额外占用内存空间进行计数,(但是主流Java虚拟机没有用引用计数法);
弊端:循环引用导致计数永远不为零。
-
可达性算法
Reachability Analysis (Java,C#使用);
使用GC Roots作为起始节点集,根据引用关系向下搜索,不可达的就被判断为可回收对象;
GC Roots可以是虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量应用的对象、本地方法栈中引用的对象、虚拟机内部的引用(Class对象、异常对象、类加载器等)、被synchronized关键字持有的对象、虚拟机内贸部注册的回调缓存等。
引用(强软弱虚)
强引用:类似于传统引用的定义,只要强引用关系存在,就不会被回收;
软引用:还有一定作用,但不是必须的对象。在内存Out Of Memory之前会被回收,如果回收之后还没有足够内存,才抛出溢出异常;
弱引用:非必须对象,存活到下一次垃圾收集时,无论内存是否足够,都会回收掉弱引用对象;
虚引用:虚引用不会对对象生存时间有影响,也无法通过虚引用获得对象实例,只能用于在对象被回收时收到一个系统通知。
两次标记
在对象可达性分析后,且没有与GC Roots相连接的引用链,就会被第一次标记,然后进行筛选:
if 对象没有覆盖finalize()方法 or finalize()方法已经被虚拟机调用过一次
then 判断为 没有必要执行 finalize()方法
else 有必要执行finalize()方法,对象会被放在一个F-Queue队列中,并被虚拟机建立的、低调度优先级的Finalizer线程执行它的finalize()方法(仅触发运行,不等待结束),然后收集器会对F-Queue中对象进行第二次标记
这时,如果要拯救自己,则可以在finalize()函数中将自己和引用链上的一个对象关联即可
两次标记后,对象才会被真正地回收。
方法区回收
主要回收:废弃的常量和不再使用的类型
不再使用的类(只是被允许回收,不是一定会回收):
-
堆中不存在该类和任何派生子类的实例
-
加载该类的类加载器已经被回收(通常很难达成)
-
该类的java.lang.Class对象也没有被引用,无法再任何地方通过反射访问该类的方法
垃圾收集算法
下列均为”追踪式垃圾收集“(Tracing GC)
分代收集理论
大多数商业虚拟机使用,建立在两个假说之上:
-
弱分代假说:绝大多数对象都是朝生夕灭的
-
强分代假说:熬过越多次垃圾收集的对象越难以消亡
-
跨代引用假说:跨代引用相对于同代引用仅占少数
由前两个假说得出的设计原则:收集器将堆划分为多个区域,按垃圾收集后存活次数来分配对象存储区域。垃圾收集器每次回收其中一个或某些部分的区域。
根据第三条假说:我们不会为了少量跨代引用去扫描整个老年代,而是在新生代维护一个数据结构”记忆集”Remembered Set,标志出老年代哪一块内存由跨代引用,Minor GC时只有跨代引用的内存对象才会被加入扫描。
-
Minor GC:新生代收集
-
Major GC:老年代收集(通常没有单独收集老年代,只有CMDS收集器会)
-
Mixed GC:收集整个新生代和部分老年代(只有G1收集器会)
-
Full GC:收集整个堆和方法区
标记-清除算法
最早出现也是最基础的算法。标记需要回收的对象,然后统一回收;或标记存活的对象, 然后统一回收未标记的对象。
缺点有:1.效率不稳定;2.产生内存空间碎片
标记-复制算法
理论:将内存分为等大小的两块,一块用完了就将存活对象复制到另一块上,在将用过的一块全部清理。
实现简单,运行高效,但空间浪费。
现在商用虚拟机大多采用这种方法回收新生代。
Appel式回收:按照弱分代假说,HotSpot虚拟机将空间分为Eden:Survivor:Survivor = 8:1:1,每次垃圾收集时,将Eden和一块Survivor中存活的对象复制到另一块Survivor上。如果一块Survivor不够容纳所有存活对象,可以去其他内存区域(大多是老年代)进行分配担保,将对象放入老年代。
标记-整理算法
针对老年代的特征,让所有存活对象向一段移动,并清理边界以外的内存
弊端:存活对象移动需要暂停用户应用程序(标记-清除也需要暂停,但时间相对较短)
回收策略
-
对象优先在新生代Eden分配,当Eden没有足够空间时,虚拟机发起一次Minor GC.
-
大对象直接进入老年代,避免在Eden和Survivor之间来回复制。
-
长期存活对象进入老年代,默认为存活15次。
-
动态对象年龄判定,如果Survivor中相同年龄的对象大小总和大于Survivor空间的一半,大于这个年龄的所有对象都可以直接进入老年代。
-
空间分配担保,在Minor GC之前
if 老年代最大连续可用空间大于新生代所有对象总空间
then Minor GC安全
else if 参数设置允许担保失败 and 老年代最大连续可用空间大于历次晋升到老年代对象的平均大小
then 尝试 Minor GC
else 空间小于 or 参数不允许担保失败
then Full GC
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!