-
前言
❗表示必掌握,❔表示基本不会问 -
更新
1 | 24-06-14 初始记录 这JVM的图居然是我以前画的,一点都不记得了。 |
类加载器与反射
简述 java 类加载机制?
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的 java 类型。
描述一下 JVM 加载 Class 文件的原理机制?
Java 中的所有类,都需要由类加载器装载到 JVM 中才能运行。类加载器本身也是一个类,而它的工作就是把 class 文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
类装载方式,有两种 :
-
隐式装载, 程序在运行过程中当碰到通过 new 等方式生成对象时,隐式调用类装载器加载对应的类到 jvm 中
-
显式装载, 通过 class.forname() 等方法,显式加载需要的类
Java 类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类 (像是基类) 完全加载到 jvm 中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
说一下类装载的执行过程?
类装载分为以下 5 个步骤:
-
加载:根据查找路径找到相应的 class 文件然后导入;
-
验证:检查加载的 class 文件的正确性;
-
准备:给类中的静态变量分配内存空间;
-
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
-
初始化:对静态变量和静态代码块执行初始化工作。
什么是类加载器,类加载器有哪些?
宏观来看只有两种类加载器:启动类加载器(c++ 实现)和其他所有的类加载器(java 语言)。
主要有一下四种类加载器:
-
启动类加载器 (Bootstrap ClassLoader):用来加载 java 核心类库,无法被 java 程序直接引用。
-
平台/扩展类加载器 (extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。(JDK9 之后,扩展类加载器被重命名为平台类加载器)。
-
系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader() 来获取它。
-
用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。
什么是反射机制?
JAVA 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 java 语言的反射机制。
什么是静态编译,什么是动态编译?
静态编译:在编译时确定类型,绑定对象
动态编译:运行时确定类型,绑定对象
反射机制的优缺点有哪些?
优点: 运行期类型的判断,动态加载类,提高代码灵活度。
缺点: 性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,过程比直接的 java 代码要多了一步委托的过程,反射需要类加载器通过双亲委派模型实现动态编译,效率较低。
什么是双亲委派机制?
首先,JVM 中有三大类加载器:启动类加载器(最顶层),平台类加载器(中层),系统类加载器(下层)。
双亲委派模型就是指一个类加载器收到了类加载请求,它不会直接自己先加载,而把请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,就再往上委托,赴到最顶层的类加载器,如果父类加载器可以完成类加载任务,就成功返回,若不能,就向下传递,让子加载器去加载,这就是双亲委派模式。
双亲委派模型主要是用来保证同一个类只能被一个类加载器加载。
怎么破坏双亲委派机制?
一般在自定义类加载器中,我们不希望通过双亲委派机制一层层向上再下来,而是希望直接通过自己定义的类加载器直接实现类加载,来提升加载性能,比如 Tomcat 中的 web 容器类加载器就是破坏了双亲委托模式的,里面的 WebApplicationClassLoader 除了核心类库外,都是优先加载自己路径下的 Class。
要打破双亲委派机制,只要在重写 loadclass 的过程中,不遵从 JVM 规范就行了,也就是不盲目优先向 Parednt 的 ClassLoader 查找即可。
你在哪些场景下用过反射?
反射在框架中有频繁的被使用,比如 JDK 动态代理,Spring 中的注入属性,调用方法等。
反射更多是为了灵活舍弃一部分性能,自己使用一般用在工具类中,比如频繁通过参数名来调用指定的方法时,可以用通过反射去匹配指定的方法名,然后实现功能。
Java 中获取反射的三种方法是什么?
-
类名.class 属性
-
对象名.getClass() 方法
-
Class.forName(全类名) 方法
反射可以获取私有方法或构造函数吗或私有成员变量吗?
可以。有专门反射私有构造函数的方法 clazz.getDeclaredConstructor(int.class);
来读取私有的构造函数,私有成员变量和私有方法也一样,但用这个方法读取完还需要设置一下暴力反射才行:c.setAccessible(true)
。
JVM
内链:[[JVM.excalidraw]]
外链:
JVM 包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、Execution engine(执行引擎);两个组件为 Runtime data area(运行时数据区)、Native Interface(本地接口)。
Class loader(类装载):根据给定的全限定名类名 (如:java.lang.Object) 来装载 class 文件到 Runtime data area 中的 method area。
Execution engine(执行引擎):执行 classes 中的指令。
Native Interface(本地接口):与 native libraries 交互,是其它编程语言交互的接口。
Runtime data area(运行时数据区域):这就是我们常说的 JVM 的内存。
顺序 :
-
首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内。
-
而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令。
-
再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
❗JVM 中有哪几块内存区域?Java 8 之后对内存分代做了什么改进?
首先:一般来说:Spring boot 会内置一个 tomcat,tomcat 自己是基于 java 来开发的。我们启动的其实是 tomcat(一个 JVM 进程),我们写的代码,会被 tomcat 加载到 JVM 中。
tomcat 去负责接收请求,执行我们写好的代码(基于 Spring 框架的一堆代码)
-
程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,个人感觉的他就是为多线程准备的,程序计数器是每个线程独有的,所以是线程安全的。它主要用于记录每个线程的执行情况。
-
Java 虚拟机栈(Java Virtual Machine Stacks):线程私有,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
-
本地方法栈(Native Method Stack):线程私有,与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的(Native 方法是 JVM 底层的 C 语言对其它系统或硬件进行交互)。
-
Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;Java 堆也叫 GC 堆,是垃圾收集器管理的主要区域,堆中可以细分为:新生代、老年代;再细致一点,新生代中又分为:Eden Space(伊甸园)、Survivor 空间,Survivor 空间又分为 From 区和 to 区。
-
方法区(Methed Area):1.8 之后方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
-
方法区补充点:
- 在 JDK1.7 以前 HotSpot 虚拟机使用永久代来实现方法区,永久代的大小在启动 JVM 时可以设置一个固定值(-XX:MaxPermSize),不可变。
- 在 JDK1.7 中 存储在永久代的部分数据就已经转移到 Java Heap(堆)或者 Native memory。譬如符号引用 (Symbols) 转移到了 native memory,原本存放在永久代的字符常量池移出。但永久代仍存在于 JDK 1.7 中,并没有完全移除。
- JDK1.8 中进行了较大改动:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap(堆);
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)
-
永久代(元空间)
- 在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间,Metaspace)的区域所取代。
- 值得注意的是:元空间并不在虚拟机中,而是使用本地内存(之前,永久代是在 jvm 中)。
- 这样,解决了以前永久代的 OOM 问题,元数据和 class 对象存在永久代中,容易出现性能问题和内存溢出,毕竟是和老年代共享堆空间。java8 后,永久代升级为元空间独立后,也降低了老年代 GC 的复杂度。
❔你知道 JVM 是如何运行起来的吗?我们的对象是如何分配的
大白话:比如说我们有一个类里面包含了 main 方法,你去执行这个 main 方法,此时会启动一个 JVM 的进程。默认会有一个 main 线程,这个 main 线程就负责执行这个 main 方法的代码,进而创建各种对象。
tomcat 也是一样的,类加载到 JVM 里面,Spring 将我们的类实例化成各种 Bean 容器。会有工作线程来执行我们 bean 实例对象里的方法和代码,进而也会创建其他的各种对象,实现业务逻辑。
例子:Spring 容器中的 JVM。
-
类加载器把我们写的类加载到元空间
-
Spring 容器通过反射技术,获取元空间中的类,创建 bean 实例对象对象存入堆内存。
-
tomcat 线程执行请求会在栈内存通过栈帧存放局部变量,引用堆内存中的实例变量
❗JVM 在哪些情况下会触发垃圾回收
在 JVM 内存里必然有一个内存的分代模型。比如说一个 4 核 8G 的机器,堆内存可能也就 4G 左右(其他例如栈内存、元空间区域存放类信息也需要空间)。
堆内存内部再分,比如给年轻代 2GB、给老年代 2GB,默认情况下 Eden 和 s1、s2 的比例是:8:1:1。
如果 Eden 满了,必然会触发垃圾回收(young GC)。回收的对象就是没有人引用的对象:方法执行过程中的局部变量引用对象、类中的静态变量引用的对象这两类对象不会被回收,其他对象基本上都可以被回收。
什么是 Full GC?什么情况下会触发?
Full GC 是指清理整个堆空间——包括年轻代和老年代。
什么时候触发:
-
调用 System.gc
-
方法区空间不足
-
老年代空间不足,包括:
- 新创建的对象都会被分配到 Eden 区,如果该对象占用内存非常大,则直接分配到老年代区,此时老年代空间不足。
- 做 minor gc 操作前,发现要移动的空间(Eden 区、From 区向 To 区复制时,To 区的内存空间不足)比老年代剩余空间要大,则触发 full gc,而不是 minor gc。
❗JVM 年轻代的回收算法
垃圾回收的时候有一个概念:stop the world。停止 jvm 内的工作线程的运行,然后扫描所有的对象,判断哪些可以回收,哪些不可以回收。
年轻代内大部分都是垃圾对象。
垃圾回收:复制算法。
-
把年轻代内的存活对象复制到 s1,触发 young GC 把 Eden 清空。
-
第二次满,把年轻代内的存活对象和 s1 内存活的对象,复制到 s2。然后把 Eden 和 s1 清空
-
Eden 又满,把年轻代内的存活对象和 s2 内存活的对象,复制到 s1。然后把 Eden 和 s2 清空
❗对象什么时候会转移到老年代中
-
有的对象在年轻代里熬过了很多次垃圾回收(默认是 15 次,可以设置),就会被认为是长期存活的对象,会从年轻代转移到老年代。(例如:Spring 容器内的一些 Bean 对象)
-
Eden 垃圾回收时,发现存活对象的大小,比 Suivivor 区还要大,就会直接放入老年代。
-
很大的对象,会直接存入老年代。
❗JVM 老年代的回收算法
老年代内的对象,很多都是长期被引用的,不能用复制算法,效率比较低。
老年代使用的算法是标记清除(回收)算法或者标记压缩算法。
标记清除(回收):
-
首先会从 GC root 进行遍历,把可达对象(存过的对象)打标记
-
再从 GC root 二次遍历,将没有被打上标记的对象清除掉。优点:老年代对象一般是比较稳定的,相比复制算法,不需要复制大量对象。之所以将所有对象扫描 2 次,看似比较消耗时间,其实不然,是节省了时间。举个栗子,数组 1,2,3,4,5,6。删除 2,3,4,如果每次删除一个数字,那么 5,6 要移动 3 次,如果删除 1 次,那么 5,6 只需移动 1 次。
缺点:这种方式需要中断其他线程(STW),相比复制算法,可能产生内存碎片。
标记压缩:和标记清除算法基本相同,不同的就是,在清除完成之后,会把存活的对象向内存的一边进行压缩,这样就可以解决内存碎片问题。
当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
怎么判断对象是否可以被回收?
垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。
一般有两种方法来判断:
-
引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
-
可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。
❗常用的垃圾回收器
-
parnew+cms 的组合(jdk8 以及 8 以前)
parnew 是新生代回收器,多线程。cms 是老年代垃圾回收(比较慢,一般比年轻代慢 10 倍以上),cms 的垃圾回收算法,刚开始用标记清理,然后整理。会产生几个阶段,尽可能并发进行 -
G1(Garbage First)(从 jdk9 往后主推)回收整个堆。
❗生产环境中的 Tomcat 如何设置 JVM 参数。如何检查 JVM 运行情况
Java Web 系统,在 tomcat 的一个配置脚本,catalina 脚本里找一下。
核心了解点:
-
内存区域大小的分配,每个线程的栈大小,metaspace 大小,堆内存的大小,年轻代和老年代分别的大小,eden 和 survivor 区域的大小。(没有设置,会有默认值,可以通过命令行查看)
-
垃圾回收器用了什么,每种垃圾回收器是否有对应的一些特殊的参数设置,那些特殊的参数分别用来干什么的。
❗为什么要这么设置?当前系统运行的时候,jvm 的表现如何?
// todo 额,这部分都在打广告,打算看了另一个视频再补充。
❗实际项目中是否做过 JVM GC 优化,怎么做的?
没做过,直接单机压测,然后调优。
// todo
❗发生 OOM 之后,应该如何排查和处理线上系统的 OOM 问题?
解决的一个思路:在 JVM 里可以设置几个参数,如果一旦 JVM 发生了 OOM 之后,就会导出一份内存的快照。可以用类似 MAT 这样的工具去分析(找出当时的内存占用最大的对象是谁,找出那些对象是在哪些地方创建出来的,一般来说是对内存去做一个调优)。
// todo 这里要结合业务去思考,结合项目(模拟)
Java 会存在内存泄漏吗?请简单描述
内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java 是有 GC 垃圾回收机制的,也就是说,不再被使用的对象,会被 GC 自动回收掉,自动从内存中清除。
但是,即使这样,Java 也还是存在着内存泄漏的情况,java 导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 java 中内存泄露的发生场景。
有遇到过栈溢出吗?一般是什么问题导致?
栈溢出(StackOverflowError)是指栈内容全部被占用,而数据还要往里放。一般是递归错误或者出现死循环导致。