Java杂货铺JVMJava高墙之内存模型

24次阅读

共计 5579 个字符,预计需要花费 14 分钟才能阅读完成。

Java 与 C ++ 之间有一堵由内存动态分配和垃圾回收技术所围成的“高墙”,墙外的人想进去,墙外的人想出来。——《深入理解 Java 虚拟机》

前言

《深入理解 Java 虚拟机》,学习 JVM 的经典著作,几乎学习 JAVA 的小伙伴人手一本。当初买了,翻看了一部分,到了字节码那边彻底读不下去了,遂弃之。最近打算看 Spring 源码,反射、动态代理、设计模式等基础工具的确可以让我更加容易理解源码内容。然而,看着看着才发现,这个平常我们几乎用不到的东西(除了面试),才应该是理解 java 生态的出发站。所以,停下手来,重新看下这本书,再全面的了解下虚拟机,这次无论多么困难,也要把书读完,同时记好内容笔记和思考补充。作为 Java 围城之一的内存模型,比当时第一个要看的内容。

出发,看看 JVM 大工厂

刚开始学 Java 的时候,被贯彻最多的两句话就是“一次编译,到处运行”和“Java 不需要手动释放内存”。能做到这两点都是由于 Jvm 的存在。记得大学第一个启蒙语言 c,电脑安装了一个 cfree(一个体积超小的 ide)就可以直接写了。而 Java 还需要下载一个叫 JDK 的东西,来开发。JDK 包含一个叫 JRE 的东西,是 Java 的运行环境,之所以可以运行,是 jre 下拥有着 JVM 虚拟机。JVM 作为一个程序,一定会占用电脑内存,而它所管辖内存间数据的互动,驱动着 Java 的工作。

线程的指挥官:程序计数器

作为面向对象语言,Java 每个类都有自己的属性和使命,并且暴露方法出来供其他成员调用。一个业务逻辑,不同对象之间调用方法、返回调用者,一个方法内部分支、循环等基础功能,都需要一个指挥官来完成,指挥官告诉这个线程内的对象执行的先后顺序。这个指挥官就叫做 程序计数器 。<mark> 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。</mark> 因为一个 CPU 同一时间只能操作一个线程中的指令,所以每个线程需要私有一个指挥官,所以程序计数器这类内存也叫做 线程私有 内存。

如果一个线程正在执行的是 Java 方法,这个计数器记录的是正在执行的虚拟机字节码 指令地址;如果是正在执行的 Native 方法,这个计数器值则为空(Undefined)。Native 方法就是 Java 调取本地其他语言的方法,此方法实现不受 JVM 管控,所以无法感知到地址,计数器值自然为空。

另外,程序计数器区域是唯一一个 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的内存区域。

引用的地盘:Java 虚拟机栈

我们使用 Java 新建一个对象,首先需要声明类型,此时就出现了一个 引用 ,引用指向创建出的对象。我们都知道引用在栈中,对象在堆中,此时说的栈就特指 Java 虚拟机栈。Java 虚拟机栈同样属于线程私有的,所以生命周期和线程相同。每个方法在创建的同时,都会创建一个 栈帧 用于储存局部变量表、操作数栈、动态链接、方法出入口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了 编译 时克制的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference)。对象引用直接或者间接指向堆中对象的地址。由于此过程是在编译时期完成的,所以局部变量内存分配大小是固定的,不会在运行时改变大小。其中 64 位长度的 long 和 double 类型的数据都占用了 2 个局部变量空间(Slot),其他数据类型只占 1 位。

在这个区域可能会出现两种异常:如果线程请求的栈深度过大,也就是说虚拟机栈在自己管辖的内存造成的原因,会抛出 StackOverflowError 异常,这个一般比较深的递归可能会造成。如果虚拟机栈发现自己内存不够,动态扩展,并且无法申请到足够的空间时,就会抛出 OutMemoryError 异常。

虚拟机栈的孪生兄弟:本地方法栈

本地方法栈几乎与虚拟机栈发挥的作用基本相似,毕竟孪生兄弟嘛。区别是 Java 虚拟机栈是为字节码服务的,也就是 Java 方法本身。而本地方法栈是为了 Native 方法服务的,这个涉及调取本地的语言,例如 C。

