JVM八股文学习

须弥零一

共 11291字,需浏览 23分钟

 ·

2021-04-23 00:28

须弥零一

JVM八股文学习

本篇文章转自Gakkiyomi的博客
作者: Gakkiyomi
原文地址:  https://fangcong.ink/articles/2021/04/17/1618650579784.html

之前零零散散的看过一些 JVM 之类的知识,但都没有整理,导致每次想不起来都要去翻书或者 Google,这里做一次完整的记录,可被方便的索引到。

内存分配和回收策略

Minor GC/Major GC /Full GC

Minor GC:回收新生代(包括 Eden 和 Survivor 区域),因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。Major GC / Full GC: 回收老年代,出现了 Major GC,经常会伴随至少一次的 Minor GC,但这并非绝对。Major GC 的速度一般会比 Minor GC 慢 10 倍 以上。在 JVM 规范中,Major GC 和 Full GC 都没有一个正式的定义,所以有人也简单地认为 Major GC 清理老年代,而 Full GC 清理整个内存堆。

对象优先在 eden 区分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

大对象直接进入老年代

大对象是指需要大量连续内存空间的 Java 对象,如 byte[1024 * 1204 * 1024]
一个大对象能够存入 Eden 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及大量的复制,就会造成效率低下。
虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

长期存活的对象进入老年代

既然 JVM 采用了分代收集的思想,那必然就会为对象标注当前属于那个时代,所以 JVM 给每个对象定义了一个对象年龄计数器。如果对象在新生代发生一次 Minor GC 后仍然能存活,那么就会被移动到 survivor 区,并且对象年龄设为 1,之后对象每熬过一次 Minor GC,对象年龄加 1,当年龄增长到一定程度后(默认 15),就会晋升到老年代中。设置阈值:-XXMaxTenuringThreshold=15
使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。

动态对象年龄判定

如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

JDK 6 Update 24 之前的规则是这样的:
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立,Minor GC 可以确保是安全的;如果不成立,则虚拟机会查看 HandlePromotionFailure 值是否设置为允许担保失败, 如果是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那此时也要改为进行一次 Full GC
JDK 6 Update 24 之后的规则变为:
只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC
通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。HandlePromotionFailure 这个参数不会在影响空间分配担保策略了。
以上这个过程就是分配担保。

JVM 触发 Full GC 的情况

1.我们手动调用 system.gc() 方法,但这也不一定会执行 Full GC,只会增加 Full GC 的执行频率我们可以通过 -XX:DisableExpliitGC 来禁止调用 Full GC2.老年代空间不足,当老年代空间不足后会触发 Full GC,若触发后仍然不足,则会抛出 java.lang.OutOfMemoryError:Java heap space3.方法区(永久代)空间不足,方法区存放一些类信息,常量,和静态变量等数据,若系统加载的类,反射的类和调用的方法较多的时候,永久代可能被占满,则触发 Full GC,若经过 Full GC 仍然回收不了,则会抛出 java.lang.OutOfMemoryError:PemGen space4.统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的空间,则会触发 Full GC5.CMS GC 时出现 promotion failed 和 concurrent mode failure ,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。

垃圾收集器

(A)图中展示了 7 种不同分代的收集器:

SerialParNewParallel ScavengeSerial OldParallel OldCMSG1

(B)而它们所处区域,则表明其是属于新生代收集器还是老年代收集器:

新生代收集器SerialParNewParallel Scavenge
老年代收集器Serial OldParallel OldCMS
整堆收集器G1

(C)两个收集器间有连线,表明它们可以搭配使用:

class 类文件结构

class 文件是一组 8 位字节为基础的二进制流,各个数据项目严格按照顺序紧凑的排列在 Class 文件中,中间没有任何空格分隔符,这使得整个 claas 文件全部都是程序运行时必要的数据,没有一丁点的空隙存在。当遇到需要占用 8 个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个 8 字节进行存储。
这张表里的名称含义大多都见名知意。值得一提的是,constant_pool 常量池。
常量池中主要存放两大常量,字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于 Java 语言层面的常量含义,如用 final 声明的常量值等。而符号引用则属于编译原理方面的概念,包括了下面 3 类常量:

