JVM的简单认识

发布时间:2026/7/18 19:50:38
JVM的简单认识 JVM前面学习到的多线程和网络相关的知识,在日常开发过程中会经常使用到.不过对于这一节的JVM,在日常开发中则几乎不会涉及到.并且由于JVM是一个非常大的知识点,想要系统学习清楚注定要花费大量时间那为什么还会单独理出一节JVM呢?虽然在开发中几乎遇不到JVM,但面试中可以说是经常出现的,故我们学习JVM也仅仅是为了应对面试面试中,JVM主要考察三个方面:内存区域划分,类加载机制,垃圾回收机制JVM 是什么?在了解三个机制前,我们得先知道JVM是什么.JVM的全称是Java Virtual Machine,即java虚拟机要知道我们写的java代码本质上就是一个文本文件,而为了让机器理解代码的含义(机器只懂二进制),就要把代码给编译成字节码文件(由二进制文件组成),最后再把编译好的字节码文件传给JVM,让JVM解释执行(即把字节码文件转化成CPU能看懂的机器指令,即转化为CPU机器码)所以java代码的运行路线是:.java源码–编译–.class字节码–JVM–CPU机器码这也就是为什么你每次运行java代码时都必须指定一个JDK的原因,JDK中包含了JVM,通过JVM才能调用系统硬件来执行程序那又是为什么要专门搞一个JVM呢?C语言就没说有一个’CVM’虚拟机来转化指令这个原因就是java引以为傲的特点:一次编译,到处运行.只要你编译好了代码,你就能在任何一台安装了JVM的设备上运行这个代码.不管这个设备是手机,还是电脑,甚至是车机,电视等而C语言程序是直接从.c源码–编译–CPU机器码.好处是少了解释层,内存占用低,性能更佳,坏处是兼容性差.windows编译出来的.exe文件无法给ubuntu使用.甚至同为windows的x86程序不能在arm上运行.如果想要跨平台,就必须重新编译.针对系统,CPU架构来单独生成其结果内存区域划分当我们启动一个java进程时,这个java进程就是一个jvm的实例了.jvm会从系统中申请一大段内存,用来存放这个实例的各种数据.在这块内存空间中,会划分出4大区域程序计数器程序计数器是一个很小的区域.它的作用只有一个:保存下一条执行的字节码的内存地址对于一个java程序,最先执行的一定是代码中的main方法.假设这个main方法是在Test类中.那么编译出来的Test.class字节码文件中就会有一块区域对应着mian方法的实现.当java程序执行起来后,这个Test.class就会被加载到到内存中,程序计数器里面就会保存mian方法在Test.class的内存地址生命周期同线程栈栈说简单点就是描述了方法的调用关系(比如从main方法中调用了一个a方法).栈还会同步保存该方法的相关信息:调用方法的形参,局部变量,方法的返回值,方法结束后应返回的地址.其保存信息的方式栈一样(先入先出),拿到信息后会自动销毁生命周期同线程堆堆是 JVM 中占用空间最大的区域,几乎所有的对象实例都在这一块区域中.也就是说你new出来的实例都存放在这里.这块也是后面学习的垃圾回收机制管理的主要区域方法区/元数据区方法区用来存放类的信息.比如类对象,静态变量等等…class文件加载到内存这个操作,就是加载到方法区内而JVM的内存区域又主要分为两大区域:线程私有区和线程共享区线程私有区(线程结束就释放)这类区域的创建和销毁伴随着线程的创建和销毁.一个进程有多个线程时,线程私有区也具有多份.里面包含程序计数器:存放下一条字节码指令的内存地址JVM栈:存放java方法具体信息本地方法栈:存放native方法具体信息线程共享区(JVM关闭才释放)只具有一个,多个线程间共享同一份Java堆:存放new出来的java实例方法区:存放类对象信息补充说明变量的存放区域局部变量属于栈帧的一部分,故在栈中;成员变量属于对象的一部分,故在堆中;静态成员变量属于类的一部分,故在元数据区中类加载机制前面我们讲解了内存区域划分,里面有提到方法区这个东西.我们知道方法区是用来存放类信息,常量,静态变量等等.类加载机制的作用就是JVM把一个.class字节码文件转换成二进制数据加载到方法区并生成java.lang.Class对象的过程对于这个加载过程,我们可以用八个字来概括:按需加载,双亲委派具体加载过程如下:类加载的五个阶段(最后的使用和卸载不属于加载范畴)加载-验证-准备-解析-初始化-使用-卸载加载作为第一步,JVM要做的内容其实很简单.就是找到.class文件,然后打开文件,并读取文件内容具体来说:先通过类的名称(如java.lang.String)来获取定义此类的二进制字节流(流来源可以是本地磁盘,也可以是来自网络)其次把这个字节流代表的静态存储结构数据转为方法区的运行数据结构最后在堆(内存)中生成一个代表这个类的java.lang.Class对象,该对象为方法区这个类的访问入口验证验证是对加载出来的class文件中的信息进行判断,判断其是否符合java虚拟机规范的全部约束要求.保证这些代码运行后不会危害java虚拟机自身准备开始为类变量(static修饰的内容)分配内存(分配的内存会先进行格式化,即将内存空间的值全设为0)并设置变量的初始值(此时变量的初始值也是0)例如:public static int value 10;在准备阶段中此时value值为0例外:final修饰的常量在准备阶段就会直接被赋值.public static final int value 10;在准备阶段中的value为10解析解析过程中,会把符号引用转化为直接引用符号引用:用一组符号来描述引用对象直接引用:直接找到目标内存地址的指针/偏移量为什么要转换?在写代码和编译时我们不可能知道一个类,一个方法,一个变量在运行时的内存地址.例如在代码中我们是以nums这个名字来表示一个数组.这个nums的符号,就代表了nums这个对象.后续代码要使用它时,只需要通过nums这个符号引用,就能正常调用了.但是在JVM中,只能使用直接引用.因此需要解析这个过程把符号引用转化为直接引用初始化这是类加载的最后一步.此时JVM会对静态成员进行初始化,并执行所有的静态代码块.前面准备阶段的public static int value 10;此时value才会被真正赋值10使用卸载加载机制了解了,现在我们了解一下双亲委派模型双亲委派模型,是类加载第一个阶段(加载)里面的一个环节它的核心工作就是找class文件当一个类加载器(class loader)收到加载类请求时,它不会先自己去加载这个类,而是把这个请求委派给父类加载器去完成在JVM中,其内置了三种加载器Bootstrap ClassLoader(启动类加载器)有其专门的目录存储.负责加载标准库的类Extension ClassLoader(扩展类加载器)有其专门的目录存储.负责加载扩展库的类扩展库一般是实现该jdk的厂商额外加入的库文件(现在很少使用)Application ClassLoader(应用程序类加载器)存放在项目目录(maven目录)中.负责加载第三方库的类,也就是我们自己写的代码和引入的第三方镜像Maven依赖既然是双亲委派模型,那么这些一定是有父子类关系的,从上到下依次如下Application ClassLoaderExtension ClassLoaderBootstrap ClassLoadernull类加载器流程(双亲委派模型流程)从Application ClassLoader出发,作为委派的入口每个类加载器都不是立即自己加载,而是先判断有无父亲,有父亲则转交给父亲,没有则自己加载若自己找到了加载类,则自己加载若自己未找到加载类,则委派回儿子加载假设有一个用户自定义类想要运行.那么其加载过程如下:从应用程序类加载器开始由于应用程序类加载器有父亲,故委派给父亲加载由于扩展类加载器有父亲,故委派给父亲加载由于启动类加载器没有父类故启动类加载器尝试自己加载由于启动类加载器没找到这个类,故委派给其儿子加载由于扩展类加载器没找到这个类,故委派给其儿子加载应用程序加载器找到了对应类,进行加载开始加载Application ClassLoaderExtension ClassLoaderBootstrap ClassLoadernull加载完成为什么要这么设计?不知道你有没有想过,为什么不设计成一个类加载器呢?为了三点:安全性:防止恶意代码自定名为java.lang.String,欺骗加载器其为原生String类.提升安全性职责分离:当一个程序有成百上千个class文件时.如果只有一个加载器,则哪怕是printf这样的方法都得在这几千个类里寻找,效率低.而三层类加载中只会搜索启动类那里面固定的少量类.提高加载效率扩展性:每一个应用程序都可以通过应用程序加载器来加载不同版本的依赖.如果只有一个类加载器,那么依赖的版本只能加载同一个垃圾回收机制(CG)首先我们要知道垃圾回收机制回收的垃圾是什么?在JVM中,垃圾指的就是无用的内存占用.比如一个程序在运行时申请了一块内存,过了一段时间,这个程序运行完成了并结束了,但它占用的内存却没有被释放.这个未被释放的内存就是垃圾在C/C中,内存是需要手动释放的.如果你用C编写了一个程序,但却没有为其设置释放内存的逻辑,最终就会导致内存泄漏(无用内存一直未被释放)通常需要释放的内存不是定义的全局变量/局部变量这些.而是new出来的实例对象在java中,内置了垃圾回收机制.也就是内存释放不是只依靠程序员自己手动控制.JVM也会自动对无用内存进行回收.内存回收机制不仅仅是java独有的,python语言也有其垃圾回收机制既然垃圾回收机制这么好用,为什么C/C没有呢?实际上C/C确实有考虑过加入垃圾回收机制,但由于其会产生STW(Stop The World)问题:强制暂停所有进程,等待垃圾清理完后才继续执行进程.故最终未假如垃圾回收机器.并且回收也是有运行时开销的,也就是说会在一定程度上降低运行效率像C/C这种最求极致性能的编程语言,引入垃圾回收所导致的效率降低是难以被接受的垃圾回收是如何运行的?垃圾回收需要几步?第一步:识别垃圾;第二步:清理垃圾对于JVM来说,做到垃圾回收也就是得先识别出垃圾,然后清理垃圾首先我们回顾一下前面内存区域划分简单来说内存区域被划分为4个部分:程序计数器:生命周期同线程,线程销毁了与之对应的程序计数器也销毁了栈:保存方法的调用关系,使用后自动释放堆:保存new出来的对象实例.占用空间最大方法区:保存类对象,静态变量等,占用空间较小对于这个4个部分,由于程序计数器和栈都会自动销毁,故GC会处理的主要地点就是堆和方法区了.垃圾回收第一步,识别垃圾在java中,使用一个对象只能通过引用来使用.因此我们可以得到两点信息如果一个对象有引用指向它,那么这个对象就可能被使用如果一个对象无引用指向它,那么这个对象一定不会被使用因此,GC识别垃圾就是看这个对象还有无引用.对于第二种情况:无引用的对象一定是不会被使用的对象了, 因此一定是垃圾.对于第一种情况:虽然有引用的对象不一定会被真正使用,但由于GC无法确定这个一定是不使用的对象,故不会对其进行垃圾回收如果把这些对象比作放在家里的药,那么GC就是这个家的主人.家主人会定期检查这些药有没有过期,如果过期了,那一定是用不了的,直接丢了就行.而如果是没过期的药,哪怕家主人没有生病,他也不会把这些没过期的药丢了.毕竟家主人并不知道自己什么时候会再生病现在我们知道GC是通过判断有无引用来判断是不是垃圾.那么这个引用又是怎么判断出来的呢?判断引用有多种方法,以下是常见的几种判断方法方法1:引用计数应用计数这个方法思路很简单:通过一个计数器,记录有多少引用指向这个对象.每有一个引用就计数器就加一,每减少一个引用计数器就减一.当计数器为0时,就说明这个对象没有人引用,可以清理掉了具体流程:创建对象:User u1 new User();当一个对象被创建时,内存空间不仅仅会存放对象数据,还会在里面存放一个计数器,由于创建时有u1指向User对象,故此时计数器值为1增加引用假如写了一个User u2 u1;.那么此时有u1和u2两个变量指向User,此时计数器自增为2减少引用假如又把u1 null;.此时指向该对象的只剩下u2,计数器自减为1垃圾回收如果再把u2 null;.此时没有变量指向User,计数器自减为0.当计数器自减为0时,会立即销毁该对象,甚至都不需要GC来处理引用计数的优缺点优点:极其实时高效:只要计数器为0,这个内存就会以极快的速度释放,而不是等待GC才清理这个垃圾不存在STW:内存回收会在每次代码的赋值,变量结束后就进行释放,不会得把所有线程卡死才能来清理垃圾实现简单:实现起来非常简单轻量缺点:会产生循环引用:虽然这个缺点只有一条,但这却是一个极其严重的缺点.会导致无法完全清理垃圾循环引用下的引用计数对于下段伪代码:classNode{Nodenext;}//创建两个节点NodeanewNode();//a计数器为1NodebnewNode();//b计数器为1//使两节点互相引用a.nextb;//a计数器为2b.nexta;//b计数器为2//断开原始指向anull;//a计数器为1bnull;//b计数器为1虽然此时a和b都有引用,但是没有任何办法能获取到a对象和b对象了.因为你如果要获取a对象,那么你只能通过b.next来获取,而你要获取b对象,又得获取a.next,可你现在就是要获取a对象,但获取a对象的方法又是你得通过a.next获取…此时不仅获取不到a和b的对象,而且这个对象占据的内存也不会被清理,因为它们的引用数还是1随着时间推移,这样的内存泄露越来越多,直到JVM崩溃…好在JVM实际上并不是使用的应引用计数来标记清理的.JVM使用的是更高级,也更复杂的可达性分析2.方法2:可达性分析可以说KVM使用可达性分析就是为了能标记出循环引用这种的垃圾.具体的实现原理如下:在代码中,所有可用的对象都是能通过引用来访问到的,因此JVM可用通过能否访问到一个对象来判断这个对象是否可达JVM会先分配出一个 GC Root(根对象) .这个根对象通常是临时局部变量,静态变量,常量为什么要选择这些作为根对象呢?因为JVM就是通过根对象的引用来判断其它对象是否可达.如果根对象自己就容易不可达(无法使用),那么想通过根来访问其它的对象也会变得不可达,哪怕这个对象还能正常使用.因此根对象通常选择为那些不容易被删除清理的对象选择好根对象后,JVM就会周期性的尝试访问所有对象.尽可能通过引用关系访问到所有的对象,每一个被访问的对象都会有一个可访问的标记,即可达JVM中内置了一个对象表,其中记录了所有被new过的对象.对于没有标记的对象GC就会自动清理它们由于可达性会选择一个根对象.对于循环引用这种情况,由于循环引用不会被根对象引用到,故可达性分析也标记不到循环引用的对象.最终循环引用也会被GC回收掉可达性分析的优缺点优点:可清理循环引用缺点:不实时高效:不是检测到无引用就会立即清理,而是等待一个周期性的GC进行清理存在STW:虽然JVM通过优化已经将STW问题的影响大幅度缩小了,但仍存在该问题实现复杂可以说可达性分析的优缺点和引用计数的优缺点是反着来的释放垃圾对象前面说了那么多GC,那么GC究竟是怎么释放的呢?方法很多,且各有各的优缺点标记删除实现原理:被标记的对象直接进行删除优点: 实现简单缺点: 内存碎片要知道申请内存都是申请的连续内存.而内存空间不连续会导致一个情况:我空闲内存有32kb,我程序要申请的内存只有16kb,但是程序申请不到内存空间.因为空闲的32kb内存被随机分在了4块地方,每块地方都是4kb的连续空闲内存,可程序需要的是16kb的连续空闲内存复制算法实现原理:将内存分割为两个部分,程序一开始申请和使用的是第一部分的内存,待GC标记出要被清理的对象时,把不是垃圾的对象复制到另一块完全空闲且连续的内中复制算法就是为了解决内存碎片的问题而诞生的.虽然其解决了内存碎片问题,但也引入了新的问题优点:不存在内存碎片问题缺点1:内存利用率低,只能使用一半的内存缺点2:存在复制开销,需要额外进行复制对象操作标记整理实现原理:将被标记删除的对象删除后,自动将未被删除的对象移动到内存碎片中,以此解决内存碎片问题优点:不需要额外内存就可解决内存碎片问题缺点:仍然存在额外开销(移动对象)既然这三个方法都存在一定的问题,那么有没有既不太影响效率,又不会产生内存碎片问题,还能保证内存利用率高的方法吗?有的,当然有.现在JVM使用的垃圾回收方法分代回收就是如此分代回收分代回收会专门根据不同对象的特点来采取不同的方式进行回收首先我们要知道一个经验规律:存活时间越长的对象,越不容易成为垃圾;存活时间越短的对象,和越容易成为垃圾.对于刚创建的对象,很大部分都活不过第一轮的GC回收周期分代回收将一块内存划为了三个区域:为了便于理解我将其称为新生区,中年区,老年区新生区:每一个新创建的对象都在这里中年区:新生区中经历一次可达性性分析后仍然可达的对象会进入中年区老年区:中年区中经历过多轮可达性分析后仍然可达的对象会进入老年区区域划分我们现在知道了,那么分代回收的优点在哪呢?对于内存碎片问题新生区和中年区经历每一次GC都会将其清空.如果不是垃圾,就通过复制转移至下一个区,剩余的直接将区域清空.区域都删干净了,自然不存在内存碎片老年区则不同,使用的是标记整理法.由于老年区删除的对象极少,故此时移动开销相对较小,可以接受对于性能开销问题新生区和中年区中约8成的对象都会被标记删除,需要复制的对象较少,性能开销不大老年区使用的标记整理,且GC执行的周期明显大于其它区域,故性能开销也较低优点:分代回收既解决了内存碎片问题,还将内存回收的开销控制在一定范围内,STW的时间也能控制在极低的时间内