网络原理--JVM简介
在计算机导论中,编程语言可以分为:1.编译型的语言。2.解释型的语言。
按照这种划分,Java可以认为是“半编译,半解释”的语言,最初Java这么设计,是为了跨平台。
想c++这样的语言是直接生成二进制的机器指令,不同cpu,上面支持的指令不一样,而且不同系统上的可执行文件格式也不同。
而Java不想重新编译,而是期望能够直接执行。
先通过javac 把 .java文件==》.class文件,.class文件是字节码文件,包含的就是Java字节码,java自己搞的一套“cpu指令”。
然后在某个具体的平台上执行,此时再通过JVM,把上述的字节码转换成对应的cpu能够识别的机器指令。
在上述过程中,jvm就起到了一个“翻译官”的作用。
因此我们在发布Java程序时,其实只要发布.class文件即可,jvm拿到.class文件就知道如何转换,比如Windows上的jvm可以把.class文件转换为Windows上能支持的可执行指令,Linux也是如此。
当前的主流jvm是.HotSpot VM
JVM主要涉及三个话题:
一、jvm中的内存区域划分
jvm也是一个进程,进程在运行中,要从操作系统中申请一些资源,内存就是其中的典型资源,这些内存空间,支持了后续Java程序的执行,比如java程序中定义变量,就会申请内存,内存就是jvm从系统这里申请到的内存,这里的jvm就类似于“二房东”。
jvm从系统申请了一大块内存,这一块内存给Java程序使用时,又会根据实际用途来划分出不同的空间(这就是“区域划分”)不同的虚拟机的划分会略有不同,这里举一个典型例子。
1.堆(只有一份)
代码中new 出来的对象,就都是在堆里,对象持有的非静态成员变量,也是在堆里。
2.栈(可能有n份)
包含本地方法栈和虚拟机栈,包含了方法调用关系和局部变量,由于jvm内部是通过c++写的代码,调用关系和局部变量一般不会关注本地方法栈,一般来说谈到栈,默认指的就是虚拟机栈。每个线程都有自己的栈。
3.程序计数器(可能有n份)
这个区域空间比较小,专门用来储存下一条要执行的Java指令的地址,每个线程都有一份程序计数器。
4.元数据区(只有一份)
在1.8以前,也叫做“方法区”,“元数据”是计算机中的一个常见术语,往往指的是一些辅助性质的,描述性质的属性。在Java程序中,类的信息,方法的信息,一个程序,有哪些类,每个类中有哪些方法,每个方法中包含哪些指令,都会记录在元数据区中。
经典的面试题
class Test{
private int n;
private static int m;
}
main(){
Test t = new Test()
} 问上述代码中,t,n,m各自处于jvm内存中哪个区域?
首先,n是Test的成员变量,而成员变量是在“堆”上的。
而t是一个局部变量(引用类型),t这个变量本身是在“栈”上的。
static修饰的变量,称为“类属性”;static修饰的方法,称为“类方法”,非static修饰的变量,称为“实例属性”,非static修饰的方法,称为“实例方法”。
而m被static修饰,在类对象(Test.class)中,也就是在元数据区中。
关于类对象:
jvm把.class文件加载到内存之后,就会把这里的信息使用对象来表示,此时这样的对象就是类对象,类对象里包含了一系列信息:类的名称,类的继承关系,实现关系,有什么属性、方法,分别叫什么名字,是什么类型,有什么权限等待。当然,注释是不会包含的。
二、jvm的类加载机制
类加载,指的是,Java进程运行的时候,需要把.class文件从硬盘读到内存,并进行一系列的校验解析的过程。
类加载的过程:
类加载的过程在Java的标准文档中是有说明的,它是最为准确的。
类加载的过程大体可以分为5个部分(也有分为3个部分的,是将2、3、4何为一个了)
1.加载。
把硬盘上的.class文件找到并打开,读取文件内容(jvm认为读到的是二进制的数据)。
2.验证。
当前需要确保读到的文件的内容是合法的.class文件格式。(字节码文件)具体的验证依据,在Java的虚拟机规范中有明确的说明。
3.准备。
给类对象申请内存空间,此时申请到的内存空间,里面的默认值都是全0的。
4.解析。
主要是针对类中的字符串常量进行处理(将常量池内的符号引用替换为直接引用等)
符号引用和直接引用:例如:假如.class文件中有这样一句代码 String s = "hello";在未转换时,hello是在硬盘中存储的,会有一块空间叫“s”。因为硬盘没有地址这一概念,所以“s”这块空间会有一个叫做偏移量的东西,对应从“s”到存储“hello”常量池的距离。这个偏移量就可以认为是“符号引用”。
当将.class加载到内存中后,“hello”这个字符串就会被加载到内存中,此时“hello”就有地址了,这时“s”里面就可以替换为“hello”的地址了,这个就叫做直接引用。
5.初始化。
针对类对象完成后续的初始化。不仅会执行静态的代码块,而且有可能会触发父类的加载。
三、双亲委派模型
是jvm类加载过程中的 1.加载 过程的环节,描述了如何查找.class文件的策略。
jvm中进行类加载的操作,是有一个专门的模块,称为“类加载器”。jvm中类加载器默认是有三个的(也可以自定义):
1.BootstrapClassLoader:负责查找标准库的目录。
2.ExtensionClassLoader:负责查找扩展库的目录。
3.ApplicationClassLoader:负责查找当前项目的代码目录以及第三方库的目录。
以上三个类加载器存在父子关系有一个指针parent,指向自己的父类加载器。
双亲委派模型工作过程:
1.从ApplicationClassLoader为入口,先开始工作。
2.ApplicationClassLoader不会立即搜索自己负责的目录,会把搜索任务交给自己的父亲ExtensionClassLoader。
3.代码进入到ExtensionClassLoader的范畴后,ExtensionClassLoader也不会立即搜索自己负责的目录,也要把自己的搜索任务交给父亲BootstrapClassLoader。
4.BootstrapClassLoader拿到任务后,也不会立即执行,也要把任务交给自己的父亲。
5.当BootstrapClassLoader发现自己没有父亲后,才会真正的搜索负责的目录,如果找到了,接下来就进入到打开文件/读文件等流程中。如果没有找到,回到孩子这一层中,继续尝试加载。
6.ExtensionClassLoader收到父亲交给它的任务后,自己进行搜索负责的目录,找到了,进入后续操作,没找到,交给孩子。
7.ApplicationClassLoader收到父亲交给它的任务后,搜索自己负责的目录,如果找到,进入下一流程,没找到,交给孩子。
但是默认情况下,ApplicationClassLoader是没有孩子的,此时说明类加载过程失败了,就会抛出ClassNotFoundException异常。
四、垃圾回收机制(GC)
像C语言等语言中,通过malloc函数申请到的内存,生命周期是跟随整个进程的,这一点对于服务器是十分不友好的,针对每个请求服务器都要申请一块内存的话,如果不free掉,就会使得申请的内存越来越多,后续就没内存可以申请了,这就是内存泄漏问题。
能否让释放内存的操作让程序自动完成呢?
可以的,java就是早期就支持垃圾回收这样的语言,引入这样的机制后,程序会自动判断某个内存释放会继续使用,如果内存后续不用了,就会自动释放掉。
后来的大部分语言都引入了垃圾回收机制,但是C/C++是个例外,一个原因是C在摆烂,另一个是因为C++是追求极致性能的,引入垃圾回收机制无可避免的会影响性能。
针对jvm划分出的各个内存块是否需要垃圾回收机制:
1.程序计数器:不需要GC。
2.栈:不需要GC,局部变量都是在代码块执行结束后自动销毁(栈自己的特点),生命周期都很明显。
3.元数据区/方法区:一般不需要GC。
4.堆:GC的主战场!
垃圾回收,回收的其实是对象,垃圾回收的具体展开:
1)识别出垃圾,哪些对象是垃圾,哪些不是。
2)把标记为垃圾的对象的内存空间进行释放。
1.识别出垃圾
在Java中,使用对象,一定要通过引用的方法来使用(匿名对象除外: new Mytread().start();但是这行代码执行完后,对应的Mythread对象就会被当作垃圾)。
例如:void F(){
Test t = new Test();
t.sort();
}
执行到“}”之后,此时局部变量t就被直接释放了。此时再进一步,上述的new Test()这个对象,就没有引用指向它了,这时便没有代码可以访问它,这个对象就是垃圾了。
但是如果情况更复杂一些呢?
例如:Test t1 = new Test();
Test t2 = t1;
t3 = t2;
t4 = t3;
.........
此时就会有很多引用指向同一个对象,要将new Test对象视作垃圾,需要确保所有指向Test对象的引用都销毁了,如果代码较复杂,而且各个引用生命周期不同,此时情况就不好弄了。
解决方法:
1.引用计数。
对每一个对象都加上一个计数器,如果有一个引用指向它,就加一,如果有一个引用销毁了,计数器就减一,当计数器为0时,就认为没有引用指向这个对象了,就可以将它视为垃圾了。(会有一个专门的扫描线程,去获取当前每个对象的引用计数的情况)
但是这种方法在jvm中并未使用,但是在python,PHP等其他主流语言中较为常用。
引用计数机制中,仍然存在两个关键问题:
问题一:消耗额外的内存空间。
要给每个对象都安排一个计数器,如果对象很多的话,消耗的内存也是很多的。
问题二:引用计数可能会产生“循环引用的问题”,此时,就无法正常工作了。
例如:Class Test{
Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
a = null;
b = null;
上述代码会导致即使对a和b进行了null,但是计数器仍不为0,不能被GC回收掉,但是这两个对象又无法使用。类似于死锁问题。
2.可达性分析(jvm使用的)
本质上是时间换空间,相比于引用计数,需要消耗更多的额外的时间,但是总的来说还是可控的。不会产生类似于“循环引用“这样的问题。
在写代码时,会定义很多的变量,那么就可以以这些变量为出发点,尝试去进行”遍历“。
所谓遍历,就是沿着这些变量中持有的引用类型的成员,再进一步的往下访问,所以能被遍历到的对象,自然就不是垃圾了,剩下遍历一圈也遍历不到的对象,自然就是垃圾了。
这里以一个二叉树为例子:
class Node{
char val;
Node left;
Node right;
}
Node Tree() {
Node a = new Node();
Node b = new Node();
Node c = new Node();
Node d = new Node();
Node e = new Node();
Node f = new Node();
a.left = b;
a.right = c;
b.left = d;
b.right = e;
c.rigth = f;
return a;
}
Node root = Tree();
(天啊,我为什么要在这里写代码,而不去IDEA?)
我们根据上述代码画出树的样子。
jvm中存在扫描线程,会不断地对代码中已有的遍历进行遍历。
如果有这样一句代码:
root.rigth.rigth = null;
这就是将a和c之间的连接进行断开,此时虽然c和f还有连接,但是c和f都遍历不到了,就会被标记为不可达,就被视为垃圾了。
如果:root = null;
那么这些所有节点就都是被标记为不可达,全都是垃圾了。
2.把标记为垃圾的对象的内存空间进行释放
主要的释放方式,还有三种:
a)标记-清除
把标记为垃圾的对象,直接释放掉(最直接的做法)
这样虽然简单,但是在释放掉后,无法避免的产生了大量的内存碎片问题,由于jvm每次申请内存空间都需要一块连续的内存空间,而且比较大,使用这种方式就可能会导致虽然总的内存空间很大,但是却申请失败的情况。
所以一般不会使用这个方案。
b)复制算法
核心就是将申请到的内存一分为二,将不是垃圾的对象,复制到为空的一半里,此时另一半就全是垃圾了,再将全是垃圾这一半全释放掉即可。
优点很明显,可以规避内存碎片问题,缺点也很明显,总的可用空间变少了,只有一半了,而且如果要复制的对象太多,复制开销也很大。
c)标记-整理
类似于顺序表的删除中间原始问题,通过搬运,来将标记的区域进行覆盖。
通过这个方式,也可以有效解决内存碎片问题,而且不会浪费很多内存空间,
但是搬运时的内存开销很大,因为涉及到了大量的复制和销毁。
Jvm针对以上方案,进行了取长补短,搞出了一个“综合性方案”。
d)分代回收(jvm使用的)
依据不同种类的对象,采取不同的方式。
引入了一个概念,对象的年龄。
jvm中有专门的线程负责周期性扫描/释放一个对象,如果被线程扫描了一个,而且没有被标记为垃圾,年龄就加一。
jvm就会根据对象年龄的差异,把整个堆内存分成两个大的部分:1.新生代(年龄小的)2.老年代(年龄大的)。
新生代又被分为伊甸区(Eden),幸存区0(s0),幸存区1(s1)。s1和s0大小相等。
原理:
1.当代码new出一个新的对象,这个对象是被创建在伊甸区的,伊甸区中有很多新的对象。
根据经验,伊甸区中大部分对象是活不过第一轮GC的。
2.第一轮GC后,伊甸区中少数生存下来的对象,就会通过复制算法复制到幸存区0(s0)中,之后的GC扫描中,就不仅会扫描伊甸区,还会扫描s0,之后s0中幸存下来的,会被复制到s1中,之后每经过一轮GC扫描,对象的年龄就会加1。
3.如果经过了若干轮GC,幸存区中的某些对象仍然健在,jvm就会认为这个对象生命周期大概会很长,就会把这个对象从幸存区拷贝到老年区。
4.老年区的对象,也会被GC扫描,但是频率大大降低了。
5.对象在老年区寿终正寝了,此时jvm就会按照标记整理的方式释放内存。
上述只是jvmGC的核心思想,在实际使用上,还会有一些变数和优化。
最后,列举一些常见的垃圾收集器:CMS,G1,ZGC。