Java虚拟机(JVM)从入门到实战【上】,涵盖类加载,双亲委派机制,垃圾回收器及算法等知识点,全系列6万字。
一、基础篇
P1 Java虚拟机导学课程

P2 初识JVM
什么是JVM
Java Virtual Machine 是Java虚拟机。
JVM本质上是一个运行在计算机上的程序,职责是运行Java字节码文件。
因为计算机只能运行机器码,所以Java虚拟机负责将字节码转化为机器码。

JVM可以自动为对象和方法分配内存,具有自动的垃圾回收机制,回收不再使用的对象。
JVM的功能
JVM包含:内存管理、解释执行虚拟机指令、即时编译三大功能。

功能1:即时编译
Java语言如果不做优化,性能不如C和C++语言,因为C类语言可以将源代码文件直接通过编译和链接转化为机器码文件。

Java多了一步实时解释,目的是为了能够支持跨平台特性,将字节码指令解释为不同平台的机器码文件。

热点代码就是多次反复出现的代码,会被优化保存到内存中,再次执行可以直接调用。
常见的JVM


P3 Java虚拟机的组成
1.类加载器:把字节码文件的内容加载到内存中。
2.运行时数据区域(JVM管理的内存):负责管理JVM使用到的内存,比如创建对象和销毁对象。
3.执行引擎:即时编译器、解释器、垃圾回收器。执行引擎负责本地接口的调用。
4.本地接口。native方法,用C++编写。

字节码文件的组成
P4 正确打开字节码文件
字节码文件中保存了源代码编译之后的内容,以二进制方式存储,无法用记事本直接打开阅读。
可以通过NotePad++使用十六进制插件查看class文件:

推荐使用jclasslib工具查看字节码文件。
P5 基础信息
1.基础信息:包含魔数、字节码文件对应的Java版本号,访问标识。父类和接口。
文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改,但不影响文件的内容。
文件是通过文件的头几个字节去校验文件的类型,如果软件不支持该种类型就会出错。
Java字节码文件中,将文件头称为magic魔数。

主副版本号指的是编译字节码文件的JDK版本号,主版本号用来标识大版本号。注意JDK1.2版本号是46,之后每升级一个大版本就加1。所以1.2之后大版本号计算方法是主版本号-44。比如主版本号为52,52-44=8,主版本号52就是JDK8。
版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。
报下面错误是兼容性出现问题。

方法1:升级IDEA编译的Jdk版本。(容易引发其它的兼容性问题)
方法2:改变依赖的版本,替换包名,降低版本。(工作中推荐选用该方法)
2.常量池:保存了字符串常量,类或接口名,字段名主要在字节码指令中使用。
3.字段:当前类或接口声明的字段信息。
4.方法:当前类或接口声明的方法信息字节码指令。
5.属性:类的属性,比如源码的文件名内部类的列表等。
P6 常量池和方法
常量池作用:避免相同内容重复定义,节省空间。
在常量池中存放1份字符串,在别处引用,节省空间。
常量池中的数据都有一个编号,编号从1开始。在字段或字节码指令中通过编号可以快速的找到对应的数据。
字节码指令中通过编号引用到常量池的过程称之为符号引用。
i=0;i=i++,问i的值为?答案:0

iconst_值,把操作数的值放入到操作数栈中。
istore_下标,弹出会把操作数栈中的数据弹出存放到局部变量表下标对应的数组中。操作数栈->局部变量表。
iload_下标,将局部变量表下标中的数据放入操作数栈。局部变量表->操作数栈。


iinc 1 by 1,将局部变量表中1号位置上的数据+1。

P7 字节码文件常见工具使用1
javap -v , javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容,适合在服务器上看字节码文件内容。
使用步骤:
1.如果是jar包需要先使用jar -xvf命令解压。
2.输入javap -v 字节码文件名称,查看具体的字节码信息。
如果想查看哪个文件的字节码,只需要:javap -v 绝对路径,即可。
下载一个jclasslib Bytecode Viewer,选中源代码文件选择下面:


