Java虚拟机(JVM)从入门到实战【上】,涵盖类加载,双亲委派机制,垃圾回收器及算法等知识点,全系列6万字。
【Java速成必备知识:夸克网盘分享】
一、基础篇
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的原理:
1. loadClass方法是类加载的入口,提供了双亲委派机制,内部会调用findClass。根据全限定名去找到类,并把类的二进制信息加载进来。
2. findClass由类加载器子类实现,获取二进制数据调用defineClass,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。
3. 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之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存(占用操作系统的内存)中,默认情况下只要不超过操作系统承受的上限,可以一直分配。
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
1.类的元信息:保存了所有类的基本信息。
方法区是用来存储每个类的基本信息(元信息),一般称为InstanceKlass对象。在类的加载阶段完成。
InstanceKlass对象包含:基本信息,常量池,字段,方法,虚方法表。注意常量池和方法在虚拟机中会被单独摘出来,用单独的内存去存放,而在InstanceKlass中仅仅存储的是引用。
2.运行时常量池:保存了字节码文件中的常量池内容。
方法区除了存储类的元信息外,还存储了运行时的常量池。常量池中存放的是字节码中的常量池内容。
字节码文件通过编号查表的方式找到常量,被称为静态常量池。当常量池加载到内存中后,可以通过内存地址快速定位到常量池的内容,这种叫作运行时常量池。
JDK7大概11万次方法区溢出,JDK8运行上百万次程序也没有溢出。
3.字符串常量池:保存了字符串常量。
P26 方法去字符串常量池
字符串常量池存储在代码中定义的常量字符串内容。
运行时常量池与字符串常量池被拆分(因为JDK8之后方法区由永久代到元空间)。
结果false:
变量链接使用StringBuilder,StringBuilder的底层toString方法是new了一个strinng,所以放在堆。
因为字节码指令中d=a+b涉及到new,也就有对象产生,存放在堆。
结果true:
String.intern()方法是可以手动将字符串放入字符串常量池。
JDK7之后版本中由于字符串常量池在堆上,所以intern()方法会把第一次遇到的字符串的引用放入字符串常量池。
因为java是系统关键字会在启动时存放入字符串常量池。
JDK7之后版本中,静态变量存放在堆中的Class对象中,脱离了永久代。
P27 直接内存
JDK8后方法区的内容存在直接内存的元空间中。
直接内存不在虚拟机规范中存在,所以并不属于java运行时的内存区域。
在JDK1.4后引入NIO机制,使用直接内存。
Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可(减少了一次数据复制的开销),Java堆上维护直接内存的引用,减少数据复制开销。
设置直接内存区的大小:
P28 自动垃圾回收
内存泄露指的是不再使用的对象在系统中未被回收,内存泄露的积累导致内存溢出。
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上内存进行回收。
优点:降低程序员实现难度,降低对象回收bug的可能性。
缺点:程序员无法控制内存回收的及时性。
自动垃圾回收,应用场景:
1.解决系统僵死的问题。与频繁的垃圾回收有关。
2.性能优化。对垃圾回收器性能优化。
3.高频面试题:常见的垃圾回收器
P29 方法区的回收
线程不共享的部分(程序计数器、Java虚拟机栈、本地方法栈)来说,不需要使用垃圾回收机制进行回收。都是伴随线程的创建而创建,线程的销毁而销毁。方法的栈帧在执行完之后会自动弹出栈并释放掉对应的内存。
方法区中能回收的内容主要就是不再使用的类。判断一个类可以被卸载,需要同时满足下面三个条件:
1.此类所以实例对象都被回收,在堆中不存在任何该类的实例对象和子类对象。
2.加载类的类加载器已经被回收。
3.该类对应的java.lang.Class对象没有在任何地方被引用。
如果需要手动触发垃圾回收,可以调用System.gc()方法。
调用System.gc()方法并不一定会立即回收垃圾,仅仅向JAVA虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
P30 引用计数法
Java中的对象能否被回收,是根据对象是否被引用决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
执行main方法会在栈内存中创建一个栈帧。A a = new A()创建的实例对象A会被保存在堆内存中。
判断堆上对象是否被引用有2种方法:引用计数法和可达性分析法。
引用计数法会为每个对象维护一个引用计数器,当对象被引用时+1,取消引用时-1。
引用计数法缺点:1.每次引用和取消引用需要维护计数器,对系统性能存在影响。2.存在循环引用问题,当A引用B,B引用A会导致对象无法回收。
因为A引用B,B引用A出现循环引用问题,无法被回收。
-verbose:gc
P31 可达性分析法
可达性分析将对象分为2类:垃圾回收的根对象(GC Root)和普通对象。对象与对象之间存在引用关系。
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指如果从某个到GC Root对象是可达的,对象就不可被回收。
能被称为GC Root对象的是下面4类对象:
1.线程Thread对象(创建线程之后,整个线程对象),引用线程栈帧中的方法参数、局部变量等。
2.系统类加载器加载的java.lang.Class对象。
Launcher包含应用程序类加载器和GC Root对象。
3.监视器对象,用来保存同步锁synchronized关键字持有的对象。
4.本地方法调用时使用的全局对象。
通过arthas和eclipse Memory Analyzer工具可以查看GC Root,MAT工具是eclipse推出的Java堆内存检测工具。
1.使用arthas的heapdump命令可将内存快照保存到本地磁盘中。
2.使用MAT工具打开堆内存快照文件。
3.选择GC Roots功能查看所有的GC Root。
P32 软引用
可达性算法中描述的对象引用,一般指的是强引用,即GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用外,Java还设计了其它引用方式:
1.软引用
2.弱引用
3.虚引用
4.终结器引用
软引用知识点如下:
软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
在JDK1.2版之后,提供了SoftReference类来实现软引用,软引用用于缓存中。
软引用的执行过程如下:
1.将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
2.内存不足时,虚拟机尝试进行垃圾回收。
3.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
4.如果依然内存不足,抛出OutOfMemory异常。
把最大堆内存设置为200M,无法容纳2个100M的生成,所以当第2个100M生成时内存空间不足,会把软引用释放掉,所以第2次输出软引用的内容是null。
结果如下:
软引用中的对象如果在内存不足时回收,SoftReferece对象本身也需要被回收。如何知道哪些SoftReference对象需要回收呢?
SoftReference提供了一套队列机制:
1.软引用创建时,通过构造器传入引用队列。
2.在软引用中包含的对象被回收时,该软引用对象会被放入引用队列。
3.通过代码遍历引用队列,将SoftReference的强引用删除。
P33 弱虚终结器引用
弱引用:整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。
弱引用对象本身也可以使用引用队列进行回收。
虚引用:当对象被垃圾回收器回收时可以接收到对应的通知。当对象被回收之后,对应的内存也应该被回收。
终结器引用:
P34 垃圾回收算法的评价标准
垃圾回收要做的有2件事;
1.找到内存中存活的对象。(可达性分析法,GC Root是否关联)
2.释放不再存活对象的内存,使得程序能再次利用这部分空间。
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。
判断GC算法是否优秀,可以从3个方面考虑:
1.吞吐量。吞吐量指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即吞吐量=执行用户代码时间/(执行用户代码时间+GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
2.最大暂停时间。最大暂停指的是所有在垃圾回收过程中的STW时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短。
3.堆使用的效率。不同的垃圾回收算法,对堆内存的使用方式是不同的。标记清除法,可以使用完整的堆内存。复制算法会将堆内存一分为二,每次只使用一半内存。从堆使用效率上说,标记清除法要优于复制算法。
上面三种评价标准:堆使用效率、吞吐量、最大暂停时间不可兼得。
比如堆内存越大,最大暂停时间要越长。想要减少最大暂停时间,就会降低吞吐量。
不同的垃圾回收算法,适用于不同的场景。
P35 垃圾回收算法1
标记清除算法:
1.标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.清除阶段:从内存中删除没有被标记的非存活对象。
优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
缺点:1.碎片化问题。由于内存是连续的,所有对象被删除之后,内存中会出现很多细小可用的内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小,无法进行分配。
2.分配速度慢。由于内存碎片存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表最后才能获得合适的内存空间。
复制算法:
复制算法的核心思想是:1.准备2块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。2.在垃圾回收GC阶段,将From中存活对象复制到To空间。3.将两块空间的From和To名字互换。
复制算法的优点:1.吞吐量高。复制算法只需要遍历一次存活对象复制到To空间即可。比标记-整理算法少了一次遍历的过程,因此性能比较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动。
2.不会发生碎片化。复制算法在复制之后就会将对象按顺序存放入To空间,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:内存使用效率低。每次只能让一半的内存空间来为创建对象使用。
标记整理算法:
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为2个阶段:
1.标记阶段。将所有存活对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.整理阶段。将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优点:1.内存使用效率高。整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
2.不会发生碎片化。在整理的阶段
缺点:整理阶段的效率不高。整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGc等高效的整理算法优化此阶段的性能。
P36 垃圾回收算法 分代GC
分代垃圾回收算法是现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代。
在年轻代(新生代)Yong区中会被划分为Eden区(伊甸园区),幸存区(有2块)。
可以通过memory命令来查看内存,伊甸园区大概2G,幸存者区大概270M,老年代大概5G。
-Xms用来设置堆的初始大小即Total。-Xmx设置堆的最大大小即Max。-Xmn设置新生代的大小(包含伊甸园区和2块幸存者区)。-XX:SurvivorRatio可以设置伊甸园区和幸存者区的比例。-XX:+PrintGCDetails verbose:gc打印GC日志。
分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,成为Minor GC或者Young GC。
Minor GC会把需要eden和From中需要回收的对象回收,把没有回收的对象放入To区。
如上图,当eden区满时想再往里放入对象,依然会发生Minor GC,此时会回收eden区和S1中的对象,并把eden和from区中剩余的对象放入S0。
每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完会加1。
如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
如果当老年代中空间不足,无法放入新的对象时(可能是由于年轻代被占满,因此有些对象没达到年龄的阈值,就会被放入老年代),先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
P37 垃圾回收器1
系统中大部分对象,都是创建出来之后很快就不再使用可以被回收的,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
1.可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能(假如用户比较多,有大量用户会在同一时段访问订单数据,如果新生代设置小,大量数据创建,会频繁发生minor GC,所以我们希望这些生命周期短的数据能在)。
2.新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度高。
3.分代设计中允许只回收新生代(minor gc),如果能满足对象分配的要求,就不需要对整个堆进行回收(full gc),STW时间会减少。
垃圾回收器是垃圾回收算法的具体体现,由于垃圾回收器分为年轻代和老年代,除了G1之外的其他垃圾回收器必须成对组合进行使用。
JDK9之后推荐使用G1。
Serial 是一种单线程串行回收年轻代的垃圾回收器。
如果伊甸园区满了,垃圾回收线程就会回收垃圾,此时用户线程不能访问。
单CPU下吞吐量出色,多CPU吞吐量不佳,因为执行时只能但线程,跑在一个CPU上。
SerialOld 是Serial垃圾回收器的老年代版本,采用单线程串行回收。
注意是通过下面这行代码来设置垃圾回收器使用的参数。
P38 垃圾回收器2
parNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收。
-XX:+UseParNewGc 年轻代使用ParNew回收器,老年代使用串行回收器。
CMS(Concurrent Mark Sweep)垃圾回收器 老年代
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
参数:XX:+UseConcMarkSweepGC
浮动垃圾问题:某些垃圾可能清理不掉。
CMS执行步骤:
1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。
2.并发标记,标记所有的对象,用户线程不需要暂停。
3.重新标记,由于并发标记阶段有些对象发生了变化,存在错标、漏标等情况,需要重新标记。
4.并发清理,清理死亡的对象,用户线程不需要暂停。
缺点:
1.CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N参数调整N次Full GC之后再整理。
2.无法处理并发清理过程中产生的浮动垃圾,不能做到完全的垃圾回收。
3.如果老年代内存不足无法分配对象,CMS会退化成Serial Old单线程回收老年代。
P39 垃圾回收器3
Parallel Scavenge垃圾回收器,是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量,具备自动调整堆内存大小(年轻代、老年代大小,年轻代内每一个组成部分,包括阈值等都会自动调整)的特点。
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集。
参数:-XX:+UseParallelGC或-XX:+UseParallelOldGC可以使用Parallel Scavenge + Parallel Old这种组合。
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
吞吐量设置为99,代表用户线程会执行99%的时间,垃圾回收线程仅会执行1%的时间。
最大暂停时间为1时的内存大小要远比为10时小,堆内存越小,回收的范围越小,回收时间更少,暂停时间更短。当最大暂停时间变短,会主动减小堆内存,减少最大停顿时间。
P40 g1垃圾回收器
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。
G1对老年代的清理会选择存活度最低的区域进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。
Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。
CMS关注暂停时间,但吞吐量会下降。
而G1设计目标就是将上述2种垃圾回收器的优点融合:
1.支持巨大的堆空间回收,具有较高的吞吐量。
2.支持多CPU并行垃圾回收。
3.允许用户设置最大暂停时间。
在G1出现之前的垃圾回收器,内存结构一般是连续的,如上图。
G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden,Survivor,Old区。Region的大小可以通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。
G1垃圾回收有2种方式:1.年轻代回收(Young GC)。2.混合回收(Mixed GC)。
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数-XX:MaxGCPauseMillis=n(默认200)设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
1.新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%,伊甸园区、Survivor),无法分配对象时需要回收时会执行Young GC。
2.标记出Eden和Survivor区域中的存活对象。
3.根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),情空这些区域。
G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和 Survivor区的平均耗时,以作为下次回收时的参考依据。这样就能根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
4.后续Young GC时与之前相同,只不过Survivor区中国存活对象会被搬运到另一个Survivor区。
5.当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
6.部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
7.多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeapOccupancyPercent默认45%)会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法完成。
初始标记:标记Gc Roots引用的对象为存活。
用户线程:将第一步中标记的对象引用的对象,标记为存活。
最终标记:标记一些引用改变漏标的对象,不管新创建、不再关联的对象。
并发复制清理:将存活对象复制到别的Region不会产生内存碎片(优先回收存活度低的)。
如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停,所以尽量保证应该用的内存有一定多余的空间。
-XX:+UseG1GC打开G1的开关,JDK9之后默认打开。
-XX:MaxGCPauseMillis=毫秒值最大暂停的时间。
优点:对比较大的堆如超过6G的堆回收时,延迟可控(因为它会去判断应该回收哪个区域,而不是所有的区域)。采用复制算法,不会产生内存碎片。采用并发标记的SATB算法效率高。
二、实战篇
P41 内存泄漏和内存溢出
内存泄露(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况被称为内存泄露。
内存泄露绝大多数情况都是堆内存泄露引起的。
少量的内存泄露可以容忍,但如果发生持续的内存泄露,内存消耗完最终会导致内存溢出。
内存泄露导致溢出的常见场景是在大型的Java后端应用中,在处理用户的请求之后,没有及时将用户的数据删除。随着用户的请求数量越来越多,内存泄露对象占满堆内存,最终导致内存溢出。可以通过重启解决,但一段时间后依然会出现内存溢出。
P42 解决内存泄漏 监控 top命令
监控工具会实时监控,如果达到阈值,会通过邮件、短信去通知运维人员。
top命令是linux下用来查看系统信息的一个命令,它提供给我们去实时的查看系统的资源,比如执行时的进程、线程和系统参数等信息。
进程使用的内存为RES(常驻内存)- SHR(共享内存)
CPU使用情况是1,分别代表当前和5分钟和10分钟的内存占用情况。
内存使用情况是2,total是服务器中全部内存大小,free是当前空闲内存,used是已使用内存。
VIRT是虚拟内存不用关注,RES是常驻内存是重点关注目标,SHR S是共享内存。
%MEM是当前程序占用内存的占比。
top命令操作简单,无额外的软件安装。但只能查看最基础的进程信息,无法查看到每个部分的内存占用。
P43 解决内存泄漏 监控visualvm
在JDK8之前是自带的,JDK8之后需要自己下载。软件在老师的工具包里有。
P44 解决内存泄漏 监控arthas tunnel
在java项目中引入如下依赖:
把下面的jar包存入服务器:
启动arthas:
启动2个微服务:
可以看到被注册上:
P45 解决内存泄漏 监控prometheus grafana
下面是大型企业使用的监控程序:
P46 解决内存泄漏 堆内存状况对比
下面是正常的:
P47 解决内存泄漏 内存泄漏产生的几大原因
代码中的内存泄露,很容易在测试中发现,只需要做一次压力测试:
原因1.不正确的equals()和hashCode()实现导致内存泄露。
1.以JDK8为例,首先调用hash方法计算key的哈希值(对整个对象进行哈希计算),hash方法中会使用到key的hashcode方法。根据hash方法的结果决定存放数组的数组中的位置。
2.如果没有元素,直接放入。如果有元素,先判断key是否相等,会用到equals方法,如果key相等直接替换value;如果key不相等,走链表或者红黑树查找逻辑,其中也会使用equals比对是否相同。
异常情况:
1.hashCode方法实现不正确,会导致相同id的学生对象计算出来的hash值不同,可能会被分到不同的槽中。
2.equals方法实现不正确,会导致key在比对时,即便学生对象的id是相同的,也被认为是不同的key。
3.长时间运行之后HashMap中会保存大量相同id的学生数据。
解决方案:
1.在定义新实体时,始终重写equals()和hashCode()方法。
2.重写时一定要确定使用了唯一标识去区分不同的对象,比如用户的id等。
3.hashmap使用时尽量使用编号id等数据作为key,不要将整个实体类对象作为key存放。
P48 内存泄漏产生的原因2
非静态的内部类默认会持有外部类,尽管代码上不再使用外部类,如果有地方引用了这个非静态的内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类。
解决方法:将内部类和外部类的变量改为static
匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者。
解决方法:使用静态方法,避免匿名内部类持有调用者对象。
P49 内存泄漏产生的原因3
案例3:ThreadLocal的使用
如果仅仅使用手动创建的线程,就算没有调用ThreadLocal的remove方法清理数据,也不会产生内存泄漏。因为当线程被回收时,ThreadLocal也同样被回收。
通过new方法来创建线程,就算不调用remove也会被回收。
但是如果使用线程池就不一定了。因为回不回收,什么时候回收取决于线程池的参数。
解决方案:线程方法执行完,一定要调用ThreadLocal中的remove方法清理对象。
案例4:String的intern方法
JDK6中字符串常量池位于堆内存中的Perm Gen永久代中,如果不同字符串的intern方法被大量调用,字符串常量池会不停的变大超过永久代内存上限后就会产生内存溢出问题。
P50 内存泄漏产生的原因4
案例5:通过静态字段保存对象
问题:如果大量的数据在静态变量中被长期引用,数据就不会被释放,如果这些数据不再使用,就成为了内存泄露。
解决方法:
1.尽量减少将对象长时间的保存在静态变量,如果不再使用,必须将对象删除(或者将静态变量设置为null)
2.使用单例模式时,尽量使用懒加载,而不是立即加载。
在使用Spring的时候,在类上加上@Component注解,就可以在sprig容器启动过程中生成一个对象放入容器中。
@Lazy注解的作用是让对象的加载变成懒加载。只有当要获取这个对象的时候,才会加载该对象。
3.Spring的Bean中不要长期存放大对象,如果是缓存用于提升性能,尽量设置过期时间定期失效。
案例6:资源没有正常关闭
问题:连接和流这些资源会占用内存,如果使用完之后没有关闭,这部分内存不一定会出现内存泄露,但是会导致close方法不被执行。
解决方案:
1.为了防止出现这类的资源对象泄露问题,必须在finally块中关闭不再使用的资源。
2.从Java7开始,使用try-with-resources语法可以用于自动关闭资源。
P51 内存泄漏产生的原因2 并发请求
并发请求问题指的是用户通过发送请求向Java应用获取数据,正常情况下Java应用将数据返回之后,这部分数据就可以在内存中被释放掉。
但由于用户的并发请求量有可能很大,同时处理数据的时间很长,导致大量的数据存在于内存中,最终超过了内存的上限,导致内存溢出。
这类问题的处理思路和内存泄漏相似,首先要定位到对象产生的根源。
可以Apache Jmeter软件进行并发请求测试,Jmeter是使用Java语言编写,可用来测试Web程序支持数据库、消息队列、邮件协议等不同内容的测试。支持插件扩展,生成多样化的测试结果。
具体可见我写的博客:Jemeter测试。
P52 导出堆内存快照并使用MAT分析
当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照文件。
使用MAT打开hprof文件,并选择内存泄露检测功能,MAT会自行根据内存快照中保存的数据分析内存泄露的根源。
P53 MAT内存泄漏检测原理
MAT提供了支配树的对象图,支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。
MAT中深堆和浅堆。
支配树中对象本身占用的空间称之为浅堆。
支配树中对象的子树就是所有被该对象支配的内容,这些内容组成了对象的深堆,也称之为保留集。深堆的大小表示该对象如果可以被回收,能释放多大的内存空间。
MAT是根据支配树,从叶子节点向根节点遍历,如果发现深堆的大小超过整个堆内存的一定比例阈值就会将其标记成内存泄露的“嫌疑对象”。
P54 服务器导出内存快照和MAT使用小技巧
可以生成内存报告,内存报告比内存快照文件小,可以在开发机内存有限的情况下进行浏览。
P55 查询大数据量导致的内存溢出
第1步生成内存快照是下面这段代码设置的:
第3步:分析和解决内存溢出问题:
‘
P56 mybatis导致的内存溢出
点击下方展示直方图:
找到HandlerMethod,看当前处理器对象引用了哪些对象。
问题出现在下面:查看id存在的个数,用的是foreach拼接,如果结果特别大,拼接的字符串会非常长。
P57 k8s容器环境导出大文件内存溢出
hutool的导出性能在XSSFWorkbook之上。
easy excel采用的是分批导出,100000条数据分100次进行导出,每次导出1000条。
P58 系统不处理业务时也占用大量内存
要在拦截器的afterCompletion中把生成的对象删除掉。
P59 文章审核接口的内存问题
P60 btrace和arthas在线定位问题
可以看到内存占用最多的对象:
加上-n参数可以限制输出几次。然后发送请求即可。
找到问题发生的位置:
引入3个jar包,把地址改为自己的。
编写btrace脚本:
把btrace和脚本上传
P61 GC调优的核心目标
GC调优没有唯一的标准答案。
P62 GC调优的常用工具
S0C S1C是幸存者区的容量,S0U S1U是幸存者区使用量,EC是伊甸园区容量,OC是老年代容量,MC是元空间容量。
FGC是Full GC次数,FGCT是Full GC的耗时。
jstat -gc 998655 1000 10,998655是ID,1000是1秒时间,监控10次。
P63 GC调优的常见工具2
P64 常见的GC模式
第4种:持续的FullGC。年轻代和老年代被占满,达到了内存上限,说明内存中存储大量对象。吞吐量较低,说明GC的时间很长,用户的请求被处理速度慢,响应时间长,用户体验差。JVM频繁尝试通过FullGC来清除内存数据。
第5种:元空间不足导致的FULLGC。当元空间的大小达到一个阈值就会触发FULLGC。
P65 基础JVM参数的设置
-Xmx 参数设置的是最大堆内存。在设置JVM内存的时候需要把操作系统和其它软件占用总内存排除掉。
-Xms用来设置初始堆内存,建议将-Xms和-Xmx设置为一样大。因为堆扩容会导致性能下降。如果其它程序正在使用内存,操作系统内存分配可能失败。如果初始堆太小,Java应用程序启动会变慢。
-XX:MaxMetaspaceSize=值 设置的是最大元空间大小,默认值比较大,如果出现元空间内存泄露会让操作系统可用内存不可控。
-XX:MetaspaceSize=值 当元空间大小达到这个值以后会触发FULLGC,后续什么时候会再触发JVM会自行计算。如果设置成和MaxMetaspaceSize一样大,就不会FULLGC,但会导致一些对象不会被回收掉。
-Xss虚拟机栈大小 如果不指定,JVM将创建具有默认大小的栈。一般默认为1MB,如果用不到这么大的栈内存,可以将值调小节省内存空间。
-Xmn 设置年轻代的大小,默认为堆的1/3。根据流量动态调整,使得更多的对象只存放在年轻代,不进入老年代。
-XX:SurvivorRatio 伊甸园区和幸存者区大小比例,默认值为8。
-XX:MaxTenuringThreshold 最大晋升阈值。当年龄大于此值会进入老年代。
P66 垃圾回收器的选择
Parallel Scavenge + Parallel Old 即 PS和PO注重的是垃圾回收的吞吐量。
ParNew + CMS 注重的是最大的暂停时间,将暂停时间限定在一定范围。
G1垃圾回收器。
可以看到G1垃圾回收器的性能非常出众:
P67 垃圾回收参数调优
P68 GC调优和内存调优
高峰期接口调用时间长一般是由垃圾回收导致的。
首先用Gceasy分析是否存在GC问题,如果是内存问题可以用jmap或arthas将内存快照保存,通过MAT等工具来分析内存问题原因。
根源是定时任务使得内存吃紧,所以可以把定时任务关闭:
P69 性能问题的现象和解决思路
如下图可以生成线程的转储文件:
P70 定位CPU占用率高的问题
协议http。io模式是nio(非阻塞io)。端口号是8882。exec表示是线程池里的。50表示线程在线程池中的编号。#79只是编号,不用关注。
daemon表示是守护线程(不影响进程结束,如果所有非守护线程结束,守护线程也会结束。守护线程一般用于记录日志和垃圾回收。)
prio是java里的优先级,os_prio是操作系统里的优先级。
tid是java中线程的唯一ID。nid是操作系统中的唯一id。
第1步:top -c找到CPU占用率高的进程,获取进程ID:
第2步:top -p 进程id ,按大写H,找到CPU占用率最高的线程:
第3步:jstack 进程ID > 文件名 ,可以生成一个tdump文件:
第4步:把线程ID从十进制转为十六进制(十进制:2667735,十六进制:28b620):
P71 接口响应时间很长问题的定位
arthas的trace命令可以展示出整个方法的调用路径以及每一个方法的执行耗时。
skipJDKMethod默认true,默认把JDK核心包中的方法和耗时忽略掉。
#cost > 毫秒值 只会显示超过该毫秒值的调用。
-n 数值 可以控制最多显示该数值条数的数据。
所有监控结束后,输入stop结束监控。
可以看到是调用了b方法才产生了大量的耗时:
设置耗时时间:
记得stop才不会对当前性能构成影响:
使用watch命令可以打印参数和返回值,有利于进行还原
总结:
P72 火焰图定位接口响应时间长的问题
黄色的是Java虚拟机自身的方法调用,看图主要关注左侧绿色的部分
绿色部分最顶上的部分是需要重点排查的性能瓶颈。
P73 死锁问题的检测
检测是否有死锁产生。
如果没死锁,打印线程栈看在执行哪个方法,优化慢方法即可。
死锁定位:
方法1:
方法2:
Rantrantlock解决方法:
方法3:
P74 基准测试框架JMH的使用
改下面2个地方:一个是组名,一个是项目名
@Warmup是预热,iterations是预热次数,time是时间
@Benchmark是测试方法。
@Fork是启动多少个进程,value为1代表只启动一个进程,可以追加Java虚拟机的参数。
@BenchmarkMode是指定当前显示的结果,Mode.AverageTime平均时间,Mode.Throughput吞吐量, Mode.All是显示所有内容。
@OutputTimeUnit是指定显示时间单位,TimeUnit.NANOSECONDS。
@State是变量共享范围(在测试过程中共享还是单个线程共享)。Scope.Benchmark
使用方式:
注意下面几个点:
include写入的是要测试的类名。forks手动设置进程数。直接点击左侧运行按钮运行即可:
1.死代码问题:如果没有将变量i返回,编译器会认为i没有使用,可能是会直接把变量优化掉。
2.如果有多个变量,想返回多个变量,可以用黑洞来消费。
把变量存入ThreadLocal,测试localdateTime:
使用LocalDateTime把格式化对象保存下来,可以获得最好的性能。
性能测试如下:
P75 性能调优
用arthas的trace命令来定位耗时最长的部分,在下图中是因为有2层循环,循环次数太多:
用hashmap来替换循环,可以让1亿次的循环变成1万次。
进一步可以优化日期的格式化。
可以使用并行的parallelStream流可以在多核CPU的环境下并行执行for循环。
优化思路:首先用HashMap来存放当前用户信息,避免双重for循环导致循环次数过大,然后对日期的格式化进行优化了Local。然后用stream流来改造for循环。最终使用并行流进行优化。
如何判断一个方法需要耗时多少时间?
使用OpenJDK中的jmh基准测试框架对某些特定的方法比如加密算法进行基准测试,jmh可以完全模拟运行环境中的Java虚拟机参数,同时支持预热能通过JIT执行优化后的代码获得更准确的数据。
三、高级篇
P76 GraalVM介绍
P77 GraalVM的两种运行模式
JIT(Just-In-Time)模式,即时编译模式。
JIT模式的处理方式与Oracle JDK类似,满足两个特点:
1.一次编写,到处运行。
源代码文件会被编译(使用javac命令编译)成字节码文件
使用GraalVM AOT模式制作本地镜像并运行。
P78 使用SpringBoot3构建GraalVM应用
P79 将GraalVM应用部署到函数计算
自动扩容:当用户访问量大会自动增加服务器,流量小时会自动缩减服务器。
P80 将GraalVM应用部署到Serverless
P81 参数优化和故障诊断
P82 垃圾回收器的技术演进
STW:在回收时要停止全部用户线程。并行:垃圾回收线程和用户线程可以并行执行。
1. PS+PO,会产生长时间停顿,但吞吐量高,不会产生内存碎片:
年轻代:复制算法。
老年代:标记整理算法。
2. ParNew+CMS,CMS并行清理无整理会产生内存碎片(解决方案:产生FULLGC并整理,会产生长时间STW。退化成串行回收,产生长时间STW)。
年轻代:复制算法。
老年代:并行标记和并行清理算法。
3. G1,大的内存空间会被划分成一个个小内存空间,可以只回收小区域,而不是回收大空间。采用启发式的方式进行垃圾回收,尽早回收。
年轻代:复制算法。
老年代:并行标记和复制-整理算法。
G1垃圾回收器比较平衡,Shenandoah ZGC停顿时间短。
P84 ZGC
P83 P85-94 【面试一般不考跳过】
四、原理篇
P95 栈上的数据存储
操作数栈和局部变量表是栈上的2块空间,操作数栈存放临时数据,局部变量表存放方法中的局部变量。
注意操作数栈是栈结构,局部变量表是数组结构,操作数栈的数据先进后出。
局部变量表每个数组元素空间大小(每个槽)在32位虚拟机中为32位,占4字节,在64位虚拟机中占64位,8字节。
long和double长度为8个字节,因此会占用2个数组元素的位置(slot槽)。但对64位操作系统来说,产生了8字节的冗余,主要是因为字节码文件要能够支持跨平台,既能在32位操作系统上使用也要能在64位操作系统上使用。
操作数栈里存储大小与局部变量表相同(局部变量表中16字节的数据,放到操作数栈中也是16字节)。
空间换时间,不用判断数据是什么类型的采用不同的处理,因为判断会产生时间开销。
P96 boolean在栈上的存储方式
因为boolean类型占1个槽,int类型也占1个槽,所以是把boolean类型当作int类型来处理。boolean类型true代表1,反之代表0。byte数据也被当作int类型处理。
特例是float类型:
long类型:
ifeq是把操作数栈中的数与0进行对比。
堆中的数据加载到栈上:
是小数据加载为大数据。所以直接复制,但要注意符号位。
在堆上boolean占1个字节,boolean和char为无符号,直接低位复制,高位补0。
byte、short为有符号数,低位复制,高位为负补1,为非负补0。
boolean比较特殊,只取低位的最后一位保存。
只取最后一位:
P97 对象在堆上的存储1
内存布局:对象在堆中存放时各个组成部分。
内存布局分为2部分对象头和对象数据。
对象类型分为普通对象和数组对象。
对象头包含标记字段和元数据指针,保存锁、垃圾回收等特定功能的信息,32位占4字节,64位占8字节。
标记字段占8个字节,元数据指针占8个字节。(如果对象数据占20个字节, 总共36字节,现在只占32字节,是压缩指针导致的)。
P98 对象在堆上的存储2
如果元数据指针占8字节,寻址范围是2^64,一般内存占用不到这么大的空间,过于浪费,因而64位Java虚拟机使用了指针压缩的技术。
指针压缩思想把寻址单位放大,比如原来按1个字节寻址,现在按8个字节寻址。
现在指针压缩完指针里保存的是编号,不是真实地址。
内存对齐:将对象的内存占用填充至8字节的倍数。
指针压缩之后是4字节,相当于2^32。现在每个对象最小8字节。所以最大寻址空间是2^32,相当于32GB。
内存对齐填充:
如下把age和id位置颠倒,因为id是long型占8个字节,不能被12整除。同时也为了避免数据在不同缓存行。因为一个缓存行是8个字节。
引用对象一般会被排到最后。
P99 方法调用的原理1 静态绑定
方法调用的本质是通过字节码指令的执行,在栈上创建栈帧,并执行调用方法中的字节码命令。
invoke开头的指令都是执行方法的调用。
#2是编号,编号指向的是常量池里的内容。
当执行指令,虚拟机可以定位到方法对应的内容,获取方法的字节码指令,创建对应的栈帧。
Invoke方法的核心作用是找到字节码指令并执行。
Invoke指令执行时,需要找到方法区中instanceKlass中保存的方法相关的字节码信息。但是方法区中有很多类,每个类又包含很多方法,怎么精确定位到方法的位置呢?
编译期间,invoke指令会携带一个参数符号引用,引用到常量池中的方法定义。方法定义中包含了类名+方法名+返回值+参数。
在方法第一次调用时,这些符号引用会被替换成内存地址的直接引用,这种方法称之为静态绑定。
静态绑定适用于处理静态方法、私有方法、或者final修饰的方法,因为这些方法不能被继承后重写。
P100 方法的调用的原理2 动态绑定
对于非static、非private、非final的方法,有可能存在子类重写方法,那么需要通过动态绑定来完成方法地址绑定的工作。调用的是Cat类对象的eat方法,编译完后虚拟机指令调用Animal类的eat方法,运行过程中通过动态绑定找到Cat类的eat方法。
字节码指令调用Animal.eat方法,最后调用Cat.eat方法。
动态绑定是基于方法表来完成的,invokevirtual使用了虚方法表(vtable),invokeinterface使用了接口方法表(itable)。
每个类都有一个虚方法表,本质是一个数组,记录方法的地址。子类方法表中包含父类方法表中的所有方法。
子类如果重写了父类方法,则使用自己类中方法的地址进行替换。
产生Invokevirtual调用时,先根据对象头中的类型指针找到方法区中InstanceClass对象,获取虚方法表。根据虚方法表找到对应的方法,获得方法的地址,最后调用方法。
P101 异常捕获的原理
异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
起始/结束PC:此条异常捕获生效的字节码起始/结束位置。
跳转PC:异常捕获之后,跳转到的字节码位置。
程序运行时触发异常时,Java虚拟机会从上至下遍历异常表中的所有条目,当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。
1.如果匹配,跳转到 跳转PC 对应的字节码位置
2.如果遍历完不能匹配,说明异常无法再当前方法执行时被捕获,方法栈帧直接弹出,在上一层的栈帧中进行异常捕获查询。
比如:如果在study方法中抛出异常,在study的异常表中无法处理,study方法的栈帧会被弹出,把异常交给main方法的异常表处理。
在多个catch分支情况下,异常会从上往下遍历。
finally指不管走到哪个代码块try块也好,catch块也好,没被捕获也好,都必须执行。
在字节码层面,finally的逻辑实现是在异常表中增加2个条目,覆盖try和catch的字节码指令范围内。
P102 JIT即时编译器
Java源代码文件编译成字节码文件,类加载器将字节码指令读取到内存中,通过JVM的解释器,将字节码文件解释成不同平台的机器码。
解释器在解释的过程中会耗费时间。
JIT即时编译器,是用来提升应用程序代码执行效率的技术。当字节码指令被Java虚拟机解释执行时,如果有一些指令执行的频率高,称之为热点代码,这些字节码指令会被JIT即时编译器编译成机器码的同时进行部分优化,最终保存到内存中。
JIT做的2件事:1.优化热点代码,2.将字节码指令编译成字节码保存在内存中。
随着C1收集的信息越来越完整、越来越多,性能也会随之下降。但收集这么多信息的目的是为了把这些信息交给C2即时编译器处理。
P103 JIT即时编译器优化手段1 方法内联
JIT编译器的优化手段:方法内联和逃逸分析。
方法内联:方法体中的字节码指令会直接复制到调用方的字节码指令中,节省了创建栈帧的开销。
比如执行1000000次循环,C2会把循环累加优化成乘法操作,C1乖乖的进行循环。
P104 JIT即时编译器优化手段2 逃逸分析
逃逸分析指的是如果JIT发现在方法内创建的对象不会被外部引用,那么可以采用锁消除,标量替换等方式进行优化。
优化的前提是对象不能作为参数传递到一个方法中,也不能return到方法外面,仅仅在局部进行使用。
锁消除:
标量替换:
在Java虚拟机中,对象中的基本数据类型称为标量,引用的其他对象称为聚合量。标量替换指的是如果方法中的对象不会逃逸,那么标量就可以直接在栈上分配。
如下图可以免去创建变量的步骤。
开启或关闭逃逸分析,标量替换是否生效的效率测试:
JIT优化建议:
P105 g1垃圾回收器原理 年轻代回收
分块,分为Eden,Survivor,Old等区,如果在年轻代多次存活会晋升到老年代。
垃圾回收分为年轻代回收和混合回收。
1. 新创建对象会被放在Eden区,当G1判断年轻代区不足默认60%,无法分配对象需要回收会执行Young GC。
2. 标记出Eden和Survivor区域的存活对象。
3. 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的SurVivor区(年龄+1),清空区域。
4. 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个。
5.某个存活对象的年龄达到阈值(默认15),将被放入老年代。
6.部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M,就被放入Humongous区,如果对象过大会横跨多个Region。
7.多次回收后,会出现很多Old老年代区,此时总堆占有率达到阈值会触发混合回收MixedGC,回收所有年轻代和部分老年代对象以及大对象区,采用复制算法完成。
年轻代回收只扫描年轻代对象(Eden+Survivor),所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代对象都很容易扫描出来。
年轻代回收只扫描年轻代对象,如果有老年代中的对象引用了年轻代中的对象,如何知道呢?
方案1:从GC Root开始,扫描所以对象,如果年轻代对象在引用链上,标记为存活。
缺点:需要遍历引用链上所有对象,效率太低。
方法2:维护一张详细的表,记录哪个对象被哪个老年代引用过,在老年代中被引用的对象不可回收。
缺点:如果对象太多表会占很大的内存空间,还可能存在错标的情况。
方案2优化:只记录Region被那些对象引用了。这种引用详情表被称为记忆集RememberedSet,是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到GC Root中,就可以根据引用链判断哪些对象需要回收了。
方案2的第二次优化:将所有区域的内存按一定大小划分成很多个块,每个块进行编号。记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销。
现在不需要记录对象的地址,只需要记录块的编号。
每一个Region都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个Region对应的卡表上就会将字节内容进行修改,JDK8源码中0代表被引用了称为脏卡。这样就可以标记出当前Region被老年代中的哪些部分引用了。要生成记忆集就比较简单,只需要遍历整个卡表找出脏卡即可。
JVM使用写屏障技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令,从而维护卡表。
记忆集中不会记录新生代到新生代的引用,同一个Region中的引用也不会记录。
1.通过写屏障获得引用变更的信息。
2.将引用关系记录到卡表中,并记录到一个脏卡队列中。
3.JVM中会由Refinement线程定期从脏卡队列中获取数据,生成记忆集,不直接写入记忆集的原因是避免过多线程并发访问记忆集。
P106 g1垃圾回收器原理 混合回收
总堆占有率达到阈值触发混合回收。会回收整个年轻代和部分老年代。标记老年代存活对象的耗时较长,要做到和用户线程并行。
混合回收步骤:
1.初始标记。STW采用三色标记法标记从GC Root可直达的对象。
黑色:存活。在GC Root引用链上,引用的其他对象都标记完成。
灰色:待处理。在GC Root引用链上,引用的其它对象还未标记完成。
白色:可回收。不在GC Root引用链上。
注意下面3点:
1.灰色对象是用队列来保存。黑色和白色对象是用bitmap位图来标记。
2.虽然ACEF都在引用链上,但初始时颜色都是白色,后面会进行处理。
3.图中可以看到B引用了A和C,因此它是灰色,因为还有其它对象没标记。D为黑色因为已经标记完。
2.并发标记。并发执行,对存活对象进行标记。
从灰色队列中取出尚未完成标记的对象,然后标记与B关联的对象,标记颜色为黑色。
最后会用位图来标识所有可回收和不可回收的对象:
3.最终标记。STW,处理SATB相关的对象标记。
4.清理。STW,如果区域中没有存活对象就直接清理。
5.转移。将存活对象复制到别的区域。
比如要将Region 1中的A回收,GC Root会把A复制一份存放到Region 4中,等到Region 1中的所有对象复制完毕后,会把Region 1中的对象清理。
P107 ZGC原理
P108 ShenandoahGC原理
五、面试篇
P109 什么是JVM
你对Java虚拟机的理解+工作中的实际应用,解决工作中的问题。
什么是JVM:
定义:JVM指的是Java虚拟机。本质上:是一个运行在计算机上的程序。职责是:运行Java的字节码文件,在Java虚拟机上可以运行Java、Kotlin、Scala、Groovy等语言。具有编写一次(Write Once,Run AnyWhere)、到处运行的跨平台特性(我们编写的是源代码,硬件运行要求机器码,所以会将源代码先编译为字节码然后再解释为不同机器运行的机器码)。
作用:
1.解释和运行:对字节码文件中的指令,实时解释成机器码,让计算机运行。
2.内存管理:自动为对象和方法分配内存。还有自动的垃圾回收机制,回收不再使用的对象。
3.即时编译:对热点代码进行优化,提升执行效率。
JVM的组成:
1.类加载子系统:通过类加载器将class字节码文件加载到内存中。
2.运行时数据区:是Java虚拟机里面用到的内存。包含:堆空间,栈空间(细分为:本地方法栈和虚拟机栈),方法区(里面存放class字节码信息),程序计数器。
3.执行引擎:包含:解释器(将字节码指令转化为机器码)、即时编译器(对热点代码进行优化)、垃圾回收器(对不再使用的对象进行回收)。
常见的JVM:
P110 字节码文件的组成
字节码文件本质上是一个二进制的文件,所以无法直接用记事本打开。在开发环境可以使用:jclasslib插件打开。在服务器可以使用:javap -v命令打开。
字节码文件组成:
(魔数是字节码文件的前4个字节,起校验作用,比如如果字节码文件的前4字节为:ca fe ba be,java虚拟机就会认为这是一个合法的文件)
1.基本信息。对整个类的描述信息。比如:编译版本号,访问标识,父类和接口。
2.常量池。保存了字符串常量、类和接口名、字段名等。
3.字段。当前类或接口声明的字段信息(字段相当于就是变量,描述符是变量的类型,大写的I代表的是int类型)。
4.方法。当前类或接口声明的方法信息,字节码指令(通过这些字节码指令解释成机器码运行程序)。
类里面的方法,init是构造方法,描述符指的是方法的参数和返回值。
在每个方法下面有一个Code,里面有字节码,还有异常表(异常的捕获和跳转)。
5.属性。类的属性,比如源码的文件名,内部类列表。
P111 什么是运行时数据区
运行时数据区是Java虚拟机运行时存放数据的地方。
分为4个部分:堆、栈、方法区、程序计数器。
线程共享的是:方法区、堆。线程不共享的是本地方法栈、虚拟机栈、程序计数器。
直接内存主要是NIO,由操作系统直接管理,不属于JVM内存。
一、程序计数器
程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的字节码指令的地址。
主要有2个作用:
1.可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
2.在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前执行到哪一句指令并继续解释运行。
二、Java虚拟机栈
Java虚拟机栈采用栈的数据结构来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈帧来保存。
每个线程都会包含一个自己的虚拟机栈(线程不共享),它的生命周期和线程相同。
栈帧是Java虚拟机栈中的一个概念,分为三部分:
1.局部变量表,在方法执行过程中存放所有的局部变量。
2.操作数栈,虚拟机在执行指令过程中用来存放临时数据的一块区域。
做赋值、加法和减法等操作时需要用到多个数据的时候,运算的数会被存入操作数栈。
3.帧数据,主要包含动态链接、方法出口、异常表等内容。
动态链接:方法中要用到其他类的属性和方法,这些内容在字节码文件中是以编号保存的,运行过程中需要替换成内存中的地址,这个编号到内存地址的映射关系保存在动态链接中。
方法出口:方法调用完需要弹出栈帧,回到上一个方法,程序计数器要切换到上一个方法的地址继续执行,方法出口保存的就是这个地址。
异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
三、本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法参数同时方便出现异常时也把本地方法的栈信息打印出来。
四、堆
一般Java程序中堆内存是空间最大的一块内存区域,创建出来的对象都存在于堆上。
栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
堆是垃圾回收最主要部分,堆结构更详细的划分与垃圾回收器有关。
五、方法区
方法区是Java虚拟机规范中提出的一个虚拟机概念,在HotSpot不同版本中会用永久代或元空间来实现。方法区中主要存放的是基础信息,包含:
1.每一个加载的类的元信息(基础信息)
2.运行时常量池,保存了字节码文件中的常量池内容,避免常量内容重复创建减少内存开销。
3.字符串常量池,存储字符串常量。
P112 哪些区域会出现内存溢出
内存溢出指的是内存中某一块区域的使用量超过了允许使用的最大值,从而使用内存时因空间不足而失败,虚拟机一般会抛出指定的错误。
在Java虚拟机中,只有程序计数器不会出现内存溢出的情况,因为每个线程的程序计数器只保存一个固定长度的地址。
堆内存溢出:
堆内存溢出指的是在堆上分配的对象空间超过了堆的最大大小,从而导致的内存溢出。堆的最大大小使用-Xmx参数进行设置,如-Xmx10m代表最大堆内存大小为10m。
溢出之后会抛出OutOfMemoryError,并提示是Java heap space导致的。
栈内存溢出:
栈内存溢出指的是所有栈帧空间的占用内存超过了最大值,最大值使用-Xss进行设置,比如-Xss256k代表所有栈帧占用内存大小加起来不能超过256k。
溢出之后会抛出StackOverflowError。
方法区内存溢出:
P113 JDK6-8内存区域上的不同
P114 类的生命周期
回答要点:
类的生命周期分为:
1.加载
2.连接(验证,准备,解析)
3.初始化
4.卸载
具体回答:
1.加载(Loading)阶段:
类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码的信息。
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中,会在方法区中生成一个InstanceKlass对象,保存类的所有信息。
在堆中会生成一份与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息。
2.连接(Linking)阶段
P115 什么是类加载器
回答要点:
1.类加载器作用
2.类加载器分类
作用:类加载器负责在类的加载过程中将字节码信息以流的方式获取并加载到内存中。
分类:
1.启动类加载器:加载Java中最核心的类。底层是用C++代码编写(JDK8及之前)。底层使用Java语言编写(JDK9及之后)。
默认加载Java安装目录/jre/lib下的类文件,比如rt.jar、tools.jar、resources.jar等。
2.扩展类加载器(JDK8及之前)/平台类加载器(JDK9及之后):允许扩展Java中比较通用的类。
默认加载Java安装目录/jre/lib/ext下的类文件。
3.应用程序类加载器:加载应用程序使用的类。
默认加载的是应用程序classpath下的类。
4.自定义类加载器:实现自定义类加载规则。
允许用户自行实现类加载的逻辑,可以从网络、数据库等来源加载类信息。自定义类加载器需要继承自ClassLoader抽象类,重写findClass方法。
className是类名,bytes是字节码信息(字节数组),偏移量(字节数组从哪一位开始读取),size是读取多少字节数据。
P116 什么是双亲委外机制
回答要点:
1.类加载器和父类加载器
2.什么是双亲委派机制
3.双亲委派机制的作用
回答:
类加载器有层级关系,上一级称为下一级的父类加载器。
启动类加载器是扩展类加载器的父类加载器,扩展类加载器是应用程序类加载器的父类加载器,应用程序类加载器是自定义类加载器的父类加载器。
双亲委派机制指的是:当一个类加载器收到加载类的任务时,会向上查找是否加载过,再由顶向下进行加载(看类是否在加载路径上)。
在类加载过程中,每个类都会检查是否已经加载了该类,如果已加载则直接返回,否则将加载请求委派给父类加载器。
注意:接收到类加载任务的类加载器,就是向上查找和向下加载的起点和终点。
功能:
1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库。
2.避免重复加载,避免一个类被多次加载。
P117 如何打破双亲委派机制
loadClass:类加载的入口,提供双亲委派机制,内部会调用findClass。
findClass:由类加载器的子类实现,获取二进制数据,调用defineClass,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。
defineClass:做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中。
resolveClass:执行类生命周期中的连接阶段。
打破双亲委派机制的唯一方法是实现自定义类加载器,重写loadClass方法,将其中双亲委派机制代码去掉。
P118 tomcat的自定义类加载器
P119 如果判断堆上的对象有没有被引用
回答要点:
1.引用计数法
2.可达性分析法
3.使用可达性分析法的原因
回答:
1.引用计数法:会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
优点:实现简单。
缺点:
1.每次引用和取消引用都需要维护计数器,对系统性能有一定影响。
2.存在循环引用问题,当A引用B,B引用A会导致对象无法回收的问题。
2.可达性分析法:
Java使用的是可达性分析算法判断对象是否可以回收。可达性分析将对象分为2类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。
如果某个对象到GC Root对象是可达的,对象就不可被回收。
GC Root对象:
1.线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
2.系统类加载器加载的java.lang.Class对象,引用类中的静态变量。
3.监视器对象,用来保存同步锁synchronized关键字持有的对象。
4.本地方法调用时使用的全局对象。
P120 JVM中有哪些引用类型
1.强引用:对象被局部变量、静态变量等GC Root关联的对象引用。
public class Test26 {
private static int _1MB=1024*1024*1;
public static void main(String[] args) {
List<Object> objects = new ArrayList<>();
while(true){
byte[] bytes = new byte[_1MB];
objects.add(bytes);
}
}
}
2.软引用:当程序内存不足时,就会将软引用中的数据进行回收。 软引用主要在缓存框架使用(软引用中的数据不是特别重要的核心数据,缓存的主要目的是为了加快数据访问的速度)。
//-Xmx10m -verbose:gc
public class Test26 {
private static int _1MB=1024*1024*1;
public static void main(String[] args){
List<SoftReference> objects = new ArrayList<>();
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[_1MB];
SoftReference<byte[]> softReference = new SoftReference<>(bytes);
objects.add(softReference);
System.out.println(i);
}
}
}
3.弱引用:弱引用的机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会被直接回收,弱引用主要在ThreadLocal中使用。
//-Xmx100m -verbose:gc
public class Test26 {
//效果前9个数据被回收,最后一个对象有数据
private static int _1MB=1024*1024*1;
public static void main(String[] args) {
List<WeakReference<byte[]>> objects = new ArrayList<>();
System.out.println("----------");
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[_1MB];
WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);//弱引用
objects.add(weakReference);//弱引用对象放入集合中
}
byte[] last = objects.get(9).get();//设置一个强引用
//手动执行一次垃圾回收,弱引用对象只要没有强引用,就会被直接回收。
System.gc();
System.out.println("-----------");
for (WeakReference softReference : objects) {
System.out.println(softReference.get());
}
}
}
4.虚引用:不能通过虚引用获取到包含的对象,唯一的用途是当对象被垃圾回收器回收时可以接受到对应的通知。
直接内存中为了及时知道内存对象不再使用,从而回收内存,使用了虚引用来实现。
P121 ThreadLocal中为什么要使用弱引用
ThreadLocal类中可以存放线程自身的本地变量,线程和线程之间是彼此隔离的,保证数据的线程安全(数据如果只能在线程内部可见,就可以免去加锁等操作)。
1.在每个线程中,存放了一个ThreadLocalMap对象,本质上是一个数组实现的哈希表(key是ThreadLocal对象,value是set方法放进去的值),里面存放多个Entry对象。
2.每个Entry对象继承自弱引用,内部存放ThreadLocal对象。同时用强引用,引用保存的ThreadLocal对于的value值。
1.通过ThreadLocal对象的hash值,找到线程中ThreadLocalMap对于的槽位。
2.创建一个Entry对象
1.通过ThreadLocal对象的hash值,找到线程中ThreadLocalMap对应的槽位。
2.找到Entry中保存的value值,将地址赋给user局部变量。
不再使用ThreadLocal对象时,ThreadLocal=null,由于是弱引用,那么在垃圾回收之后,ThreadLocal对象就可以被回收。
ThreadLocal使用时要注意调用refang
P122 有哪些垃圾回收算法
垃圾回收算法:
1.标记清除
标记阶段:将所有存活对象进行标记。从GC Root开始通过引用链遍历出的所有存活对象。
清除阶段:从内存中删除没有被标记的非存活对象。
优点:实现简单,只要在第一阶段给每个对象维护标志位即可,第二阶段删除对象即可。
缺点:
1.碎片化问题:由于内存是连续的,当对象被删除后,内存中会出现很多细小的可用内存单元(内存碎片)。
2.分配速度慢:由于内存碎片存在,需要维护空闲链表,极有可能发送每次遍历到链表的最后才能获得合适的内存空间。
2.复制算法
核心思想:
1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间。
2.在垃圾回收GC阶段,将From中存活对象复制到To空间。
3.将两块空间的From和To名字呼唤。
优点:
1.吞吐量搞:复制算法只需要遍历一次存活对象复制到To空间即可,比标记整理算法少了一次遍历过程,因而性能较好,但是不如标记清除算法,因为标记标记清除算法不需要进行对象的移动。
2.不会发生碎片化:复制算法在复制之后就会将对象按顺序放入To空间,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:内存使用效率低:每次只有一半的内存空间供创建对象使用。
3.标记整理
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片的一种解决方案。
分为2个阶段:
1.标记阶段:将所有存活的对象进行标记,判断依据是从GC Root引用链可达。
2.整理阶段:将存活对象移动到堆的一端,清理掉非存活对象的内存空间。
优点:
1.内存使用效率高:整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
2.不会产生碎片化问题:在整理阶段可以将对象往内存的一侧移动,剩下的空间都是可以分配对象的有效空间。
缺点:整理阶段效率不高:整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体效率不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段性能。
4.分代GC
分代垃圾回收算法(Generational GC):将整个内存区域分为年轻代(Young)和老年代(Old)。
年轻代中存放存活时间短的对象,老年代中存放存活时间长的对象。
在年轻代中有:Eden区伊甸园区(存放的是最开始创建出来的对象)、Survivor幸存者区(S0和S1,两块的大小相等,采用复制算法)。
分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
随着对象在Eden区越来越多,如果Eden区满,新创建的对象无法放入,会触发年轻代的GC,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
接下来To区和From区会互换,当伊甸园区满会触发Minor GC,此时会回收伊甸园区和From区中的非存活对象对象,并把存活的对象放入S0区。
每次Minor GC都会为对象记录他的年龄,初始值为0,每次GC完+1。
如果Minor GC后对象的年龄达到阈值(最大15,默认只和垃圾回收器有关),对象会被晋升到老年代。CMS默认值为6,G1默认15。
当老年代空间不足,无法放入新对象时,会先尝试Minor GC,如果空间还是不足会触发Full GC,Full GC会对整个堆(年轻代+老年代)进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代,会抛出Out of Memory的错误。
优点:年轻代和老年代可以使用不同的算法,更灵活。
P123 有哪些常用的垃圾回收器
1.Serial垃圾回收器+SerialOld垃圾回收器
Serial是一种单线程串行回收年轻代的垃圾回收器。
Serial采用复制算法。
SerialOld采用标记整理算法。
优点:单CPU处理器下吞吐量出色。
缺点:多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间等待。
适用场景:适合Java编写的客户端,硬件配置有限的场景(比如电子手表)。
2.PS+PO
PS+PO是JDK8默认的垃圾回收器,多线程并行回收(可以理解为Serial的升级版),关注系统吞吐量。具备自动调整堆内存大小的特点。
PS是Parallel Scavenge垃圾回收器,年轻代采用复制算法。
PO是Parallel Old垃圾回收器,老年代采用标记整理算法。
优点:吞吐量高,手动可控。为了提高吞吐量,虚拟机会动态调整堆参数。
缺点:不能保证单次的停顿时间。
适用场景:后台任务,不需要与用户交互,并且容易产生大量对象(比如大数据处理,大文件导出)。
3.ParNew+CMS
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收。
ParNew是年轻代垃圾回收器,采用复制算法。
优点:多CPU处理器下停顿时间短。
缺点:吞吐量和停顿时间不如G1。
适用场景:JDK8及之前版本中与CMS配合使用。
CMS(Concurrent Mark Sweep 并发的标记清理)垃圾回收器
CMS是老年代的垃圾回收器,用的是标记清除算法。
主要关注系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时进行,减少了用户线程的等待时间。
优点:系统由于垃圾回收出现的停顿时间较短,用户体验好。
缺点:
1.内存碎片问题(因为用的是标记清除算法,单CMS会在若干次Full GC后进行碎片的整理)。
2.退化问题(如果老年代内存不足无法分配对象,CMS会退化为Serial Old单线程回收老年代)。
3.浮动垃圾问题(在并发标记阶段,用户线程同时也可以对对象进行修改,这就导致在并发清理阶段有些对象可能不再被GC Root引用,但仍旧不会被并发清理,因此会拖到下一轮再进行清理,不能做到完全的垃圾回收)。
4.并发阶段会影响用户线程执行的性能。
4.G1
G1的内存结构不再是一块年轻代和老年代的空间。将整个堆内存空间划分成一块一块的Region,分为伊甸园区、幸存者区、老年代和大对象区。G1回收器在回收时会选择某一块区域进行回收。
G1是年轻代+老年代的垃圾回收器,用的是垃圾回收算法是复制算法。
优点:
1.延迟可控(用-XX:MaxGCPauseMillis控制最大暂停时间)。
2.不会产生内存碎片(因为用的是复制算法)。
3.并发标记的SATB算法效率高。
缺点:JDK8之前不够成熟。
适用场景:JDK8最新版本、JDK9之后建议默认使用。
5.Shenandoah和ZGC
P124 如何解决内存泄露问题
内存泄漏(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC Root引用链上,这个对象不会被垃圾回收器回收,这种情况称之为内存泄漏。
如果发生持续的内存泄漏,最终会导致内存溢出。
第1步:发现问题。
生产环境可以通过运维工具Promesheus+Grafana等监控平台查看。
开发、测试环境通过visualvm查看。
第2步:诊断原因。
首先生成内存快照:
当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照(Heap Profile)文件。
方法1:添加生成内存快照的Java虚拟机参数。
可以用如下命令:-XX:+HeapDumpOnOutOfMemoryError 。该命令是指当发生OutOfMemoryError错误时,自动生成hprof内存快照文件。
-XX:HeapDumpPath=<path> 。该命令是指定path为hprof文件的输出路径。
方法2:导出运行中系统的内存快照,只需要导出标记为存活的对象:
通过JDK自带的jmap命令导出,格式为:
jmap -dump:live,format=b,file=文件路径和文件名 进程ID
通过arthas的heapdump命令导出,格式为:
heapdump --live 文件路径和文件名
MAT定位问题:
使用MAT打开hprof文件,并选择内存泄漏检测功能,MAT会自行根据内存快照中保存的数据分析内存泄漏的根源。
第3步:修复问题。
代码不当:如何是代码写法不合理。只需要修改代码。
参数不当:如果是用户请求量过大导致的并发问题。需要设重新设置合理的参数。
设计不当:比如从数据库获取超大数据量的数据,线程池设计不当,生产者-消费者模型,消费者消费性能问题。优化设计方案。
第4步:测试验证。
P125 常见的JVM参数
1.最大堆内存参数
-Xmx 设置的是最大堆内存(设置的时候需要排除掉元空间、操作系统和其它软件占用的内存。最合理的设置方式应该根据最大并发量估算服务器的配置)。
-Xms 设置的是最小堆内存(建议将-Xms设置的和-Xmx一样大,运行过程中可以省去扩容的开销)。
-Xmn 年轻代的大小(默认值为整个堆的1/3,应该根据峰值流量计算最大的年轻代大小,尽量让对象存放在年轻代,不进入老年代,G1垃圾回收器尽量不要设置该值,G1会自动调整年轻代的大小)。
2.最大栈内存参数
-Xss256k 设置的是栈内存大小,如果我们不指定栈的大小,JVM将创建一个具有默认大小的栈,比如Linux x86 64位设置的是1MB。
3.最大元空间内存参数
-XX:MaxMetaspaceSize=值 ,设置的是最大元空间大小,默认值比较大,如果元空间出现内存泄漏会让操作系统可用内存不可控,建议根据测试情况设置最大值,一般设置为256M。
4.日志参数
在JDK8及之前:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
在JDK9及之后:-Xlog:gc*:file=文件路径
5.堆内存快照参数
6.垃圾回收器参数
7.垃圾回收器调优参数