文章目录
- 内存结构概述
-
- 1、程序计数器
- 2、Java虚拟机栈
- 3、本地方法栈
- 4、方法区
- 5、运行时常量池
- 6、Java堆
- 类加载器与类的加载过程
-
- 1、类加载器
- 2、类的加载过程
-
- 加载
- 链接
- 初始化
- 类加载器分类
内存结构概述
Java文件的执行过程大致为:Java文件通过编译器(javac)编译为.class文件,然后类加载子系统将class文件加载进运行时数据区,再通过执行引擎将字节码文件编译/解析为机器指令。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这个数据区域就叫运行时数据区。运行时数据区主要包含了PC寄存器(程序计数器)、Java虚拟机栈、本地方法栈、Java堆、方法区以及运行时常量池,这其中Java堆、方法区跟Java虚拟机栈是学习的重点。
(简图)
(详细图)
1、程序计数器
程序计数器(Program counter Register)是记录当前线程正在执行的字节码的地址。程序计数器是线程隔离的,每一个线程在工作的时候都有一个独立的计数器。因为Java是可以多线程执行的,一个线程执行到一半可能因为CPU时间片轮转切换到了另外一个线程,在切换回之前线程的时候,需要回到线程上次的执行位置,所以要线程私有。
程序计数器的特点
-
程序计数器具有线程隔离性
-
程序计数器占用的内存空间非常小,可以忽略不计
-
程序计数器是java虚拟机规范中唯一一个没有规定任何OutofMemeryError的区域
-
程序执行的时候,程序计数器是有值的,其记录的是程序正在执行的字节码的地址
-
执行native本地方法时,程序计数器的值为空。原因是native方法是java通过jni调用本地C/C++库来实现,非java字节码实现,所以无法统计
2、Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同到都会创建一个栈帧(Stack Frame)用于存储局部量表、操作数栈、动态链接、方法出口等信息。栈帧是Java方法运行时的基础数据结构,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟栈中从入栈到出栈的过程(说人话就是要执行一个方法,将该方法的栈帧压入栈顶,方法执行完成其栈帧出栈)。在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法称为当前线程方法,而该方法的栈帧就称为当前帧。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象始地址的引用指针,也可能是指向一个代表对象的句柄或其地与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
在Java虚拟机规范中,对这个区域定了两种异状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常;一般的虚拟机栈都是可扩展的,如果扩展时无法丰请到足够的内存,就会抛出OutOfMemoryError异常,可以通过-Xss设置每个线程的堆栈大小。
Java虚拟机栈的结构如下图所示:Java虚拟机栈的生命周期与线程一致,一个方法对应一块栈帧内存区域,栈帧中包含局部变量表、操作数栈、动态链接、方法出口等信息。拿下面代码举例,程序执行main(),main()先压入栈顶,然后main()方法中new了一个Math对象,math变量是指向堆中Math对象的引用,math变量就属于局部变量表,创建Math对象之后,调用了其compute(),然后compute()压入栈顶,compute方法执行完成后其栈帧出栈,然后根据程序计数器记录程序执行的行号,继续回到main方法执行,main方法中已经没有其他执行指令了,则main方法退出,main方法对应的栈帧出栈,虚拟机栈中已经没有其他栈帧,main线程生命周期结束。
3、本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈非常相似,也是线程私有的,它们的区别不过是虚拟机栈执行的是Java方法(也就是字节码),而本地方法栈用到的是Native方法。与虚拟机战一样。本地方法栈区域也会出现StackOverFlowError和OutOfMemoryError异常。
4、方法区
方法区(Method Area),是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等。虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non一Heap(非堆),目的就是要和堆分开。这部分存储的是运行时必须的类相关信息,装载进此区域的数据是不会被垃圾收集器回收的,只有关闭Jvm才会释放这块区域占用的内存。
对于Hotspot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)",但严格本质上说两者不同,或者说使用永久代来实现方法区而己,永久代是方法区(相当于是一个接口interface)的一个实现,idkl.7的版本中,己经将原本放在永久代的字符串常量池移走。Jdk1.7中方法区是用永久代实现的,到1.8中是用元空间(MetaSpace)实现的,而元空间使用的是直接内存。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时会抛出OutOfMemoryError异常。可以通过-XX:PermSize和 -XX:MaxPermSize来分别设置永久区最小、最大空间。
5、运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生产的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:
类和接口的全限定名(Fully Qualified Name)
字段的名称和描述符(Descriptor)
方法的名称和描述符
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存人口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
Java语言不要求常量一定只有编译器才能产生,运行时也可能将新的常量放入池中,该特性用的比较多的就是String类的intern()方法。运行时常量池是方法区的一部分,在内存不够时,也会抛出OutOfMemoryError异常。
6、Java堆
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是线程共享的,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是着JIT编译器的发展与逸分析技术逐渐成熟,栈上分配、标量替换优化技术会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么"绝对"了。
Java堆是被收集管理的主要区域,因此很多时候也被称做"GC堆"(Garbage Collected Heap)。从内存回收角度来看,由于现在收集器基本都采用分代算法(为什么要采用分代算法,常用的垃圾收集算法有哪些后面会进行介绍),所以堆中还以细分:新生代(Young/New)和老年代(Old/Tenure),新生代又可以划分为Eden(伊甸园)空间、survivor(幸存区,其又可以分为from survivor和to survivor,也就是S0和S1)空间等。从内存分配的角度来看,线程共享的Java堆中可划分出多个程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的是为了更好地回收内存,或更快地分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只逻辑上是连续的即可。.Java虚拟机中可以对堆进行扩展,可以通过-Xms 设置起始堆大小、通过-Xmx设置最大堆大小、通过-XX:NewSize设置新生代最小空间大小、通过 -XX:MaxNewSize设置新生代最大空间大小。如果在堆中没有完成实例分配,并且地也无法再扩展时,将会抛OutOfMemoryError异常。
类加载器与类的加载过程
1、类加载器
类加载子系统的作用
- 类加载子系统负责从文件系统或者网络中加载Class文件,Class文件在文件开头有特定的文件标识。
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
- 加载的类信息存放于一块称为方法区的内存空间。除了累的信息外,方法去中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。
类加载器ClassLoader角色
- class file存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
- class file加载到JVM中,被称为DNA元数据模板,放在方法区。
- 在.class文件–> JVM–>最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。
2、类的加载过程
加载
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载.class文件的方式
- 从本地系统中直接加载
- 通过网络获取,典型场景:web applet
- 从zip压缩包中读取,成为日后jar、war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP应用
- 从专有数据库中提取.class(比较少见)
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
链接
验证(Verify)
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。
准备(Prepare)
- 为变量分配内存并且设置该类变量的默认初始值。
- 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化。
- 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。
解析(Resolve)
- 将常量池内的符号引用转换为直接引用的过程
- 解析操作往往会伴随着JVM在执行完初始化之后再执行
- 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info等。
初始化
- 初始化阶段就是执行类构造器方法()的过程。
- 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
- 构造器方法中指令按语句在源文件中出现的顺序执行。- - ()不同于类的构造器。(关联:构造器是虚拟机视角下的())。
- 若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕。
- 虚拟机必须保证一个类的()方法在多线程下被同步加锁。
类加载器分类
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器User-Defined ClassLoader)。
从概念上讲,自定义类加载器一般指的是程序中有开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示: