JVM 是 Java Virtual Machine
的简称,意为 Java虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box
。
JVM 和其他两个虚拟机的区别:
VMwave
与VirtualBox
是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;JVM
则是通过软件模拟Java字节码的指令集,JVM
中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。JVM
是一台被定制过的现实当中不存在的计算机。
JVM出现的初心是为了"跨平台",“—次开发,到处运行”。
JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键
,那么 JVM 是如何执行的呢?
JVM 运行流程:
程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式:类加载器(ClassLoader)
把文件加载到内存中 运行时数据区(Runtime Data Area)
,而字节码文件是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)
将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface)
来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
总结来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:
JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:
JVM实际上是一个Java 进程,进程就是用来管理硬件资源的,比如内存。
JVM中内存来自于操作系统.JVM启动之后就会从操作系统这里申请到一大块内存,再针对这个内存划分出一些区域。
具体的内存布局:
对于堆区和方法区,在整个JVM中只存在一份,而程序计数器和栈区是跟进程绑定在一起的,一个java进程中,可能包含着多个线程,每个不同的线程都有独立的一份程序计数器和栈区。
堆溢出java.lang.OutOfMemoryError
,循环创建对象,把对象加到List集合类里.(防止被GC回收)典型的情况就是不断地去new 对象而不去释放内存。
栈溢出StackOverflowError
:写个递归方法,无限递归。
堆和栈的空间大小,都可以通过JVM(Java进程的命令行参数)来进行配置。
对引用类型的理解:
可以把引用类型当作一个“低配指针”,但从更严谨的角度去看,引用并不是一个指针。Java的引用相当于堆C语言的指针功能进行了裁剪,Java中的引用只能用来解引用(如:使用 .
就是默认地解引用)和比较(==或!=
) )。
关于.class文件的格式规范:
类加载其实是JVM 中的一个非常核心的流程,做的事情就是把.class
文件,转成JVM
中的类对象。
要想完成类加载,必须要明确的知道.class
文件中都有啥,按照.class
文件中的规则进行解析。因此,编译器和类加载器(JVM)必须要商量好.class
文件的格,而.class
文件的格式在 JVM虚拟机规范文档里面已经约定好了的,则编程语言的语法也可以理解为一种“协议”。
在JVM虚拟机规范文档中可以看到:
关于.class
文件格式的规范:
可以看到,它把java代码中定义的一个类的核心信息都体现进去了,只不过这个文件的格式是二进制的。
因此,根据上述的格式我们也可以自己开发一个编程语言,然后编译就根据.class
文件的格式一样,就可以直接在JVM中去解析执行了。
对于一个类来说,它的生命周期是这样的:
其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来说总共分为以下几个步骤:
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段。
加载的目的是把.class
文件给找到。如果代码中需要加载某个类,就需要去特定的目录下去查找该.class文
件,找到之后,就需要打开这个文件,并且读取这个文件。此时这些数据就已经读到内存里了。
验证:目的是验证后缀为.class
的文件是否是编译器编译生成的,如果是人为地去改后缀变为.class
的文件,那么就不是一个合法的.class 文件。
除了验证.class
文件的格式外,还需要验证文件里面的字节码指令是否正确。(方法里面具体要执行的指令) 确保Class文件的字节流中包含的信息符合《Java虚拟机
规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
目的是为类对象中的一些成员变量分配内存空间(静态变量…),并且进行一个初步的初始化(初始空间大小为0).
比如此时有这样一行代码:
public static int value = 123;
它是初始化 value 的 int 值为 0,而非 123。
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程.
解析主要是针对字符串常量进行的处理。.class
文件涉及到一些字符串常量,在解析的过程中就把这些字符串常量替换成当前JVM中的字符串常量。
注:不是程序一启动,就把所有的类都加载完毕的,而是用到哪个类就加载哪个类,而字符串常量是最初启动JVM的时候就有的。
初始化:主要针对在“准备”环节中,对初步初始化的静态变量进行真正地初始化。同时也会执行static
的代码块。
双亲委派模型描述的是类加载中,根据类名找类的.class
文件的查找过程.
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
在JVM中,有三个类加载器(三个特殊的对象)来负责找文件的操作。这三个类加载器对象都有各自找的区域。
BootStarpClassLoader
:负责加载标准库中的类;
ExtClassLoader
:负责加载扩展库中的类;
ApplicationClassLoader
:负责加载程序员自定义的类/第三方库的类;
当代码中使用到某个类的时候,就会触发类加载。
首先是从AppClassLoader
开始的,但是AppClassLoader
并不会直接开始去扫描自己负责的目录,而是先找它的爸爸。
找到了ExtClassLoader
之后,它也一样,不会立刻去扫描自己负责的目录,而是又去找它的爸爸。
找到BootStarp
之后,它也不会立刻去扫描自己负责的目录,而去找它的爸爸。但是它并没有爸爸,因此就只能自己先去扫描自己负责的目录。如果在自己的目录中,找到了复合的类,就没有其它类加载器的事情了。但是如果没有找到匹配的类,就告诉儿子(ExtClassLoader
),ExtClassLoader
再来找自己负责的目录,如果找到,就加载,找不到就告诉儿子(AppClassLoader
)去查找,AppClassLoader
就在自己负责的目录去查找,如果找到就加载,找不到就抛出ClassNotFound
异常
双亲委派模型就约定了上述类加载器之间的优先级(先查哪个目录,后查哪个目录)。
java.lang.Object类
的话,那么程序运行的时候,系统就会出现多个不同的 Object
类,而有些 Object
类又是用户自己提供的,因此安全性就不能得到保证了。只是标准库中的三个类加载器要遵守.其他的类加载器不太需要遵守.
例如Tomcat 中的类加载器就没遵守.(要从 webapps
目录中加载指定的webapp
的类)
垃圾回收回收的是内存。JVM 其实是一个进程(java),一个进程会持有很多的硬件资源,如(CPU,内存,硬盘,带宽),而系统的内存总量是一定的。因此对内存的合理使用是非常重要的。
内存要经过:申请->使用->释放 过程。内存是有限的,并且要给很多的进程去使用。从代码编写的角度看,内存申请的时机是很明确的,但是内存的释放时机很模糊。对于C语言来说内存的释放是靠程序员自己去手动释放的,如malloc、free
等。但是一旦忘了释放内存,就会造成内存泄漏,直到内存耗尽为止。
对于内存泄漏问题,不同的语言有不同的解决方法:
C++
中引用了智能指针,在合适的时机去自动释放内存,(一般是通过引用计数的方式来衡量这个内存被引用了多少次,当引用计数为0时就真正释放内存)。Rust
中,采取的方案是基于语法上的强校验,Rust引入了很多对内存操作相关的语法规则,在编译器编译期间就会对进行严格的检查和校验,一旦发现有代码存在内存泄漏的风险,就编译报错。但是也有不好的地方,它的语法非常丑陋,同时也限制了很多功能的实现。以至于在实现一些特殊功能的时候要使用个’unsafe’操作,引入这个操作,之前的校验也就部分的失效了。Java
中采用垃圾回收的方式,对于该机制来说,哪一个代码申请都可以,哪里申请都可以,都是由JVM统一去进行垃圾回收(内存释放),具体来说,就是由JVM 内部的一组专门负责垃圾回收的线程来进行这样的工作。垃圾回收的优缺点:
优点:能够非常好地保证不出现内存泄漏的情况(不是100%保证),并且是自动去进行内存释放。
缺点:
stop the world
),比如说有一大段内存需要去释放,那么可能系统的资源都用来去释放该内存了,而其它的代码就不能够继续执行,没法去做别的事情。但是现在大佬们能够将STW
问题限制在了1ms
之内。堆为主,方法区为次
Java运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。方法区里面的是类对象,它是类加载过来的,而对方法区进行垃圾回收,就相当于“类卸载”,这里的规则比较特殊,我们不用考虑。
在上述几个区中,堆占据的内存空间就是最大的,它占据了一个程序的大部分内存。
堆内存分成三个部分:正在使用,已经用过(主要回收),尚未使用.回收以对象为单位.
垃圾回收机制主要回收的就是 完全不再使用的内存。对于一半在使用,一半不再使用的内存,是不回收的,因为回收的成本比较大,当然实现起来也比较麻烦。
因此,Java中的垃圾回收,是以“对象”为基本单位的,一个对象,要么被回收,要么不被回收,不会出现一个对象被回收一半的情况。
GC的基本指导方针:先标记,再回收
引用计数描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
引用计数的优点:
规则简单,实现方便,比较高效(程序运行效率高)
缺点:
如:
class Test {
Test t = null;
}
Test t1 = new Test();//引用计数为1
Test t2 = new Test();//引用计数为1
t1.t = t2;//2
t2.t = t1;//2
//下面这一个操作销毁了两个引用,但是引用计数只减了1,
//这个操作即少了t1,也少了 t1.t
t1=null;//
t2=null;//
很多编程语言虽然使用了引用计数,但是实际上都是改进的引用计数。
因此在Java中没有使用引用计数的方式去判定垃圾,而是第二种方式——可达性分析。
此算法的核心思想为 : 通过一系列称为"GC Roots
"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots
没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。(类似于二叉树)
对象f-g之间虽然彼此还有关联,但是它们到GC Roots
(a)是不可达的,因此他们会被判定为可回收对象
JVM中采取的方案是:在JVM 中就存在一个/一组线程,来周期性地进行上述遍历的过程,不断地找出这些不可达的对象,由JVM进行回收.
在Java语言中,可作为GC Roots
的对象(即可达性分析的初始位置)包含下面几种:
(栈上的局部变量表
常量池中的引用指向的对象
方法区中,引用类型的静态成员变量)
和引用计数相比可达性分析确实更麻烦,同时实现可达性分析的遍历过程开销是比较大的。但是带来的好处是解决了引用计数的两个缺点:内存上不需要消耗额外的空间,也没有循环引用的问题。
从上面我们可以看出“引用”的功能,除了最早我们使用它(引用)来查找对象,现在我们还可以使用“引用”来判断对象的生死了。所以在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。
"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
"标记-清除"算法的不足主要有两个 :
复制算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图 :
复制算法的缺点:
因此复制算法,适用于:对象会被快速回收,并且整体的内存不大的场景下。
能够解决复制算法的内存空间利用率的问题。它类似于顺序表的“删除”的搬运操作。
初始:假设此时要回收3,5,7 的内存空间。就把未被回收的依次往前搬,4搬到3位置;5要被回收,不动;6搬到4的位置;7要被回收,不动;8搬到5的位置;搬到最后7没有被覆盖,那么就回收7。
优点:能够有效避免内存碎片,同时也能提高内存利用率。
缺点:在搬运的过程中,是一个很大的开销,这个开销可能比复制算法里面的开销更大。
分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。
根据“年龄”去进行划分。年龄是是根据GC的次数来的,每次经历一个扫描周期,就认为“长了一岁”。在JVM中,垃圾回收扫描(可达性分析)是周期性地进行的。因此就根据不同的年龄,就采用不同的垃圾回收算法来处理了。
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清除"或者"标记-整理"算法。
划分结构:
新生代:一般创建的对象都会进入新生代;
老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代移动到老年代。
了解Minor GC和Full GC吗,这两种GC有什么不一样吗
- Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
- Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
分代回收的过程:
生存区的内存比较小,那么空间小能放下这么多对象吗?
答:根据经验规律,伊甸区(Eden)的对象,绝大部分都是活不过一岁的,只有少数对象能够来到生存区,大部分对象都是“朝生夕死”的。
在生存区中,对象也要经历若干轮GC,每一轮GC 逃过的对象,都通过 复制算法 拷贝到另外的生存区里。这里面的对象来回拷贝,每一轮都会淘汰掉一批对象。
在生存区中,熬过一定轮次的GC之后,这个对象如果还没有被回收的话,JVM就认为这个对象未来能够更持久地存在下去。于是就将这样的对象拷贝到老年代了。
进入老年代的对象,JVM都认为是属于能够持久存在的对象。这些对象也需要使用GC 来扫描。但是扫描的频次就大大地降低了。老年代这里通常使用的是标记-整理算法。
特殊地,如果一个对象的内存特别大,它会直接放入老年代。因为如果把它放入到新生代,如果经过一轮GC没有被淘汰,就放到生存区中。在生存区中拷贝来拷贝去的开销会比较大,甚至有的对象的内存太大在生存区可能放不下,因此直接放入老年代更合适。
主要的特点:尽可能地降低STW.
CMS
收集器是基于“标记—清除”算法实现的,整个过程分为4个步骤:
- 初始标记(CMS initial mark)
初始标记仅仅只是标记一下GC Roots
能直接关联到的对象,速度很快,需要“Stop The World”。- 并发标记(CMS concurrent mark)
并发标记阶段就是进行GC Roots Tracing的过程。- 重新标记(CMS remark)
重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。- 并发清除(CMS concurrent sweep)
并发清除阶段会清除对象
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的.
优点:并发清除、低停顿。
最主要的特点是将内存划分成了更多的小区域(不像上面所说的新生代和老年代),以小区域单位进行GC .