运行时数据区
Java堆(Heap)
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大
的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建
。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存
。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆
”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
方法区(Method Area)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代
”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
程序计数器(Program Counter Register)
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器
。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
。
JVM栈(JVM Stacks)
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同
。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息
。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
运行时栈帧结构
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
本地方法栈(Native Method Stacks)
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务
。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
对象的访问定位
句柄方式
如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
优点
使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改
直接指针方式
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址
优点
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
GC
对象存活分析
引用计数法
原理
给对象中每一个对象分配一个引用计数器,每当有地方引用该对象时,引用计数器的值加一,当引用失效时,引用计数器的值减一,不管什么时候,只要引用计数器的值等于0了,说明该对象不可能再被使用了
优缺点
实现原理简单,而且判定效率很高。大部分情况下都是一个不错的算法。但很难解决对象之间相互循环引用的问题
可达性分析
原理
通过一系列被称为“GC Roots‘’的对象作为起始点,从这些节点向下搜索,搜索所走过的路径叫做引用链,当一个节点到GC Roots没有任何引用链时,证明该对象不可用了
被作为GC Roots的对象有以下几种:
- 虚拟机栈中引用的对象(栈帧中的引用变量表)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法(JNI)引用的对象
方法区的垃圾回收
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类
废弃常量回收
回收废弃常量与回收Java堆中的对象非常相似。以常量池中字面量的回收为例,若字符串“abc”已经进入常量池中,但当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用该字面量,若发生内存回收,且必要的话,该“abc”就会被系统清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似
无用的类回收
- 该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出
新生代、老年代以及永久代
堆大小 = 新生代 + 老年代
新生代
1个Eden区和2个Survivor区(分别叫from和to),默认比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”
老年代
老年代与新生代不同,对象存活的时间比较长,比较稳定,因此采用标记(Mark)算法来进行回收
永久代
在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,但是在其他类型的虚拟机中,没有永久代的概念
GC分类
-
Minor GC
发生在新生代中的垃圾收集动作,所采用的是复制算法 -
FullGC (Major GC)
发生在老年代的垃圾收集动作,所采用的是标记-清除算法
JVM内存管理参数
- -Xms 设置堆的最小空间大小
- -Xmx 设置堆的最大空间大小
- -XX:NewSize 设置新生代最小空间大小
- -XX:MaxNewSize 设置新生代最大空间大小
- -Xmn:设置新生代的内存空间大小
- -XX:PermSize 设置永久代最小空间大小
- -XX:MaxPermSize 设置永久代最大空间大小
- -Xss 设置每个线程的堆栈大小。
- -XX:NewRatio 设置新生代和老生代的相对大小
- -XX:SurvivorRatio 指定Eden区和Survivor区的大小比例,注意两个幸存区是一样大的
垃圾收集算法
-
复制算法(新生代)
该算法的核心是将可用内存按容量划分为大小相等的两块, 每次只用其中一块, 当这一块的内存用完, 就将还存活的对象复制到另外一块上面, 然后把已使用过的内存空间一次清理掉 -
标记清除算法(老年代)(mark-sweep)
该算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象该算法会有以下两个问题
- 效率问题: 标记和清除过程的效率都不高;
- 空间问题: 标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集
-
标记整理算法(老年代)(mark-compact)
标记清除算法会产生内存碎片问题, 而复制算法需要有额外的内存担保空间, 于是针对老年代的特点, 又有了标记整理算法. 标记整理算法的标记过程与标记清除算法相同, 但后续步骤不再对可回收对象直接清理, 而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存
垃圾收集器
- 串行收集器 Serial开头的
- Serial
- Serial Old(MSC)
- 并行收集器 Parallel开头的
- ParNew
- Parallel Scavenge
- Parallel Old
- 并发收集器
- CMS (Concurrent Mark Sweep)
- G1(Garbage First)
图片中的问号位置
JDK 1.7后有G1
说明:
- Parallel Scanvenge
也叫吞吐量优先收集器,吞吐量=运行用户代码时间/(运行用户代码时间 + GC时间)。停顿时间短,适合与用户交互的程序;高吞吐量,可以有效利用CPU时间,尽管完成计算任务,适合后台运算而不需要太多交互的任务 - CMS
目标:最短回收停顿时间。并发收集,低停顿,适合B/S架构,需要低延迟时间的应用需求
GC日志
日志格式
[GC(GC类型,当前是Minor GC) (Allocation Failure) [DefNew(GC的区域,当前为新生代): 5688K(垃圾回收前的大小)->790K(垃圾回收以后的大小)(9216K)(该区域总大小), 0.0060899 secs] 5688K(堆在垃圾回收前的大小)->4886K(堆在垃圾回收后的大小)(19456K)(堆的总大小), 0.0061183 secs] [Times: user=0.00(用户态消耗CPU时间) sys=0.01(内核态小时CPU时间), real=0.00 secs(操作的实际时间)]
- 大对象直接进入老年代
- 长期存活的对象进入老年代
Class文件结构
类加载机制
加载
- 通过一个类的全限定名来获取其定义的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口
除了可以使用系统提供的引导类加载器外,还可以使用用户自定义的类加载器。任意一个类,都需要由加载它的类加载器和这个类本身共同确定其在Java 虚拟机中的唯一性
类加载器
- 启动类加载器:启动类加载器无法被 java 程序直接引用,如需要,直接使用 null 代替即可
- 扩展类加载器
- 应用程序类加载器:它负责加载用户路径(ClassPath)上所指定的类库,开发者可以使用这个类加载器,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
双亲委派模型
如果一个类加载器收到了类加载器的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类时),子加载类才会尝试自己去加载
验证
验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
- 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,主要是验证类的继承关系、数据类型是否符合
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体进行校验分析,以保证被校验类的方法在运行时不会做出危害虚拟机安全的事件
- 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配
- 这阶段进行内存分配的仅包括类变量(即被static修饰的变量),不包括实例变量
- 这里所说的初始值“通常情况”下是数据类型的零值
解析
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程
- 符号引用(Symbolic References):即用一组符号来描述所引用的目标。它与虚拟机的内存布局无关,引用的目标不一定已经加载到内存中
- 直接引用(Direct References):直接引用可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。它是和虚拟机内存布局相关的,如果有了直接引用,那引用的目标必定已经在内存中存在了
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7类符号引用进行
初始化
初始化是类加载过程的最后一步,此阶段才开始真正执行类中定义的Java程序代码(或者说字节码,也仅限与执行()方法)。在准备阶段,我们已经给变量付过一次系统要求的初始值(零值),而在初始化阶段,则会根据程序员的意愿给类变量和其他资源赋值。主要是通过()方法来执行的
虚拟机字节码执行引擎
Java编译期优化
解析与填充符号表过程
解析步骤包含了词法分析和语法分析两个过程,首先词法分析是将源代码的字符流转变成为标记集合(token),然后语法分析是根据token序列来构造抽象语法树(一种用来描述程序代码语法结构的树状表示方式)。完成词法分析和语法分析之后,下一步是填充符号表,符号表是由一组符号地址和符号信息构成的表格,符号表中所登记的信息在编译的不同阶段都要用到(比如语义分析中符号表所登记的内容将用于语义检查和产生中间代码,目标代码生成阶段当对符号名进行地址分配时,符号表是地址分配的依据)
插入式注解处理器的注解处理过程
插入式注解处理器可以看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,那么编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有再对语法树进行修改为止
语义分析与字节码生成过程
语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能够表示结构正确的源程序的抽象,但是无法保证源程序是否符合逻辑,而语义分析主要是对结构上正确的源程序进行上下文有关性质的检查
标注检查
标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等。还有一个重要的动作称为常量折叠也在此阶段完成
数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否有返回值、是否所有的受查异常都被正确处理了等问题
解语法糖
语法糖是指在计算机语言中添加某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。java中的泛型,变长参数,自动拆箱与装箱,条件编译等就属于语法糖,它们在编译阶段就被还原成简单的语法结构(比如List和List在运行期间其实是同一个类)
字节码生成
此过程是javac编译过程的最后一个阶段,字节码生成阶段将之前各个步骤所生成的信息转化成字节码写到磁盘中,另外还进行少量的代码添加和转换工作
Java运行期优化
在部分商用虚拟机中,java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机就会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器或JIT编译器。
即时编译器并不是虚拟机必须的部分,但是即时编译器编译性能的好坏、代码优化程度的高低确是衡量一款商用虚拟机优秀与否的最关键的指标之一。
众多主流的虚拟机都同时包含解释器和JIT编译器,解释器与JIT编译器各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。当程序运行后,随着事件的推移,JIT编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
会被即时编译器编译的热点代码有两类
- 被多次调用的方法体
- 被多次调用的循环体
即时编译器会以整个方法作为编译对象,将其编译成机器码。这种编译方式因为编译发生在方法执行过程之中,因此被称作栈上替换(OSR)
判断一段代码是否是热点代码的方式(热点探测)有两种
- 基于采样的热点探测:此方法会周期性检查各个线程的栈顶,如果发现某个或某些方法经常出现在栈顶,那么这个方法就是热点方法。此方法的缺点是很难精确地确认一个方法的热度,容易受到诸如线程阻塞等因素影响
- 基于计数器的热点探测:此方法会为每个方法甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一个阀值就认为它是热点方法
默认设置下,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的地址,下一次调用该方法时就会使用已编译的版本。也就是说,在编译器还未完成之前,执行引擎仍按照解释方式继续执行,而编译动作则在后台的编译线程中进行
优化技术
一般来说即时编译器所产生的本地代码会比javac产生的字节码更优秀。即时编译器采用了一系列的技术来优化代码,比如公共子表达式消除,数组范围内检查消除,方法内联,逃逸分析等