其实本篇的题目叫做《Java的内存模型》有些不准确,更准确的说法应该是JVM的内存模型,但是这里又牵扯了一些其他的前置知识,主要是想从Java入手,从源头上梳理一遍整个Java底层运行的机制,中间会额外补充一些和题目无关的前置基础,导致主讲内存模型的篇幅所占的比例就不是那么绝对, 关于这点只能请小伙伴们多担待些了。
JVM的内存模型
"“一次编写,到处运行"的真实面目
说JVM内存模型之前,先聊一个老生常谈的问题,为什么Java可以 “一次编写,到处运行”,这个话题最直接的答案就是,因为Java有JVM啊,解释这个答案之前,我想先回顾一下一个语言被编译的过程:
一般编程语言的编译过程大抵就是,编译——连接——执行,这里的编译就是,把我们写的源代码,根据语义语法进行翻译,形成目标代码,即汇编码。再由汇编程序翻译成机器语言(可以理解为直接运行于硬件上的01语言);然后进行连接,所谓连接就是将目标代码与函数库相连接,并将源程序所用的库代码与目标代码合并,并形成最终可执行的二进制机器代码(程序)。
编译运行的整个流程,有一个前提,那就是到汇编的层面,指令编码就和处理器的架构强关联了,说白点就是和硬件关联了,可以粗暴的理解为,一类硬件机器只认识一种汇编,一种机器只认一种机器码。在这个基础下,很容易就会发现一个问题,一个编程语言经过编译、连接形成的可运行的机器码X,可以在硬件环境1的情况下运行,当机器码X到硬件环境2,就未必可以运行了,或者说运行结果就不是硬件环境1的结果了,所以,同一个程序,换台PC,我们就可能需要重新编译、打包成可运行在当前硬件环境的程序。这样在工程化运用中真的是灾难。
现在我们回到开篇问题的答案,之所以Java可以“一次编译,到处运行”,是因为有JVM,为了便于理解,我们可以这样认为:JVM就是一个完备的中间环境,它提供编译运行Java字节码的全套环境,换句话说,它就像一个小隔离空间,我的Java程序只要编译一次,只要满足可以跑在JVM中,那它就可以随便移植在任何硬件环境中,所以Java的“一次编译,到处运行”的本质就是,它处处都要依赖JVM,它其实就是一个运行在JVM中的寄生虫,这也是为什么想要运行环境,你就必须要装JDK的原因。
JVM的本质
通常工作中所接触的基本是Java库和应用以及Java核心类库,知道如何使用就可以了,但是归根结底代码都是要编译成class文件由Java虚拟机装载执行,所产生的结果或者现象都可以通过Java虚拟机的运行机制来解释。一些相同的代码会由于虚拟机的实现不同而产生不同结果。
然后是我们要介绍的JVM,首先我们要明确一个概念,JVM它并不是某一个具体的产品,也不是一个成品的软件,更准确地说JVM是一种理论规范,对JVM的具体实现要么是软件,要么是软件和硬件的组合,JVM可以由不同的厂商来实现成不同的产品。由于厂商的不同必然导致JVM在实现上的一些不同,像国内就有著名的TaobaoVM;
在Java平台的结构中,可以看出,Java虚拟机(JVM)处在核心的位置,是程序与底层操作系统和硬件无关的关键。它的下方是移植接口,移植接口由两部分组成:适配器和Java操作系统,其中依赖于平台的部分称为适配器;JVM通过移植接口在具体的平台和操作系统上实现;在JVM的上方是Java的基本类库和扩展类库以及它们的API, 利用Java API编写的应用程序(application)和小程序(Java applet)可以在任何Java平台上运行而无需考虑底层平台,就是因为有Java虚拟机(JVM)实现了程序与操作系统的分离,从而实现了Java的平台无关性。
JVM在它的生存周期中有一个明确的任务,那就是装载字节码文件,一旦字节码进入虚拟机,它就会被解释器解释执行,或者是被即时代码发生器有选择的转换成机器码执行,即Java程序被执行。因此当Java程序启动的时候,就产生JVM的一个实例;当程序运行结束的时候,该实例也跟着消失了。
JVM的内存模型总览
总体来讲,JVM会将Java进程所管理的内存划分为若干不同的数据区域. 这些区域有各自的用途、创建/销毁时间。以上这张图,就是Java的编译运行过程,上半部分(运行时区域)其实就是JVM的内存分配,它把从操作系统获取来的内存空间进行了独立的划分,分别为方法区、堆、虚拟机栈、本地方法栈、程序计数器。下半部分就是连接——运行阶段的,JVM将Java语言处理完毕,变成适配与当前机器的机器码,然后与本地库进行连接,运行。
线程私有区域
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束而创建/销毁(在Hotspot VM内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死)。
程序计数器
一块较小的内存空间, 作用是当前线程所执行字节码的行号指示器(类似于传统CPU模型中的PC), PC在每次指令执行后自增, 维护下一个将要执行指令的地址. 在JVM模型中, 字节码解释器就是通过改变PC值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖PC完成(仅限于Java方法, Native方法该计数器值为undefined).
不同于OS以进程为单位调度, JVM中的并发是通过线程切换并分配时间片执行来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 这类内存被称为“线程私有”内存。
JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。
从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。
首先我们要搞清楚JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
程序计数器特点
1.线程隔离性,每个线程工作时都有属于自己的独立计数器。
2.执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址。
3.执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。4.程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。
5.程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域。
虚拟机栈
这里的虚拟机栈主要是针对Java的方法执行,我们都知道方法在编程中使用的是栈的数据结构;每个方法被执行时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息. 每个方法被调用至返回的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程(VM提供了-Xss
来指定线程的最大栈空间, 该参数也直接决定了函数调用的最大深度)。这里特别说明一下局部变量表,这里的局部变量表,其实就是我们定义的方法内部的变量,它基本的范围包括基本数据类型(如boolean、int、double等) 、对象引用(reference : 不等同于对象本身, 可能是一个指向对象起始地址的指针, 也可能指向一个代表对象的句柄或其他与此对象相关的位置),也就是我们私下常说的‘堆栈’中的‘栈’。
Java虚拟机使用局部变量表来完成方法调用时的参数传递。局部变量表的长度在编译期已经决定了并存储于类和接口的二进制表示中,一个局部变量可以保存一个类型为boolean、byte、char、short、float、reference和returnAddress的数据,两个局部变量可以保存一个类型为long和double的数据。
Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果。
每个栈帧中都包含一个指向运行时常量区的引用支持当前方法的动态链接。在Class文件中,方法调用和访问成员变量都是通过符号引用来表示的,动态链接的作用就是将符号引用转化为实际方法的直接引用或者访问变量的运行是内存位置的正确偏移量。
总的来说,Java虚拟机栈是用来存放局部变量和过程结果的地方。
Java虚拟机栈可能发生如下异常情况: 如果Java虚拟机栈被实现为固定大小内存,线程请求分配的栈容量超过Java虚拟机栈允许的最大容量时,Java虚拟机将会抛出一个StackOverflowError异常。
如果Java虚拟机栈被实现为动态扩展内存大小,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
引用:符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
引用:直接引用
直接引用可以是
- 直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
- 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
- 一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
本地方法栈
本地方法栈其实作用和虚拟机栈的作用一样,不同的是,虚拟机栈是为虚拟机解析运行Java方法,而本地方法栈是为虚拟机调用Native方法服务(Native方法简单点来说就是一个java调用非java代码的接口。一个Native 方法是这样一个java的方法:该方法的实现由非java语言实现)
线程共享区域
这一区域的生命周期,同虚拟机一致,也就是虚拟机内部的公共内存区域,随虚拟机的启动/关闭而创建/销毁
堆区
这里的堆,是虚拟机从操作系统那里申请来的的内存空间,这块空间是Java虚拟机所管理的内存中最大的一块,并且是所有线程共享的一块内存区域,Java堆在虚拟机启动的时候被创建,主要用来为类实例对象和数组分配内存。这块区域可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样,在实现时,既可以实现成固定大小的,也可以是扩展的,如果是可扩展的,则通过(-Xmx和-Xms控制),如果在队中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
Java堆是垃圾回收器管理的主要区域,很多时候也被称为“GC”堆,在现在的实现上,堆被划分成两个不同的区域:新生代( Young )、老年代( Old );这也就是JVM采用的“分代收集算法”,简单说,就是针对不同特征的java对象采用不同的 策略实施存放和回收,自然所用分配机制和回收算法就不一样。新生代( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。关于GC后续会单独起一篇文章讨论。
方法区
方法区和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量、静态变量,还包括在类、实例、接口初始化时用到的特殊方法。虚拟机规范上把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做“非堆”,目的就是与Java的堆区分开来。
补充说明:
直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配堆外内存, 然后使用DirectByteBuffer对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能。
显然, 本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置), 但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现OutOfMemoryError异
以一个实际例子来看JVM的内存活动
这里我们引入一个比较简单的程序样例,从具体的代码角度去理解Jvm的内存
public class Persion {
private String name;
public static String aninmal = "dog"
public Persion(String name){
this.name = name
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class App
{
public static void main( String[] args )
{
Persion persion1 = new Persion("张三");
Persion persion2 = new Persion("李四");
persoion1.setName("王五");
}
}
程序开始运行,系统启动了一个Java虚拟机进程,Java虚拟机定位到方法区中App类的main()方法的字节码,开始执行它的指令。分别去创建Persion1和Persion2(这里我们以persion对象为跟踪点)
- 程序从main方法开始执行,既然提到了方法,根据上面的知识,我们知道,它首先会在栈区动工。在JAVA虚拟机进程中,每个线程都会拥有一个方法调用栈,用来跟踪线程运行中一系列的方法调用过程,栈中的每一个元素就被称为栈帧,每当线程调用一个方法的时候就会向方法栈压入一个新帧。这里的帧用来存储方法的参数、局部变量和运算过程中的临时数据。这时候执行main方法的主线程会在栈区申请一片区域。根据源码,它会识别出persion1和persion2分别为两个变量,并且给它们定性是方法内局部变量,因此,它被会添加到了执行main()方法的主线程的JAVA方法调用栈中。
- 接下来就是 “=” 赋值操作了,Java虚拟机接受运行指令,发现右侧是个对象实例,于是就直奔方法区而去,试图找到Persion类的类型信息。首次运行,发现并没有找到Persion的信息,这时候Java虚拟机根据预设的规则,在无法找到类信息的情况下,自行去加载Persion类,把Persion类的类型信息存放在方法区里。
- 现在Persion类的信息已经被加载到了方法区,这里Persion类中的静态变量animal也会被填充上值“dog”存放于方法区,此时Java虚拟机根据我们代码中的两句new指令,分别去堆中划出两块内存区域,分别用于存放persion实例1和persion实例2,这两个实例对象分别拥有自己独立的内存空间, 同时这俩实例持有着指向方法区的Persion类的类型信息的引用。这里所说的引用,实际上指的是Persion类的类型信息在方法区中的内存地址,其实,就是有点类似于C语言里的指针,而这个地址呢,就存放了在persion实例1、persopn实例2的数据区里。我们也能发现persion实例1和persion实例2共享animal这个变量,也就是说,无论使用哪一个引用(persion1和persion2)去修改这个animal变量,任何一个Persion对象使用这个变量的时候,都会发生改变。
- 到此为止已经将main方法中的两个成员变量persion1和persion2分别关联到了堆中的对象。当Java虚拟机执行到persion1.setName()的时候,Java虚拟机根据main方法栈区中的persion1变量,定位到堆中的Persion名字为张三的实例(persion实例1),再根据这个实例所持有的类信息引用(或者说指针),定位到方法区的Persion类信息,从中获得setName(String name)方法,然后栈区再压入一个新帧,并在其中完成参数(String name)的复制,然后根据指令,将堆中的Persion实例1 空间中的Name变成“王五”,然后结束。