2018-05-19
深入理解JVM总结
1. 大概内容
-
内存区域
- 内存泄漏和内存溢出
- 类型擦除
- 对象创建,分配和访问
- GC的判定
- GC实现方法
- 类加载过程
- 双亲委派模型
- 分派
- GC收集器
2. 内存结构介绍
我们在Java开发时经常会遇到OutOfMemory的错误,那我们有时候会不清楚问题在哪里,需要花很大力气调试;我们在开发时需要设置JVM参数,那么,我们就只有在了解了JVM的内存结构之后,才能更好的帮助我们进行Java开发。
首先,JVM的内存结构主要分为三个最主要的部分:堆,方法区和栈,其中堆负责存放对象实例,是虚拟机内存中最大的一部分;方法区存储类信息、常量、静态变量等数据;栈分为java虚拟机栈和本地方法栈主要用于方法的执行。下面详细介绍一下JVM内存各部分的作用:
- 堆
- 对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 用途:
- 存放对象实例,几乎所有的对象实例都在这里分配内存。
- 垃圾收集:
- Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。
- 如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
- 所有的对象在实例化后的整个运行周期内,都被存放在堆内存中。堆内存又被划分成不同的部分:伊甸区(Eden),幸存者区域(Survivor Sapce),老年代(Old Generation Space)
- 物理上不连续,逻辑连续;可固定大小,也可扩展(通过-Xmx和-Xms控制)。
- 异常情况:
- 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
- 方法区
- 与Java堆一样,是各个线程共享的内存区域
- 用途:
- 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 虽然规范中看作堆的逻辑部分,但被称为Non-heap
- 性质:前两点与堆相同
- 物理不连续,逻辑连续
- 可扩展
- 可以选择不实现垃圾收集
- 内存回收目标主要是针对常量池的回收和对类型的卸载
- 异常情况:
- Java虚拟机栈
- 线程私有的,生命周期与线程相同,与前两者有明显区别
- 用途:
- 记录方法执行的信息,每个方法在执行时会创建一个栈帧
- 存储方法中的局部变量表、操作栈、动态链接、方法出口等信息
- 每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
- 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
- 本地方法栈
- 与虚拟机栈作用基本相同,只是面向本地方法进行栈帧的管理
- 与虚拟机栈相同,会产生StackOverflowError和OutOfMemoryError异常
- 程序计数器
- 一块较小的内存,线程私有
- 用途:
- 当前线程所执行的字节码的行号指示器
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 为了线程切换,程序可以执行正确的指令,需要程序计数器来记录
- 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
- 实例说明:
import java.text.SimpleDataFormat;
import java.util.Date;
import org.apache.log4j.Logger;
public class HelloWorld{
private static Logger LOGGER = Logger.getLogger(HelloWorld.class.getName());
public void sayHello(String Hello){
SimpleDataFormat formatter = new SimpleDataFormat("dd.MM.YYYY");
String today = formatter.format(new Date());
LOGGER.info(today + " : "+messeage);
}
}
在上面的实例中,数据在内存中的存放如下:
内存位置 |
存放数据 |
堆 |
Object: HelloWorld Object: SimpleDataFormat Object: String Object:Logger |
方法区 |
Class: HelloWorld Class: Logger Class: SimpleDataFormat |
JVM栈 |
Parameter Ref: String “message” Variable Ref: formatter local primitive: “lineNo” |
2018-05-26更新
3.垃圾回收器
由于堆中的对象和方法区中所占用的内存,既不能像程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理。因此,需要进行垃圾回收。
-
对象存活判断
-
垃圾收集算法
- 标记-清除算法
- 首先标记需要回收的对象,然后统一回收掉所有被标记的对象
- 后续的收集算法都是基于这种思路并对其缺点进行改进而得到的
- 缺点:1. 效率问题,标记和清除过程的效率都不高;2. 空间问题,标记清除之后会产生大量不连续的内存碎片,会导致不能找到足够大的内存分配较大对象
- 复制算法
- 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:内存分配时也就不用考虑内存碎片等复杂情况
- 缺点:将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低;可能执行较多的复制操作,影响效率
- 标记-压缩算法
- 首先标记需要回收的对象,然后让所有存活的对象都向一端移动,直接清理掉端边界以外的内存
- 提高清理效率
- 分代收集算法
- GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
- “分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
-
几种垃圾收集器
- Serial收集器
- 串行收集器:新生代,老年代都穿行;新生代复制算法、老年代标记-压缩;稳定,效率高;可能产生较长停顿
- ParNew:新生代并行,老年代串行;新生代复制算法、老年代标记-压缩
- Parallel收集器
- 类似ParNew收集器
- 更关注系统的吞吐量
- 动态调整这些参数以提供最合适的停顿时间或最大的吞吐量
- 也可以通过参数控制GC的时间
- 新生代复制算法、老年代标记-压缩
- Parallel Old收集器
- CMS收集器
- 一种以获取最短回收停顿时间为目标的收集器
- 基于“标记-清除”算法
- 初始标记 -> 并发标记 -> 重新标记 -> 并发清除
- 其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
- 优点: 并发收集、低停顿
- 缺点: 产生大量空间碎片、并发阶段会降低吞吐量
- G1收集器
- 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC
- 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
2018-6-16更新
JVM类加载机制
- 什么是类的加载?
- 类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class对象, Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
- 类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误
- 类的生命周期
- 加载 - 验证 - 准备 - 解析 - 初始化 - 使用 - 卸载(其中加载 验证 准备 初始化的顺序是一定的,但是解析则不一定,这是为了支持java语言的运行时绑定)
- 加载
- 查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:(1)通过一个类的全限定名来获取其定义的二进制字节流;(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(3)在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口。
- 连接
- 验证:连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:(1)文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型;(2)元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object之外。(3)字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;(4)符号引用验证:确保解析动作能正确执行。
- 准备:为类的 静态变量分配内存,并将其初始化为默认值
- 解析:把类中的符号引用转换为直接引用,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。
- 初始化:初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:①声明类变量是指定初始值 ②使用静态代码块为类变量指定初始值
- 执行顺序:构造函数代码 -> 由类入口开始的变量初始化或静态代码块按顺序执行
- 含有父类变量:先执行父类的初始化,在执行子类初始化
- 结束生命周期
-
- 执行了 System.exit()方法;2. 程序正常执行结束;3. 程序在执行过程中遇到了异常或错误而异常终止;4. 由于操作系统出现错误而导致Java虚拟机进程终止