深入理解Java虚拟机-2-Java内存区域及异常

Java 与 C++ 之间有一堵由内存动态分配和垃圾回收技术所围成的高墙,墙外的人想进去,墙里的人想出来。

从大二开始学习 Java ,到现在工作的第四个年头,早已经习惯了 Java 的开发模式——想要对象就 new 一个,内存的分配和溢出从来没放在心上。前两天在 ESP8266 上写代码,开发板的 flash 只有 4M 内存,每行代码都要思前想后,申请释放内存的工作让人精疲力竭。C 语言考验技术, Java 用起来省心。

运行时数据区域

  • 方法区(Method Area)-线程共享
  • 堆(Heap) -线程共享
  • 虚拟机栈(VM Stack)
  • 本地方法栈(Native Method Stack)
  • 程序计数器(Program Counter Register)

程序计数器

一块较小的内存空间,当前线程执行字节码指令的行号指示器,字节码指示器通过改变计数器的值获得下一条将要执行的指令,实现分支、循环、异常处理等功能。

Java虚拟机的多线程是通过线程轮换、分配处理器执行时间来实现的,同一时刻只有一个线程运行,为了使线程处理器执行时间结束后能回到正确的位置,每个线程都需要一个独立的计数器(线程私有)。类似于体能测试跑800米1000米一样,不跑的几个人分别盯着跑的那几个人,每个旁边的人记着自己的盯的人是第几个到终点,最后再和老师的计时器对比得到完成时间。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

Java 虚拟机栈

Java 虚拟机栈也是线程私有,每个方法执行的时候,虚拟机都会创建一个栈,用来存放这个方法相关的临时变量,操作数等。在 IDEA IDE 中,调试模式下,控制台左侧会有一个 Frame 的区域,展示流程中的方法栈。

栈主要用来存放基本数据类型及对象引用(地址)。

  1. boolean
  2. byte
  3. char
  4. short
  5. int
  6. float
  7. double
  8. long

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈类似,不过本地方法栈是为本地方法(Native Method)服务的,虚拟机栈是执行字节码服务的。

编译期与运行期

编译期:

指把源码交给编译器生成计算机可执行文件的过程。在 Java 中即把 .java 文件编译成 .class 文件的过程。编译期只是做了一些翻译功能,并没有把代码放在内存中运行起来,而只是把代码当成文本进行操作,比如检查错误。

运行期:

是把编译后的文件交给计算机执行,直到程序运行结束。所谓运行期就把在磁盘中的代码放到内存中执行起来,在Java中把磁盘中的代码放到内存中就是类加载过程,类加载是运行期的开始部分。

编译期分配内存并不是说在编译期就把程序所需要的空间在内存中分配好,而是说在编译期生成的代码中产生一些指令,在运行代码时通过这些指令把程序所需的内存分配好。只不过在编译期的时候就知道分配的大小,并且知道这些内存的位置

而运行期分配内存是指只有在运行期才确定内存的大小、存放的位置。

参考:编译期与运行期

堆由所有线程共享,几乎所有的 Java 对象和数组都在这里分配内存,所以该区域也是垃圾回收的主要区域。

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数 -Xmx-Xms 设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出 OutOfMemoryError 异常。

方法区

方法区和堆一样,是各个线程共享的内存区域,用于存储被虚拟机加载的类型信息、常量、静态变量等数据。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的 确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生 成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

深入理解 intern() 方法:Java intern() 方法

HotSpot 虚拟机对象探秘

对象的创建

当虚拟机遇到一条 new 指令时,先检查这个指令能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过,如果有,则分配内存,否则会先加载这个类。

为对象分配内存时,如果堆中的内存是规整的(使用过的与未使用的内存区域是分开的),则在未使用的区域中划分一块确定大小的内存区域(指针移动所需分配内存大小的距离);如果内存区域使用过的和空闲的未分开,则需要维护一个列表来管理未划分和已划分的内存。

Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Comp act)的能力决定。

分配内存时,还需要考虑并发的问题,可能会出现分配内存的过程中又将此内存区域分配给了另一个对象的问题。一种解决办法是对分配内存的操作进行同步处理,另一种方法是为每个线程在堆中预先分配一块内存,即本地线程分配缓冲(TLAB)。虚拟机是否使用 TLAB ,可以通过参数:**-XX:+/-UseTLAB** 设置。

内存分配完成之后需要对对象进行初始化,将分配到的除对象头信息外的内存空间初始化为零值。

还需要对对象进行必要的设置(元数据信息、对象哈希码、所属类等)。

✅ 内存分配已经全部完成,在内存中已经产生一个新对象了,后面还需要根据构造函数来对这个对象进行初始化,执行完构造函数之后,这个对象就完整了。