运行时数据区域

最近看到一本好书,叫《深入理解Java虚拟机》,发现看书真妙啊,知识都串在一起了。之前看面经背书,真的是太容易忘记了,知识又很零散,书本则不同,它是成体系的!

下面来看看JVM运行时的数据区域。
image20210715223551031.png

这个图是书本上的插图,我照搬画下来了,但是感觉有点“不大形象”。从大小上看,堆占的空间是最大的,程序计数器占的空间是最小的,画出来却……以后有再深入的理解时再重新画吧。

程序计数器

程序计数器(program counter register)是一块很小的内存空间,它是线程独有的,可以看作是当前线程所执行的字节码的行号指示器。

大白话地说,就是当前线程执行到哪行代码了,它就指向哪行(地址形式?),做完这行工作,就继续取需要执行的下一条字节码指令。

在多线程程序中,它是每个线程独有的,当切换到别的线程的时候,需要保存当前线程执行到的位置,以便他们不会相互影响。

另外,如果在执行native方法,则它的值为空。

是唯一一个在JVM规范中没有OutOfMemoryError的区域

Java虚拟机栈

它与PC一样,也是线程独有的,生命周期与线程相同。为什么与线程关系这么密切呢?

因为在每个Java方法执行的时候,会创建一个栈帧,栈帧中存储了局部变量表、操作数表、动态链接、方法出口等信息。每个方法从调用直到执行完成的过程,就对应着一个栈帧在JVM栈中入栈到出栈的过程。

局部变量表中,存放了编译期可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double)和对象引用(reference类型,不同于对象本身)和returnAddress类型(指向一条字节码指令的地址)。

局部变量表需要的内存空间大小也是确定的,除了long和double占用两个**局部变量空间(Slot)**外,其它都是占用一个。

这里会出现的异常有两个:当线程请求的栈深度大于虚拟机所允许的深度,就会出现StackOverflowError;若虚拟机栈可以动态扩展,当无法申请足够内存时,会抛出OutOfMemoryError

本地方法栈

这个和Java虚拟机差不多的,只不过它运行的方法是本地方法(native

是JVM管理的最大的一块内存空间。

被所有线程共享,在JVM启动的时候创建,这块区域的唯一目的是存放对象实例

按照我现在掌握到的知识,可以认为所有的对象实例以及数组都要在堆上分配。这话应该也没错,但不能说得太绝对,毕竟技术发展得这么快,说不定就出了哪些优化、哪些特殊情况的……

它也是GC管理的主要区域,从内存回收的角度上,按照分代收集算法的思想,堆可以被细分成新生代和老年代,更细致的就是Eden、From survivor、To survivor空间等。无论哪个区域,存储的都是对象实例,进行这种划分的目的是为了更好的回收内存,或者更快地分配内存。

方法区

方法区和堆一样,是各个线程共享的内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然JVM规范把方法区描述成堆的一个逻辑部分,但它却有一个叫非堆的别名,目的是与堆分开。

这里有一个容易与方法区混淆的名词:永久代。它与方法区并不等价。JVM规范中,是有一块内存叫方法区的东西,而永久代属于方法区的一个实现细节。在JDK1.7的hotspot中,已经把原来要永久代的字符串常量池移出,而且有种要去除永久代的感觉 ?

对于GC,在这块是比较少的,因为这里存放的东西几乎是不变的,就像永久代的名字一样“永久”了,但仍然会有OutOfMemoryError

运行时常量池

它是方法区的一部分。

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一块信息是常量池,存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存储。

简单地想就是存不变的东西的!

接触到的,就是String的intern()方法,判断字符串对象是否在常量池中,若在则返回,若不在则把它加入字符串常量池中。

对于异常,同方法区,OOM。

直接内存

就是本机的内存了,不是Java程序想着的。

它只受本机总内存的大小以及CPU寻址空间的限制 。

它是通过native库函数直接分配堆外内存,NIO,然后在堆中与之操作。

HotSpot虚拟机对象探秘

对象的创建

当JVM遇到一个new指令时。

首先,会检查这个指令的参数是否能在常量池中找到这个类的符号引用 ,并检查这个符号引用所代表的类是否被加载、解析和初始化。简单地说就是看看是不是曾经创建过了,如果没有,就要执行相应的类加载过程

检查通过后,会为对象分配内存,就是把一块确定大小的内存从Java堆中划分出来。它根据Java堆中内存是否规整来分配空间:若规整,则使用“指针碰撞”的方式分配,即把指向空闲空间的指针移动一段与对象大小相等的距离;若不规整,则使用“空闲列表”,找到一块足够大的空间划分给对象实例。

上面说的“规整”,其实是看GC是否有压缩整理的功能。比如CMS这种标记清理的,按区域一块一块地清,肯定就是空闲列表了。

这里的分配空间,还要考虑到多线程的问题,因为创建对象是一个频繁的行为,并发情况下可能会出现些问题。一般有两种解决方案:对分配内存这个动作采用同步处理——采用CAS配合失败重试的方式保证更新操作的原子性;另一种是让每个线程先在堆中预留一小块内存(本地线程分配缓冲TLAB),哪个线程需要分配内存,就在它的TLAB上分配,当TLAB用完之后,再同步分配。

内存分配完成后,需要把分配到的内存空间初始化为零值(不包括对象头),它保证了对象的实例字段在Java代码中可以不赋值就直接使用。若是TLAB形式的,这步甚至可以提前做。

接下来,还要对对象进行必要的设置,就是上面说的对象头(Object Header)。比如这个对象是哪个类的实例、如何才能找到这个类的元信息、对象的哈希码、GC分代年龄等等……

从JVM的视角看,一个新的对象已经产生了。但是从Java程序员来看,对象的创建才刚刚开始:<init>方法还没有执行,所有字段都还是0。一般来说,在执行new之后,还会执行<init> 方法,按照程序员的意愿进行初始化,这样就是一个真正可用的对象。

对象的内存布局

对象在内存中可以分为三块区域:对象头、实例数据和对齐填充。

对象头里面存放两部分信息:

  • 存储对象自身的运行时数据
    • 如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等。
  • 类型指针,即对象指向它的类元数据的指针
    • 通过这个指针来确定这个对象是哪个类的实例

对于实例数据,就是这个对象里面有哪些字段这些。

对齐填充是它要求对象的大小为8的整数倍,不够就填充。

对象的访问定位

句柄

在堆中有一块内存作为句柄池,存储的是对象的句柄地址,而句柄中包含了对象实例的数据与类型数据各自的地址信息。

就是从句柄池中找,某个实例的指针,再通过这个指针找真正的对象所在的区域。

这种方式的好处是,稳定的句柄地址,虽然句柄池中的地址会变,但是指向的句柄却不会变。

直接指针

就是直接指向需要的对象,减少一次指针定位的开销。