目录
今日良言:只要你足够努力,生命都会庇佑你
一、JVM内存区域划分
二、类加载过程
三、垃圾回收机制(GC)
先来了解一下什么是JVM :
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。
JVM 和其他两个虚拟机的区别:
1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进
行了裁剪。
JVM 是一台被定制过的现实当中不存在的计算机。
对jvm有了简单的认识以后,接下来重点介绍一下 JVM 相关的三个面试常考知识点.
JVM内存区域划分:
JVM 是一个应用程序,一个 JVM 就是一个 java 进程,JVM 在启动的时候,会从操作系统这里申请到一整个很大的内存区域.
JVM 就根据需要将整个空间分成5个部分,各个部分都有不同的作用和功能.如下图:
本地方法栈:
native 表示是 JVM 内部的 C++ 代码.
本地方法栈就是给调用native方法(JVM内部方法)准备的栈空间,存储的是native方法之间的调用关系.
程序计数器:
记录当前线程执行到哪个指令,每个线程有一份.
虚拟机栈:
给 java 代码使用的栈空间.存储的是方法之间的调用关系.
整个栈内部空间,可以认为是包含很多个元素(每个元素表示一个方法),每个元素又叫做"栈帧",每个栈帧里面包含了这个方法的入口地址、局部变量、参数、返回地址等.每个线程有一份.
堆:
整个 JVM 空间最大的区域, new 出来的对象就是在堆上,类的成员变量也是在堆上.
元数据区(又叫方法区):
存储的是类对象、静态成员、常量池等.
这里相关的面试题最主要的考点是,给一段代码,问某个变量在哪个区域上:
1.局部变量在虚拟机栈上.
2.普通成员变量在堆上.
3.静态成员变量在元数据区/方法区.
介绍完了 JVM 的内存区域划分,再介绍第二个知识点:类加载过程.
类加载过程就是将一个 .class 文件(字节码文件, .java文件通过javac(java编译器)得到),从文件(硬盘)加载到内存(元数据区)的过程.
主要有以下流程:
加载
通过双亲委派模型找到 .clas 文件,打开文件,将文件内容读到内存中.
验证
检查 .class 文件的格式对不对. .class 文件是一个二进制文件.
准备
给类对象分配内存空间(在元数据区占个位置),将静态成员设置成0值.
解析
初始化字符串,将符号引用转为直接引用.
初始化
调用构造方法,进行成员初始化,执行静态代码块,代码块,加载父类等...
发生类加载的时机:
1)构造类的实例.
2)调用类的静态方法/使用类的静态属性.
3)加载子类之前先加载其父类.
双亲委派模型:
双亲委派模型描述的就是上述加载阶段找 .class 文件的过程.
JVM 默认提供了三个类加载器.
1) BootstrapClassLoader
负责加载java标准库中的类.(java规范,无论是哪种 JVM 的实现,都会提供的一样的类)
2)ExtendsionClassLoader
负责加载 JVM 扩展库中的类.(java规范之外,由实现 JVM 的厂商提供的额外的类)
3)ApplicationClassLoader
负责加载用户项目/用户提供的第三方库 中的类.
上述这三个类存在"父子关系",并不是继承中的父类和子类,而是每个类加载器中有一个parent 属性,指向自己的父亲---类加载器.
上述类加载器配合流程如下:
首先,加载一个类从ApplicationClassLoader开始,但是 ApplicationClassLoader 并不是真的加载,而是交给自己的父类加载,于是 ExtendsionClassLoader 开始加载, 但是也不是真的加载,而是交给自己的父类加载器BootstrapClassLoade,BootstrapClassLoader发现自己的parent属性为null,于是自己开始加载,搜索自己负责的标准库目录相关的类,找到了就加载,找不到就交给自己的子类加载器,于是ExtendsionClassLoader开始加载,搜索自己负责的扩展库目录相关的类,找到了就加载,找不到就交给自己的子类加载器,于是ApplicationClassLoader开始加载,搜索用户项目相关的类,找到了就加载.找不到就交给自己的子类加载器,但是此时ApplicationClassLoader的子类加载器为空,于是报类找不到这样的异常.
大致流程图如下:
为什么要有上述顺序呢?
上述这套顺序其实是出自于 JVM 实现代码的逻辑.
这段代码大概是类似于"递归"的方式写的.
这个顺序最主要的目的就是为了让Bootstrap能够先加载,Application最后加载,这就可以避免因为用户创建了一些奇怪的类,引起不必要的bug.
比如说:一个用户在自己的代码中写了一个 java.lang.String 这个类,按照上面的类加载流程,此时 JVM 加载的还是标准库中的类,不会加载到用户自己写的这个类,这样就能保证,即使出现上述情况,也不会让 JVM 内部的代码混乱,最多就是让用户自己写的这个类不生效.
接下来介绍一下最后一个知识点:垃圾回收机制.
首先理解一下什么是"垃圾"?
Java中的垃圾指的是:不再使用的内存.
垃圾回收就是将不再使用的内存自动释放.
GC是最主流的解决垃圾回收的一种方式.
GC优点:非常省心,让程序员写代码简单点,不容易出错.
GC缺点:需要消耗额外的系统资源,也有额外的性能开销.
另外,GC这里还有一个比较关键的问题:STW(Stop The World):
如果有时候,内存中的垃圾太多了,此时触发一次GC操作,开销可能非常大,大到可能就把系统资源吃了很多,另一方面,GC回收垃圾的时候可能会涉及到一些 锁操作,导致业务代码无法正常执行,这样的卡顿,极端情况下,可能出现几十毫秒甚至上百毫秒.
GC主要是针对 堆 进行释放的.
GC是以"对象"为基本单位进行回收的!!(不是字节).
GC回收的是整个对象都不再使用的情况.
这样的设定就是为了"简单".
GC实际工作过程:
1.找到垃圾/判定垃圾
2.进行垃圾释放
1.找到垃圾/判定垃圾
java中通过引用来使用一个对象,如果没有引用指向该对象了,说明这个对象就不再使用了.
具体如何知道对象是否有引用指向呢?
两种典型实现:
1).引用计数
给每个对象分配一个计数器,只要有引用指向这个对象就让计数器+1,指向这个对象的引用销毁了,就让这个计数器-1.
{ Test t = new Test(); // Test 对象的引用计数 1 Test t2 = t; // Test 对象的引用计数 2 Test t3 = t; // Test 对象的引用计数 3 }
这是一种简单有效的办法,但是会带来一定问题:
内存空间浪费的多(利用率低):每个对象都要分配一个计数器,如果按4个字节算,代码中
的对象非常少,无所谓,如果对象特别多,占用的额外空间就会很多,尤其是每个对象比较
小的情况.
如果一个对象1k,此时多4个字节无所谓.
如果一个对象4个字节,此时多4个字节,体积扩大一倍.
还存在循环引用的问题:
class Test { Test t = null; } Test a = new Test(); // 1号对象,引用计数是1 Test b = new Test(); // 2号对象,引用计数是1 a.t = b; // a.t也指向2号对象,2号对象引用计数是 2 了. b.t = a; // b.t也指向1号对象,1号对象引用计数是 2 了.
接下来,如果a和b引用销毁了,1号对象和2号对象的引用计数都-1,但是结果都是1,不是0,
虽然不是0,不能释放资源,但是这两个对象都无法访问到了.
2).可达性分析
java中的对象都是通过引用来访问的,通常是一个引用指向一个对象,这个对象里的成员又
指向其它对象.(比如二叉树的节点)
可达性分析就是把组织这些所有对象的结构视为是树,就从根节点开始遍历树,所有能访
问到的节点标记为"可达"(不能访问的就是不可达).
JVM 自己捏着一个所有对象的名单.通过上述遍历,把可达的标记出来,不可达的就是垃圾
进行释放.
可达性分析需要进行类似于"树遍历",这个操作相比于引用计数来说是要慢一些的,但是速度
慢是没关系的,上述可达性分析遍历操作,并不需要一直执行,只需要每隔一段时间,分析一遍
就可以了.
进行可达性分析遍历的起点,称为GCroots,可以作为GCroots的有:
栈上的局部变量,常量池中的对象,静态成员变量.
一个代码中,有很多个这样的起点,把每个对象都往下遍历一遍就完成了一次扫描过程.
2.进行垃圾释放
主要是三种基本做法.
1)标记清除
简单粗暴,直接标记垃圾,然后进行释放,但是会带来"内存碎片问题",被释放的空间是零散
的,不是连续的.
申请空间要求的是连续空间,上述总的空闲可能很大,但是具体的每一个空间又很小,可能
导致申请大一点的内存就会失败.比如总的空闲内存是10k,分成1k一个,此时申请2k的空
间就会失败.
2)复制算法
解决了"内存碎片问题".将一块大的内存空间分成两部分,用一半丢一半.
复制算法就是把不是垃圾的对象复制到另一半,然后将整个空间删除.每次触发复制算法,
都是向另一半进行拷贝.
复制算法的缺点:1.空间利用率低(用一半丢一半) 2.(垃圾少的话,复制成本比较大)
3)标记整理
解决了复制算法的缺点.
类似于顺序表删除中间元素,会有元素搬运的操作.
保证了空间利用率,同时也解决了"内存碎片问题".但是这种做法,效率也不高,如果要搬运
的空间比较大,此时开销也很大.
基于上述基本策略,搞了一个复合策略"分代回收"
分代是怎么分的呢?
分代是基于一个经验规律:如果一个东西,存在的时间比较长了,那么大概率还会持续长时间的存在下去.(要没早就没了)
上述规律,对于 java 中的对象也是有效的, java 的对象要么生命周期特别长,要么生命周期特别短,根据生命周期的长短,分别使用不同的算法.
给对象引用一个概念:年龄 (不是以年为单位,而是熬过GC的轮次),年龄越大,这个对象存在的时间就越久.
熬过GC的轮次是指:经过一轮可达性分析遍历以后,发现这个对象不是垃圾.
堆划分成两个区域:新生代和老年代
新生代又划分成三个区域:一个比较大的伊甸区,两个比较小且一样大的幸存区.
刚 new 出来的对象,年龄是0,放在伊甸区.
熬过一轮GC以后,就要放到幸存区了.从伊甸区-->幸存区使用复制算法.
对象到了幸存区以后,就要周期性的接受GC的考验.
如果变成垃圾就要释放,如果不是垃圾,就要拷贝到另外一个幸存区(两个幸存区同一时刻只能使用一个).在二者之间使用复制算法来回拷贝.由于幸存区体积不大,此处的空间浪费也能接受,
如果这个对象在两个幸存区来回拷贝很多次了,此时就要进入老年代了.
老年代都是年纪大的对象,生命周期周期普遍长.
这个对象在老年代也要周期性的接受GC的扫描,但是扫描频率更低了.
如果老年代的对象是垃圾了,就要使用标记整理的方式进行释放.