[读书笔记]深入理解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