JVM内存区域的划分
和C/C++开发不同,在从事JAVA的开发过程中,我们对内存区域的关注相对较轻,但是了解和掌握JAVA的内存结构会帮助我们做出合理的优化决策。首先,先大体的了解一下JAVA虚拟机运行时的内存结构:
从线程的角度来看,总体分为私有和共享的两部分。共享的数据区为方法区,堆,本地库接口,以及每个JVM虚拟机中的JVM执行引擎,而线程私有的数据区则为虚拟机栈,本地方法栈,程序计数器。
1、线程共享数据区
1.1 堆
在Java中我们最熟悉的就是对象,在内存中用来存放内存对象实例的区域称之为堆(Heap),此区域由线程内存共享,在进行垃圾回收时,此区域是垃圾回收器重点关注的地方,因此我们也称之“GC堆”。早起的Java虚拟机严格按照JVM虚拟规范来涉及:任何的对象实例及数组都要在堆上分配,但随着JIT编译器的发展,许多新生的优化技术(比如对象逃逸分析技术)可允许对象根据实际情况在栈中分配,也就是说现在的虚拟机中的对象并不一定是分配在Heap当中。
要注意堆并不一定是连续的内存空间,只要保证是逻辑上的连续就行。(那么对象在连续的物理内存空间上分配和在不连续的物理内存空间分配的区别是什么?)
可能会抛出OutOfMemoryError:当没有足够的区域实现对象实例的分配,并且该堆也没法实现扩展。
1.2 方法区
和堆相对的是方法区,用来存储已经被虚拟机加载的类信息、常量、静态变量以及经过JIT优化过后的代码等,和堆一样的是此区域也是线程共享的内存区域。
在虚拟机规范中,方法区虽然被划分为堆的一个逻辑部分,本质上并不属于堆,因此也称为非堆(Non-Heap)。
对于方法区的实现,虚拟机规范限制较少,因为不同的JVM团队可以选择不同的实现方案,比如HotSpot会用永生代来实现,而JRockit则采用了另外的方案。除此之外,方法区在物理上的内存空间可连续也可不连续、可实现动态扩展该区域的大小以及可实现或者不实现垃圾回收(对该区域的回收通常是常量池及类的卸载,但是效果往往较差,因此对该区域很少实现垃圾回收)。
可能会抛出OutOfMemoryError:当方法区无法满足内存分配需求时
1.3 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期间生成符号引用和各种字面量,其中符号引用会在连接中的解析阶段直接被替换为直接引用。需要注意的是,常量也可以在运行期间动态的被添加到常量池,以便提高效率,比如常见String中的intern()方法。
可能会抛出OutOfMemeoryError:因常量池也属于方法区,因此在申请不到内存的时候也会抛出异常。
2、线程私有数据区
2.1 虚拟机栈
当一个线程被创建时,虚拟机栈也随之被创建,该区域为线程私有,无法被其他线程使用,因此不存在线程安全问题(从一个线程的角度来看,其中的方法执行顺序都是串行;当线程销毁时,该虚拟机栈也随之被销毁,JVM会自动释放该内存区域。虚拟机栈本身是一个先进后出的数据结构。在该线程中,每执行一个方法,就会生成一个栈(Stack Frame),并被压入虚拟机栈,当该方法执行完毕之后,该栈帧会被弹出,也就是说方法调用和方法返回的过程对应着栈帧入站和出栈的操作。
可能抛出的异常:
StackOverflowError:如果线程内请求的栈深度超过虚拟机允许的深度,会抛出该异常。
OutOfMemoryError:如果虚拟机允许动态扩展栈的大小,但是在扩展时无法申请到足够的内存空间,会抛出该异常。
2.2 本地方法栈
虚拟机栈中栈帧对应的是java方法,执行的是字节码,而本地方法栈则对应的是Native方法。另外需要本地方法的实现方式并没有严格的规定,其使用的语言、数据结构没有统一的规定,对于该部分的实现,不同的团队也会有不同的实现。
可能抛出的异常:
和虚拟机栈类似,也会抛出StackOverflowError和OutOfMemoryError异常。
2.3 程序计数器
每个线程内都有一块内存区域用来记录要执行的指令的地址,也就是所说的程序计数器。它用来描述需要执行哪一条字节码指令,该区域同样为线程私有。在一个线程内,从宏观的角度去看,所有要执行的指令可视为一个指令表(如下图所示),而程序计数器可视为当前正在执行的指令的行号。在执行过程中,字节码解释器,通过修改该区域的值来选择下一条要执行的指令的地址,而执行引擎则根据该区域的值来执行相应的指令,进而实现跳转、循环、线程恢复等操作。如果执行的native方法,则该计数器的值为Undefined。
(上面两图我表示一个函数的指令集合,因为在线程内,函数的执行时串行的,同样可视为一个大的指令表)
现在我们简单的谈谈程序技术器为什么会被设计成线程私有的?如果JVM是单线程,那么程序计数器被设计成全局性的,是没有问题,因为同一时刻只能有一条指令被执行。而JVM是多线程,并且其多线程是则主要是通过处理器切换时间片来实现的。在支持多核多线程的处理器中,在同一时刻会有多个线程同时执行,而每个线程执行的指令或许都是不同的,如果此时在将程序计数器设计成全局唯一的,那显然是有问题的(程序计数器在某一个时刻,只能指向一条正在执行的指令),因此为了在线程切换能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,且不能被外部随意修改和访问。
该区域占用空间极小,虚拟机规范中没有规定该区域会抛出OutOfMemoryError异常,换言之,根本不会抛出异常。
对象的内存结构、创建以及对象查找
1. 对象的内存结构
上面简单谈了JAVA的内存结构,下面简单的说说我们常见的“对象”的内存结构。我们以Hotspot虚拟机为例,其对象的内存结构大体分为三块区域:1、对象头;2、实例数据;3、对齐填充。结构如下图所示:
1、实例数据是JAVA开发者最熟悉的部分,存储了我们在程序代码中所定义的各种类型的字段内容,是我们在开发过程中终点关注也是频繁操作的地方。
2、对齐填充区根据JVM的设计,可有可无,没有具体的功能实现,更多是为了让整个对象整齐(以倍数的形式存在),能被以统一的形式被处理。
3、对象头主要是对内服务,普通JAVA无需关心,其目的是为该对象设置附加数据,用于内容的识别和查找。该区域被划分为两部分,一部分用于存储对象自身的运行时数据,比如hashcode,锁标志,时间戳等信息,该部分也被称为Mark Word。另一部分则是类型指针1,通过该指针,JVM能够确定该对象是那个类的实例。(为了更方便认知,我将第一部分称之为状态区,第二部分成为定位区)
那么对象头的存在解决了什么问题呢?通俗的说就是帮助JVM虚拟机了解这个对象:解决了它是谁的实例,它的独特性质是什么?
现在再来细化一下上面的结构图:
2. 对象的创建过程
在JAVA开发中,常用的几种创建对象的方式有以下几种:
通过new创建对象
通过使用反射机制,java.lang.reflect.Constructor类的newInstance()
通过调用对象的clone()方法
同对象反序列化技术,调用java.io.ObjectInputStream对象的readObject()方法。
第一种方式是我们最常使用,我们就来简单的解释一下该过程中,对象是如何被创建起来的,首先我们简单的参考该流程图:
1、虚拟机碰到new指令时,首先检查该指令的参数是否在常量池中能定位到一个类的引用,并检查符合引用代表的类是否已经被加载、解析和初始化过,如果没有则需要首先执行类加载过程。
2、类加载检查通过后,则为该对象分配内存空间。
3、内存分配完成后,该内存区域会被重置,业绩空间会被初始化为零值(对象头例外)
4、设置对象头信息
5、前四部完成之后,代表已经生成了一个未被初始化的对象,但是init()方法还未执行,对象的字段还是零。因此一旦init()执行完毕之后,一个完整的对象才正式生成。
3. 对象的查找
在栈中,对象的引用(reference)并不代表堆中真正的对象,那如何通过栈中对象的引用(reference)来操作堆中的具体对象呢?这个从对象引用来定位到真实对象的过程有两种实现的方式:
1、句柄定位
该种方式会在堆中实现划分出一块区域做句柄池,reference中存储的是对象的句柄地址,而句柄中包含对象的实例数据和类型数据的具体内存地址。
其结构如下:
2、直接定位
在该种方式中,reference中存储的是堆中真实对象的地址。
两者比较:
句柄定位中reference存储的是句柄地址,相对稳定,对象改变时不会影响只需修改对象实例数据,而无须修改reference。
和句柄定位相比,直接定位的reference存储的是就是对象的地址,少了一层定位的过程,因此效率更高。
来自微信公众号:Java高级架构师
网友评论