【Java杂货铺】JVM#Java高墙之内存模型

时间:2024-01-01 11:28:09

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

【Java杂货铺】JVM#Java高墙之内存模型

前言

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

出发,看看JVM大工厂

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

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

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

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

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

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

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

GC最喜欢的地方:Java堆

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

永久代的伪装:方法区

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

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

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

对象设置

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

对象的内存分布

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

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

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

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

对象的访问定位

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

句柄

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

【Java杂货铺】JVM#Java高墙之内存模型

直接指针

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

【Java杂货铺】JVM#Java高墙之内存模型

这两种方式的优缺点就好像数组和链表一样,一个访问速度快,一个操作快。毕竟世界是公平的,省功不省力,省力不省功。句柄访问的最大优点就是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的小伙伴还是买来看一看吧。我一直觉得,从长远来看,比起看博客看视频,看书是效益最高的方式,毕竟伴随者大量的思考。