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的原理:
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算法效率高。