一、概述
用c或者c++写过算法的人都该知道,对于内存管理区域,需要手动设置和管理,即拥有每个对象的所有权,但也背负着每个对象生命的开始和结束。但是在java中,就不需要这么复杂的操作了,在虚拟机自动内存的管理机制下,不需要特意的去管理对象的 ‘生’ 和 ‘死’ 了,也因虚拟机的存在,不容易出现内存泄露和溢出问题。但是正是因为虚拟机帮java程序员解决了内存管理的问题,因此需要去学习 jvm 是如何操作的,这样一旦在出现内存泄露的问题的时候,将可以快速的排查错误和解决问题
二、运行时数据区
如上图所示,java虚拟机在执行java程序的过程中会将它管理的内存划分为若干个不同的数据区域,不同的区域有着不同的用途,根据《java虚拟机规范》的规定,java虚拟机所管理的内存包括上图上面的几个运行时数据区域
1,程序计数器
程序计数器:Program Counter Register,指的是一块较小的内存空间,可以当做是当前线程所执行的字节码的行号暗示器,主要是通过改变这个计数器的值选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都是需要这个计数器来完成。一个线程只会执行一条程序中的命令,每条线程都会有一个独立的程序计数器。
2,java虚拟机栈
属于线程私有,即它的生命周期与线程的相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在被执行的时候,都会创建一个栈帧,用于存储局部变量表,操作数栈,动态连接,方法出口等。主要用于存放局部变量,如java虚拟机的基本数据类型和对象应用类型。局部变量表所需的空间在编译期间完成分配,因此在进入一个方法时,这个方法所需要的栈帧是确定的,在方法运行期间不会改变局部变量表的大小
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果java虚拟机可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError
3,本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别是虚拟机栈为虚拟机执行的java方法服务,而本地方法则是为虚拟机使用到本地方法服务,与虚拟机栈一样,当栈扩展失败或者栈深度溢出时分别会抛出OutOfMemoryError异常和StackOverflowError异常
4,java堆
java堆是被所有线程共享的一块区域,在虚拟机启动时创建,此区域的唯一目的是存放对象的实例,在java中,“几乎” 所有的实例都是这分配内存。如 A a = new A(),new A()存储的地点就是在堆里面的,而a是存储在栈里面的。
根据《java虚拟机规范》的规定,java堆可以处于物理上不连续的内存空间,但在逻辑上他应该是被视为连续的,这点就像磁盘空间去存储文件一样,并不要求每个文件都连续存放。
java堆也可以根据参数(-Xmx和-Xms)来实现扩展,如果在java堆中没有内存完成实例分配,并且对也无法在进行扩展时,java虚拟机将会抛出OutOfMemoryError异常
5,方法区
各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在《java虚拟机规范》中对方法区的约束是非常宽松的,除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集;并根据规定,如果方法区中无法满足内存分配的需求时,将抛出OutOfMemoryError异常
6,运行时常量池
该常量池也属于方法区的一部分,用于存放编译器生成的各种字面量与符号应用,这部分内容将在类加载后存放到方法区的运行常量池中,如一些静态的变量或者属性。因此在static 的变量或者属性中,这些都是随着类的加载而加载的,并且这些属性或者变量即存在常量池中,所以有一些很坑的面试题,问你 String str = new String(“abc”) 该对象可能创建了几次,因此就是可能在静态常量池中创建了一次,又在堆中创建了一次,或者只在堆中创建了一次,所以答案是一次或者两次,这取决于在常量池中是否创建过。
运行时常量池相对于Class文件常量池的另外一个重要的特征就是具备动态性,java语言并不要求一定只有在编译期才能产生,也就是说,运行期间也能将新的常量放入到常量池中。常量池属于方法区的一部分,所以如果常量池中无法满足内存分配的需求时,将抛出OutOfMemoryError异常
三,对象的创建
java是一门面向对象的语言,java程序中无时无刻都有程序被创建出来。当java虚拟机在遇到一条字节码为new的指令的时候,首先会去检查这个指令的参数是否能在常量池中去定位到一个类的符号引用,并检查这个符号引用代表的类是否被加载、解析和初始化,如果没有,则必须执行相应的类加载过程。
在类加载通过之后,接下来虚拟机将会为新生对象分配内存,对象所需的内存大小在类加载之后便可完全确定,为对象分配空间的任务实际上就是将一块确定大小的内存从java堆中划分出来。主要方式有两种,分别是 指针碰撞 和 空闲列表
指针碰撞:假设堆中内存是绝对规整的,所有使用过的内存都被放在一边,空闲的放在另外一边,之间放着一个指针作为分界点的指示器,分配内存的方式就是把那个指针向空闲的一方挪动一段与对象大小相等的距离
空闲列表:假设堆中内存并不规整,那么虚拟机需要维护一张表,记录哪些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表的记录
区别:选择哪种方式由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有 空间压缩整理 的能力决定
当然以上是在单线程的情况下操作,如果在并发情况下,线程也是不安全的,解决这个问题有两种方案,一种是CAS来保证更新操作的原子性,另一种就是将内存分配的动作按照线程划分在不同的空间进行,即每个线程在java堆中预先分配内存,称为本地线程分配缓冲
在以上工作都完成之后,从虚拟机的视角来看,一个新的对象以及产生了,但是从java程序的视角来看,对象创建才刚刚开始——构造函数,即Class文件的()方法还没有开始执行。因此在java编译器遇到new关键字的地方会同时生成这两条字节码指令,new指令后会接着执行()方法,这样一个真正的可用的对象才被构建出来
四,对象的访问定位
创建对象自然就是为了后续的使用该对象,主流的方式主要有 句柄 和 直接指针 等两种方式
图一使用的为句柄访问,即在java堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄包含了对象实例数据域类型数据各自具体的地址信息
图二为使用直接指针访问,Java堆中对象的内存布局就必须考虑放置访问数据类型的相关信息,reference中存储的对象地址就是直接地址,如果就是访问对象本身的话,就不需要一次间接访问的开销
优缺点:
使用句柄访问时带来的最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动的时候(垃圾回收而移动)只会改变句柄中的实例数据指针,而reference本身不需要改变。但是会很废内存,增加内存的开销和访问的时间开销
使用直接指针来访问最大的好处就是速度更快,他节省了一次指针单位的时间开销和内存的开销,由于对象在java中访问非常的频繁,因此这类开销积少成多也是一项极为客观的执行成本。但是就是不稳定,随着对象的移动而需要重新定位
因此从整个软件的开发范围来看,各种语言和框架还是更偏向使用句柄访问
参考书籍:《深入理解Java虚拟机》–周志明版