可以查看字节码:

P8 字节码文件常见工具使用2
阿里的arthas,Arthas是一款线上监控诊断产品,通过全局视角实时查看应用的load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查的效率。
启动:java -jar arthas-boot.jar,会出现进程id,输入想进入的进程id。

监控面板,查看字节码信息,方法监控,类的热部署(线上某个类有问题,可以在不停机的情况下,把类的代码替换掉),内存监控,垃圾回收监控,应用热点定位。
当前系统的实时数据面板,按ctrl+c退出。

cls可以清除所有命令。
dashboard -i 2000 -n 3 :隔2秒,执行3次。

第1部分展示了每个线程的信息,第2部分展示了内存区,第3部分是运行中的配置信息。
dump 类的全限定名:dump已加载类的字节码文件到特定目录。
jad 类的全限定名:反编译已加载类的源码。
jad 包名.类名


通过arthas可以获取到当前运行的状态和字节码信息,甚至是反编译出来的源代码信息。

P9 类的生命周期加载阶段
总结:根据类的全限定名把字节码文件的内容加载并转换成合适的数据放入内存中,存放在方法区和堆上。

类的生命周期描述了一个类加载、使用、卸载的整个过程。
加载,连接,初始化,使用,卸载。

1.加载阶段第一步是类加载器根据类的全限定名通过不同渠道(本地文件,通过网络传输的类,动态代理生成)以二进制流的方式获取字节码信息。
2.类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。
3.生成一个InstanceKlass对象,保存类的所有信息,里面还包含实现特定功能比如多态的信息。

4.Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。作用是在Java代码中获取类的信息以及存储静态字段的数据。

对于开发者来说,只需要访问堆中的Class对象,而不需要访问方法区中所有信息。
方法去是用C++代码写,堆区是java代码编写,在代码中可以获取到。
把方法区中能让开发者访问的资源拷贝到堆区中,Java虚拟机能很好控制好开发者访问数据的范围。开发者不能访问方法区,提升了安全性。

P10? 类的生命周期连接阶段
总结:连接阶段:对魔数、版本号等进行验证,一般不需要程序员关注。准备阶段:为静态变量分配内存并设置初始值。解析阶段:将常量池中的符号引用(编号)替换为直接引用(内存地址)。
连接:
1.验证:验证内容是否满足Java虚拟机规范。


major是主版本号,>=常量一般是45,对jdk1.8来说最高版本号是52,对jdk8只能支持45-52之间的主版本号。副版本号不能大于0


2.准备:给静态变量赋初值。
准备阶段为静态变量(static)分配内存并设置初始值。

特殊情况:final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。

3.解析:将常量池中的符号引用替换成指向内存的直接引用。
直接引用不再使用编号,而是使用内存中的地址进行访问具体的数据。
P11 类的生命周期初始化阶段
总结:执行静态代码块和静态变量的赋值。
初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
初始化阶段会执行字节码文件中clinit(class init类的初始化)部分的字节码指令。

putstatic是给类中的静态字段赋值。静态字段的名字会从常量池中获取。值会从操作数栈中弹出。将操作数栈中的值赋值给常量池中的变量。
clinit方法中的执行顺序与Java中编写的顺序是一致的。
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法(如果变量时final修饰的并且等号右边是常量不会触发初始化,因为在连接阶段会直接赋常量值)
2.调用Class.forName(String className)
3.new一个该类的对象时。
4.执行Main方法的当前类。
ldc #9是从常量池中将字符串D加载到操作数栈中。

invokevirtual是调用Println方法打印操作数栈上的内容。
clinit指令在特定情况下不会出现:
1.无静态代码快且无静态变量赋值语句。
2.有静态变量的声明,但没有赋值语句。
3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
注意下面:
1.直接访问父类的静态变量,不会触发子类的初始化
2.子类的初始化clinit调用之前,会先调用父类的clinit初始化方法。
下面这题因为B02是A02的子类,所以会先调用A02的方法再调用B02的方法。


