前言👀~
上一章我们介绍网络原理相关的知识点,今天我们浅浅来了解一下java虚拟机JVM
JVM( Java Virtual Machine )
JVM内存区域划分
方法区/元数据区(线程共享)
堆(线程共享)
虚拟机栈(线程私有)
本地方法栈
程序计数器(线程私有)
类加载
双亲委派模型
类加载器
类加载过程(也就是上面说的找class文件的过程)
JVM垃圾回收机制(GC)
找到不再使用的对象
回收不再使用的对象
分代回收
如果各位对文章的内容感兴趣的话,请点点小赞,关注一手不迷路,讲解的内容我会搭配我的理解用我自己的话去解释如果有什么问题的话,欢迎各位评论纠正 🤞🤞🤞
个人主页:N_0050-CSDN博客
相关专栏:java SE_N_0050的博客-CSDN博客 java数据结构_N_0050的博客-CSDN博客 java EE_N_0050的博客-CSDN博客
JVM( Java Virtual Machine )
虚拟机其实就是一个计算机系统,就是通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境的完整计算机系统。虚拟机也有自己的虚拟CPU、内存、硬盘等资源。通常在单个物理服务器上运行,可能会与其他虚拟机共享硬件资源
常见的虚拟机:JVM、VMwave、Virtual Box
JVM和两者的区别:
1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪
不管是JDK6、7、8默认的虚拟机都是HotSpot。不同系统的jvm是不同的,但是对上(给java层面提供的内容)是统一的
接下来我们主要讨论三个方面:
1.JVM内存区域划分
2.JVM的类加载机制
3.JVM的垃圾回收机制
了解一个java文件的编译以及执行的过程:在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。首先我们的java源代码文件就是我们平常idea创建的文件(.java)通过java编译器(javac)编译生成字节码文件(.class)在这个过程中,代码中的方法会被转换成字节码指令,字节码文件包含了JVM可以理解和执行的字节码指令,JVM加载这些字节码文件时,会将这些字节码指令通过解释器翻译成机器指令(每个cpu支持的指令不一样)再执行,对于频繁执行的代码会通过即时编译器(JIT属于是JVM的一个组件)将字节码一次性编译成机器码
JVM内存区域划分
一个运行起来的java进程,就是一个JVM虚拟机。每个进程运行起来就需要从操作系统申请获取一块内存空间过来,所以运行一个java进程也是一个道理,在JVM中将这个内存又划分了不同的内存区域,不同的区域起不同的作用
方法区/元数据区(线程共享)
存储被JVM加载的类信息、常量、静态变量即编译器编译后的代码等数据,也就是通过类加载器把.class文件加载到内存,创建一个Class对象来表示这个类也可以称为类对象(包含了类的定义、字段、方法、继承哪个类等信息)
方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的,在《Java虚拟机规范中》把此区域称之为“方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace),不管是永久代还是元空间都属于是对方法区的实现,和永久代不同的是元空间并不在虚拟机中,而是使用本地内存。运行时常量池属于是方法区的一部分,存放的是字面量(final常量、基本数据类型的值)和符号引用(类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符),JDK1.7 字符串常量池从永久代移动了 Java 堆中
堆(线程共享)
存储的是代码中所有new的对象,并且堆是占据内存空间最大的区域
常见的JVM 参数设置 -Xms10m 最小启动内存是针对堆的,-Xmx10m 最大运行内存也是针对堆的,堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象会放入老生代。新生代还有 3 个区域:一个 Endn + 两个 Survivor(S0/S1)。垃圾回收的时候会将 Endn 中存活的对象放到一个未使用的 Survivor 中,并把当前的 Endn 和正在使用的 Survivor 清除掉
虚拟机栈(线程私有)
虚拟机栈的生命周期和线程相同。存储的是代码执行过程中,方法之间的调用关系。我们调用一个方法就创建一个栈帧压入栈中,这个栈帧就代表了一个方法的调用,里面包含操作栈、动态链接、局部变量表()、方法返回地址等信息。当方法调用结束后就弹出栈帧释放内存。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈
本地方法栈
就是存储的是本地方法,也就是native修饰的方法不是使用java实现的
程序计数器(线程私有)
占的空间比较小,存储的是地址,表示下一条要执行的指令可以看作是当前线程所执行的字节码的行号指示器,和之前讲的cpu程序计数器差不多
补充与注意:
多线程的情况下,每个线程都有属于自己的虚拟机栈和程序计数器和本地方法栈,因为我们知道线程调度执行的顺序是不确定的,所以需要记录当前线程执行的位置,从而当线程被切回来的时候能够知道上次执行到哪里了。所以属于线程的这些区域的生命周期随着线程的创建而创建,随着线程的结束而死亡。然后堆和元数据区属于是线程共享的
类加载
java源文件被编译成.class字节码文件后,要想让java程序运行起来,就需要把class文件加载到jvm中,jvm读取class文件里的内容,创建Class对象放到本地内存的方法区中,然后执行代码。所谓的执行代码其实就是调用一个个方法,编译成class文件后方法就成了一个个字节码指令,然后jvm根据程序计数器指向的位置开始执行
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
1.加载:找到class文件,打开并读取文件内容。根据啥找class文件呢?类加载器根据类的"全限定类名"(就是带着包名,例如java.lang.String)找到class文件后读取里面的字节码然后加载到内存中。加载完成后,在方法区中创建一个代表该类的java.lang.Class对象,程序在运行中所有对该类的访问都通过这个类对象,也就是这个Class对象是提供给外界访问该类的接口。加载这一步主要是通过我们 类加载器 完成的,后面的双亲委派模型进行讲解
2.验证:class文件是一个二进制格式的文件,它的结构是严格按照 Java 虚拟机规范定义的。所以我们需要验证当前读取的这个class文件是否符合要求,确保类文件的字节码内容是合法的,保证里面的这些信息被当作代码运行后不会危害虚拟机自身的安全
3.准备:给类对象分配内存空间(最终目的构造出类对象),这步只是分配内存空间没有初始化,Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始
4.解析:针对类对象中包含的字符串常量进行处理,虚拟机将常量池内的符号引用(文件偏移量)替换为直接引用(内存地址)的过程,简单点说这个解析就是一个替换的过程
就比如说下面这个代码中的字符串常量在编译之后会放到class文件中,并且class文件的二进制指令中会创建一个s这样的引用。由于引用保存的是一个变量的地址,class文件属于文件,在内部不涉及内存地址,所以在class文件这个s引用会被设置成一个"文件的偏移量",通过这个偏移量能定位到下面这个例子中字符串所在的位置。等到这个类真正被加载到内存中的时候,再把这个偏移量替换回s这个引用保存的地址
例子:final String s="java"
5.初始化:针对类对象初始化,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程
双亲委派模型
属于类加载机制的一部分,"加载"过程中的的一个环节,类加载器以分层的方式工作,一个类加载器在加载该类时会先委托给它的父类加载器,如果父类加载器无法找到该类,再由当前类加载器进行查找
类加载器
首先了解类加载器,JVM中的一个模块,JVM中内置了三个类加载器。我们也可以自定义类加载器
1.BootStrap ClassLoader(爷):启动类加载器
2.Extension ClassLoader(父):扩展类加载器
3.Application ClassLoader(子):应用程序类加载器
类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码,这样理解这几个ClassLoader中里有一个parent属性指向了一个父"类加载器",也就是指向了它爹
类加载过程(也就是上面说的找class文件的过程)
1.给定一个类的全限定类名
2.Application ClassLoader作为入口,开始执行扫描的逻辑
3.Application ClassLoader不会立即去扫描自己的目录(它负责扫描项目当前的目录和第三方库对应的目录),会先把查找的任务给它的爹Extension ClassLoader
4.Extension ClassLoader不会立即去扫描自己的目录(它负责扫描JDK中一些扩展的库对应的目录,扩展库属于对标准库之外的一些扩展),会先把查找的任务给它的爹BootStrap ClassLoader
5.BootStrap ClassLoader也不会立即去扫描自己的目录(它负责扫描标准库对应的目录),它想把查找的任务给它的爹,可是没爹,只能自己扫描标准库对应的目录,如果给定的类不是标准库的类,就把任务交给它儿子Extension ClassLoader
6.没有扫描到交给Extension ClassLoader,它就负责扫描扩展库对应的目录,找到就执行后面类加载的操作查找结束,没有找到就把任务交给它儿子Application ClassLoader
7.没有扫描到交给Application ClassLoader,它就负责扫描项目当前的目录和第三方库对应的目录,找到就执行后面类加载的操作查找结束,没有找到就抛异常ClassNotFoundException
总结:首先扫描标准库中的类被加载的优先级最高,接着扩展库最后自己写的类和第三方库,这样做可以避免重复加载类也保证了 Java 的核心 API 不被篡改
学习了类加载后看看以下代码怎么输出的呢?
class A {
public A() {
System.out.println("A");
}
static {
System.out.println("A static");
}
}
class B extends A {
public B() {
System.out.println("B");
}
static {
System.out.println("B static");
}
}
public class Test3 {
static B b = new B();
public static void main(String[] args) {
new A();
new B();
}
}
输出结果
JVM垃圾回收机制(GC)
默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代),JDK 9 ~ JDK20: G1
对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关。因为当方法结束或者线程结束时,内存就自然跟着线程回收了,所以这三个区域的内存分配与回收具有确定性,GC回收的对象主要是堆中的对象,也就是new出来的这些对象需要回收。对于栈里的栈帧,方法调用结束栈帧销毁内存自然释放
GC可以分为两大步骤:
找到不再使用的对象
如何判断某个对象是否是垃圾?GC中有两种主流方案分别是引用计数和可达性分析
1.引用计数:new出来的对象,单独安排一块空间保存一个计数器用来描述有 几个引用 指向这个对象,因为java中要使用对象必须靠引用。所以每当有一个引用它时,计数器就+1;当引用失效时,计数器就-1;
为什么java不使用引用计数这个方案?
1浪费内存:一个计数器就按2个字节算,如果对象本身空间大那还好,如果一个对象本身空间就小并且又很多这样的对象,这一个计数器就会占据对象很多的空间
2.存在循环引用问题:类似死锁的问题,要想回收一个对象但是那个对象中又包含另外一个对象的引用,彼此包含了对方的引用,所以无法进行回收
2.可达性分析(java采用的方案):使用时间换空间的手段,会有一个或一组线程 周期性 的去扫描代码中的所有对象,判断哪些对象可以被回收,哪些对象不能回收。通过一系列称为"GC Roots"的对象作为起始点出发,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。这里的遍历可以看作是N叉树的遍历,通过判断某个对象是否有用再访问这个对象看里面有多少个 引用类型的成员,然后又针对 这些引用类型的成员 进行遍历。这里的可达性分析是周期性的,因为某个对象是否是垃圾是随着代码的执行进行变化的,所以可达性分析比较耗费系统资源开销比较大
在Java语言中,可以作为GC Roots对象的包含下面几种:
1. 虚拟机栈(栈帧中的局部变量表)中引用的对象
2. 方法区中类静态属性引用的对象
3. 方法区中常量池中引用的对象
4. 本地方法栈中引用的对象
小结:你会发现这些都是非堆的内存区域,其实堆中的对象只有被使用的时候才算有用的,没用的话就应该被回收,判断该不该被回收前面这句属于是一个,还有就是如果这个有用对象中还有对象,然后这个对象中又有一个对象类似引用链这种也属于是有用的。就和上面说的N叉树一个意思把根想象成一个GC Roots从它出发开始找,有引用链关系或在它遍历范围的我们可以称为有用对象,反之则无用
四种引用
我们会发现无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关!!!
1.强引用:我们直接通过new关键字创建出来的对象都叫强引用对象,就类似我们new一个Object对象。只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例
2.软引用:java中提供了SoftReference类来实现软引用,软引用是用来描述一些还有用但不是必须的对象。对于软引用关联着的对象,在内存要不够用的时候会对这些对象进行回收,如果内存还是不够用就直接抛内存溢出异常
3.弱引用:java中提供了WeakReference类来实现弱引用,弱引用也是用来描述非必需对象的,强度要弱于软引用。被弱引用关联的对象无论内存空间够不够,当垃圾回收器开始工作这些对象都会被回收,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象
4. 虚引用 : java中提供了PhantomReference类来实现虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动
回收不再使用的对象
如何回收?以下三种基本思路
1.标记清除算法:通过标记和清除,标记有用对象然后对这些未被标记的对象进行清除,在标记清除后会产生大量不连续的内存碎片,首先我们申请内存的时候都是申请连续的内存空间,如果在释放内存的时候是不连续的会导致申请内存的时候明明有这么10MB却只能申请5MB的情况
2.复制算法:为了解决标记-清除算法的效率和内存碎片问题,它将可用内存划分为大小相等的两块,把有用的对象放到一起,然后对没用的对象进行统一回收。但是也有缺点,内存利用率不高可用内存缩小一半,并且复制的开销大如果有用对象很多的话
3.标记-整理算法:这个算法既解决了内存碎片问题又解决了内存利用率不高问题,将垃圾回收之后,剩下的有用对象往前挪,类似顺序表删除元素的操作,所以缺陷就是移动元素的开销大
分代回收
采取区域划分,不同的区域采取不同的垃圾回收策略,首先将堆分成两部分和复制算法不同不是相同的两部分,一部分叫新生代一部分叫老年代,然后又在新生代中进行划分两部分,一部分叫伊甸区一部分叫幸存区
1.伊甸区用来存放刚new出来的对象,刚new出来的对象到第一轮可达性分析扫描这个过程中大部分的对象都会成为垃圾,因为一般我们创建的对象,指向这个对象的引用会随着方法调用结束后就消亡了所以很快就会变成垃圾,然后通过GC清除掉。活下来的对象就会拷贝到幸存区中,所以每一轮GC活下来的对象会被放到幸存者区另外一部分则被清除,这里面的思想就用到了复制算法,由于这里活下来的对象并不多所以很适合使用复制算法。
2.在我们的幸存区中又划分为相同的两部分也是用到了复制算法的思想,在上面活下来的对象会被放到幸存者区,GC也会对幸存区进行扫描,扫描过程中可达性分析标记可达的则拷贝到幸存区的另外一部分,剩下的就会被回收掉,在幸存区会扫描挺很多轮
3.如果在幸存区中存活比较久的对象,会被拷贝到老年代。在老年代的对象也会被GC扫描,但是频率比新生代低很多,因为新生代的对象容易挂,老年代的对象不容易挂,因为老年代的对象都是经历过多轮GC的要挂早挂了,所以频率低也是为了减少GC扫描的开销。对于老年代主要采用标记整理的思想
主要进行GC的区域(重要):
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集,需要注意的是 Major GC 在有的语境中也用于指代Full GC
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集
整堆收集 (Full GC):收集整个 Java 堆和方法区
注意:就是当准备Minor GC的时候如果发现新生代中晋升到老年代的平均对象大小超过了当前老年代的可用空间,不会立即触发Minor GC转为Full GC,但这些措施的具体行为和实现取决于所使用的垃圾收集器类型和JVM配置
GC的缺陷:需要额外的系统开销,就需要一个/一些特定的线程不停的扫描内存中的所有对象,判断是否能回收,此时需要额外的内存+cpu资源。效率问题,扫描线程不一定能及时的释放内存(扫描有一定周期),如果一个时间点,有大量的对象需要回收,在垃圾回收过程中,JVM 会暂停所有的用户线程,这个暂停称为"Stop The World"事件,为了防止在回收过程中用户线程修改了堆中的对象,导致垃圾收集器无法准确地回收垃圾。与此同时这个暂停的时候过久可能导致程序卡顿的情况,对于这种情况可以通过减少暂停时间进行优化
以上便是JVM的部分知识点属于是面试常考,要想深入了解可以去看深入理解java虚拟机,我们下章再见💕