初学java时,我们都会被告知,java具有跨平台特性。至于为什么?当时只是知道java代码被编译成class文件后在JVM中被加载执行,只要在不同平台(Windows、Linux、Mac OS、Solaris)安装不同版本的JDK,同一套java代码都能在上面运行。这样java的初学者也就不用再去了解程序所运行的物理硬件和操作系统内存模型,因此大大降低了初学者的门槛,这也是有人说java简单的原因所在。为了实现这个设计理念,Java的创造者们在JVM中定义了一种java内存模型,类型于操作系统的内存模型但是运行在不同操作系统之上的统一的内存模型,JDK1.5在实现了JSR-133规范之后,这个内存模型开始成熟,虽然后续jdk的版本一直也小的变动,从sun公司的官方路线图来看未来也会有变动,但是这并不影响java内存模型已经完善这个事实,在搞懂内存模型前你需要先学习JVM的划分的内存区域。
接下来我们就介绍本文的重点,java内存区域组成,首先提醒下,java的内存区域或者叫内存划分是在运行java虚拟机(JVM)之上才存在的,它不是什么空中楼阁,你每启动一个java项目,就是先启动了一个JVM进程,在这个进程之上构建的java内存区域营造了你的java代码运行的线程环境,我们称这个JVM进程之上的内存区域就为java运行时数据区。其实这样描述不是很准确,但是这样描述我觉得很容易去理解。因为java运行时,操作的内存中除了java划分的内存区域外还有可能是一种被称之为直接内存的内存区域,它不是java运行时的数据区,即它不在java虚拟机规范定义的内存中,后面我也会介绍这块。
根据java虚拟机规范,jvm内存区域分为虚拟机栈、虚拟机堆、本地方法栈、方法区和程序计数器供五个部分,这五个部分也是五个不同的数据区域,他们和java对象的生命周期一样也是有自己的创建和销毁时间的。如虚拟机栈、本地方法栈、程序计数器都是依赖java线程的启动、结束而创建和销毁的,虚拟机堆和方法区又是随jvm的进程而存在的。下面就分别介绍这五个部分。
1、虚拟机栈
java虚拟机栈是线程私有的,所以也被称为线程栈,线程运行首先就需要分配一块栈内存空间,线程每执行一个方法,都会在这个栈内存中创建一个栈帧。我们学习过数据结构都知道栈,入栈和出栈。对应这里就是java执行一个方法时,创建一个栈帧,然后在这个java线程分配的栈内存中入栈,在执行这个方法当中又执行了另一个方法,那么另一个方法也会创建一个栈帧并再入栈,以此类推,方法中再执行方法,如函数递归调用就是栈帧的不断入栈,所以栈顶的栈帧就是当前java线程被jvm执行引擎执行的方法,当栈顶的方法执行完毕,返回时就是栈顶的栈帧出栈,执行引擎就会执行新的栈顶的栈帧,即调用这个方法的上一个方法,故栈帧的数目也就是栈的深度。那么栈帧中有什么数据呢?栈帧是方法运行时的基本数据结构,你可以想想你写java代码时,方法里面你都会写什么。栈帧中有局部变量表,操作数栈,动态方法链接,方法返回地址和一些额外的附加信息。
这里我简单介绍栈帧的结构,目的主要是建立概念上的模型。因为这个栈帧如果铺开来描述清楚那其实就是在描述基于栈的java字节码执行引擎了,这里面东西还挺多的,比如java的多态性,动态调用、栈的指令集、栈的解释器等等,如果用大量篇幅介绍这些会冲淡本文的重点-内存区域,我们应该先把书读薄再把书读厚,我后面会写这个,因为我打算写一个完整的本人对jvm的理解的系列博文。局部变量表就是包括方法参数和方法内你定义的局部变量,它的变量类型就是常见的八种基本的数据类型,byte、char、short、int、float、long、double,它们占的字节数分别为1、2、4、4、4、8、8,还有一个布尔类型boolean,取值就两种true、false,顺便提一句局部变量表的容量是以Slot(变量槽)为最小单位存的,一个Slot长度为32位,那么对于long、double这两种64位的数据类型就需要分配两个连续的Slot槽了,所以对于32位的虚拟机来说操作这两种类型的基本数据就要分两次进行了,这种操作是不是原子性的在多线程下就很重要了,虚拟机栈中的局部变量操作是某个线程私有的,不存在多线程,也就不用关心这种是不是原子性的安全了,但是如果是类变量并且是在多线程环境下就必须考虑了。另外局部变量表所需多大的内存在java代码编译期间就完全可以确定了。操作数栈是栈帧里面的栈,同局部变量一样,操作数栈的最大深度在java编译的时候就确定的了。方法的执行就是通过操作栈来实现的,例如算术运算、调用其它方法时的参数传递时,各种字节码指令向操作栈中写入和读取数据,即入栈和出栈。如当执行iadd字节码指令时,操作栈的栈顶元素和下面一个元素出栈并交由cup完成运算,结果再入栈,因此操作数栈的数据必须与字节码指令严格匹配,这一点是有编译器保证的,因此当你代码中出现不同数据类型的运算操作时,编译是通不过的,编译器会报错。动态链接就是一个指向运行时常量池的某个栈帧所属方法的引用,我们常常说某些java方法是在程序运行时动态调用的,这就是通过这个动态链接实现的。方法返回地址就是方法退出后,都需要返回到方法被调用的地方,退出分为两种,一种是方法正常执行完退出,当前栈帧出栈,另一种是异常退出并且未在方法体内得到异常处理时退出,其返回地址由异常处理器决定。栈帧中还有一些附加信息,这是虚拟机为扩大自由度而被规范允许的,如调试相关信息,此部分由具体的虚拟机自由实现。以上就是栈帧中所包含的信息。
java虚拟机的栈这是由这些独立又互相联系调用的栈帧组成,一个栈帧就是一个方法(函数)。
2、虚拟机堆
虚拟机堆或称为java堆,是虚拟机所管理的内存中最大的一部分,几乎所有java实例对象都是在堆上分配,因此它是所有线程共享的一块区域,也是GC工作的主战场。由于现在GC收集器都使用分代收集算法,分代就是java堆分为新生代和老年代,处于新生代和老年代状态的堆内存所采用的GC收集器是不一样的。一个刚被new的对象就属于新生代,它有一个年龄计数器(GC分代年龄),每经过一次Minor GC(分代收集中的新生代回收)对象还存活的话,对象年龄计数器就加一,默认年龄加到15,这个对象属于老年代了,对它的回收就要使用老年代回收器了。老年代除了这些长期存活的对象外,可能还有一些大对象,如很长的字符串、很大的字节数组,有几MB以上的那种,这种对象在java堆中被分配内存时会直接进入老年代。java堆的新生代又是堆内存中最大的一块,新生代又可以细分为Eden空间、From Survivor空间、To Survivor空间,并且它的内存空间的划分比例是8:1:1,为什么这样划分呢?有两个原因,一:现在的虚拟机的新生代收集器都是采用复制算法的,二:IBM有研究统计表明,处于新生代中的对象有百分之九十八都是随线程结束而被废弃的,甚至线程没结束就没用了,如局部对象、临时对象,这个数字跟我们日常的编程经验也是吻合的。所以Eden空间、From Survivor空间是作为对象的内存分配区域,To Survivor作为对象被回收时存活对象的复制空间,这是从概率学的角度去划分内存的,因此在大多数情况下,内存空间是满足的,如果From Survivor空间不够的话,会使用老年代作为分配担保。
上面介绍堆时说了一些GC方面东西,我后续博文会详细介绍,如果有不清楚的话,只能先建立起一个模糊的概念,因为JVM的内存区域、GC、类加载机制等等都是彼此合作运行的,你不可能将它们分开,所以JVM分块学习后要从整体上思考琢磨,融汇起来。
3、方法区
我们知道所有的class文件在运行时,首先就需要被虚拟机加载并完成类的链接和初始化工作(不是对象初始化哦),而且只会被执行一次。类的初始化完成以后就会得到类信息(名称、修饰符)、常量、静态变量、类的Field信息、方法信息等等,这些信息就被存在方法区,也被称为类的元(meta)数据。所以方法区明显也是被所有线程共享的,java代码中定义的静态变量、静态方法都是被存在了这里,你通过类名就可以访问,在任意线程里,同一个类(全限名相同)的静态变量都是同一个。这个区域也有人称为永久代,永久代是在这个区域被GC的条件很苛刻,回收效果不好,因为方法区回收主要是针对常量池的回收和类卸载。常量池是方法区的一部分,常量池中存储了字符串、final常量等字面量和类名、方法名等引用量,这个常量池还有一个重要特性就是也具有动态性,就是常量既可以在编译时产生,也可以在java运行期间将新的常量放入池中,如String类的inter()方法。
4、本地方法栈
本地方法栈的和虚拟机栈是类似的,都是为执行方法服务的,只是它们的方法来源和类型不一样,虚拟机栈执行的是java方法,本质是执行字节码,本地栈执行的方法是Native方法,本质执行的是C++的代码,其可以直接调用操作系统函数去操作硬件如CUP、物理内存,不同平台这些方法实现是不一样的,但是它们的本地库接口是一样的,本地方法库不一样。虚拟机规范对这块使用没有强制规定,这是说本地方法使用的语言(不一定是C++,别的底层语言也可以)、数据结构等可以由具体的虚拟机自由实现。我们常使用Sun HotSpot直接就把虚拟机栈和本地方法栈合二为一了,故本地方法栈也是线程私有的,其生命周期追随执行线程。
5、程序计数器
如果你知道PC寄存器的话,这个程序计数器就是虚拟机的PC寄存器。它是某个线程所执行的字节码行号指示器,这个东西怎么理解呢?就是你要做一件事,这个事情分很多步骤,你需要一个步骤一个步骤去完成,每个步骤你用序号标记,步骤1,步骤2,步骤3...,这个标记就是程序计数器,如果你做到步骤3时,你要停下去做别的事,程序计数器就停在这,当你再回来做这件事时,你一看程序计数器在3这儿,就知道要从这再接着做下去。对应JVM,这个程序计算器记的就是JVM要执行的一连串的字节码指令的内存地址,执行Native方法,这个对应的计数器值则为空(undefined)。因为只是存内存地址,所以这块区域占用的内存很小,虚拟机规范对这块没有规定任何OOM(OutOfMemoryError)的情况。很明显这块也是线程私有的。
以上五块就构成java运行时的数据区域,但是还有一块内存区域,它并不是虚拟机规范中定义的运行时的数据区的一部分,却是可以被java程序直接使用的,就是本文开头提到的直接内存。怎么真正清楚的理解这个直接内存呢?我们先理解什么是内存。
我刚学JVM的时候,虚拟机内存区域只是在大脑里面建立了一个概念框架而已,哦,就是这样划分的嘛,但是还是有种空中楼阁的感觉,不得劲。看过很多别人写的博客,他们有很多都是直接从工作内存和主内存的角度去理解虚拟机的内存,这个需要指出工作内存、主内存和我们上面介绍的java内存区域是不同的概念,有人说可以大致的去理解工作内存就是虚拟机栈内存,主内存就是虚拟机堆内存,这个我肯定是不同意的。我个人理解,从工作内存和主内存的角度去理解虚拟机,其实是参考操作系统的缓存一致性问题来理解的,我觉得这也是理解虚拟机的一个好的维度,它从虚拟机规范所定义的内存区域中挣脱出来,将JVM做为一个整体,你不用再去关心java内存中class字节码数据结构中各个变量的访问规则的底层细节的规定实现了。类比是学习不熟悉的理论知识的一种很好的手段。
现在有一个很厉害的组织,他们想建一座城市,这座有自己的特色和特殊功能,那么他们先申请到了一大块土地,这块本地怎么划分,怎么使用就完全由这个组织决定了,然后这个组织就起草了一份规范协议,规定了这块土地建生活区,里面要有公寓、酒店、超市、购物中心、健身房,这几块土地是交通出入口,有飞机场,公路出入口,管理着进出这座城市的人员,包括安全检查和跟这座城市以外的城市的信息交流,还有这块土地建立一大片赌场等娱乐区,这些划分的区域构成了这座城市,这座城市有了一个梦幻般的名字:拉斯维加斯。这座城市的区域划分是这个组织规定的,具体建造实现由工程建造团队完成。这座城市建成了,并且有了管理者,包括各种警察、保安、服务员等等,开始正式服务于世人。之后每一天都有游客进出这座城市游玩、娱乐,对于他们来说,这座城市是怎么划分的他们不关心,它们只要能查到目的地就行,享受公共服务,体会着自己的乐趣和感悟。有些人是来赌博的,但是你不能说他只能去赌场,他也要去酒店啊,甚至购物中心啊。这样类比不知道你能不能有所理解,这座城市就是虚拟机,主内存就是这座城市的所有公共服务区域,每个游客就是一个工作内存,你因为这座城市才跟这片原本什么都没有的土地上建立起来的公共服务区域发生了联系。每个线程的工作内存会跟这座城市的各个区域都有关系的。通过上面的类比就解释了工作内存、主内存以及java内存区域之间的区别和联系,我觉得这是从两个不同的维度(或角度)去理解虚拟机内存。
理解上面这些,就能回到直接内存了,拉斯维加斯只是占了美国一小部分土地呀,美国土地上还有很多城市呢,有西雅图、洛杉矶、纽约、旧金山等等,美国中部还有很多没有被利用的土地呢。直接内存就是在拉斯维加斯这块土地之外直接拿到一块土地为自己服务。java通过Native函数就能直接分配到堆外内存。在java的NIO类中,就有一种基于通道与缓冲区的IO方式,通过java堆中的DirectByteBuffer对象作为直接内存的引用对其进行操作。
本文到这就介绍完了,但是还没结束呢,下一篇继续介绍jvm内存模型之内存溢出问题。

被折叠的 条评论
为什么被折叠?