如果去掉new,因为a是在A02中,所以直接访问A02中的变量即可。
3.数组的创建不会导致数组中元素的类进行初始化。
4.final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。
P12? 类加载器的分类
类加载器ClassLoader:是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。
类加载器:负责在类加载过程中的字节码获取并加载到内存中。通过加载字节码数据放入内存转换成byte[],接下来调用虚拟机底层方法将byte[]转换成方法区和堆中的数据。
本地接口JNI是Java Native Interface的缩写,允许java调用其它语言编写的方法。在hotspot类加载器中,主要用于调用Java虚拟机中的方法,这些方法使用C++编写。

企业级应用:SPI机制,类的热部署,Tomcat类的隔离。大量的面试题:什么是类的双亲委派机制,如何打破类的双亲委派机制,自定义类加载器。解决线上问题:使用Arthas不停机修复BUG,解决线上故障。
类加载器被分为2部分。

JDK9之后出现模块化,所以JDK9是分水岭。


P13 启动类加载器

Bootstrap:加载Java中最核心的类。
启动类加载器Bootstrap ClassLoader是由Hotspot虚拟机提供的,使用C++编写的类加载器。
默认加载Java安装目录/jre/lib下的类文件。
rt.jar是最核心的jar包。string,integer,long,日期类。
再Arthas中选择BootstrapClassLoaderDemo,输入sc -d 类名,sc是search class的简称,用来查看jvm已加载的类信息。-d可以输出当前类的详细信息,加载ClassLoader等详细信息。

如何让启动类加载器去加载用户jar包:
1.把要扩展的类打成jar包,放入jre/lib下进行扩展(不推荐,会要求名称符合规范)。
2.使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名进行扩展。
P14 扩展和应用程序类加载器
扩展类加载器和应用程序类加载器都是JDK提供的,使用Java编写的类加载器。
它们源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。或者指定jar包将字节码文件加载到内存中。

默认加载Java安装目录/jre/lib/ext下的文件。
通过扩展类加载器去加载用户jar包:
1.放入/jre/lib/ext下进行扩展。
2.使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录。

应用程序类加载器加载的内容包含:启动类加载器和扩展类加载器。
P15 双亲委派机制
Java虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题。
1.保证类加载的安全性:通过双亲委派机制避免恶意代码替换JDK中的核心类库。
2.避免重复加载:双亲委派机制可以避免同一个类多次加载。
双亲委派机制:当一个类加载器接收到加载类的任务时,会自底向上查看类是否加载过,如果加载过加载流程就结束了,把类的class对象返回即可;如果所有的加载器都没加载过,就会层层向上委派查看是否加载过,如果都没加载过,就会由顶向下尝试进行加载,如果一个类加载器,发现这个类在自己的加载路径中,就会选择去加载这个类。

一个类优先由启动类加载器加载,加载不了才交给扩展类加载器处理。因为底层代码是用C++编写。
如果类加载器返回的是null,说明是启动类加载器加载,因为启动类加载器底层是用C++编写。
每个Java实现的类加载器中保存了一个成员变量叫“父”Parent类加载器,可以理解为它的上级,不是继承关系。

下面很重要:
1.先描述双亲委派机制的流程。
2.然后描述类加载器之间的关系。
3.双亲委派机制的好处

P16 打破类的双亲委派机制 自定义类加载器
为什么打破:比如一个Tomcat程序中可以运行多个Web应用,如果两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。
如果不打破双亲委派制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载。
Tomcat为每一个web应用都单独生成了一个类加载器。

ClassLoader的原理:
-
loadClass方法是类加载的入口,提供了双亲委派机制,内部会调用findClass。根据全限定名去找到类,并把类的二进制信息加载进来。
-
findClass由类加载器子类实现,获取二进制数据调用defineClass,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。
-
defineClass在堆和方法区上创建包含类信息的对象。做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中。
4.resolveClass执行类生命周期中的连接阶段。

