Java运行时环境---内存划分

时间:2022-03-10 00:45:30

背景:听说Java运行时环境的内存划分是挺进BAT的必经之路。

内存划分

Java程序内存的划分是交由JVM执行的,而不像C语言那样需要程序员自己买单(C语言需要程序员为每一个new操作去配对delete/free代码),放权给JVM虚拟机处理有利也有弊,好处是不容易出现内存泄漏和内存溢出问题,坏处就是自己的屁股不能自己擦,万一有一天JVM罢工不释放了,还是自个忘了释放,So了解虚拟机容易引起内存泄漏和溢出的场景对Java程序员来说还是必不可少的。【内存溢出:Out Of Memmory,系统已经不能再分配空间了,好比你需要50M的空间,系统就只剩下40M;内存泄露:Memmory Leak,开辟了资源空间但用完后忘记释放,内存还在被占用,多次内存泄漏就会导致内存溢出;】了解JVM内存划分要端到端,先从Java程序执行的具体过程来看:

Java运行时环境---内存划分

从图1中可以清楚看到Java程序的执行过程,大致就是Java源代码(后缀为.java)首先被Java编译器编译成字节码文件(后缀为.class),然后交由JVM中的类加载器加载各个类的字节码文件,加载好字节码文件后再交由JVM引擎执行。在整个程序执行过程中,上图中运行时区域会用一段空间来存储程序执行期间需要用到的数据和相关信息,也就是我们弄懂内存划分要深度研究的区域,即JVM。

运行时数据(内存)区:

Java运行时环境---内存划分

图2中,梯形形状的部分是所有线程之间共享的区域,长方形形状的部分则是线程运行时独有的数据区域;《Java虚拟机规范》规定了运行时区域包括:程序计算器、Java栈(虚拟机线)、本地方法栈、方法区和堆五大部分。

1、程序计算器(Program Counter Register)

程序计算器又称为PC寄存器,这块内存区域相当小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计算器的值来选取下一条需要执行的字节码命令;在JVM中多线程是通过线程轮流切换来获得CPU执行时间的,So无论何种情况下一个CPU的内核只会执行一条线程中的指令,而为了保证每个线程都在线程切换后能够恢复到切换之前的程序执行位置,每个线程就必须要有自己独立的程序计数器,因此程序计数器是每个线程私有的;在JVM中,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址,而线程若是执行native方法则程序计数器中的值是undifined;因为程序计数器中存储的数据所占内存空间的多少不会随这程序的执行而改变,于是程序计数器不可能发生内存溢出OOM现象;

特性:

a. 是当前线程所执行的字节码的行号指示器;

b. 是当前线程私有的;

c. 不会发生OOMError的错误;

2、Java栈(虚拟机Java stack)

简单的说Java栈就是Java方法执行的内存模型。其内部存放的是一个个的栈帧,每个栈帧对应着一个被调用的方法;栈帧中包括局部变量表(Local Variables)、操作数栈(Operation Stack)、指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息;由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的互不干扰的Java栈;当当前线程执行了一个方法,随之就会创建一个与之对应的栈帧,并将建立的栈帧进行压栈操作,当方法执行完毕后便会将栈帧出栈;So线程当前执行的方法所对应的栈帧必定位于Java栈的顶部,即如队列的先进后出。下图表示了一个Java栈的模型:

Java运行时环境---内存划分

局部变量:

局部变量表就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参);若变量是基本数据类型则直接存储它的值,若变量是引用类型则存储的是指向对象的引用;而局部变量表的大小在编译的时候就已经确定了,So程序执行期间其大小亦是不会改变的。

操作数栈:

操作数栈就是用于对表达式求值计算的,当个线程执行过程实际上就是不断执行语句的过程,也就是不断计算的过程,So程序中的所有计算过程都是通过操作数栈来完成的。

指向运行时常量池的引用:

指向运行时常量池的引用是由于在方法的执行过程中有可能需要用到类中的常量,因此必须要有一个引用指向运行时常量。

方法返回地址:

一个方法执行完毕后,要返回之前调用它的位置,于是在栈中就必须保存一个方法返回的地址。

Java栈的生命周期和线程相似;在每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、指向运行时常量池的引用和返回地址(方法出口)等信息,每个方法从调用到执行完毕的过程都对应着一个栈帧JVM中入栈到出栈到过程。(栈的大小与具体虚拟机的实现有关,一般在256~756之间)

特性:

a. 生命周期与线程相似且线程属于私有;

b. 当线程请求的栈深度超过了JVM所允许的最大深度就会发生*Error异常;

c. 若是栈的扩展无法申请到足够的内存则会产生OutOfMemmoryError异常;

3、本地方法栈(Native Method Stack)

