当前位置: 首页 > article >正文

网络原理--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。


http://www.kler.cn/a/579122.html

相关文章:

  • 微服务与无服务器:我的理解与实践
  • C#实现软件重启的功能
  • Mysql表的复合查询
  • Java初级入门学习
  • 若依vue前端queryParams搜索下拉框无法重置或者重置值有问题
  • Linux Shell脚本-分隔符问题
  • ArduPilot开源代码之AP_OSD
  • 深度学习编译器(整理某survey)
  • 前端 | 向后端传数据,判断问题所在的调试过程
  • GDB调试技巧:多线程案例分析(保姆级)
  • 家政小程序源码功能方案详细php
  • 【论文阅读】VAD: Vectorized Scene Representation for Efficient Autonomous Driving
  • Python爬虫入门实战:爬取博客文章数据并保存
  • 线程安全问题(面试重难点)
  • 复现 MODEST 机器人抓取透明物体 单目 ICRA 2025
  • 游戏引擎学习第147天
  • openharmony适配HDF编译进Linux内核
  • 40岁开始学Java:控制反转IoC
  • 蓝桥备赛(13)- 链表和 list(上)
  • vue3组合式API怎么获取全局变量globalProperties