通过loadClassData方法传递类的全限定名,找到字节码文件,加载到内存中,变成二进制数组。
byte[] data = loadClassData(name);
调用defineClass把二进制数组传入,在堆和方法区生成对应数据,完成加载阶段。
return defineClass(name,data,0,data.length);
如果不给自定义类加载器定义parent,它会默认parent为应用程序类加载器。

如果没传入parent,会自动默认传入系统类加载器。


P17?打破类的双亲委派机制 线程上下文类加载器
JDBC使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql、oracle驱动。
DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。

DriverManager怎么知道jar包中要加载的驱动在哪里?
spi全称为(Service Provider Interface),是JDK内置的一种服务提供发现机制。
spi的工作原理:
1.(驱动jar包中)在ClassPath路径下的META-INF/service文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现。
2.使用ServiceLoader加载实现类

在驱动jar包中暴露出要让别人加载的类,放到固定的文件中(在META-INF/service下的文件中通过全限定名暴露);接下来在DriverManager中就会去使用这个ServiceLoader去加载文件中的类名,然后用类加载器去加载对应的类,创建对象。
SPI中如何获取到应用程序类加载器的?DriverManager是由启动类加载器加载,它怎么拿到应用程序类加载器?
因为SPI中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。

1.启动类加载器加载DriverManager
2.在初始化DriverManager时,通过SPI机制加载jar包中的mysql驱动
3.SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
这种有启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。
是否打破双亲委派机制?

没有打破双亲委派机制。因为JDBC只是在DriverManager加载完之后,通过初始化阶段出发了驱动类的加载,类的加载依然遵循双亲委派机制。
P18 打破双亲委派机制 osgi和类的热部署
同级之间的类加载器互相委托加载。OSGI还是用类加载器实现了热部署(在服务不停止的前提下,更新字节码文件到内存中)的功能。

1.jad命令反编译,然后可以使用其它编译器,比如vim来修改源码。
jad --source-only com.itheima.springbootclassfile.controller.UserController > /opt/jvm/UserController.java

2.记得添加-c参数让类加载器去编译。mc命令用来编辑修改过的代码。
mc -c 21b8d17c /opt/jvm/UserController.java -d /opt/jvm
3.用retransform命令加载新的字节码
retransform /opt/jvm/com/itheima/springbootclassfile/controller/UserController.class
注意事项:
1.程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新。
2.使用retransform不能添加方法或字段,也不能更新正在执行的方法。
P19 JDK9之后的类加载器
在JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java
JDK9引入了module的概念,类加载器在设计上发生了很多变化。
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2.扩展类加载器被替换为平台类加载器。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系丛URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,本身没有特殊的逻辑。
P20 运行时的数据区程序计数器
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。
线程不共享:创建一个线程每个线程里都有一份程序计数器、Java虚拟机栈、本地方法栈对应的数据,自己的数据由自己维护,其它线程不能访问对方线程中的数据。
线程共享:放入任何数据,每个线程都能访问数据,共享。

应用场景:Java的内存分成哪几部分?详细介绍一下。
Java内存中哪些部分会内存溢出?
JDK7和8在内存结构上的区别是什么?
工作中的实际问题:内存溢出。
内存调优的学习路线:
1.了解运行时内存结构,了解JVM运行过程中每一部分的内存结构以及哪些部分容易出现内存溢出。
2.掌握内存问题的产生原因,学习代码中常见的几种内存泄露,性能问题的常见原因。
3.掌握内存调优的基本方法,学习内存泄露,性能问题等常见JVM问题的常规解决方法。
程序计数器(Program Counter Register):也叫PC寄存器,每个线程会通过程序计数器来记录接下来要执行的字节码指令的地址。
ifne 9 ,意思是将操作数栈中的数与0进行比较,如果相等执行6,如果不相等执行9。