本地方法栈的原理和作用与Java栈类似,不同的是Java栈是为执行Java方法服务的,而本地方法栈是为执行本地方法服务的。

4、堆(Heap)

Java中的堆事要来存储对象本身以及数组(数组引用存储在Java栈中),Heap事JVM所管理的内存中最大的一块,它在虚拟机启动事创建且在JVM中只有一个堆;由于JVM垃圾收集器采用的基本都是分代收集算法,So堆还可以划分为Young Generation(年轻代)、Old Generation(年老代)以及Perm Generation(永久代)【JDK7之后,Hotspot虚拟机便将永久代这个概念移除了】,其中的Young Generation又分为Eden、From和To,而From和To又统称为Survivor Spaces(幸存区);正常情况下,一个对象从创建到销毁,应该是从Eden开始,然后到Survivor Spaces,再到Old Generation,最后在某次GC下消失。

特性:

a. 堆是被所有线程共享的,且JVM中只有一个堆;

b. 存储对象实例;

c. 当在堆中没有完成实例的分配且无法再扩展内存则会有OutOfMemory异常;

5、方法区(Method Area)

方法区主要用于存储每个类的信息(类的名称、方法信息、字段信息)、静态变量、常量和编译器编译后的代码等;此外,在Class文件中除了类的字段、方法和接口等描述外,还有常量池,用来存储编译期间生成的字面量和符号引用;在方法区中还有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后对应的运行时常量池就被创建出来,非Class文件常量池的内容也可以将新的常量放入运行事常量池中,如String的intern方法(如果常量池中存在当前字符串就会直接返回当前字符串,若是常量池中无此字符串则会将其放入常量池中再返回)。

在大概了解了Java运行时环境JVM内存的划分后,个人感觉要进入BAT还有必要了解下对象的创建和定位,这应该对自己写代码也有莫大的助力。

对象的创建

1、JVM接收到一条new指令后,首先会去检查这个指令的参数能否再常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化,如若没有,则会先执行类的初始化;

2、类加载检查通过后,JVM会为新生对象分配内存,而对象所需内存大小在类加载完成后便可完全确定,随后就在Java堆中划分出一块确定大小的内存为对象分配空间;

case1: 若内存是规整的,则JVM将采用指针碰撞发来为对象分配内存;指针碰撞发会将所有用过的内存放在一边,空闲的内存放置于另一边,中间放着一个指针作为分界点的指示器,分配内存的时候只需把指针向空闲内存的那边挪动一段与对象大小相等的距离;如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的机制,则虚拟机采用指针碰撞发分配内存;

case2: 若内存时不规整的,已使用的内存和未使用的内存相互交错,那么JVM采用的是空闲列表发来为对象分配内存;就是虚拟机维护了一个列表,记录下那些内存块是可用的,然后在分配内存的时候就从列表中找到一块足够大的空间划分给对象实例,并更新列表上维护的内容;若是垃圾回收器选择CMS这种基于标记-清除算法的机制,则JVM采用这种方式分配内存;

case3: 在JVM中可能会出现虚拟机正在给对象A分配内存,但指针还没有来得及修改,此时对象B又同时使用了原来的指针来分配内存的情况;为了及时保证new对象时候线程的安全性,JVM采用了CAS配上失败重试的方式保证更新更新操作的原子性和TLAB两种方式来解决这个问题;

3、分配内存结束后,JVM将分配到的内存空间都初始化为零值(不包括对象头【Object Header第一部是Mark Word用,于存储对象自身的运行时数据,第二部分是类型指针,用于确定这个对象是哪个类的实例】);这一步保证了对象的实例字段在Java代码中可以不用赋值就能够直接使用,且程序能访问到这些字段的数据类型所对应的零值;

4、对Object进行必要的设置,如该对象属于哪个类的实例、任何才能访问到类的元数据信息、对象的哈希值、对象的GC分代年龄等信息,这些信息存放在Object的对象头中;

5、执行<int>方法,把对象按照程序猿的意愿进行初始化,这样一个真正可用的对象就完完全全的产生了。

对象的访问定位

在Java中需要通过栈上的reference(引用)数据来操作堆上具体对象;比如创建一个对象 String name = new String(); ,其中new String()其实有两部分,一部分是类数据(如代表类的Class对象),另一部分则是实例数据;由于reference在JVM中只是一个指向对象new String()的引用name,并没有规定name应该通过何种方式去定位及访问Heap中对象的具体位置,So对象访问的最终方式还是由虚拟机决定的,目前主流方式有两种:

case1: 指针访问,java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,该访问方式下reference中存储的就是对象地址;

case2: 句柄访问,java堆中将会划分出一块内存作为句柄池,此访问方式reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;