深入理解Java虚拟机第3版(自动内存管理)
一、Java内存区域与内存溢出异常
1.1 运行时数据区域
1.1.1 程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条指令,分支、循环、跳转、异常处理、线程恢复等基础功能就是靠这个来完成。
没有规定任何OutOfMemoryError情况的区域
1.1.2 Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是JAVA方法执行的线程内存模型:每个方法被执行的时候,JAVA虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用到执行完毕,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常(如无限递归)
- 如果Java虚拟机栈容量可以动态扩展,当无法申请到足够的内存时会抛出OutOfMemoryError异常。
1.1.3 本地方法栈
本地方法栈与虚拟机栈所发挥的作用非常相似,区别只是虚拟机栈为虚拟机执行java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
和Java虚拟机栈情况相同
1.1.4 Java堆
Java堆是被所有线程共享的一块内存区域,用来存放对象实例。
Java堆可以设置成固定大小,也可以是可扩展的(主流,通过-Xmx和-Xms设置)。当没有内存完成实例分配,且堆也无法扩展时,抛出OutOfMemoryError异常。
1.1.5 方法区
各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即使编译后的代码缓存等数据。
虚拟机规范中把方法区描述为堆的一个逻辑部分。
具体实现是:元空间(JDK8及以后)、永久代(JDK8之前)。
- 在JDK1.7的时候:符号引用(Symbols),字面量(interned strings)和静态变量(class statics)移至java堆
- 在JDK1.8的时候:剩余部分(主要是类型信息)移到元空间中
如果方法区无法满足新的内存分配需求时抛出OutOfMemoryError异常。
1.1.6 运行时常量池
是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后放到方法区的运行时常量池中。
当常量池无法再申请到内存时抛出OutOfMemoryError异常。
注意:JDK1.8中字符串常量池和运行时常量池逻辑上属于方法区,但是实际存放在堆内存中
1.1.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。
但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常。
1.2 HotSpot虚拟机对象探秘
1.2.1 对象的创建
类加载检查
当JAVA虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存
通过类加载检查后,虚拟机将为新城对象分配内存。对象所需要的内存大小在类加载完成后就可以确定了,分配任务等同于把一块确定大小的内存块从Java堆中划分出来。
内存7分配方式:
- 指针碰撞:内存是规整的,即空闲的都在一起。
- 空闲列表:不规整的,已使用内存和空闲内存交错。这时候要维护一个列表,记录哪些可用。
内存分配并发:
- 对内存分配动作,同步处理
- 按照线程划分在不同的空间中进程,即每个线程在Java堆中预先分配一小块内存(本地缓冲区),满了以后,分配新的缓存区时,才进行同步
初始化内存空间
内存分配完成后,将分配到的空间进行初始化为零值。(即Java对象不需要赋初值就可以直接使用其默认值)
对对象进行必要的设置
如这个对象是哪个类的实例、如何才能找到元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头中。
完成对象的产生
从虚拟机角度看,已经完成一个新的对象的产生。但是从Java程序看,对象创建才刚刚开始——构造函数,即Class文件中的init方法还没执行。当按照程序员的意愿对对象初始化后,一个真正的可用的对象才算完全被构造出来。
1.2.2 对象的内存布局
- 对象头
- 用于存储对象自身的运行时数据。如,哈希码、对象的GC分代年龄等,官方称为
Mark Word
(有32比特)- 25个比特用于存储对象哈希码
- 4个比特用于存储对象分代年龄(最大15,即1111)
- 2个比特用于存储锁标志位(P51,具体标志代表信息)
- 1个比特固定为0
- 类型指针。即对象指向它的类型元数据的指针(JAVA虚拟机通过这个指针确定该对象是哪个类的实例,但是并非所有虚拟机都有)
- 用于存储对象自身的运行时数据。如,哈希码、对象的GC分代年龄等,官方称为
- 实例数据:对象真正存储的有效信息。即程序代码里定义的各种类型的字段内容。
- 对齐填充:占位符作用 。任何对象大小都必须是8字节的整数倍。
1.2.3 对象的访问定位
二、垃圾收集器与内存分配策略
2.1 对象是否死亡的判断
2.1.1 引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效的时,计数器值减一;任何时刻计数器为零的对象就是不可能再被使用的。
客观来说,引用计数算法虽然占用一些额外的内存空间来进行计数,但是它原理简单,判定效率高。(但是很少主流虚拟机使用这个算法)。主要原因是可能发生问题!比如两个对象的某字段相互引用,除此之外这两个对象没有任何引用。实际上这两个对象已经不可能再被访问,但是因为它们相互引用对方,导致他们的引用计数都不为零,也就无法回收他们,如:
public static void testGC() { |
2.1.2 可达性分析算法
当前主流都是使用该算法。该算法通过一系列称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索。如果某个对象到GC Roots间没有任何引用链相连,即图论中的不可达,证明次对象不可能再被使用。
Java技术体系中,固定可作为 GC Roots 的对象:P70
2.1.3 引用的分类
JDK1.2以后,对引用的概念进行了扩充:
强引用
传统的引用,类似
Object obj = new Object()
。只要对象有强引用,垃圾收集器永远不会收集该对象。软引用
描述一些还有用,但非必须的对象。只被软引用的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围中进行二次回收,如果这次回收还是没有足够内存,才会抛出内存溢出异常。
弱引用
和软引用类似,但是更弱。关联的对象只能生存到下一次垃圾收集发生为止。不论内存是否足够都会回收。
虚引用
最弱的一种引用关系。一个对象是否有虚引用,完全不影响其生存,也无法通过虚引用获取对象实例。一个对象关联虚引用唯一目的只是为了能在这个对象被回收的时候收到一个系统通知。
2.1.4 生存 OR 死亡
在被可达性分析算法中判定为不可达对象时,暂时处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,将被第一次标记。
随后进行一次筛选,筛选条件是此对象是否有必要执行
finalize()
方法(该方法是最后自救的机会。Object的方法,可以在该方法里面重新引用。不推荐使用 !)。如果该对象没有覆盖该方法,或者该方法已经被虚拟机调用过了(该方法只能被调用一次),那么虚拟机将这两种情况都视为“没有必要执行该方法”。
如果判断该方法需要执行,那么会将该对象放到一个F-Queue队列中,并在稍后由一条由虚拟机自动建立的、地调度优先级的Finalizer线程去执行他们的 finalize() 方法(这里的执行,只是触发。并不等它结束。万一这个方法是死循环,回收子系统不久奔溃了)。
在第二次标记时,它将被移出“即将回收”的集合。如果这时候对象还没有逃脱,基本上就是要被回收了。
2.1.5 回收方法区
方法区也是可以有垃圾收集行为的(虚拟机规范不要求在方法区中实现垃圾收集,因为性价比低)。
方法区的垃圾收集主要有两部分:废弃的常量和不再使用的类型(与Java堆中的对象类似)。
常量的“废弃”判定简单,而“不再使用的类型”判断条件苛刻:P74
2.2 垃圾收集算法
2.2.1 分代收集理论
收集器应该将Java堆划分出不同的区域,然后将回收的对象依据年龄分配到不同的区域之中存储(因此有了新生代、老年代等区分)。
新生代随着年龄的增长晋升到老年代中。
2.2.2 标记 - 清除算法
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象(也可以返回来)。标记过程就是对象是否属于垃圾的判定过程(参考2.1)。
缺点
- 执行效率不稳定。如果Java堆中包含大量对象,而且大部分要被回收,这时候需要进行大量标记和清除动作。效率随着对象数量增长而减低。
- 内存空间碎片化问题。标记、清除后会产生大量不连续的内存碎片,空间碎片大多可能导致后续分配大对象时,没有足够的连续空间而不得不触发另一次垃圾收集动作。
2.2.3 标记 - 复制算法
将可用内存按容量划分为大小相等的两块,每次只是用其中一块。当这块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
如果多数对象是存活的,这种算法将会产生大量的内存间复制的开销。
但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是整对整个半区进行内存回收,分配内存时也就不用考虑碎片化问题。这样实现简单,运行高效(现在商用Java虚拟机大多数都优先采用了这种收集算法去回收新生代。)。
缺点是将可用内存缩小为了原来的一半,空间浪费太多了。
Appel 式回收
把新生代分为一块较大的Eden空间和2块较小的Survivor空间(默认比例为8:1)。每次分配内存只使用Eden和其中一块Survivor空间。发生垃圾搜集时,将Eden和Survivor中任然存活的对象一次性复制到另外一块Survivor中(即90%可用,10%浪费)。当另一块Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象便将通过分配担保机制直接进入老年代。
2.2.4 标记 - 整理算法
过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象清除,而是让所有存活对象都想内存空间一端移动,然后直接清理掉边界的内存。
该算法针对老年代对象的存亡特征,进行回收。
和稀泥式 解决方案
由于“标记清除”和“标记整理”都有一定的不足。这里可以对两种方法相结合:
让虚拟机平时多数时间都采用”标记清除“算法,暂时容忍内存碎片的存在,知道内存空间碎片化程度已经大到影响对象分配时,再采用”标记整理“算法收集一次,以获得规整的内存空间。(CMS收集器采用该方法)