简单了解 JVM
目录
♫什么是JVM
♫JVM的运行流程
♫JVM运行时数据区
♪虚拟机栈
♪本地方法栈
♪堆
♪程序计数器
♪方法区/元数据区
♫类加载的过程
♫双亲委派模型
♫垃圾回收机制
♫什么是JVM
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。 虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统(如:JVM、VMwave、Virtual Box)。 JVM 和其他两个虚拟机的区别是: VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器,而 JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
♫JVM的运行流程
JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢?
我们知道程序在执行之前先要把java代码转换成字节码(class文件),而 JVM 首先需要把字节码通过类加载器(ClassLoader)把文件加载到内存中的运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
♫JVM运行时数据区
从上图我们可以发现运行时数据区划分成5个部分,接下来我们就来看看他的内存布局。
♪虚拟机栈
虚拟机栈是给 Java 代码使用的栈,每个线程都会有一个,虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息:
①. 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表 所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。
②. 操作栈:每个方法会生成一个先进后出的操作栈。
③. 动态链接:指向运行时常量池的方法引用。
④. 方法返回地址:PC 寄存器的地址。
♪本地方法栈
Native Method Stack 中 Native 就表示 JVM 内部 C++ 写的代码,就是给调用 Native 方法( JVM 内部的方法)准备的栈空间。
♪堆
整个 JVM 中最大的区域,所有 new 出来的对象(类的普通成员变量)都是在堆上。
♪程序计数器
程序计数器是一块比较小的内存空间,记录当前线程执行到哪个指令,可以看做是当前线程所执行的字节码的行号指示器。 如果当前线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个 Native 方法,这个计数器值为空。
♪方法区/元数据区
元数据区是用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。
注:虚拟机栈、本地方法栈、程序计数器都是线程私有的(一个线程有一个),堆、元数据区是线程公有的(一个进程里的所有线程共用一个)。
♫类加载的过程
Java类加载是将 .class 文件中的二进制数据读入到内存中,并对数据进行校验、解析和初始化的过程。类加载来说总共分为以下几个步骤:
♩. 加载:把 .class 文件找到,读取文件内容
♩. 连接:
①. 验证:根据 JVM 规范,检查 .class 文件是否符合规范
②. 准备:给类对象分配内存空间,设置初始值(基本数据类型设为为 0,引用数据类型设为 null)
③. 解析:将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。(字符串常量有一块内存空间存字符串的实际内容,还有一个引用保存这块空间的起始地址,类加载前字符串常量存储在 .class 文件中(还没有内存地址),此时这个引用记录的是字符串常量在文件中的”偏移量“(符号引用),在类加载后字符串常量才放到内存里(有了内存地址),才会将”偏移量“替换成内存地址(直接引用))
♩. 初始化:
真正对类对象里的内容进行加载,加载父类、执行静态代码块
注:java 程序运行后不会把所有类一次性都加载,而是需要用到哪个再加载哪个
♫双亲委派模型
类加载描述的是找到 .class 文件读取内容的过程,而双亲委派模型描述的就是加载、找 .class文件的基本过程。了解双亲委派模型前,我们得先了解下 JVM 默认提供的三种类加载器:♩. BootstrapClassLoader:负责加载标准库中的类(java 规范要求提供的类,无论哪种 JVM 都会提供)
♩. ExtensionClassLoader:负责加载 JVM 扩展库中的类(规范之外,由 JVM 厂商提供的扩展功能)
♩. ApplicationClassLoader:负责加载用户提供的第三方库/用户项目代码中的类
这三个加载器彼此存在“父子类”的关系, BootstrapClassLoader 相当于 ExtensionClassLoder 的父加载器,ExtensionClassLoder 相当于 ApplicationClassLoder的父加载器。
双亲委派模型就是单加载一个类时,首先从 ApplicationClassLoader 开始,但 ApplicationClassLoader 会把加载任务交给父加载器 ExtensionClassLoader , ExtensionClassLoader 又会把加载任务交给父加载器 BootstrapClassLoader ,BootstrapClassLoader 没有父加载器才开始搜索标准库目录的类,找到了就加载,没找到就交给子加载器 ExtensionClassLoader,ExtensionClassLoader 搜索扩展库的目录,找到了就加载,没找到就交给子加载器 ApplicationClassLoader,ApplicationClassLoader 搜索用户项目相关目录,找到了就加载,没找到就抛出异常。
注:双亲委派模型的加载顺序确保了 BootstrapClassLoader 先加载,ApplicationClassLoader 后加载,可以避免因用户自己写的类导致 JVM 已有代码的混乱。
♫垃圾回收机制
垃圾回收机制即 GC 主要是对堆进行释放的,是以对象为单位进行回收的,因此也叫死亡对象的回收。
要想回收垃圾,首先得判断谁是垃圾,常见的判断是否为垃圾的方法有两种:
♩.引用计数
给每个对象都分配一个计数器,有引用指向它,计数器加一;有指向它的引用销毁,计数器减一。
显然这个方法简单有效,但还是存在缺点:内存浪费的多且可能存在循环引用(a 对象的属性指向 b,b 对象的属性指向 a,当 a 和 b 销毁时,a 和 b 的引用计数仍为 1)的问题。
♩.可达性分析
java 里的对象都是通过引用指向来访问的,通过遍历所有对象的引用指向就可以判断出某个对象可达不可达,java 的做法就是通过可达性分析。
确认了哪个对象是垃圾就可以对垃圾进行回收,常见的回收垃圾的做法有:
♩.标记清除
基本概念:标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。在标记阶段,它从根节点开始遍历,标记所有可达的对象。未被标记的对象被视为垃圾,这些对象在清除阶段被回收。
优点:标记清除算法实现简单,不需要移动存活对象。
缺点:标记清除算法执行效率较低,且清除后容易产生大量不连续的内存碎片,这可能导致后续对象分配时找不到足够的连续内存空间而提前触发垃圾回收。♩.复制算法
基本概念:复制算法将内存分为两块,每次只使用其中一块。当一块内存用完时,将还存活的对象复制到另一块上,然后清理掉已使用的内存。
优点:由于只处理其中一块内存区域,复制算法运行速度较快,且不会产生内存碎片。
缺点:复制算法需要两倍的内存空间,代价较高。同时,如果对象的生命周期较长,这种复制操作会导致效率低下。
♩.标记整理
基本概念:标记整理算法结合了标记清除和复制算法的优点。在标记阶段后,将所有存活对象压缩到内存的一端,然后清理边界以外的内存。
优点:标记整理算法避免了标记清除算法的碎片问题,也不需要复制算法那么多的内存空间。
缺点:标记整理算法实现较为复杂,且移动对象的过程会产生额外的开销。♩.分代回收
基本概念:分代收集算法基于这样一个事实:大部分对象会在年轻时死亡。它将堆内存分为新生代和老年代,不同年代采用不同的回收算法。新 new 出来的对象在伊甸区,熬过一轮 GC 就通过复制算法来到了幸存区,幸存也要经过周期性的 GC 考验,如果通过考验就进入另一个幸存区,没通过就释放掉,当一个对象在两个幸存区来回拷贝很多次了后就进入老年区,老年区偶而也要经历 GC 的考验,如果没通过就通过标记整理算法释放掉。
优点:分代收集算法通过将内存分区,可以更高效地回收垃圾,特别是针对新生代中大量短命的对象。
缺点:分代收集算法设计相对复杂,需要根据不同代的特点选择合适的回收算法。注:JVM 就是基于分代回收算法回收垃圾的。