1、JVM内存模型

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。jvm所管理的内存将会包含以下几个运行时数据区域,如下图所示:

jvm内存模型

​ jvm运行时数据区

1.1 程序计数器

​ 多线程是通过轮流执行时间片来处理线程的,为了线程每次切换后能恢复到正确的执行位置,所以每个线程都需要一个独立的程序计数器。针对java方法,程序计数器记录的是地址;针对native方法,这个值为空(undefined)。

1.2 本地方法栈

​ 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

1.3 虚拟机栈1.3.1 局部变量表

​ 基本数据类型存储数据值本身,引用类型存储指向堆内存的引用指针。存放数据类型是以slot(32bit/位)为最小单位,所以在存储double、long类型数据时会需要2个slot来存储。

1.3.2 操作数栈

​ 操作数栈是一个后入先出的栈,以压栈和出栈的方式存储操作数的。假设有如下代码:

int c = a+b;int d = c+1;

那么操作数栈的流程就是:

先将a入栈,再将b入栈将b出栈,将a出栈,计算a+b的结果c,将结果c压栈重复以上步骤,算出d1.3.3 动态链接

​ 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

上面这段话的读起来可能会晦涩难懂,不着急,我们先来看看下面的内容来理解这句话。

符号引用与直接引用符号引用

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

直接引用

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针

相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量

一个能间接定位到目标的句柄

方法调用

​ 方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。

​ 也就是说在编译阶段,存的都是符号引用,等到类的解析阶段,会将一些静态方法、私有方法(可确定的方法的调用版本)的符号引用转换为直接引用;等真正进行方法调用(运行期)的时候才将对应的符号引用转换成直接引用。

分派

静态分派

