内存模型
HotSpot JVM 架构

由于 Java 的内存管理是由 JVM 自己实现的, Java 程序员编写代码时无需关注内存的使用情况,避免了将内存管理逻辑实现在代码中,能更好地描述业务,减少代码的冗余。但如果编写代码时对于 JVM 如何管理内存没有了解,一旦出现问题则无法定位修复问题。
内存布局
根据 JVM 规范,在运行时,内存会被分为以下几个数据区域:

程序计数器(Program Counter Register)
程序计数器(Program Counter Register)用来指示程序下一次的执行位置,程序的控制流实现依赖于它。由于 JVM 的多线程是在多个线程间轮流切换、分配处理器的执行时间来实现的,所以每个线程需要私有程序计数器,以方便在线程切换后恢复自己的运行位置。
程序计数器会记录 Java 方法的虚拟机字节码指令的地址,但执行到本地(Native) 方法时,该处会为空(undefined)。并且该区域是 JVM 规范中 唯一没有规定任何 OutOfMemoryError情况的区域。
虚拟机栈(VM Stack)

虚拟机栈(VM Stack)是描述方法执行的内存模型,每个方法被执行的时候都会同步创建一个栈帧(Stack Frame),用于存储局部变量表(方法内定义的局部变量)、操作数栈(方法内对变量的操作)、动态链接(调用别的方法)、方法出口(返回值)等信息。每个方法的执行过程对应一个栈帧的入栈和出栈的过程。当线程执行完毕时,意味着没有方法需要执行了,所以虚拟机栈也销毁了。
按照 C/C++ 的划分,内存区域被笼统地划分为 栈 和 堆 两部分,在 JVM 中,这个栈通常指的就是虚拟机栈,或者更多情况下,指的是虚拟机栈中存储的局部变量表。
在 JVM 规范中,这个内存区域会产生两类异常状况:
- 如果线程请求的栈的深度大于虚拟机所允许的深度,会产生 StackOverflowError 异常;
- 如果 JVM 的容量可以动态扩展,而栈扩展时无法申请足够的内存会抛出 OutOfMemoryError 异常;
可以使用 -Xss 调整栈内存空间;
局部变量表
存放了 编译期间 得知的 JVM 基本类型(boolean、byte、int、……)、对象引用(reference 类型,或者说指向对象的指针)和 returnAddress 类型(指向一条字节码指令的地址)。这些数据类型的存储空间单位用局部变量槽(Slot)来表示,64 位长度的 long 和 double 类型占用两个 Slot ,其余变量只占用一个。局部变量表的空间在编译期就设定好了,因为方法运行期间不会再创建新的局部变量。
具体一个 Slot 有多大取决于 JVM 的具体实现。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈的作用类似,区别只是一个是存放 Java 方法的数据,一个是存放本地方法的数据。该内存区域会抛出的错误和虚拟机栈的一样;
Java 堆
Java 堆往往是虚拟机所管理的内存中最大的一块,该区域被所有线程共享。此区域的唯一目的就是存放对象的实例,几乎所有的对象都在这里分配内存空间。Java 堆不需要连续的内存,
该区域的大小可以被实现为固定的,也可以实现为可拓展的,主流虚拟机都是实现为可拓展的(-Xmx 指定堆的最大值,-Xms 指定堆的初始大小),当堆的空间不足时,会抛出 OutOfMemoryError 异常。
Java 堆是垃圾收集器所管理的内存区域,所以也叫 GC 堆 (Garbage Collected Heap) ,目前大多的垃圾回收器都是基于分代收集理论实现的,所以 Java 堆中经常会出现 新生代 、 老年代 、 永久代 、 Eden 、 Survivor 等划分。这种划分是目前大多数垃圾收集器所规定的,而 JVM 规范中并没有规定这些划分。实际上,一些新兴的垃圾收集器技术并不是按照经典分代的理论实现的。
新生代使用 -Xmn 参数指定内存大小;
Eden 区与 Survivor 区的比例使用 -XX:SurvivorRatio 指定;
晋升老年代对象大小 -XX:PretenureSizeThreshold 指定;
方法区
方法区(Method Area) 和 Java 堆一样是线程共享的内存区域,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区是 JVM 规范中声明的一块内存区域,具体的实现在各家 JVM 厂商的产品中各有不同,在 HotSpot JVM 的 JDK 6 版本中,方法区又被叫做 永久代 ,并将垃圾收集器的分代设计扩展到方法区中,省去了专门为方法区编写内存管理的工作。但这种设计使得 JVM 更容易发生 内存溢出 ,因为永久代有 -XX:MaxPermSize 的上限,如果不设置有默认值,而其他 JVM 厂商使用 本地内存 的设计,只要没有达到进程的可用内存上限都可以继续使用,例如 32 位操作系统为 4GB ,都不会发生内存溢出。这种不一致导致 HotSpot 在吸收其他 JVM 的优点时产生了麻烦。在后续的 JDK 8 版本中,HotSpot JVM 也放弃了永久代,而采用 元空间 (本地内存)来实现 JVM 的方法区。
运行时常量池
运行时常量池(COnstant Pool Table)是方法区的一部分,用于存放编译期生成的各种 字面量 (常量)和 符号引用 ,这些内容将在类加载后存放到运行时常量中。
不同于其他区域,这个区域 JVM 规范并没有提出任何细节上的要求。 Java 虚拟机对于 Class 文件的每个部分都有严格的要求,比如每个字节用于存放那种数据都必须符合 JVM 规范才能被加载和执行。
一般说来,除了保存 Class 文件的符号引用外,常量池还会存放从符号引用翻译过来的直接引用。
运行时常量池相对于 Class 文件常量池,还有 动态性 ,并不是预先放入 Class 文件中的常量才会被放入运行时常量池中,运行期间也可以将新的常量放入池中,比如 String.intern() 方法。
直接内存
直接内存(Direct Memory)不是 JVM 运行时的数据区域之一,也没有被 JVM 规范定义。但是在编码中会频繁使用,比如 JDK1.4 后加入的 NIO 类,这种基于通道(channel)和缓冲区(buffer)的 IO 类,可以使用 Native 方法直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样避免了在 Java 堆和 Native 堆间来回复制数据。
直接内存属于物理机本地的内存区域,通过 -Xmx 参数配置只能设置 Java 堆内存,因此在做虚拟机参数配置时,需要考虑到直接内存,如果直接将堆内存设置和物理机内存一致,就会突破物理机内存限制。比如说, 32 位计算机能够为一个进程分配的内存只有 2GB (根据位长计算得来,一台计算机不是所有的内存都可以用来跑一个进程,计算机可以运行多个进程),假设 Java 堆已经分配了 1.6 GB 那么剩下的 0.4 GB 就是直接内存使用的空间。这 0.4 GB 就是直接内存,也被叫做堆外内存。
对象的创建
这里的对象指的是普通的 Java 对象,不包括数组和 Class 对象。当 JVM 遇到一个对象的 new 指令时,首先会去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并检查该类是否被加载、解析和初始化过。如果没有,则首先要执行类加载过程。
类加载检查通过之后, JVM 将为对象 分配内存 。分配内存有两种方式:
- 指针碰撞(Bump The Pointer),当 Java 堆是规整的时,就像进度条一样,一边是空闲的空间,一边是被占用的空间,为对象分配内存时,就是将分界指针向空闲一边移动;
- 空闲列表(Free List),当 Java 堆是不规整的, JVM 需要维护一张表格记录哪块内存是可用的,在分配空间的时候从列表中查出一块足够大的空间分配给对象;
而 Java 堆是否规整是取决于垃圾收集器是否带有空间压缩整理(Compact)功能决定的,当使用 Serial 、 ParNew 等收集器时,系统采用指针碰撞的分配方法。当使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存(实际上为了能够更快地分配内存, CMS 设计了一个叫做 Linear Allocation Buffer 的缓冲区,通过空闲列表拿到一大块空间分配缓冲区后,在缓冲区内仍可以使用指针碰撞的方式分配内存;
另外,在并发场景中分配内存还会面临线程不安全的问题,有两种解决方法:
- 使用 CAS 失败重试的机制保证更新操作的原子性;
- 在每个线程中都分配缓冲区,称为 本地线程缓冲区 (Thread Local Allocation Buffer, TLAB),每个线程先在自己的本地缓冲区分配内存,只有在本地缓冲区用完了,需要分配新的内存时才需要同步锁定;可以通过 -XX:+/-UseTLAB 参数来设定是否使用 TLAB 机制;
CAS(Compare And Swap):比较并替换,CAS 机制定义了三个基本的操作数:内存地址 V ,预期值 A ,要修改成值 B 。 当更新一个变量时,只有当地址 V 的变量为预期值 A 才将地址 V 中的值替换为值 B 。 考虑两个线程都在更新某个变量的场景:
- 线程 1 想要将 V 出的值 10 增加 1 ,此时对于地址 V 变量的预期值为 10 ,要更新为 11;
- 线程 1 执行更新操作时, CPU 被线程 2 抢占,地址 V 被抢先一步更新为 11 ;
- 线程 1 开始执行更新操作时,比较地址 V 的值发现和预期值 10 不一样,更新失败;
- 线程 1 重新获取地址 V 的值 11 ,要更新为新值 12 ,这个重试的操作被称为 自旋;
- 这次线程 1 没有被抢占,比较地址 V 的值发现和预期值 11 相同,进行交换操作,将地址 V 的值替换为 12;
CAS 对于并发程度的估计是乐观的,所以让线程不断地重试之前的操作,分类上叫 乐观锁。 而 Synchronized 属于 悲观锁 ,对并发程度的估计是悲观的,所以对资源的访问严防死守。
在 Java 中 Atomic 系列类和 Lock 系列类的底层实现都是使用 CAS 机制实现的。
内存分配完成后, JVM 将分配的空间都 初始化为零值 ,如果使用 TLAB 的话,这项工作可以提前至 TLAB 分配时进行。这是为了保证对象的实例字段可以不用赋初始值就可以直接使用。
然后, JVM 开始 设置对象头 ,对象头包含了对象的一些元数据信息。
经历上面的步骤, 从 JVM 的视角看来,一个对象已经创建完成了,然而这时尚未进入 Java 程序的构造函数,即 Class 文件中的 <init>() 方法。 JVM 编译器在程序中遇到 new 关键字的地方同时生成 new 指令和 invokespecial 指令,new 指令后会接着执行 <init>() 方法。这时一个对象才真正被构造出来。
下面的代码片段来自于 Hotspot 的字节码解释器,可以用来描述前面所说的对象创建过程:

对象的内存布局
对象在堆内存的存储分布如下:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头
对象头部分包含两类信息:
- 对象的运行时数据(Mark Word),哈希码、 GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 、偏向时间戳等;
- 类型指针,指向对象的类型元数据, JVM 通过该指针确定对象的类型。(该信息不是所有虚拟机实现都有,因为查找对象的元数据信息不一定要经过对象本身)