
Java栈 - JVM
1. Java 虚拟机运行区域
Java 虚拟机主要分为 方法区、堆、虚拟机栈、本地方法栈、程序计数器五个区域,下面依次介绍各个区域。
1.1 程序计数器(线程私有)
当前线程所执行执行的字节码行号指示器。
JVM 概念模型中,字节码解析器会通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程回复等基础功能都需要该计数器完成。
由于JVM 多线程通过**线程轮切(并发)**实现,在任何一个确定时刻,一个处理器都只会执行一条线程的指令。因此线程之间计数器互相独立。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法(java 调用非 java 代码的接口),这个计数器值为 Undefined。
1.2 Java 虚拟机栈(线程私有)
java 虚拟机栈生命周期与线程相同,它描述 Java 方法执行的内存模型:每个 java 方法执行时候都会创建一个栈帧(用于存储局部变量表、操作数、动态连接、方法出口等信息)。每个方法调用到执行完毕的过程,对应着一个栈帧在虚拟机中从入栈到出栈过程。栈帧的内部结构如下:
- 局部变量表:存放编译期可知的各种 Java 虚拟机基本数据类型(boolean、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中 64 位长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照 1 个变量槽占用 32 个比特、64 个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
- 操作数栈:操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
该区域的两种异常:
(A) 如果线程请求栈深度超过 JVM 允许范围,则会抛出 StackOverflowError 异常;
(B) 如果在动态扩展虚拟机栈时候(现在大多数 JVM 都可以动态扩展),内存不足,则是 OOM 异常;
1.3 本地方法栈(线程私有)
与虚拟机栈的作用相似,区别:虚拟机栈为虚拟机执行 Java 方法(字节码)服务;本地方法栈则为虚拟机使用到的 Native 方法服务。
1.4 Java 堆(线程共享)
Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。在《Java 虚拟机规范》中对 Java 堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx 和-Xms 控制)。
年轻代 (Young Generation):年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为 from/to 或 s0/s1),默认比例是 8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
- Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
- 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代
老年代(Old Generation)
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主 GC(Major GC),通常需要更长的时间。大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝元空间:不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
所以元空间放在后边的方法区再说。
设置堆内存大小和 OOM
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定
- -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
- -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize
⠀ 如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。
我们通常会将 -Xmx 和 -Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
1.5 方法区(线程共享)
1.5.1 方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对这个区域的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
1.5.2 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才会产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是 String 类的 intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
2. HotSpot 虚拟机对象探秘
2.1 对象的创建
Object obj = new Ojbect();
当虚拟机遇到一条 new 指令时,首先检查常量池中是否有该对象所属类的符号引用,并且检查该类是否被加载、解析和初始化:
- 如果常量池没有该类的符号引用,抛出 ClassNotFoundException;
- 如果存在并经过 JVM 的执行、解析和初始化等一系列工作,执行下一步工作;
类加载完成后,虚拟机将为新生对象分配内存。
一个对象所需内存,在 JVM 把该类加载进入方法区的时候就已经确定了,且一个类所产生的对象所占内存大小是一样的。从堆中划分一块相应大小的内存给新的对象:
给对象分配内存有两种方式:- 指针碰撞(Bump the Pointer)
如果堆中的内存是规整的,也就是说使用中的内存在一边,空闲内存在另一边,中间有一个指针作为分界点指示器。那么只需要把指针向空闲区域挪动一段与新对象大小相等的距离。 什么样的情况下堆内存是规整的呢?当然是经过整理的,比如 JVM 的垃圾收集器采用复制算法或标记-整理算法,那么堆内存是相对规整的。 - 空闲列表(Free List)
如果堆中的内存不是规整的,而是已使用内存和未使用内存交错的,那么就需要虚拟机维护一个列表并记录哪些内存是可用的。在可用内存中找到一块足够大的空间划分给新对象。JVM 的垃圾收集器采用标记-清除算法,就会使用这种方式分配内存。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 指针碰撞(Bump the Pointer)
对象中的成员变量附上初始值;
对对象进行必要的设置,保存对象头信息(Object Header),对象头信息包括该对象是哪个类实例、对象的哈希码、对象的 GC 分代年龄等信息。
说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员规定的构造函数进行初始化。
经过以上步骤,对象的创建过程就完成了。
在第三步划分空间时,还要注意并发创建对象时的线程安全问题,有可能出现正在给 A 对象分配内存,指针还没有来得及修改,对象 B 又同时使用了原来的指针分配内存的情况,那么,解决这个问题有两种方案:
a) 分配内存空间的动作进行同步处理 :实际上虚拟机采用 CAS 配上失败重试的方式保证了更新操作的原子性。 b) 内存分配的动作按照线程划分在不同的空间中进行: 为每个线程在 Java 堆中预先分配一小块内存 ,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。
2.2 对象的内存布局
java 对象分为三部分:对象头(Object Header), 实例数据(instance data),对齐填充(padding)
2.2.1 对象头
对象头分为两部分:Mark Word 与 Class Pointer(类型指针)。
Mark Word 存储了对象的 hashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳。Class Pointer 存储了指向类对象信息的指针。如果对象时 Java 数组,对象头中还要有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据确定对象大小,如果数组长度不确定将无法通过元数据推断数组大小。
在 32 位 JVM 上对象头占用的大小是 8 字节,64 位 JVM 则是 16 字节,两种类型的 Mark Word 和 Class Pointer 各占一半空间大小。
2.2.2 实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
2.2.3 对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或者 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.3 对象的访问定位
创建对象自然是为了后续使用该对象,我们的 Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
如果使用句柄访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就虚拟机 HotSpot 而言,它主要使用第二种方式进行对象访问。
3. 垃圾回收
3.1 如何判断对象已死?
- 引用计数法:给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。
- 优点:实现简单,判定效率也很高;
- 缺点:很难解决对象之间相互循环引用的问题。
//相互引用问题 objA.instance = objB; objB.instance = objA; objA = null; objB = null;
- 可达性分析
- **基本思路:**通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到 GC ROOTS 没有任何引用链相连时,则证明此对象时不可用的。
- Java 语言中 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈 JNI(Native 方法)引用的对象
3.2 引用的四种类型
- 强引用:就是在程序代码之中普遍存在的,类似 Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
MyClass obj = new MyClass(); // 强引用 obj = null // 此时‘obj’引用被设为null了,前面创建的'MyClass'对象就可以被回收了
- 软引用:用来描述一些还有用但并非必须的元素。对于它在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存才会抛出内存溢出异常。(SoftReference 类用来实现弱引用)
SoftReference<MyClass> softReference = new SoftReference<>(new MyClass());
- 弱引用:用来描述非必须对象的,但是它的强度比软引用更弱一些,被引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象。(WeakReference 类用来实现弱引用)
WeakReference<MyClass> weakReference = new WeakReference<>(new MyClass());
- 虚引用:也称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否存在虚引用,完全不会对其生存时间造成影响,也无法通过虚引用来去的一个对象实例。 它的唯一目的就是:能在这个对象被收集器回收时收到一个系统通知。(PhantomReference 类用来实现弱引用)
PhantomReference<MyClass> phantomReference = new PhantomReference<>(new MyClass(), new ReferenceQueue<>());
3.3 GC 算法
3.3.1 标记-清除算法
标记-清除算法在概念上是最简单最基础的垃圾处理算法。
该方法简单快速,但是缺点也很明显,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.2 复制算法
复制算法改进了标记-清除算法的效率问题。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点也是明显的,可用内存缩小到了原先的一半。
应用
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。
在前面的文章中我们提到过,HotSpot 默认的 Eden:survivor1:survivor2=8:1:1,如下图所示。
3.3.3 标记整理算法
前面说了复制算法主要用于回收新生代的对象,但是这个算法并不适用于老年代。因为老年代的对象存活率都较高(毕竟大多数都是经历了一次次 GC 千辛万苦熬过来的,身子骨很硬朗 😎)
根据老年代的特点,提出了另外一种标记-整理(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.3.4 分代收集算法
有没有注意到了,我们前面的表述当中就引入了新生代、老年代的概念。准确来说,是先有了分代收集算法的这种思想,才会将 Java 堆分为新生代和老年代。这两个概念之间存在着一个先后因果关系。
这个算法很简单,就是**根据对象存活周期的不同,将内存分块。在 Java 堆中,内存区域被分为了新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
就如我们在介绍上面的算法时描述的,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活**,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记—清理” 或者 “标记—整理” 算法 来进行回收。
- 新生代:复制算法
- 老年代:标记-清除算法、标记-整理算法