程序计数器记录的是下一行字节码指令的地址(假如当前执行的是1,那程序计数器中记录的就是2)。
程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
在多线程的情况下,Java虚拟机需要通过程序计数器(线程不共享)记录CPU切换前执行到哪一句指令并继续解释运行。


程序计数器在运行中会出现内存溢出吗?
内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。
因为每个线程只存储一个固定长度的内存地址,程序计数器不会发生内存溢出。程序员无需对程序计数器做任何处理。
P21 栈局部变量表
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据。先进后出(First In Last Out),每一个方法的调用使用一个栈帧来保存。
栈帧用来保存方法的基本信息。

当某个方法执行完栈帧就会被弹出。
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都有一个自己的虚拟机栈。
栈帧的组成:
局部变量表:作用是在运行过程中存放所有的局部变量。
操作数栈:是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域。
帧数据:包含动态链接、方法出口、异常表的引用。
局部变量表:作用是在方法执行过程中存放所有的局部变量。编译成字节码文件时就可以确定局部变量表的内容。
Nr表示的是变量的编号,按照生命的顺序,起始PC保存了从哪一行字节码指令开始可以访问这个局部变量,长度以生效那行到销毁那行计算。

栈帧中的局部变量表是一个数组,数组中的每一个位置称之为槽slot,long和double类型占2个槽,其他类型占用一个槽。

实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。

方法参数也会保存在局部变量表中,其顺序与方法中参数的定义顺序一致。
局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。

为了节省空间,局部变量表中的槽可以复用,一旦某个局部变量不再生效,当前槽可以被复用。


P22 栈操作数栈和帧数据
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而执行时正确的分配内存大小。

帧数据:包含动态链接,方法出口,异常表的引用。
当前类的字节码指令引用了其它类的属性或者方法时,需要将符号引用(编号,比如#10)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
方法出口指的是方法在正确或异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
异常表存放的是代码中异常的处理信息,包含异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

通过异常表可以知道要在什么范围内捕获异常,如果出现异常要跳转到哪一行。
P23 栈内存溢出
Java虚拟机如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。
Java虚拟机内存溢出时会出现StackOverflowError的错误。
如果我们不指定栈的大小,JVM将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
Linux等操作系统一般是1MB。
一般一个线程的栈能容纳10000-11000个栈帧。创建一个方法会生成一个栈帧。


通过修改-Xss的参数可以让栈帧的大小调小:

对windows来说,JDK8测试最小值为180K,最大值为1024M。
如果局部变量过多,操作数栈的深度过大也会影响栈内存的大小。

Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
P24 堆内存
一般Java程序中堆内存是空间最大的一块的内存区域,创建出来的对象都存在于堆上。
栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间的共享。

堆内存大小具有上限,当一直向堆中放入对象达到上限之后,会抛出OutOfMemory的错误。
可以通过:dashboard 进行访问,如果只能看内存只需要输入:memory

used是已经使用的堆内存,total是总共能使用的堆内存,max是虚拟机能分配的上限堆内存。

最后发现total还远没有达到max的量级就已经溢出了,所以不是当used=max=total的时候,堆内存就溢出。
如果不设置虚拟机的参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般需要设置total和max的值。
-Xmx4g表示最大堆内存的大小,-Xms4g表示total的大小。
为什么arthas中设置的heap堆大小与设置的值不一样呢?

arthas中的heap堆内存使用了JMX技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。
建议将-Xmx和-Xms设置为相同的值,这样程序启动后可使用的总内存就是最大内存,无需向java虚拟机再次申请,减少了申请与分配内存时间上的开销,也不会出现内存过剩后堆收缩的情况。
P25 方法区的实现
方法区是虚拟机中的虚拟概念,每款Java虚拟机在实现上各不相同。
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数控制。
JDK8之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存(占用操作系统的内存)中,默认情况下只要不超过操作系统承受的上限,可以一直分配。
Java虚拟机(JVM)从入门到实战知识汇总

最低0.47元/天 解锁文章
1247

被折叠的 条评论
为什么被折叠?