这里插个小曲,native 对于咱们 Java 编程者来说很少直接操作,但是这东西无处不在,比如说 Object 类,你看源码,很多方法都有 native 关键字。这些方法具体实现在 java 代码里面无论如何都找不到的,因为具体实现就是调取的本地,并且调取本地的代码不受 JVM 控制!在编译的过程中,如果发现一个类没有显示继承,那么就会被隐式继承 Object 类,也就有了 Object 类所有的方法。

GC 最喜欢的地方:Java 堆

我们常说的堆栈,说的就是这个堆。可以说 Java 堆是虚拟机所管辖最大的一块内存空间,并且此空间是所有线程 共享 的。<mark> 几乎所有的对象实例都分配在这里 </mark>,所有的对象实例和数组都要在堆上索取空间。Java 堆也是垃圾收集器管理的主要区域,这个以后会细讲。
Java 堆可以处于物理上不连续的空间中,只要逻辑上是连续的即可。如果堆中没有内存完成实例分配,并且对也无法再拓展时,将会抛出 OutOfMemoryError 异常。

永久代的伪装:方法区

大佬书中讲这部分内容的时候还是以 JDK1.6 为范本,但是直接被堆内存所托管了。JDK1.8 这部分已经变成元空间了,并且成为了堆外内存,不受 JVM 直接管辖。但是为了更好的理解 JVM 内存模型的设计理念还是看下这部分内容。

方法区也属于线程共享区间,它储存着 <mark> 类信息、常量、静态变量即时编译后的代码等数据 </mark>

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这群有同样的内存回收目标主要是针对常量池的回收和堆类型的卸载,但是回收条件相当苛刻。同堆一样,可能会导致 OutOfMemeoryError 异常。

运行可变区域:运行时常量池

既然有运行时常量池,就会有普通的常量池(简称常量池)。常量池用于存放编译期生成的各种字面量和符号引用,字面量相当于 Java 语言层面常量的概念,如文本字符串,声明为 final 的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:类和接口的全限定名、字段名称和描述符、方法名称和描述符。

运行时常量池相对于普通的常量池(又称 Class 文件常量池)有一个重要特征 动态性 。Java 语言并不要求常量只能在比那一起才能产生,运行期间也可以加入常量到常量池(运行时常量池)中,比如 String 的 intern() 方法。

运行时常量池属于方法区的一部分,自然受到方法去内存的限制,也会抛出 OutOfMemoryError 异常。

JVM 外的世界:直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范定义的内存区域。还记着前面说的有 native 关键字的方法吗?包括 netty 模块的一些 Native 函数库都是直接分配堆外内存的,然后通过一个储存在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用来操作。这样做,就是以为需要操作的数据在 Native 堆(你电脑上不被 JVM 管辖的内存空间)上,避免了将 Java 堆数据和 Native 堆数据来回复制。当然这块内存也不能无限放大,比如超过你电脑的内存,所以也可能出现 OutOfMemoryError 异常。

让数据动起来

内存空间不在于划分,在于使用。大佬在书中继续以 HotStop 虚拟机堆内存为例,讲解了数据的创建、分布、与访问。

一个对象的诞生

内存分配

虚拟机遇到一条 new 指令时,首先将去 检查 这个指令的参数是否能够在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。接下来,虚拟机会为这个新生儿 分配内存 (加载完成后的内存是完全确定大小的)。和计算机管理内存的方式一样,Java 堆维护内存,有一张 空闲列表 ,用于记录堆内哪些空间没有被使用过。由于堆在物理上是不连续的,所以就需要有个地方记录哪些空间是被使用的,哪些是空闲的。还有一种记录方式叫 指针碰撞 ,假定 Java 堆中的内存是绝对规整的连续的(这显然很难做到,需要 GC 做 压缩整理)。在这条十分规整的,十分长的堆内存空间上,有一个指针,左右两侧分别是空闲区间和已使用空间,如果有空间需要被申请或者释放,指针就左右移动。就好像温度计,水银好似已使用空间,上方空闲部分就是空闲空间,当温度达到 100 度,到了温度计的量程,就会炸了(出现 OutOfMemoryError 异常)。

原子操作

为了保证内存在使用的时候是 线程安全的 ,需要采用一些机制。第一种就是CAS 机制,这是一种乐观锁机制,再加上失败重试,可以保证操作的原子性。还有一种就是 本地线程分配缓冲,把内存的动作按照线程划分在不同的空间上进行,即每个线程在 Java 堆中预想分配一小块内存供自己使用,让 Java 堆的共享强制编程线程私有。

对象设置