/** * 静态分派 编译期可知 * @author fuzy * @date 2022/1/22 15:48 */public class StaticDispatch { static abstract class Human{} static class Man extends Human{} static class Woman extends Human{} public void sayHello(Human man){ System.out.println("Hello,guy!"); } public void sayHello(Man guy){ System.out.println("Hello,gentleman!"); } public void sayHello(Woman guy){ System.out.println("Hello,lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch staticDispatch = new StaticDispatch(); //静态类型,在编译器通过参数的静态类型作为判定依据的 staticDispatch.sayHello(man); staticDispatch.sayHello(woman); staticDispatch.sayHello((Man) man); staticDispatch.sayHello((Woman)woman); }}

如上代码所示,我们来看看输出结果:

Hello,guy!Hello,guy!Hello,gentleman!Hello,lady!

为什么结果会这样呢?我们试着通过javap -verbose StaticDispatch.class来查看字节码,main方法解析出来的结果(部分省略)如下:

26: invokevirtual #13 // Method sayHello:(Lorg/fuzy/example/StaticDispatch$Human;)V 29: aload_3 30: aload_2 31: invokevirtual #13 // Method sayHello:(Lorg/fuzy/example/StaticDispatch$Human;)V 34: aload_3 35: aload_1 36: checkcast #7 // class org/fuzy/example/StaticDispatch$Man 39: invokevirtual #14 // Method sayHello:(Lorg/fuzy/example/StaticDispatch$Man;)V 42: aload_3 43: aload_2 44: checkcast #9 // class org/fuzy/example/StaticDispatch$Woman 47: invokevirtual #15 // Method sayHello:(Lorg/fuzy/example/StaticDispatch$Woman;)V

可以看到在26和31对应的参数还是Human,所以当方法重载时,调用的还是方法签名为Human参数的方法。所以对于这些方法的调用,在编译时就已经确定好了对应的方法版本。

动态分派

/** * 动态分配:运行期根据实际类型确定方法最终版本的称为动态分配 * @author fuzy * @date 2022/1/22 16:00 */public class DynamicDispatch { static abstract class Human{ protected abstract void sayHello(); } static class Man extends Human{ @Override protected void sayHello() { System.out.println("man say Hello!"); } } static class Woman extends Human{ @Override protected void sayHello() { System.out.println("Woman say Hello!"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello();; woman.sayHello(); man = new Woman(); man.sayHello(); }}

如上代码所示,输出结果想必大家都知道:

man say Hello!Woman say Hello!Woman say Hello!

为什么以上结果和静态分派的结果不一致,这就是运行时确定方法调用版本的例子。

由于多态的机制,方法在编译时期无法确定最终调用的是哪一个方法版本,所以最终方法版本的确定是在运行时确定的,此时会将对应的符号引用转换成直接引用。

回到动态链接的概念上,本质上是找到正确的方法入口(多态使得编译器无法确定方法版本),将编译期间的符号引用在运行时转换成对应方法的直接引用。

1.3.4 方法出口

记录方法结束时的出栈地址。方法执行后只有两种方式可以退出这个方法:

正常结束(通常调用者的PC计数器的值可以作为返回地址,栈帧中可能会保存这个计数器值)抛出异常(返回地址要通过异常处理器来确定,栈帧中一般不会保存这部分信息)

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

1.4 方法区

​ 方法区主要存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。当内存不足时,将抛出OutOfMemoryError异常。

运行时常量池

​ 运行时常量池时方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

​ 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

1.5 直接内存

​ 直接内存并不是虚拟机运行时数据区的一部分。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

1.6 堆

​ 堆是存放对象(包括数组)的区域,也是垃圾收集器管理的主要区域。堆的空间还可以细分成新生代(Eden区和Survivor区)和老年代。

1.7 运行时数据区的关系栈指向堆

方法中,有如下代码Object obj = new Object(),此时局部变量obj指向堆中对象

方法区指向堆

有静态变量private static Object = new Object(),因为静态变量存储与方法区中,而对象实例存储与堆中,所以有方法区指向堆。

堆指向方法区

试想一下,方法区中会包含类的信息,堆中会有对象,那怎么知道对象是由哪个类创建的呢?所以,在对象的对象头中会有一个指针,用来指向方法区对应的类元数据信息。

2、类的加载机制

类的加载机制

​ 类的加载过程

2.1 类的加载过程

​ 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。如上图所示,一个.java源文件被编译成.class文件后,class字节码文件类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载这7个步骤。其中加载、验证、准备、初始化、卸载这五个阶段顺序是一定的,但解析阶段就不一定(前文所讲的由于多态导致在运行时确定方法版本)。下面来具体讲解每个步骤。

2.1.1 javac编译器

.java源文件被javac编译成一个二进制文件,里面的内容是16进制。想要深入了解的请参考文章Class文件十六进制背后的秘密。

2.1.2 加载

“加载”与“类加载”过程的一个阶段,不要混淆两个概念,在加载阶段虚拟机需要完成以下3件事情:

通过一个类的全限定名来获取定义此类的二进制字节流。将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

.class二进制文件有很多中获取形式:

从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。从网络中获取,这种场景最典型的应用就是Applet。运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect。

……

相对于类加载过程的其他阶段,一个非数组类的加载阶段时开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。类加载器会在接下来的内容里面介绍。

2.1.3 连接

连接包含以下三部分:

验证

文件格式验证

比如文件是否以16进制开头;版本号是否正确……

元数据验证

这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法……

字节码验证

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

符号引用中通过字符串描述的全限定名是否能找到对应的类。

在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

……

准备

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

类变量与类成员变量的区别:类变量是指被static修饰的变量,类成员变量的内存分配需要等到对象实例化后才开始分配

//类变量
public static int LeiBianLiang = 666;
//类成员变量
public String ChenYuanBL = "jvm";
//常量在准备阶段后该变量的值是666,因为被final修饰的变量一旦赋值就不会再发生改变;
public static final int ChangLiang = 666;

为类变量(静态变量)分配内存并设置默认初始值这里不包含final修饰的类变量,因为final在编译的时候就分配了,准备阶段会显示初始化;这里不会为实例变量(也就是没加static)分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程(把符号转换成实际地址)。

2.1.4 初始化类的初始化时机

对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始),如下:

new关键字实例化对象;访问某个类或接口的静态变量,或者对该静态变量赋值;使用java.lang.reflect包的方法对类进行反射调用的时候初始化一个类时,如果发现其父类未初始化,则先触发父类的初始化用户需要指定一个执行的主类(包含main()方法的那个类)反射类的初始化过程

​ 类初始化阶段是类加载过程的最后一步,初始化阶段开始真正执行类中定义的java代码,特别强调:这里的初始化并不是对象实例的初始化。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序指定的计划去初始化类变量(特指被static修饰的变量,不包括final修饰的)和其他资源。

初始化阶段是执行类构造器<clinit>方法的过程,该过程细节如下:

<clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下代码:

public class ClassInitialization { static { i = 0;// System.out.println(i); 编译不通过 } static int i= 1;}<clinit>方法与类的构造函数(或者说实例构造器<clinit>方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。因此在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。由于父类的<clinit>方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,示例如下:

public class ClinitExample { static class Super{ protected static int A = 1; static { A =2; } } static class Sub extends Super{ protected static int B = A; } public static void main(String[] args) { //运行结果是2,说明Super早于Sub初始化 System.out.println(Sub.B); }}接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>方法。但接口与类不同的是,执行接口的<clinit>方法不需要先执行父接口的<clinit>方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>方法。虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>方法完毕。2.2 类加载器2.2.1 什么是类加载器

​ 虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

负责读取Java字节代码,并转换成java.lang.Class类的一个实例的代码模块。类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。

一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的, 这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过 instanceOf 、equals 等方式的校验。

2.2.2 类加载机制全盘负责

当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该 类加载器负责载入,除非显示使用另外一个类加载器来载入。

双亲委派双亲委派模型

指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。

双亲委派

​ 类加载器

如上图,在java虚拟机中类加载器包括Bootstrap ClassLoader、Extension ClassLoader、Application ClassLoader、Custom ClassLoader这四类,对于一个类的加载,通常是由下往上找到合适的类进行加载,如下图:

双亲委派加载机制

​ 双亲委派

双亲委派机制加载Class的具体过程:

1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

2、当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。

3、如果BootStrapClassLoader加载失败(例如在 $JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载。

4、若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会寻找自定义的类加载器,再不存在,则会报出异常ClassNotFoundException。

双亲委派的意义

​ 假设用户自定一个类java.lang.Integer,通过双亲委派机制传到启动类加载器,而启动类在核心API发现这个类的名字,发现该类已被加载,就不会重新加载这个用户自定义的类,而是直接返回已加载过的Integer.class,这样可以防止核心API库被随意篡改。简而言之双亲委派的意义是:系统类防止内存中出现多份同样的字节码;保证Java程序安全稳定运行。

破坏双亲委派机制重写loadClass方法首先我们来看下双亲委派机制的核心代码,java.lang.ClassLoader#loadClass(java.lang.String, boolean)如下:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{ synchronized (getClassLoadingLock(name)) { // 先从缓存查找该class对象,找到就不用重新加载 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { //如果找不到,则委托给父类加载器去加载 c = parent.loadClass(name, false); } else { //如果没有父类,则委托给启动加载器去加载 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // 如果都没有找到,则通过自定义实现的findClass去查找并加载 c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //是否需要在加载时进行解析 resolveClass(c); } return c; }}所以我们进行在继承classLoader类的时候重写loadClass方法即可,如下代码:

public class MyClassLoader extends ClassLoader { private String root; @Override public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先从缓存查找该class对象,找到就不用重新加载 Class<?> c = findLoadedClass(name); //由于全盘委托机制,demo类继承Object类,所以当类是Object类时需要将其交给对应的加载器处理 if(!name.startsWith("Demo")){ c = this.getParent().loadClass(name); }else{ c = findClass(name); } if (resolve) { //是否需要在加载时进行解析 resolveClass(c); } return c; } } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] classData = loadClassData(name); if (classData == null) { throw new ClassNotFoundException(); } else { return defineClass(name, classData, 0, classData.length); } } private byte[] loadClassData(String className) { String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; try { InputStream ins = new FileInputStream(fileName); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int bufferSize = 1024; byte[] buffer = new byte[bufferSize]; int length = 0; while ((length = ins.read(buffer)) != -1) { baos.write(buffer, 0, length); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } public String getRoot() { return root; } public void setRoot(String root) { this.root = root; } public static void main(String[] args) { MyClassLoader classLoader = new MyClassLoader(); classLoader.setRoot("D:\\Code"); Class<?> testClass = null; try { //code目录下新建Demo.java文件并且javac编译成class文件,不含任何包名 testClass = classLoader.loadClass("Demo"); Object object = testClass.newInstance(); System.out.println("当前类:"+object.getClass()); System.out.println("当前类所属加载器:"+object.getClass().getClassLoader()); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (InstantiationException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } }}jdk spi机制

​ 一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码。但启动类加载器不认识这些代码,该如何解决?

​ 为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是(Application ClassLoader)应用程序类加载器。

执行如下代码:

public class Bootstrap { public static void main(String[] args) { ServiceLoader<Driver> serviceLoader = ServiceLoader.load(Driver.class); serviceLoader.forEach(driver -> { System.out.println(driver.connect()); System.out.println(driver.getClass().getClassLoader()); System.out.println(Driver.class.getClassLoader()); }); }}

输出结果:

连接mysql数据库sun.misc.Launcher$AppClassLoader@18b4aac2sun.misc.Launcher$AppClassLoader@18b4aac2

验证了上述如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是(Application ClassLoader)应用程序类加载器这段话。

jdk-spi代码

OSGI

软件在部署时希望能实现热替换、模块热部署等等,而不用重启来解决问题。所以就引入了OSGI规范。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

缓存机制

缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class 时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass方法不会被重复调用。

3、垃圾收集与内存分配策略3.1 垃圾收集概述

现在的垃圾收集相关的技术已经相当成熟了,大部分人也都了解这些相关的概念。但是设计这个模型的时候,人们就在思考GC需要完成的3件事情:

哪些内存需要回收?什么时候回收?如何回收

下面我们就来逐步分析上述的问题。

3.1.1 如何确定一个对象是垃圾引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。即可被视为可回收对象。如下代码:

Object object = new Object();
object =null;//new Object()这个对象没有被任何引用指向

但是引用计数法存在一个缺陷,如下图所示:

引用计数法

采用引用计数法时,上图所示有以下步骤:

a = new A(); a的引用+1; count=1b.setA(a); a的引用+1; count=2a=null; a的引用-1; count=1当a=null时,如果此时发生垃圾回收,由于a的引用不为0,依然不会被回收。此时就会造成内存泄漏(内存可用空间减小)。可达性算法

通过一系列的GC Roots(全局视角)的对象作为起始点,从这些根节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

GC ROOTS

如上图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。在java语言中可作为GC Roots对象的包括以下几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。方法区中类静态属性引用的对象。方法区中常量引用的对象。方法区中常量引用的对象。…….3.1.1 对象回收时机

在被可达性算法分析后,垃圾是一定就会被回收吗?其实不是,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

代码示例如下:

public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK =null; public void isAlive(){ System.out.println("I am alive!"); } /** * 要真正宣告一个对象死亡,至少要经历两次标记过程:第一次标记进入finalize方法;第二次标记进入队列中() * 任何对象的finalize方法只会被执行一次 * finalize方法是对象逃脱死亡的最后一次机会 * @throws Throwable */ @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws InterruptedException { SAVE_HOOK = new FinalizeEscapeGC(); //第一次成功拯救自己 //输出:finalize method executed // I am alive! gc(); //任何对象的finalize方法只会被执行一次,所以再次执行,输出I am dead! gc(); } private static void gc() throws InterruptedException { SAVE_HOOK = null; System.gc(); //因为finalize方法优先级很低,所以暂停0.5s等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("I am dead!"); } }}3.2 垃圾收集算法3.2.1 标记-清除算法

找出内存中需要回收的对象,并且把他们标记出来;此时堆中所有的对象都会被扫描一遍,从而确定需要回收的对象,比较耗时。另外此算法会产生大量的空间碎片。

标记阶段

标记阶段

清除阶段

清除阶段

3.2.2 标记-复制算法

将内存划分为两块相等的区域,每次只使用其中的一块。但该算法比较消耗空间。

具体过程就是:

将空间分成2个相同大小的区域,每次只使用其中的一块区域; 当执行垃圾回收时,将存活对象复制到另一个区域上面;然后清理掉当前区域

标记阶段

标记阶段

复制阶段

复制阶段

3.2.3 标记-整理算法

区别于标记复制算法,标记-整理算法是先标记存活对象,然后将所有存活对象移动到一块连续区域,然后清理掉存活区域边界以外的内存。

标记阶段

标记阶段

整理阶段

整理阶段

3.2.4 分代收集算法

针对堆中不同区域,制定不同算法。

Young区:对象特点大多数都是朝生夕死,复制算法效率高。

Old区:该区域都是存活时间比较长的对象,一般发生垃圾回收的频率相对来说较低;所以采取标记清除或者标记整理算法。

3.3 垃圾收集器

如果说垃圾收集算法是内存回收的策略,那么垃圾收集器就是内存回收的具体实现。如下图不同的垃圾收集器会在不同的区域中工作:

image-20210130145040032

3.3.1 Serial

它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程。在新生代中称为Serial收集器,在老年代中称为Serial Old收集器。收集过程如下:

serial 收集过程

优点:简单高效,拥有很高的单线程收集效率缺点:收集过程需要暂停所有线程,严重影响用户体验算法:新生代中复制算法;老年代中使用标记整理算法适用范围:堆应用:Client模式下的默认新生代收集器3.3.2 ParNew

可以把这个收集器理解为Serial收集器的多线程版本。

优点:在多CPU时,比Serial效率高。缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。算法:复制算法适用范围:新生代应用:运行在Server模式下的虚拟机中首选的新生代收集器

ParNew收集过程

3.3.3 Parallel Scavenge与Parallel Old

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和ParNew一样,但是Parallel Scanvenge更关注系统的吞吐量。

Parallel Old是老年代的收集器。使用多线程和标记-整理算法进行垃圾回收,也是更加关注系统的吞吐量。

吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)

比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%。

若吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用CPU资源,尽快完成程序 的运算任务。

-XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间,
-XX:GCTimeRatio直接设置吞吐量的大小。

3.3.4 CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取 最短回收停顿时间 为目标的收集器。 采用的是"标记-清除算法",整个过程分为4步,如下图:

cms 收集过程

初始标记(Stop The World)标记一下GC Roots能直接关联到的对象,速度很快。并发标记进行GC Roots Tracing的过程。重新标记(Stop The World)为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。并发清除3.3.5 G1G1概述

G1是一款面向服务端应用的垃圾收集器。在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

G1的内存布局

G1内存布局

如上图所示是G1垃圾收集器的内存结构,G1把堆内存分为年轻代和老年代。年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区。默认情况下会把堆内存分成2048个内存分段,对应的不同类型的Region作用如下:

Eden Space新分配的对象会被存放到Eden区。Survivor Space每次在进行年轻代的垃圾回收时,都会将Eden区存活对象复制到S区,同时S区继续存活的对象年龄加1;复制完成后就将变成可以使用的Eden内存分段。Old GenerationSurvivor区的大龄对象(默认15,可以设置)将会被复制到Old区。Humongous如果对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。Humongous对象因为占用内存较大并且连续会被优先回收。果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。Remembered Set与Card Table

为了在回收单个内存分段的时候不必对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其他内存分段中的对象引用)引入了RS(Remembered Set)数据结构。Remembered Set记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。Rset结构如下图:

Rset结构

另外RSet作为根集,记录了老年代对新生代对象的引用。这是因为年轻代回收是针对全部年轻代的对象的,反正所有年轻代内部的对象引用关系都会被扫描,所以RS不需要保存来自年轻代内部的引用。对于属于老年代分段的RS来说,也只会保存来自老年代的引用,这是因为老年代的回收之前会先进行年轻代的回收,年轻代回收后Eden区变空了,G1会在老年代回收过程中扫描Survivor区到老年代的引用。

RSet究竟是怎么辅助GC的呢?在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

如果一个对象引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在G1中又引入了另外一个概念,卡表( Card Table)。一个 Card Table将一个分区在逻辑上划分为固定大小的连续区域每个区域称之为卡。卡通常较小,介于128到512字节之间。 Card Table通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址,上图表示了RSet、Card Tabel、Region之间的关系;上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,这就是points-into。

G1垃圾回收过程Young GC扫描根,根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RS记录的外部引用作为扫描存活对象的入口。处理dirty card queue中的cardTabel,更新RS。此阶段完成后,RS可以准确的反映老年代对所在的内存分段中对象的引用。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。复制对象,存活的对象年龄+1或者进入老年代。处理引用。老年代并发标记过程(Concurrent Marking)先进行一次年轻代回收过程,这个过程是Stop-The-World的。

老年代的回收基于年轻代的回收(比如需要年轻代回收过程对于根对象的收集,初始的存活对象的标记)。

恢复应用程序线程的执行。开始老年代对象的标记过程。

此过程是与应用程序线程并发执行的。标记过程会记录弱引用情况,还会计算出每个分段的对象存活数据(比如分段内存活对象所占的百分比)。

Stop-The-World。重新标记

此阶段重新标记前面提到的STAB队列中的对象(例子中的C对象),还会处理弱引用。

回收百分之百为垃圾的内存分段。

注意:不是百分之百为垃圾的内存分段并不会被处理,这些内存分段中的垃圾是在混合回收过程(Mixed GC)中被回收的。由于Humongous对象会独占整个内存分段,如果Humongous对象变为垃圾,则内存分段百分百为垃圾,所以会在第一时间被回收掉。

恢复应用程序线程的执行

g1回收过程

Mixed GC(混合回收过程)并发标记过程结束以后,紧跟着就会开始混合回收过程。混合回收的意思是年轻代和老年代会同时被回收。并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。Full GCFull GC是指上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生需要进行调整。什么时候回发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。

G1相关参考连接:

Java G1深入理解(转) - 简书

Java G1 GC 垃圾回收深入浅出 - 码年 - 博客园

G1 收集器原理理解与分析 - 知乎

G1详解 - 嗯嗯123 - 博客园

3.3.6 ZGC

JDK11新引入的ZGC收集器,不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念了,会将内存分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题只能在64位的linux上使用,目前用得还比较少。

3.3.7 垃圾收集器的对比

垃圾收集器

回收区域

适用算法

优点

缺点

Serial

Serial Old负责老年代;Serial负责新生代

新生代中标记-复制算法;老年代中使用标记-整理算法

简单高效,拥有很高的单线程收集效率

收集过程需要暂停所有线程耗时长并且是单线程;

ParNew

新生代

标记-复制算法

在多CPU时,比Serial效率高。

收集过程暂停所有应用程序线程

Parallel Scavenge

新生代

标记-复制算法

并行的多线程收集器;相比较ParNew更关注吞吐量

收集过程暂停所有应用程序线程

Parallel Old

老年代

标记-整理算法

关注吞吐量


CMS

老年代

标记-整理算法

并发收集、停顿低

产生大量碎片,降低系统吞吐量

G1

新生代和老年代


G1 VS CMS对比使用mark- sweep的CMS,G1使用的copying算法不会造成内存碎片;G1会根据用户设定的gc停顿时间智能评估哪几个 region需要被回收可以满足用户的设定


3.4 内存分配策略对象优先分配在Eden大对象直接分配在老年代上长期存活的对象进入老年代动态对象年龄判断

为了能更好的适应不同程序的内存状态,虚拟机并不总是要求对象的年龄必须达到-XX:MaxTenuringThreshold所设置的值才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年区,无须达到-XX:MaxTenuringThreshold中的设置值。

空间分配担保

在发生Minor GC的时候,虚拟机会检测每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则直接进行一次FUll GC;如果小于,则查看HandlerPromotionFailyre设置是否允许担保失败,如果允许那就只进行Minor GC,如果不允许则也要改进一次FUll GC。也就是说新生代Eden存不下改对象的时候就会将该对象存放在老年代。

3.5 GC类型Minor GC

指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快

Full GC

发生在老年代的GC,出现了MajorGC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

4、jvm实战4.1 常用命令jps

查看java进程

jinfo

实时查看和调整JVM配置参数

用法:jinfo -flag name PID 查看某个java进程的name属性的值

#查看属性值
jinfo -flag MaxHeapSize PID
jinfo -flag UseG1GC PID
#修改
jinfo -flag [+|-] PID
jinfo -flag <name>=<value> PID

jstat

查看虚拟机性能统计信息

4.2 常用工具jconsole

jconsole工具是JDK自带的可视化监控工具。查看java应用程序的运行概况、监控堆信息、永久区使用 情况、类加载情况等。命令行输入jconsole即可。

jvisualvm

可以监控java进程的CPU、类、线程、堆栈信息以及dupm文件等。命令行输入jvisualvm命令即可。

arthas

github:https://github.com/alibaba/arthas

Arthas是Alibaba开源的Java诊断工具,采用命令行交互模式,是排查jvm相关问题的利器。

jprfiler

idea中集成的分析内存的收费软件。