目录
1. JVM执行流程
JVM是Java运行的基础,也是实现一次编译到处执行的关键,那么JVM是如何执行的呢?
程序在执行之前先要把java代码转换成字节码 (class文件) , JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area), 而字节码文件是 JVM 的一套指令规范, 并不能直接交给底层操作系统去执行, 因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令, 再交由CPU去执行, 而这个过程需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能, 这就是4个主要组成部分的职责与功能.
总的来看, JVM 主要通过分为一下4个部分, 来执行Java程序的, 它们分别是:
1. 来加载器(ClassLoader)
2. 运行时数据区 (Runtime Data Area)
3. 执行引擎 (Execution Engine)
4. 本地库接口 (Native Interface)
2. JVM运行时数据区
jvm 从系统申请了一大块内存, 这一大块内存给 java 程序使用的时候, 右会根据实际的使用用途来换分出不同的空间, 这个就是区域划分.
JVM运行时数据区域也叫内存布局,但需要注意的是它和Java内存模型((Java Memory Model, 简
称JMM)完全不同,属于完全不同的两个概念,它由以下5大部分组成:
2.1 堆
堆的作用: 程序中创建(new) 的所有对象都保存在堆中.
我们常见的JVM参数设置-Xms10m最小启动内存是针对堆的,-Xmx10m最大运行内存也是针对堆的。
ms 是 memory start 简称,mx 是 memory max 的简称。
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一-定 GC次数之后还存活的对象会放入老生代。新生代还有3个区域:一个Endn +两个Survivor (S0/S1) 。
垃圾回收的时候会将Endn中存活的对象放到一个未使用的Survivor中,并把当前的Endn和正在使
用的Survivor清楚掉。
2.2 Java虚拟机栈(线程私有)
栈分为本地方法栈和/虚拟机栈, 其中包含了方法调用关系和局部变量;
虚拟机栈记录了 java 代码的调用关系和java代码的局部变量 ;
本地方法栈记录了本地方法的调用关系和局部变量, 也就是在 jvm 内部通过 C++ 写的代码的调用关系和局部变量. (一般不会关注本地方法栈)
Java虚拟机栈的作用: Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是Java方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。
Java虚拟机栈中包含了以下4部分:
1. 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)和对象引用. 局部变量表是所需内存空间在编译期间完成的分配, 当进入一个方法时, 这个方法需要在帧中分配多大的局部变量是完全确定的, 在执行期间不会改变局部变量表的大小. 简单来说就是存放方法参数和局部变量.
2.操作栈: 每个方法会生成一个先进后出的操作栈。
3.动态链接: 指向运行时常量池的方法引用。
4.方法返回地址: PC 寄存器的地址。
什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置, 每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存。
2.3本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过Java虚拟机栈是给JVM使用的,而本地方法栈是给本地方法使
用的。
2.4 程序计数器
这个区域比较小的空间, 专门用来存储下一条要执行的 java 指令的地址
程序计数器的作用: 用来记录当前线程执行的行号的.
程序计数器是一块比较小的内存空间, 可以看作是当前线程所执行的字节码的行号指示器.
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地
址;如果正在执行的是一个Native方法,这个计数器值为空。
程序计数器内存区域是唯一 一个 在JVM规范中没有规定任何OOM情况的区域!
2.5 元数据区
"元数据" 是计算机中的一个常见术语(mete data), 往往指的是一些辅助性质的, 描述性质的属性.
一个程序有哪些类, 每个类都有哪些方法, 每个方法都要包含哪些指令, 都会记录在元数据区
经典笔试题
小结
3. JVM的类加载机制
类加载指的是 java 进程运行的时候, 需要把.class 文件从硬盘读取到内存, 并进行一系列校验解析的过程.
对于一个类来说,它的生命周期是这样的:
其中前5步是固定的顺序并且也是类加载的过程,其中中间的3步我们都属于连接,所以对于类加载
来说总共分为以下几个步骤:
1.加载
2.连接
a.验证
b.准备
c.解析
3.初始化
下面我们分别来看每个步骤的具体执行内容。
1) 加载
把硬盘上的.class 文件找到, 打开文件读取文件的内容(认为读到的是二进制的数据)
“加载”(Loading) 阶段是整个“类加载”(Class Loading)过程中的一个阶段,它和类加载
Class Loading是不同的,一个是加载Loading另一个是类加载Class Loading,所以不要把二者搞混
了。
在加载Loading阶段,Java虚拟机需要完成以下三件事情:
1) 通过一个类的全限定名来获取定义此类的二进制字节流。
2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3) 在内存中生成-个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2) 验证
需要确保读到的文件的内容, 是合法的.class 文件(字节码文件)格式
具体的验证依据, 在java的虚拟机规范中, 有明确的格式说明.
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证选项:
- 文件格式验证
- 字节码验证
- 符号引用验证....
3) 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值
的阶段。
比如此时有这样一行代码:
public static int value= 123;
它是初始化value的int值为0,而非123。
4) 解析
解析阶段是Java虛拟机 将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
符号应用和直接应用:
5) 初始化
初始化阶段,Java 虚拟机真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程。
把类对象的各个部分的属性进行赋值填充, 触发对父类的加载, 初始化静态成员, 执行静态代码块.
双亲委派模型
双亲委派模型用于加载环节, 描述了如何查找 .class 文件的策略
双亲委派模型是一种Java类加载器和字节码加载器的实现策略。它主要用于解决类加载器之间的循环依赖问题。
在双亲委派模型中,当一个类加载器收到一个类的加载请求时,它首先不会自己去加载这个类,而是把这个请求委托给父类加载器去完成。只有当父类加载器无法完成这个加载任务时,子类加载器才会尝试自己去加载这个类。这样可以确保Java核心库中的类总是由其父类加载器(如Bootstrap ClassLoader)来加载,从而避免了类加载器之间的循环依赖问题。
JVM 中进行类加载的操作, 是有一个专门的模块, 称为"类加载器"(ClassLoader)
JVM 中的类加载器默认是有三个(也可以自定义)
BootStrapClassLoader : 负责查找标准库中的目录.
ExtensionClassLoader: 负责查找扩展库的目录
ApplicationClassLoader: 负责查找当前项目的代码目录以及第三方库的目录
图示过程:
双亲委派模型的优点:
1.避免重复加载类: 比如A类和B类都有一个父类C类, 那么当A启动时就会将C类加载起来, 那么在B类进行加载时就不需要在重复加载C类了.
2.安全性: 使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改, 如果没有双亲委派模型, 而是每个类加载器加载自己的话就会出现一些问题, 比如我们编写一个称为 java.lang.Object类的话,那么程序运行的时候, 系统就会出现多个不同的Object来, 而有些Object类又是用户自己提供的, 因此安全性就不能得到保证了.
4. java垃圾回收
上面讲了Java运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此我们本节课所讲的有关内存分配和回收关注的为Java堆与方法区这两个区域。
Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。判断对象是否已"死"有如下几种算法
4.1 死亡对象判断方法
a) 引用计数算法
引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1; 当引用失效时,计数器就-1;
任何时刻计数器为 0 的对象就是不能再被使用的,即对象已"死"。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一一个不错的算法。比如Python语言 就采用引用计数法进行内存管理。
但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题.
引用计数缺点:
1.消耗额外的内存空间.
要给每个对象都安排一个计数器.(如果计数器按照2个字节算), 如果整个程序中对象数目很多, 总的消耗空间也会非常多.
尤其是如果每个对象体积很小, (假设每个对象4字节), 计数器消耗的空间已经到达对象的一般
2.引用计数器可能会产生"循环引用的问题", 此时, 引用计数器就无法正常工作了.
b) 可达性分析算法(JVM使用此方法)
在.上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活(同样采用此法的还有C#、Lisp-最 早的- -门采用动态内存分配的语言)。
此算法的核心思想为:通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。以下图为例:
核心思路: 在写代码的过程中, 会定义很多的变量, 比如栈上的局部变量/方法区中的静态类型的变量/常量池中引用的对象....
就可以从这些变量作为起点出发, 尝试去进行"遍历", 所谓的遍历就是会沿着这些变量中持有的引用类型的成员, 再进一步的往下进行访问...
所有能被访问到的对象, 自然不是垃圾了, 剩下的遍历一圈也访问不到的对象, 自然就是垃圾.
4.2 垃圾回收算法
通过上面的学习我们可以将死亡对象标记出来了,标记出来之后我们就可以进行垃圾回收操作了,在正式学习垃圾收集器之前,我们先看下垃圾回收机器使用的几种算法(这些算法是垃圾收集器的指导思想)
a) 标记清除算法
"标记 - 清除" 算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段: 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
"标记 - 清除" 算法的不足主要有两个:
1.效率问题: 标记和清除这两个过程的效率都不高
2.空间问题: 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
b) 复制算法
"复制" 算法是为了解决 "标记清理" 的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图:
c) 标记-整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代的特点,提出了一种称之为 "标记整理算法"。标记过程仍与 "标记-清除" 过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。流程图如下:
d) 分代算法
分代算法和上面讲的3种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策
略,从而实现更好的垃圾回收。这就好比中国的一国两制方针一样,对于不同的情况和地域设置更符合当地的规则,从而实现更好的管理,这就时分代算法的设计思想。
当前JVM垃圾收集都采用的是 "分代收集(Generational Collection)" 算法,这个算法并没有新思想,
只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代
中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法; 而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用 "标记清理" 或者 "标记-整理" 算法。
哪些对象会进入新生代? 哪些对象会进入老年代?
新生代: 一般创建的对象都会进入新生代;
老年代: 大对象和经历了N次(一般情况默认是15次)垃圾回收依然存活下来的对象会从新生代
移动到老年代。