【注:本文章为自学笔记,仅供学习使用。】
一、JVM简介
JVM是Java虚拟机的缩写,本质上是运行在计算机上面的程序,作用是运行Java字节码文件。
1.1 JVM的功能
- Java如果不做优化,则性能不如C/C++,因为后者会直接将代码在编译的时候转化为机器码(可执行文件),直接可以在计算机上运行。而Java会先将代码编译成字节码指令,然后通过JVM进行解释成机器码,再提供给计算机运行。
- Java通过JVM实时解释,实现了跨平台特性。
- JVM提供的即时编译,简称JIT,进行性能优化,最终能达到接近C/C++的运行性能。
1.2 常见的JVM
1.3 JVM的组成
二、JVM组成详解
2.1 字节码文件
- 字节码文件包含:基础信息,常量池,字段,方法,属性五部分组成。
2.1.1基础信息
2.1.1.1 Magic魔数
- 文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改,不影响文件内容。
- 软件使用文件的头几个字节去校验文件的类型。
- Java字节码文件中,将文件头称为Magic魔数,Java字节码文件的魔数是CAFEBABE。
每一个Java字节码文件的头几位都是CAFEBABE开头。
2.1.1.2 主副版本号
- 主副版本号指的是编译字节码的JDK版本号,主版本号用来标识大版本号;副版本号是当主版本号相同时,作为区分不同版本的标识。
- 版本号的作用是判断当前字节码文件的版本和运行时JDK是否兼容。
1.2之后大版本号计算方法:主版本号-44。比如主版本号是52就是JDK8。
2.1.2 常量池
- 常量池的作用是:避免相同的内容重复定义,节省空间。
- 常量池中的数据都有一个编号,编号从1开始,在字段或者字节码指令中通过编号可以快速找到对应的数据。
- 字节码指令中通过编号引用到常量池的过程叫做符号引用。
2.1.3 方法
上面是i++的执行流程,讲解如下:
iconst_0,意思是将一个常量0,放入操作数栈中
istore_1,意思是操作数栈中的0,放入局部变量的数组下标是1的位置
iload_1,意思是将局部变量中的数组下标为1的数据复制一份放入操作数栈中,此时操作数栈中数据为0,局部变量数组的下标1的位置的数据为0
iinc 1 by 1,意思是将局部变量数组1号位置的数据增加1,也就是当前的0 -> 1,此时操作数栈中的数据为0,局部变量数组的下标1的位置的数据为1
istore_1,意思是将操作数栈中的数据0,放入局部变量数组1号位置,即局部变量1号位置的值1 -> 0
return,意思是放回结果,即最终i的值是0
上面是++i的执行流程,讲解如下:
iconst_0,意思是将一个常量0,放入操作数栈中
istore_1,意思是操作数栈中的0,放入局部变量的数组下标是1的位置
iinc 1 by 1,意思是将局部变量数组1号位置的数据增加1,也就是当前的0 -> 1,此时操作数栈中的数据为0,局部变量数组的下标1的位置的数据为1
iload_1,意思是将局部变量中的数组下标为1的数据复制一份放入操作数栈中,此时操作数栈中数据为1,局部变量数组的下标1的位置的数据为1
istore_1,意思是将操作数栈中的数据1,放入局部变量数组1号位置,即局部变量1号位置的值1 -> 1
return,意思是放回结果,即最终i的值是1
通过字节码文件比较一下下面代码的效率
@Test
public void tttt() {int i = 0, j = 0, k = 0;i++;j = j + 1;k += 1;System.out.println(i);System.out.println(j);System.out.println(k);
}
可以发现i,k的字节码是相同的第7行和第12行,而k的字节码指令需要9-12行四句指令。
2.1.4 字节码工具
2.1.4.1 javap -v命令
javap是jdk自带的反编译工具,可以通过控制台查看字节码文件的内容,适合在服务器上查看字节码文件内容。
- 将服务器上的jar通过jar -xvf xxx.jar进行解压。
- 找到需要查看的.class文件的路径,通过javap -v 文件绝对路径,来展示反编译后的文件内容。
2.1.4.2 Arthas
- Arthas是一款线上监控诊断产品,通过全局视角实时查看应用load、内存、gc、线程的状态信息
- 官网:https://arthas.aliyun.com/doc/
2.2 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。
2.2.1 加载阶段(Loading)
- 类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。
- 生成一个InstanceKlass对象,保存类的所有信息,里面包含实现特定功能比如多态的信息。
- Java虚拟机还会在堆中生成同一份与方法区中数据类似的java.lang.Class对象【方法区和堆通过引用关联起来】,作用是在Java代码中获取类的信息以及存储静态字段的数据。
- 对于开发者来说,只需要访问堆中的Class对象,而不需访问方法区中的所有信息。【堆中的字段和方法少于方法区中的字段和方法,因为方法区中有些变量是虚拟机使用的,开发者使用的,都在堆里,可以控制开发者访问数据的范围,提升安全性】
总结:类加载器将类的信息加载到内存中,JVM在方法区和堆区各分配一个对象用于保存类的信息,程序员用到的是java.lang.Class对象。
2.2.2 连接阶段(Linking)
2.2.2.1 验证
验证的目的是检测Java字节码文件是否遵守了《Java虚拟机规范》的约束。不需要程序员参与。
2.2.2.2 准备
- 为静态变量分配内存,并设置初始值。【不赋值】
- final修饰的基本数据类型的静态变量,准备阶段会将代码中的值进行赋值。
2.2.2.3 解析
- 将常量池中的符号引用,替换为直接引用。
- 直接引用不再使用编号,而是使用内存中的地址。
2.2.3 初始化阶段
- 执行静态代码块的代码,并为静态变量赋值。
- 执行字节码文件中的clinit部分的字节码指令。
以下几种方式会触发类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
- 调用Class.forName(String className)。
- new一个该类的对象的时候。
- 执行main方法的当前类。
public class demo {public static void main(String[] args) {System.out.println("A");new demo();new demo();}public demo() {System.out.println("B");}{System.out.println("C");}static {System.out.println("D");}
}
可以看到,在初始化的时候,C比B先输出,所以初始化代码块要比构造方法先执行。所以代码结果是DACBCB。
clinit指令在特定情况下不会出现,如下几种情况是不会进行初始化指令执行的:
- 无静态代码块且无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接初始化。
public class demo {public static int i;public static final int j = 10;public static void main(String[] args) {}
}
会发现这里只有init和main,没有clinit指令。
对于继承的类,是如何初始化的:
public class demo {public static void main(String[] args) {new B02();System.out.println(B02.a);}}class A02 {static int a = 0;static {a = 1;}
}class B02 extends A02 {static {a = 2;}
}
- 在main方法的第一行中,创建了B02对象,但B02继承了A02,所以会先初始化父类A02;
- A02中创建了一个静态变量a=0,通过静态代码块将a=1;
- B02中又将a=2,所以最终打印的值是2。
不进行new B02的情况
public class demo {public static void main(String[] args) {//new B02();System.out.println(B02.a);}}class A02 {static int a = 0;static {a = 1;}
}class B02 extends A02 {static {a = 2;}
}
- 直接访问的是父类的静态变量,只初始化父类,a=1;
- 不初始化子类,所以输出1。
总结:
- 实例化代码块(初始化代码块)优先于构造函数。
- 访问父类中存在的静态变量,不会初始化子类;访问子类的静态变量,会先初始化父类。
- 如果子类继承父类,则先执行父类的静态变量和静态代码块 ,再执行子类的静态变量和静态代码块。接着执行父类的非静态代码块和构造函数,再执行子类的非静态代码块和构造函数。
- 静态变量和静态代码块,根据代码书写顺序执行。
2.3 类加载器
- 类加载器是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。
- 类加载器负责在类加载过程中将字节码获取并加载到内存。通过加载字节码数据放入内存转换成byte[],接下来调用虚拟机底层方法将byte[]转换成方法区和堆中的数据。
2.3.1 类加载器的分类
类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。
通过Arthas,查询到类加载器,可以看到这三个类加载器。Arthas命令:classloader - 查看classloader的继承树,urls,类加载信息
BootstrapClassLoader启动类加载器
是由Hotpot虚拟机提供的,使用C++编写的类加载器,默认加载Java安装目录/jre/lib下的类文件,比如rt.jar等。
在Arthas中使用sc -d xxx类,可以查询加载改类的加载器,如果是空是,则说明是启动类加载器加载的。
扩展类加载器和应用程序类加载器
都是JDK中提供的,使用java编写的类加载器。它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。通过目录或者指定jar包将字节码文件加载到内存中。
扩展类加载器(Extension Class Loader):默认加载Java安装目录下/jre/lib/ext下的类文件。
应用程序类加载器:加载classpath下的类文件。加载的范围覆盖了启动类加载器和扩展类加载器。
2.3.2 类加载的双亲委派机制
由于Java虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题。
双亲委派机制:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载。
- 一个类会从最下Application类加载器开始,如果没有被加载过,就向上到Extension类加载器查找,如果还是没有被加载过,就向上到Bootstrap类加载器。
- 然后再从Bootstrap类加载器开始,如果发现该类不在其加载目录,则向下到Extension类加载器,这个如果发现也不在加载目录,则向下到Application类加载器,最终由Application类加载器加载。
- 【重复类】
问:如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
答:Bootstrap启动类加载器加载。因为根据双亲委派原则,它的优先级最高。- 【Stirng类会覆盖吗】
问:在自己的项目创建一个java.lang.String类,会被加载吗?
答:不能,会返回启动类加载器加载在rt.jar中的这个类。
双亲委派机制的好处:
- 避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
- 避免一个类被重复加载。
2.3.3 打破双亲委派机制
- 一个Tomcat程序中是可以运行多个Web应用的,如果两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且他们应该是不同的类。
- 如果不打破双亲委派机制,当应用类加载器加载Web应用1中的类之后,应用2中相同限定名的类就无法被加载了。
2.3.3.1 自定义类加载器
Tomcat使用了自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对于的类。
双亲委派机制的实现方式:
ClassLoader的四个重要方法:
源码分析:
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException
{synchronized (getClassLoadingLock(name)) {// 每一个类加载器都会检查这个类有没有被加载过Class<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {// 父类加载器再去调用本身,查找是否加载过这个类if (parent != null) {c = parent.loadClass(name, false);} else {// 之前说过parent为null说明目前是扩展类加载器,其父类是启动类加载器// 启动类加载器是调用了C++的方法来实现的c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}// 当c还是null,说明上面的类加载器都没加载成功,就由当前的类加载器加载if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}
}
打破双亲委派机制,就是将上面的双亲委派机制代码,重新实现。
正确地实现一个自定义类加载器的方式是重写findClass方法,这样不会破坏双亲委派机制。要打破双亲委派机制,要重写loadClass方法。
自定义类加载器父类是AppClassLoader:
自定义的类加载器,如果我们不手动设置parent,就会默认使用AppClassLoader。
两个自定义类加载器加载相同限定名的累,不会冲突:
在同一个Java虚拟机中,只有相同的类加载器+相同的类限定名,才会被认为是同一个类。
自定义类加载器的代码:
package cn.bjca;import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;public class demo extends ClassLoader {private String classPath;public void setClassPath(String classPath) {this.classPath = classPath;}@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException {byte[] data = null;try {data = loadClassData(name);} catch (IOException e) {throw new RuntimeException(e);}return defineClass(name, data, 0, data.length);}/*** 读取 class文件,将其转换为 二进制字节码* @param name class文件的全限定名(包名 + 类名)* @return 二进制字节码*/private byte[] loadClassData(String name) throws IOException {String filePath = name.replace(".", File.separator).concat(".class");InputStream is = Files.newInputStream(Paths.get(filePath));ByteArrayOutputStream bos = new ByteArrayOutputStream();int ch;while ((ch = is.read()) != -1) {bos.write(ch);}return bos.toByteArray();}public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {demo loader = new demo();loader.setClassPath("D://test//");Class<?> clazz = loader.findClass("cn.bjca.demo");clazz.newInstance();}
}
2.3.3.2 线程上下文类加载器(JDBC案例)
利用上下文类加载器加载类,比如JDBC和JNDI等。
JDBC案例
- JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如MySQL、Oracle驱动。
- DriverManager类中是在rt.jar包中,由启动类加载器加载。而用户jar包中的具体数据库驱动,由应用类加载器加载。
由启动类加载器加载的类,委派应用程序类加载器区加载类的方式,打破了双亲委派机制。
- DriverManager类是由启动类加载器加载的,但我们需要加载数据库第三方驱动的加载器是应用程序类加载器,此时就会通过SPI机制,获取线程上下文类加载器,而这个上下文类加载器,正好就是应用程序类加载器,就用它去加载并创建对象。
- SPI是JDK内置的一种服务提供发现机制
- 工作原理是(1)在ClassPath路径下的META/services文件夹中,以接口的全限定名来命名文件,对应的文件里面写该接口的实现。(2)使用ServiceLoader加载实现类。
- SPI又是如何获取到应用程序类加载器的?SPI中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。
整体上看上去是打破了双亲委派机制,而分析下发现,其实即在DriverManager类在加载时,触发了应用程序类加载器加载,第三方驱动的加载,仍然是应用程序类加载器加载的。
2.3.4 JDK9及之后
JDK9引入了module概念
- 启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
- 扩展类加载器被替换成了平台类加载器(Platform Class Loader)。平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
2.4 运行时数据区
负责管理JVM使用到的内存,比如创建对象和销毁对象。线程不共享这部分会随着线程创建而创建,随着线程销毁而销毁,所以线程不共享的三块区域不会发生内存泄漏,也不会触发GC垃圾回收。
2.4.1 程序计数器
程序计数器也叫PC寄存器,是每个线程私有的内存区域,每个线程会通过程序计数器记录当前要执行的字节码指令的地址。
- 程序计数器保存的是下一个指令的地址。例如,程序走到序号2时,程序计数器中会显示序号3这个指令的地址。
- 在多线程执行的情况下,Java虚拟机需要通过程序计数器,记录CPU切换前解释执行到哪一句指令并继续解释运行。
- 程序计数器不会发生内存溢出,因为每个线程只存储一个固定长度的内存地址。
- 程序员无需对程序计数器做任何处理。
2.4.2 栈
- Java虛拟机栈(Java Virtual Machine stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈帧(Stack Frame)来保存。
- Java虚拟机栈是随着线程的创建而创建的,回收也是线程销毁时回收。每个线程都有自己的栈帧,栈是不共享的。
- 栈帧的组成:
2.4.2.1 局部变量表
- 在方法执行过程中在内存中存放所有局部变量。
- 局部变量表中保存的内容有:(1)方法体中声明的局部变量;(2)实例方法中this对象;(3)方法的参数。
- 局部变量表本质是一个数组,数组中每一个位置称为槽,long和double类型占用2个槽,其他类型占1个槽。
public static void test() {int i = 0;long j = 1;String s = "1";
}
上面代码的栈帧:i是int类型,放在槽的第0位,长度1;j是long类型,放在槽的第1位,长度2;s是String类型,放在槽的第3位,长度1。
- 在实例方法中,槽的首位存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。
public void test2() {int i = 0;long j = 1;String s = "1";
}
上面代码的栈帧序号0槽是this:
- 方法参数也会保存在局部变量表中,顺序与方法中定义的顺序一致。
public void test3(int k, int m) {int i = 0;long j = 1;String s = "1";
}
- 为了节省空间,局部变量表中的槽是可以复用的,一旦某一个局部变量不再生效,当前槽就可以再次被使用。
public void test4(int k, int m) {{int a = 1;int b = 2;}{int c = 3;}int i = 0;long j = 1;
}
这段代码一共占用了多少个槽?可以根据字节码指令,发现在执行完a和b以后不再使用了,c的存放槽位会替换a的槽位。所以最后是用了6个槽。
2.4.2.2 操作数栈
- 操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面指令可以弹出并使用该值。
- 在编译器就可以确定操作数栈的最大深度,从而在执行时正确地分配内存大小。
2.4.2.3 帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
1. 动态链接
当前类的字节码指令引用了其他类的属性或方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
2. 方法出口
方法出口指的是方法在正确或异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
3. 异常表
异常表存放的是代码中的异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
2.4.2.4 栈内存溢出
Java虚拟机栈如果栈帧过多,占用内存超过栈内存,可以分配的最大大小就会出现内存溢出。溢出时会出现StackOverFlowError异常。
- 栈默认大小:如果我们不指定栈的大小,JVM将创建一个具有默认大小的栈,大小取决于操作系统和计算机的体系结构。Linux占用1M大小,Windows不固定。
-
栈内存溢出模拟:递归调用自身,不设置退出条件,查看错误的时候总调用次数。
public class demo {public static int count = 0;public static void recursion() {System.out.println(++count);recursion();}public static void main(String[] args) {recursion();} }
-
Windows(64位)下的JDK8测试最小值为180k,最大值为1024m。
-
局部变量过多、操作数栈深度过大,也会影响栈内存的大小。
2.4.2.5 本地方法栈
- Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
- 在Hotpot虚拟机中,Java虚拟机栈和本地方法栈用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
2.4.3 堆
- 一般堆内存是空间最大的一块内存区域,创建出的对象都存在堆上。
- 栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现线程之间的共享。
- 堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出outofMemory错误。
- 堆空间中需要关注的值:used(当前已使用的堆内存),total(Java虚拟机已经分配的可用堆内存),max(Java虚拟机可以分配到的最大堆内存)。
- 随着堆中对象增多,当total可以使用的内存即将不足时,Java虚拟机会继续分配内存给堆。total值会逐渐变大,最终值不会到max就会导致内存溢出,这和GC也有关系。
- 如果不设置任何虚拟机参数,max默认值是系统内存的1/4,total默认是系统内存的1/64。
- 设置Java虚拟机的堆内存大小:-Xms设置total的值的大小,例如:-Xms6144k、-Xms6m;-Xmx设置max的值的大小,例如:-Xmx81920k,-Xmx80m。限制:Xmx必须大于2M,Xms必须大于1M。
- Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。
静态变量存放在哪儿?
- JDK6及之前版本,存在方法区中,也就是永久代。
- JDK7及之后的版本,静态变量存放在堆中的Class对象中,脱离了永久代。
2.4.4 方法区
- 存放基础信息的位置,线程共享,主要包含三部分:类的元信息、运行时常量池和字符串常量池。
- JDK6及之前,运行时常量池逻辑上包含字符串常量池,实现方式是永久代。
- JDK7版本,字符串常量池被从方法区的永久代,拿到了堆中,运行时常量池的其他内容还在永久代中。
- JDK8及之后的版本,方法区的实现方式是元空间,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受上限,可以一直分配。可以使用【-XX:MaxMetaspaceSize=值】将元空间最大大小进行限制。
元空间使用的是虚拟机的直接内存,max=-1意思是不设上限。
2.4.4.1 类的元信息
方法区用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象,在类的加载阶段完成。
常量池和方法会被单独存放,InstanceKlass中存放的是他们的引用。
2.4.4.2 运行时常量池
- 常量池中存放的是字节码中的常量池内容。
- 静态常量池:字节码文件中通过编号查表的方式找到常量。
- 运行时常量池:当常量池加载到内存中后,可以通过内存地址快速定位到常量池中的内容。
2.4.4.3 字符串常量池
字符串常量池存储在代码中定义的常量字符串的内容。比如“123”这个字符串就会被存放到字符串常量池。
字符串常量池和运行时常量池有什么关系?
在早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的,后续做出调整,将字符串常量池和运行时常量池做了拆分。
No.1 案例一:分析c和d是否相等
public static void main(String[] args) {String a = "1";String b = "2";String c = "12";String d = a + b;System.out.println(c == d);
}
- 第1行ldc将常量池中获取字符串1的地址,放入操作数栈。
- 第2行astore将操作数栈的弹出放入局部变量表的第1位。
- 第7行new,在进行加号连接的时候,底层使用StringBuild对象。
- 第10和12行,aload指令将局部变量表中的第1位和第2位取出,通过append方法进行相加,再通过toString方法转成字符串对象,存放在堆内存中。
- 第15行astore 4就是存放的堆内存中的地址,对应代码的d变量。
- 第6行astore 3是代码变量c的地址。
- 显然,c和d不是同一个地址,故返回false。
No2. 案例二:分析c和d是否相等
public static void main(String[] args) {String c = "12";String d = "1" + "2";System.out.println(c == d);
}
- 第1行和第3行,都是从常量池中获取的12这个字符串,说明在编译阶段,已经将“+”直接连接后放入了字符串常量池中。
- 因此c和d是相等的,返回true。
No3. 案例三:String.intern()方法
String.intern()
方法可以手动将字符串放入字符串常量池中。
public static void main(String[] args) {String s1 = new StringBuilder().append("think").append("123").toString();System.out.println(s1.intern() == s1);String s2 = new StringBuilder().append("ja").append("va").toString();System.out.println(s2.intern() == s2);
}
- JDK6中intern()方法会把第一次遇见的字符串实例复制到永久代的字符串常量池中,返回的也是永久代里的这个字符串实例的引用。在s1变量new出来的时候,在堆上存放了一个think123,然后会在字符串常量池中复制一份think123。所以s1.intern()的地址取得是字符串常量池的,s1是堆中的,所以s1.intern() == s1结果是false。
- JVM在启动的时候,已经将“java”这个常量放入字符串常量池中了,所以s2.intern() == s2结果是false。
- JDK7及以上版本的字符串常量池在堆上,所以intern()方法会把第一次遇到的字符串的引用放入字符串常量池。s1.intern()是think123的引用,最终还是会通过引用找到think123,所以s1.intern() == s1结果是true。
- JVM在启动的时候已经将“java”这个常量放入字符串常量池中了,所以s2.intern() == s2结果是false。
2.4.5 直接内存
- 直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
- 在JDK1.4中引入了NIO机制,使用了直接内存,主要为了解决以下两个问题:①Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。②IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
- 现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
- 如果需要手动调整直接内存的大小,可以使用【-XX:MaxDirectMemorySize=大小】,单位k或K表示千字节,m或M表示兆字节,g或G表示干兆字节。默认不设置该参数情况下,JVM自动选择最大分配的大小。
三、垃圾回收
3.1 自动垃圾回收
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection,简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。
3.2 方法区的回收
方法区的回收主要回收不再使用的类。同时满足下面三个条件的类才可以被回收:
- 当此类对象所有实例都已经被回收,在堆中不存在任何该类实例对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用。
如果需要手动触发垃圾回收,可以调用
System,gc()
方法,但不一定会立即回收,仅仅是向Java虚拟机发送一个垃圾回收请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
3.3 堆回收
Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
3.3.1 引用计数法
- 引用计数法会为每个对象维护一个引用计数器,当对象被引用时+1,取消引用时-1。
- 优点:实现简单。
- 缺点:(1)每次引用和取消引用都需要维护引用计数器,对系统性能有一定影响;(2)存在循环引用的问题,就是当A引用B,B引用A时,会出现对象无法回收的问题。
3.3.2 可达性分析算法
Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象和对象之间存在引用关系。
从A到B再到C和D,形成一个引用链,可达性分析算法指的是如果从某个GC Root对象是可达的,对象就不可被回收。
GC Root对象
下面的对象属于GC Root对象:
- 线程Thread对象。在创建一个线程之后,整个线程对象,就称为线程Thread对象。
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量。
- 监视器对象,用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象。
3.3.3 五种对象引用
3.3.3.1 软引用
- 软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
- 在JDK1.2之后提供了SoftReference类来实现软引用,软引用常用于缓存中。
public static void main(String[] args) {byte[] bytes = new byte[1024 * 1024 * 100];SoftReference<byte[]> softReference = new SoftReference<>(bytes);bytes = null;System.out.println(softReference.get());byte[] bytes2 = new byte[1024 * 1024 * 100];System.out.println(softReference.get());byte[] bytes3 = new byte[1024 * 1024 * 100];
}
- 创建了一个100M的数组对象,并放入软引用对象,此时软引用对象中包含的数据是存在的。
- 又创建了一个100M的数组对象,此时对内存不够用了,JVM就会将第一个软引用对象释放掉,此时软引用中就是null了。
- 第三次创建的100M的对象,因为没有软引用释放了,就会报堆内存不足的异常。
软引用执行过程如下:
- 将对象使用软引用包装起来,new SoftReference<>(对象);
- 内存不足时,虚拟机尝试进行垃圾回收;
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象;
- 如果依然内存不足,抛出OutOfMemor异常。
- 软引用中的对象如果被回收后,SoftReference对象也需要被回收。
- 软引用创建时候,会通过构造器传入引用队列。
- 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列。
- 通过代码遍历引用队列,将SoftReference对象的强引用删除。
public static void main(String[] args) {ArrayList<SoftReference> softReferences = new ArrayList<>();ReferenceQueue<byte[]> queues = new ReferenceQueue<>();for (int i = 0; i < 10; i++) {byte[] bytes = new byte[1024 * 1024 * 100];SoftReference stuRef = new SoftReference<>(bytes, queues);softReferences.add(stuRef);}// 不断从 queues 中获取已被回收的软引用SoftReference<byte[]> ref = null;int count = 0;while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {count++;}System.out.println("被回收的软引用数量:" + count);
}
3.3.3.2 弱引用
- 弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
- 在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。
- 弱引用对象本身也可以使用引用队列进行回收。
public static void main(String[] args) {byte[] bytes = new byte[1024 * 1024 * 100];WeakReference<byte[]> weakReference = new WeakReference<>(bytes);bytes = null;System.out.println(weakReference.get());System.gc();System.out.println(weakReference.get());
}
上述代码在第一次打印的时候,是有数据的,然后进行了一次垃圾回收,我们知道弱引用在不管内存够不够的情况下都会打印回收里面的对象,所以第二次打印是null。
3.3.3.3 虚引用
虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
一般开发中不使用虚引用和终结器引用。
3.3.3.4 终结器引用
终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。
3.3.4 垃圾回收算法
垃圾回收要做的两件事:
- 找到内存中存活的对象。
- 释放不再存活对象的内存,使得程序能再次利用这部分空间。
3.3.4.1 垃圾回收算法评价标准
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。
- 吞吐量。吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 +GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
- 最大暂停时间。最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。
- 堆使用效率。不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。
不同的垃圾回收算法,适用于不同的场景。
3.3.4.2 标记清除算法
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GCRoot开始通过引用链遍历出所有存活对象。
- 清除阶段,从内存中删除没有被标记也就是非存活对象。
优缺点:
- 优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
- 缺点:
① 碎片化问题。由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
② 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
3.3.4.3 复制算法
-
将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
-
GC阶段开始,将GC Root搬运到To空间,并将GC Root关联的对象,也搬运到To空间。
-
清理From空间,并把名称互换。
优缺点:
-
优点:
① 吞吐量高。复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法因为标记清除算法不需要进行对象的移动。
② 不会发生碎片化。复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。 -
缺点:内存使用效率低。每次只能让一半的内存空间来为创建对象使用。
3.3.4.4 标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优缺点:
- 优点:
① 内存使用效率高。整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
② 不会发生碎片化。在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。 - 缺点:整理阶段的效率不高。整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGc等高效的整理算法优化此阶段的性能。
3.3.4.5 分代GC
现代优秀的垃圾回收算法会将上述描述算法(Generational GC)。垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收。分代垃圾回收将整个内存分为年轻代和老年代。
对象回收的执行流程:
-
分代回收时,创建出来的对象,会被放到Eden伊甸园区。随着对象在伊甸园区越来越多,就会触发年轻代的GC,叫做Minor GC或者Young GC。
-
Minor GC会根据可达性分析法,判断伊甸园区里面的对象和From区里面的对象,哪些需要回收,哪写不需要回收。需要回收的对象回收,不需要回收的对象放入To区。
-
接下来,S0会变成To区,S1会变成From区。当Eden区满了以后,再向伊甸园区放入对象,依然会发生Minor GC。此时会回收Eden区和From区的可以回收的对象,并把Eden区和From区剩余的对象放入To区。
-
每次Minor GC中都会为对象记录它的年龄,初始值是0,每次GC完加1。上图先从Eden区到To区,对象年龄从0到1,第二次从From到To,对象年龄从1到2。
-
如果Minor GC后对象年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升到老年代。
并不是一定对象年龄到阈值才会放到老年代。例如,年轻代都被对象占满了,并且都被GC Root关联到,这些对象不能被回收,同时这些对象的年龄都没到15,此时向年轻代放入对象,会触发Minor GC,但GC完以后空间依然不够,就会将一些对象放入老年代中,这些对象都没有到阈值。
- 当老年代空间不足,无法放入新的对象时,先尝试Minor GC,如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。回收包括年轻代和老年代都回收,回收时间会比较长。
- 如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,会抛出Out Of Memory(OOM)异常。
为什么分代GC算法的年轻代要远小于老年代?
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
为什么GC算法将堆分为年轻代和老年代?
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
3.3.5 垃圾回收器
垃圾回收器是垃圾回收算法的具体实现。由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合使用。
3.3.5.1 Serial和Serial Old垃圾回收器
使用方法:-XX:+UseSerialGC
,新生代和老年代都使用串行回收器。
年轻代 - Serial
- Serial是一种单线程串行回收年轻代的垃圾回收器。回收算法是复制算法。
- 优点:单CPU处理器下吞吐量非常出色。
- 缺点:多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待。
- 适用场景:Java编写的客户端程序或者硬件配置有限的场景。
老年代 - Serial Old
- Serial Old采用单线程串行回收。回收算法是标记-整理算法。
- 优缺点和适用场景同Serial垃圾回收器。
3.3.5.2 ParNew和Serial Old垃圾回收器
年轻代 - ParNew
使用方法:-XX:+UseParNewGc
新生代使用ParNew回收器,老年代使用Serial Old串行回收器
- ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收。回收算法是复制算法。
- 优点:多CPU下停顿时间较短。
- 缺点:吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用。
- 适用场景:JDK8及之前的版本中与CMS老年代垃圾回收器搭配使用。
老年代 - CMS(Concurrent Mark Sweep)
使用方式:-XX:+UseConcMarkSweepGC
- CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。算法使用标记清除算法。
- 优点:系统由于垃圾回收出现的停顿时间较短,用户体验好。
- 缺点:
① 内存碎片问题。CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGcsBeforecompaction=N
参数(默认0)调整N次Full GC之后再整理。
② 退化问题。如果老年代内存不足无法分配对象,CMS就会退化成Serial0ld单线程回收老年代。
③ 浮动垃圾问题。无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。 - 适用场景:大型的互联网系统中用户请求数据量大、频率高的场景比如订单接口、商品接口等。
3.3.5.3 ParallelScavenge和ParallelOld
参数:-XX:+UseParallelGC
或-XX:+UseParallelOldGC
可以使用Parallel Scavenge + Parallel Old这种组合。
年轻代 - ParallelScavenge
- Parallel Scavenge是JDK8默认的年轻代垃圾回收器多线程并行回收,关注的是系统的吞吐量。具备目动调整堆内存大小的特点。算法使用复制算法。
- 优点:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数。
- 缺点:不能保证单次的停顿是时间。
- 适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象比如:大数据的处理,大文件导出。
老年代 - ParallelOld
- Parallel Old是为Paralel Scavenge收集器设计的老年代版本,利用多线程并发收集。算法使用标记-整理算法。
- 优点:并发收集,在多核CPU下效率较高。
- 缺点:暂停时间会比较长。
- 适用场景:与Parallel Scavenge配套使用。
Oracle官方建议在使用这个组合的时候,不设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
- 最大暂停时间
-XX:MaxGCPauseMillis=n
设置每次垃圾回收时的最大停顿毫秒数。 - 吞吐量
-XX:GCTimeRatio=n
设置吞吐量为n(用户线程执行时间 = n / n +1)。 - 自动调整内存大小
-XX:+UseAdaptiveSizePolicy
设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小。
3.3.5.4 G1垃圾回收器
- JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。
- ParallelScavenge关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。
- CMS关注暂停时间,但是吞吐量方面会下降。
- 而G1设计目标就是将上述两种垃圾回收器的优点融合:① 支持巨大的堆空间回收,并有较高的吞吐量。② 支持多CPU并行垃圾回收。③ 允许用户设置最大暂停时间。
- JDK9之后强烈建议使用G1垃圾回收器。
内存结构
- G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。
- 分为Eden、Survivor、Old区。
- Region的大小通过堆空间大小/2048计算得到,也可以通过参数
-XX:G1HeapRegionSize=32m
指定(其中32m指定region大小为32M),Regionsize必须是2的指数幕,取值范围从1M到32M。
执行步骤
- 新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC。
- 标记出Eden和Survivor区域中的存活对象。
- 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。
- G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。比如
-XX:MaxGCPauseMilis=n(默认200)
,每个Region回收耗时40ms,那么这次回收最多只能回收4个Region。 - 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区
- 当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
- 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
- 多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(
-XX:InitiatingHeap0ccupancyPercent
默认45%)会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。
混合回收
- 混合回收分为:初始标记(initialmark)、并发标记(concurrent mark)、最终标记(remark或者FinalizeMarking)、并发清理(leanup)
- G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。
- 如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
使用方式
- 参数1:
-XX:+UseG1GC
打开G1的开关,JDK9之后默认不需要打开。 - 参数2:
XX:MaxGCPauseMillis=毫秒值
最大暂停的时间。 - G1垃圾回收器,使用的是复制算法。
- 优点:对比较大的堆,如超过6G的堆回收时,延迟可控,不会产生内存碎片,并阿飞标记的SATB算法效率高。
- 缺点:JDK8之前不够成熟。
- 适用版本:JDK8最新版本和JDK9之后建议默认使用。
3.3.5.5 垃圾回收器的选择
垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
- JDK8及之前:
ParNew +CMS(关注暂停时间)、Parallel Scavenge + Parallel Old(关注吞吐量)、 G1(JDK8之前不建议,较大堆并且关注暂停时间)。 - JDK9之后:
G1(默认)。 - 从JDK9之后,由于G1日趋成熟,JDK默认的垃圾回收器已经修改为G1,所以强烈建议在生产环境上使用G1。