前置知识补充
JDK、JRE、JVM是什么?区别与联系?
区别:
JDK(Java Development Kit):Java开发工具包
- 主要包括 Java运行环境、Java基础库及 Java工具。
JRE(Java Runtime Environment):Java运行时环境
- 提供Java程序运行时所需的核心环境,提供了在计算机上运行Java应用程序所需的最小环境。
JVM(Java Virtu Machine):Java虚拟机
- 本质是一个虚拟计算机,为 Java的运行提供特定的硬件环境,负责将字节码转换为机器可识别的机器码,并负责程序的执行。
联系:JDK为完整环境包含 JRE等,JRE只为运行时环境包含 JVM 等。
一、简单聊聊JVM
1.1先来看看简单的Java程序
现在我有一个JavaBean:
public class Java3y {
// 姓名
private String name;
// 年龄
private int age;
//.....各种get/set方法/toString
}
一个测试类:
public class Java3yTest {
public static void main(String[] args) {
Java3y java3y = new Java3y();
java3y.setName("Java3y");
System.out.println(java3y);
}
}
我们在初学的时候肯定用过javac
来编译.java
文件代码,用过java
命令来执行编译后生成的.class
文件。
Java源文件:
在使用IDE点击运行的时候其实就是将这两个命令结合起来了(编译并运行),方便我们开发。
生成class文件
解析class文件得到结果
1.2编译过程
.java
文件是由Java源码编译器(上述所说的javac.exe)来完成,流程图如下所示:
Java源码编译由以下三个过程组成:
- 分析和输入到符号表
- 注解处理
- 语义分析和生成class文件
1.2.1编译时期-语法糖
语法糖可以看做是编译器实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”。
最值得说明的就是泛型了,这个语法糖可以说我们是经常会使用到的!
- 泛型只会在Java源码中存在,编译过后会被替换为原来的原生类型(Raw Type,也称为裸类型)了。这个过程也被称为:泛型擦除。
有了泛型这颗语法糖以后:
- 代码更加简洁【不用强制转换】
- 程序更加健壮【只要编译时期没有警告,那么运行时期就不会出现ClassCastException异常】
- 可读性和稳定性【在编写集合的时候,就限定了类型】
1.3JVM实现跨平台
至此,我们通过javac.exe
编译器编译我们的.java
源代码文件生成出.class
文件了!
这些.class
文件很明显是不能直接运行的,它不像C语言(编译cpp后生成exe文件直接运行)
这些.class
文件是交由JVM来解析运行!
- JVM是运行在操作系统之上的,每个操作系统的指令是不同的,而JDK是区分操作系统的,只要你的本地系统装了JDK,这个JDK就是能够和当前系统兼容的。
- 而class字节码运行在JVM之上,所以不用关心class字节码是在哪个操作系统编译的,只要符合JVM规范,那么,这个字节码文件就是可运行的。
- 所以Java就做到了跨平台—>一次编译,到处运行!
1.4class文件和JVM的恩怨情仇
1.4.1类的加载时机
现在我们例子中生成的两个.class
文件都会直接被加载到JVM中吗??
虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(class文件加载到JVM中):
- 创建类的实例(new 的方式)。访问某个类或接口的静态变量,或者对该静态变量赋值,调用类的静态方法
- 反射的方式
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类,直接使用java.exe命令来运行某个主类(包含main方法的那个类)
- 当使用JDK1.7的动态语言支持时(…)
所以说:
- Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
1.4.2如何将类加载到jvm
class文件是通过类的加载器装载到jvm中的!
Java默认有三种类加载器:
各个加载器的工作责任:
- 1)Bootstrap ClassLoader:负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类
- 2)Extension ClassLoader:负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/ext/*.jar或-Djava.ext.dirs指定目录下的jar包
- 3)App ClassLoader:负责记载classpath中指定的jar包及目录中class
工作过程:
- 1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 2、当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 3、如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 4、若ExtClassLoader也加载失败,则会使用AppClassLoader来加载
- 5、如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException
其实这就是所谓的双亲委派模型。简单来说:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上。
好处:
- 防止内存中出现多份同样的字节码(安全性角度)
特别说明:
- 类加载器在成功加载某个类之后,会把得到的
java.lang.Class
类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。
1.4.2类加载详细过程
加载器加载到jvm中,接下来其实又分了好几个步骤:
- 加载,查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象。
- 连接,连接又包含三块内容:验证、准备、初始化。
- 1)验证,文件格式、元数据、字节码、符号引用验证;
- 2)准备,为类的静态变量分配内存,并将其初始化为默认值;
- 3)解析,把类中的符号引用转换为直接引用
- 初始化,为类的静态变量赋予正确的初始值。
1.4.3JIT即时编辑器
一般我们可能会想:JVM在加载了这些class文件以后,针对这些字节码,逐条取出,逐条执行–>解析器解析。
但如果是这样的话,那就太慢了!
我们的JVM是这样实现的:
- 就是把这些Java字节码重新编译优化,生成机器码,让CPU直接执行。这样编出来的代码效率会更高。
- 编译也是要花费时间的,我们一般对热点代码做编译,非热点代码直接解析就好了。
热点代码解释:一、多次调用的方法。二、多次执行的循环体
使用热点探测来检测是否为热点代码,热点探测有两种方式:
- 采样
- 计数器
目前HotSpot使用的是计数器的方式,它为每个方法准备了两类计数器:
- 方法调用计数器(Invocation Counter)
- 回边计数器(Back EdgeCounter)。
- 在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
1.4.4回到例子中
按我们程序来走,我们的Java3yTest.class
文件会被AppClassLoader加载器(因为ExtClassLoader和BootStrap加载器都不会加载它[双亲委派模型])加载到JVM中。
随后发现了要使用Java3y这个类,我们的Java3y.class
文件会被AppClassLoader加载器(因为ExtClassLoader和BootStrap加载器都不会加载它[双亲委派模型])加载到JVM中
1.5类加载完以后JVM干了什么?
在类加载检查通过后,接下来虚拟机将为新生对象分配内存
。
1.5.1JVM的内存结构
首先我们来了解一下JVM的内存结构的怎么样的:
- 基于jdk1.8画的JVM的内存结构
简单看了一下内存结构,简单看看每个区域究竟存储的是什么(干的是什么):
-
堆:存放对象实例,几乎所有的对象实例都在这里分配内存
堆是JVM管理的内存最大的一片区域,也是我们进行GC的主要区域,几乎所有的java对象实例都分配在堆上面,现代的垃圾收集器基本使用的是分代收集算法,所以我们的堆空间又进一步划分为新生代(Young Generation) 和老年代(Old Generation)和永久代(在Java 8之后就不存在了,取而代之的是元空间MetaSpace),新生代和老年的主要区别在于对象的存活年龄不同(当然,新创建的对象也可能分配在老年代),而新生代又被划分为Eden、From Survivor(对应下图的S0)和To Survivoe(对应下图的S1)区域,这三片区域之间的大小比例默认为Eden:From Survivor:To Survivor = 8:1:1,进行分代的原因主要是便于垃圾回收,关于垃圾回收的内容也会在后面的博客中继续讲解。
-
虚拟机栈:虚拟机栈描述的是Java方法执行的内存结构:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息
-
本地方法栈:本地方法栈则是为虚拟机使用到的Native方法服务。
-
方法区:存储已被虚拟机加载的类元数据信息(元空间)
-
程序计数器:当前线程所执行的字节码的行号指示器
补充
类的
元数据
是指描述类本身信息的数据:类的名称和包路径,父类和接口,字段信息,方法信息,常量池,访问控制信息,注解信息…
元空间
(Metaspace)是Java 8之后引入的一种新的内存区域,用于替代旧版的永久代(Permanent Generation)。元空间和永久代有一些相似之处,但也有很多不同点。元空间的主要作用是存储类的元数据信息,包括类的结构信息、方法信息、字段信息、注解信息等。与永久代不同,元空间的内存空间并不在虚拟机堆中,而是在本机物理内存中分配,因此元空间的大小不受虚拟机堆的大小限制,也不会触发垃圾收集器的工作
1.5.2例子中的流程
我来宏观简述一下我们的例子中的工作流程:
- 1、通过
java.exe
运行Java3yTest.class
,随后被加载到JVM中,元空间存储着类的信息(包括类的名称、方法信息、字段信息…)。 - 2、然后JVM找到Java3yTest的主函数入口(main),为main函数创建栈帧,开始执行main函数
- 3、main函数的第一条命令是
Java3y java3y = new Java3y();
就是让JVM创建一个Java3y对象,但是这时候方法区中没有Java3y类的信息,所以JVM马上加载Java3y类,把Java3y类的类型信息放到方法区中(元空间) - 4、加载完Java3y类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Java3y实例分配内存, 然后调用构造函数初始化Java3y实例,这个Java3y实例持有着指向方法区的Java3y类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用
- 5、当使用
java3y.setName("Java3y");
的时候,JVM根据java3y引用找到Java3y对象,然后根据Java3y对象持有的引用定位到方法区中Java3y类的类型信息的方法表,获得setName()
函数的字节码的地址 - 6、为
setName()
函数创建栈帧,开始运行setName()
函数
从微观上其实还做了很多东西,正如上面所说的类加载过程](加载–>连接(验证,准备,解析)–>初始化),在类加载完之后jvm为其分配内存(分配内存中也做了非常多的事)。由于这些步骤并不是一步一步往下走,会有很多的“混沌bootstrap”的过程,所以很难描述清楚。
1.6简单聊聊各种常量池
请看我的另外一篇文章,链接:https://blog.csdn.net/qq_40589140/article/details/134315187
1.7GC垃圾回收
请看我的另外一篇文章,链接:https://blog.csdn.net/qq_40589140/article/details/134338733
1.8JVM参数与调优
很多做过JavaWeb项目(ssh/ssm)这样的同学可能都会遇到过OutOfMemory这样的错误。一般解决起来也很方便,在启动的时候加个参数就行了。
上面也说了很多关于JVM的东西—>JVM对内存的划分啊,JVM各种的垃圾收集器啊。
内存的分配的大小啊,使用哪个收集器啊,这些都可以由我们根据需求,现实情况来指定的,这里就不详细说了,等真正用到的时候才回来填坑吧~
参考资料:
- JVM系列三:JVM参数设置、分析
二、JVM面试题
拿些常见的JVM面试题来做做,加深一下理解和查缺补漏:
- 1、详细jvm内存结构
- 2、讲讲什么情况下会出现内存溢出,内存泄漏?
- 3、说说Java线程栈
- 4、JVM 年轻代到年老代的晋升过程的判断条件是什么呢?
- 5、JVM 出现 fullGC 很频繁,怎么去线上排查问题?
- 6、类加载为什么要使用双亲委派模式,有没有什么场景是打破了这个模式?
- 7、类的实例化顺序
- 8、JVM垃圾回收机制,何时触发MinorGC等操作
- 9、JVM 中一次完整的 GC 流程(从 ygc 到 fgc)是怎样的
- 10、各种回收器,各自优缺点,重点CMS、G1
- 11、各种回收算法
- 12、OOM错误,stackoverflow错误,permgen space错误
2.1详细jvm内存结构
根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
具体可能会聊聊jdk1.7以前的PermGen(永久代),替换成Metaspace(元空间)
- 原本永久代存储的数据:符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap
- Metaspace(元空间)存储的是类的元数据信息(metadata)
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 替换的好处:一、字符串存在永久代中,容易出现性能问题和内存溢出。二、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
2.2讲讲什么情况下会出现内存溢出,内存泄漏?
内存泄漏:一个不再被程序使用的对象或变量还在内存中占有存储空间
一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
内存溢出:指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出。
内存泄漏的原因很简单:
- 对象是可达的(一直被引用)
- 但是对象不会被使用
常见的内存泄漏例子:
public static void main(String[] args) {
Set set = new HashSet();
for (int i = 0; i < 10; i++) {
Object object = new Object();
set.add(object);
// 设置为空,这对象我不再用了
object = null;
}
// 但是set集合中还维护这obj的引用,gc不会回收object对象
System.out.println(set);
}
解决这个内存泄漏问题也很简单,将set设置为null,那就可以避免上诉内存泄漏问题了。其他内存泄漏得一步一步分析了。
内存溢出的原因:
- 内存泄露导致堆栈内存不断增大,从而引发内存溢出。
- 大量的jar,class文件加载,装载类的空间不够,溢出
- 操作大量的对象导致堆内存空间已经用满了,溢出
- nio直接操作内存,内存过大导致溢出
解决:
- 查看程序是否存在内存泄漏的问题
- 设置参数加大空间
- 代码中是否存在死循环或循环产生过多重复的对象实体、
- 查看是否使用了nio直接操作内存。
2.3说说线程栈
这里的线程栈应该指的是虚拟机栈吧…
VM规范让每个Java线程拥有自己的独立的虚拟机栈,也就是Java方法的调用栈。
当方法调用的时候,会生成一个栈帧。栈帧是保存在虚拟机栈中的,栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息
局部变量表:用于存放方法参数和方法内部定义的局部变量
操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
动态连接:在Java源文件中被编译到字节码的中时,所有的变量的和方法的引用都作为符号引用,保存在class文件的常量池中,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
方法返回地址:存放调用该方法的pc寄存器的值(下一条指令)
线程运行过程中,只有一个栈帧是处于活跃状态,称为“当前活跃栈帧”,当前活动栈帧始终是虚拟机栈的栈顶元素。
通过jstack工具查看线程状态
2.4JVM 年轻代到年老代的晋升过程的判断条件是什么呢?
-
大对象直接进入老年代
这个大对象的大小是由用户指定的,使用以下jvm参数进行指定
-XX:PretenureSizeThreshold=对象大小(单位:byte)
这个参数的默认值为0,也就是说,所有的对象创建出来之后默认都是分配到新生代的,当我们指定了大小之后,只要创建出来的对象超过设定值,那么这个对像就会直接晋升到老年代;
-
长期存活的对象
长期存活的对象进入老年代。如果对象在 eden 区出生,那么它的 GC 分代年龄会初始值为 1,每熬过一次 Minor GC 而不被回收,这个值就会增加 1 岁。当它的年龄到达一定的数值时(jdk1.7 默认是 15 岁),就会晋升到老年代中。
-
动态年龄判断
大于等于某个年龄的对象超过了survivor空间一半 ,大于等于某个年龄的对象直接进入老年代
2.5类加载为什么要使用双亲委派模式,有没有什么场景是打破了这个模式?
双亲委托模型的重要用途是为了解决类载入过程中的安全性问题。
- 假设有一个开发者自己编写了一个名为
java.lang.Object
的类,想借此欺骗JVM。现在他要使用自定义ClassLoader
来加载自己编写的java.lang.Object
类。 - 然而幸运的是,双亲委托模型不会让他成功。因为JVM会优先在
Bootstrap ClassLoader
的路径下找到java.lang.Object
类,并载入它
Java的类加载是否一定遵循双亲委托模型?
- 在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。
- SPI就是打破了双亲委托机制的(SPI:服务提供发现)
2.6类的实例化顺序
- 1. 父类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
- 2. 子类静态成员和静态初始化块,按在代码中出现的顺序依次执行
- 3. 父类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
- 4. 父类构造方法
- 5. 子类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
- 6. 子类构造方法
2.7JVM垃圾回收机制,何时触发MinorGC等操作
当young gen中的eden区分配满的时候触发MinorGC(新生代的空间不够放的时候).
2.8JVM完整的GC流程
对象的正常流程:Eden 区 -> Survivor 区 -> 老年代。
新生代GC:Minor GC;老年代GC:Full GC,比 Minor GC 慢10倍。
【总结】:内存区域不够用了,就会引发GC,JVM 会“stop the world”,严重影响性能。Minor GC 避免不了,Full GC 尽量避免。
【处理方式】:保存堆栈快照日志、分析内存泄漏、调整内存设置控制垃圾回收频率,选择合适的垃圾回收器等。
2.9各种回收算法
GC最基础的算法有三种:
- 标记 -清除算法
- 复制算法
- 标记-压缩算法
- 我们常用的垃圾回收器一般都采用分代收集算法(其实就是组合上面的算法,不同的区域使用不同的算法)。
具体:
- 标记-清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
- 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 标记-整理算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
- 分代收集算法,“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
2.10各种回收器,各自优缺点,重点CMS、G1
Serial收集器,串行收集器是最古老,最稳定以及效率高的收集器,但可能会产生较长的停顿,只使用一个线程去回收。
ParNew收集器,ParNew收集器其实就是Serial收集器的多线程版本。
Parallel收集器,Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。
Parallel Old收集器,Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程“标记-整理”算法
CMS收集器,CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它需要消耗额外的CPU和内存资源,在CPU和内存资源紧张,CPU较少时,会加重系统负担。CMS无法处理浮动垃圾。CMS的“标记-清除”算法,会导致大量空间碎片的产生。
G1收集器,G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
2.11stackoverflow错误,permgen space错误
stackoverflow错误主要出现:
- 在虚拟机栈中(线程请求的栈深度大于虚拟机栈锁允许的最大深度)
permgen space错误(针对jdk之前1.7版本):
- 大量加载class文件
- 常量池内存溢出