类和接口的全限定名字段的名称和描述符方法的名称和描述符

类加载

类加载过程

第一步:Loading 加载

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

第二步:Linking 链接

链接是指将上面创建好的 class 类合并至 Java 虚拟机中,使之能够执行的过程,可分为 验证、准备、解析 三个阶段。

① 验证(Verify)
      确保 class 文件中的字节流包含的信息符合当前虚拟机的要求,保证这个被加载的 class 类的正确性,不会危害到虚拟机的安全。1.文件格式验证
        首先要验证字节流是否符合 class 文件的格式规范,并且能被当前版本的虚拟机处理。这一阶段包括:是否已魔数 0xCAFEBABE 开头主,次版本号是否在当前虚拟机的处理范围之内常量池中的常量是否存在不被支持的常量类型(检查常量 flag 标志)指向常量池的各种索引值是否有指向不存在的常量或不符合类型的常量CONSTANT_Utf8_info 型常量是否有不符合 utf8 编码的数据Class 文件中的各个部分以及文件本身是否有被删除的或者附加的其他信息……
2.元数据验证
        对字节码描述的信息做语义分析,对类的元数据信息做语义校验,保证不存在不符合 Java 语言规范的元数据信息。
3.字节码验证
        通过数据流和控制流分析,确保程序语义是合法的,符合逻辑的,这个阶段会对方法体进行校验分析,保证这个方法在运行时不会做出危害虚拟机的安全事件。
4.符号引用验证
        最后一个阶段的校验发生在虚拟机将符号引用转换成直接引用的时候,这个转换动作将在连接的第三阶段--解析阶段中发生。此阶段可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配行校验。
② 准备(Prepare)
      为类中的 静态字段 分配内存,并设置默认的初始值,比如 int 类型初始值是 0。被 final 修饰的 static 字段不会设置,因为 final 在编译的时候就分配了。
③ 解析(Resolve)
      解析阶段的目的,是将常量池内的符号引用转换为直接引用的过程(将常量池内的符号引用解析成为实际引用)。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
      事实上,解析器操作往往会伴随着 JVM 在执行完初始化之后再执行。符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
      解析动作主要针对类、接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info 等。

第三步:initialization 初始化

这个阶段主要是对类变量初始化,是执行类构造器的过程。

换句话说,只对 static 修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

类加载机制

类加载器

1.启动类加载器Bootstrap ClassLoader):由 C++ 语言实现。负责加载 JAVA_HOME\lib 目录中并且能被虚拟机识别的类库到 JVM 内存中,如果名称不符合的类库即使放在 lib 目录中也不会被加载。该类加载器无法被 Java 程序直接引用。2.扩展类加载器Extension ClassLoader):该加载器主要是负责加载 JAVA_HOME\lib\ext,该加载器可以被开发者直接使用。3.应用程序类加载器Application ClassLoader):该类加载器也称为系统类加载器,它负责加载用户类路径(Classpath)上所指定的类库,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派机制

工作原理:如果一个类加载器收到了类加载的请求,那么它首先不会尝试去加载该类,而是委托它的父类加载器去加载,每一层的类加载器都是如此,所以所有的加载请求都会传送到启动类加载器中。只有父类无法完成加载请求时,子加载器才会尝试自己去加载。

破坏双亲委派

《深入理解 Java 虚拟机》这本书,读到破坏双亲委派机制这一小节,其中有一段话,如下:

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?
     这并非是不可能的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码啊!那该怎么办?
     为了解决这个问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
     有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

JDBC 中的逆双亲委派机制源码分析