接下来,虚拟机要对对象头进行必要的设置,例如这个对象是哪个类的实例、如何才能找到 <mark> 类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息都存放在对象的对象头之中。</mark> 完成上述操作,一个对象在虚拟机的层面已经完成了,但是在代码层面还需要设置初始值,按照程序员的意愿选择不同的构造函数,传入不同的参数进行初始化。

对象的内存分布

在 HotSpot 的虚拟机中,对象在内存中储存的布局可以分为 3 块区域:<mark> 对象头、实例数据、对齐填充。</mark>

HotStop 虚拟的对象头包含两部分信息,第一部分用于储存对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 II、偏向时间戳。官方叫这部分是 Mark Word,这部分虽然在对空间上,但是这部分会根据对象的状态服用自己的储存空间。除了储存自身状态外,还有一部分内容叫 类型指针,即指向它的类元数组的指针,虚拟机通过这个指针来确定这个给对象是哪个类的实例。另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录组长度的数据。

接下了就是实例数据部分,即真实储存的有效信息,也就是程序代码中所定义的各种类型的字段内容。包含从弗雷继承的,和子类定义的。HotSpot 虚拟机默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops,从分配策略中可以看出,相同宽度的字段总是被安排在一起。在满足这个前提条年间的情况下,在 父类中定义的变量会出现在子类之前

第三部分就是对齐填充,没有什么特别的意义,就是个占位符。由于对象的大小必须是 8 字节的整数倍,由于对象头部分正好是 8 字节的倍数,实例数据不一定是,所以就需要填充一下。

对象的访问定位

我们都知道真正的对象实在堆上,但是我们操作对象使用的是引用,在虚拟机栈上的引用是如何访问对上的数据呢?主流的有两种方式。

句柄

Java 堆中将会划分出一块内存来作为句柄池,reference 中储存的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针

Java 堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,而 reference 中储存的直接就是对象地址。

这两种方式的优缺点就好像数组和链表一样,一个访问速度快,一个操作快。毕竟世界是公平的,省功不省力,省力不省功。句柄访问的最大优点就是 reference 中储存的是稳定的句柄地址,在对象被移动时指挥改变句柄中的实例数据指针,而 reference 本身不需要修改。所以修改数据特别快。

相应的直接指针访问最大的优势就是访问对象本身更快,毕竟少了一次指针的地址定位。HotShot 最主要就是采用这种方式访问对象。

一些补充

大佬在本章还进行了抛 OutOfMemoryError 异常的实战,内容较长,还是看书讲的更清楚些。更主要的是,我觉得实战这种东西不能只看,具体问题还得具体分析,等遇到的多了,自然解决起来就会得心应手。不过这部分内容有一些值得记录的知识点。

  1. 一般来说,栈深度(比如递归)达到 1000~2000 是没有问题的,所以我们写代码的时候一定要注意栈的深度,不要过深,但也要充分使用递归这种用空间省时间的方式。
  2. JDK1.6~JDK1.8 常量池的位置变动,导致一些方法展现出来的现象不同。例如 String.intern()方法,在 1.6 时代,intern()方法会将首次遇到的字符串实例复制到永久代中,返回永久代中这个字符串实例的引用。而 1.7 的 intern()方法不会复制实例,只是在常量池中记录首次出现的实例引用。
  3. 动态代理(例如 CGLib)是对类的一种增强,增强的类越多,就需要更大的内存来保存这些数据。
  4. 还有种动态生成就是 JSP(虽然现在大多数都是前后端分离,不用这个了),JSP 第一次运行需要编译成 Servlet,也需要产生大量的空间。值得一提的是,原来我在上家公司,有个系统是 JDK1.7,当时 JSP 编译出来的东西还存放在方法堆中,当时可能设置的堆内存不大,本地跑一天,每次打开 JSP 页面,电脑都会卡顿一下(当然机子差也是原因之一),普通的 Java 文件就没事,我想是不是也是这个原因呢。另外对于同一个文件,不同的加载器加载也会视为不同的类。

结束

感觉每次看 JVM 这块内容都会有新的体会。JVM 作为 Java 运行的基石,是每一个 Javaer 都需要了解的。和很多面试 JVM 总结内容相比,看本文确实是浪费时间,但我还是想记录下看书的感受,为了将来回忆起看书时灵光一现的小想法留个笔记吧。这本书真的不错,如果想了解 JVM 的小伙伴还是买来看一看吧。我一直觉得,从长远来看,比起看博客看视频,看书是效益最高的方式,毕竟伴随者大量的思考。

正文完
 0