首先,我们看一下 JDBC 连接数据库中加载驱动并获取连接的代码。
    try{          //加载MySql的驱动类          Class.forName("com.mysql.jdbc.Driver") ;      }catch(ClassNotFoundException e){          System.out.println("找不到驱动程序类 ,加载驱动失败!");          e.printStackTrace() ;      }    String url = "jdbc:mysql://localhost:3306/test" ;       String username = "root" ;      String password = "root" ;      try{          Connection con =  DriverManager.getConnection(url , username , password ) ;      }catch(SQLException se){          System.out.println("数据库连接失败!");          se.printStackTrace() ;      }   

以上就是 JDBC 连接数据并获取连接的代码,那调用这些的方法到底做了些什么呢?
     首先,我们用 Class.forName("com.mysql.jdbc.Driver") 加载了驱动,Driver 的源码很简单,如下:

public class Driver extends NonRegisteringDriver implements java.sql.Driver {    public Driver() throws SQLException {    }    static {        try {            DriverManager.registerDriver(new Driver());        } catch (SQLException var1) {            throw new RuntimeException("Can't register driver!");        }    }}
我们用系统类加载器加载了 com.mysql.jdbc.Driver,类初始化的时候执行静态代码块,静态代码块中将 new 了一个 Driver 实例并将他注册到 DriverManager 中。注意,这里的 Driver 实例的类加载器是系统类加载器。 接下来,我们调用了 DriverManager.getConnection(String url,String user, String password),其源码如下:
  @CallerSensitive    public static Connection getConnection(String url,        String user, String password) throws SQLException {        java.util.Properties info = new java.util.Properties();        if (user != null) {            info.put("user", user);        }        if (password != null) {            info.put("password", password);        }        return (getConnection(url, info, Reflection.getCallerClass()));    }
其调用了另一段关键代码,如下:
private static Connection getConnection(        String url, java.util.Properties info, Class<?> caller) throws SQLException {        /*         * 这里要确保类加载不能是BootstrapClassLoader         * 因为BootstrapClassLoader不能加载到用户类库(JDBC驱动为用户类库)         */        ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;        synchronized(DriverManager.class) {            // synchronize loading of the correct classloader.            if (callerCL == null) {                //获取系统类加载器                callerCL = Thread.currentThread().getContextClassLoader();            }        }        if(url == null) {            throw new SQLException("The url cannot be null", "08001");        }        println("DriverManager.getConnection(\"" + url + "\")");        // Walk through the loaded registeredDrivers attempting to make a connection.        // Remember the first exception that gets raised so we can reraise it.        SQLException reason = null;        for(DriverInfo aDriver : registeredDrivers) {            // If the caller does not have permission to load the driver then            // skip it.            if(isDriverAllowed(aDriver.driver, callerCL)) {                try {                    println("    trying " + aDriver.driver.getClass().getName());                    Connection con = aDriver.driver.connect(url, info);                    if (con != null) {                        // Success!                        println("getConnection returning " + aDriver.driver.getClass().getName());                        return (con);                    }                } catch (SQLException ex) {                    if (reason == null) {                        reason = ex;                    }                }            } else {                println("    skipping: " + aDriver.getClass().getName());            }        }        // if we got here nobody could connect.        if (reason != null)    {            println("getConnection failed: " + reason);            throw reason;        }        println("getConnection: no suitable driver found for "+ url);        throw new SQLException("No suitable driver found for "+ url, "08001");    }
这段代码并没有使用 ClassLoader
    private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {        boolean result = false;        if(driver != null) {            Class<?> aClass = null;            try {                aClass =  Class.forName(driver.getClass().getName(), true, classLoader);            } catch (Exception ex) {                result = false;            }            //类加载器与类相同才能确定==             result = ( aClass == driver.getClass() ) ? true : false;        }        return result;    }
小结,此处线程上下文类加载器的作用主要用于校验存放的 driver 是否和调用时的一致由此判断是否有权限获取连接。

排查 OOM

一般常见的 OOM,要么是短时间内涌入大量的对象,导致你的系统根本支持不住,此时你可以考虑优化代码,或者是加机器;要么是长时间来看,你的很多对象不用了但是还被引用,就是内存泄露了,你也是优化代码就好了;这就会导致大量的对象不断进入老年代,然后频繁 Full gc 之后始终没法回收,就撑爆了。
要么是加载的类过多,导致 class 在永久代理保存的过多,始终无法释放,就会撑爆。


---- END ----



欢迎关注我的公众号“须弥零一”,原创技术文章第一时间推送。


浏览 38
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报