上帝视角看JVM-类加载与内存模型

188 阅读16分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

大家都知道,java是一门跨平台的语言,我们平时开发完,只需要javac编译一次,就可以在windows,linux,macos等平台上运行我们的程序,那java是如何做到一次编译处处运行的呢?我想大家可能都思考过这个问题,有些卷王甚至进行了很深的研究比如阅读HotSpot源码(大家都要卷起来啊🐶),那么在开篇之前咱们就再简单聊(juan)聊(juan)这个问题.

image.png 我们开发的应用程序最终都会被javac编译成字节码文件也就是我们常见的.class文件,然后java虚拟机会去加载运行这些字节码文件。

每个平台相同的是字节码文件,不同的是java虚拟机

java团队针对不同的平台开发了不同版本的虚拟机,java虚拟机在运行字节码文件时会将字节码解释为对应平台的机器码指令,从而实现跨平台支持。所以,java虚拟机是让java语言实现跨平台的大功臣(膜拜)。java团队也是用解耦的思想来设计java虚拟机,让我们的java语言与平台解耦,让我们更能专注于java本身。

但是,作为卷王的我们,前辈的路铺的再好,我们也要回头望望,看看道路的起点究竟是怎么的风景。那就先停下我们CURD的小步伐,一起回头看看我们的大功臣是如何为我们打下这片江山的。

JVM的类是如何加载的

上边我们提到了JVM是加载运行我们的字节码文件来运行我们的程序的,那他到底是如何加载的? JVM类加载过程总共分为7步,加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析我们通常统称为链接。 image.png

加载

通过一个类的全限定名来获取定义此类的二进制字节流,也就是获取我们的.class文件。当然获取的字节流也并不一定只能从.class文件获取,也可以从网络中获取,比如Web Applet;运行时计算生成,比如动态代理(我们常说的代理类);从其他文件生成,比如JSP等等。细心的可能会注意此处我们说的是获取而不是加载,这是因为只有在使用到类的时候才会加载,例如调用类的main()方法,new对象等(想必大家也听说过懒加载这个概念),在加载阶段也会在内存中(堆中)生成一个代表这个类的java.lang.Class对象,作为方法区在这个类的各种数据的访问入口。

验证

验证是为了保证被加载类的正确性,确保Class文件的字节码流中包含的信息符合虚拟机的约束要求,是java虚拟机保护自身的一项必要措施。验证主要包含四个动作:

  1. 文件格式验证。验证字节码流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。基于二进制字节流进行,只有通过这阶段的验证,才被允许进入虚拟机内存的方法区进行存储,后续的几个验证阶段也都是基于方法区进行的,不会再重新读取以及操作字节流了,以此可以提升性能。
  2. 元数据验证。对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范
  3. 字节码验证。这个验证阶段是最复杂的一个阶段,主要通过数据流分析和控制流分析,确保程序语义是合法的、符合逻辑的,同时对类的结构体进行校验分析,保证该类的方法在运行时不会做出危害虚拟机安全的行为
  4. 符号引用验证。主要是为了确保解析行为能正常执行,如果校验无法通过,虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,eg:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等

从JVM的验证阶段中,是不是隐约能看到我们日常开发提倡的一些思路规范:前置校验、使用合适的缓存策略来提升我们的系统性能

准备

准备阶段是正式为类中定义的静态变量(被static修饰的变量)分配内存并设置变量初始值的阶段,eg:int赋值为0,boolean赋值为false,对象赋值为null,注意:如果是final修饰符修饰的话即常量,在此阶段会直接赋值。

解析

解析是将常量池内的符号引用替换为直接引用的过程,该阶段会把一些静态方法(符号引用,比如main())替换为指向数据所在内存的指针或句柄等(即直接引用),这也是常说的静态链接过程(即在类加载期间完成的)。而与之对应的是动态链接,动态链接是在程序运行期间完成的将符号引用替换为直接引用。由此不难看出,在程序运行过程中JVM依然有解析动作,所以解析阶段并不一定是按照JVM加载顺序执行的,而其他阶段加载、验证、准备、初始化还有卸载这五个阶段的顺序是确定的

初始化

此过程对类的静态变量初始化为指定的值,并执行静态代码块。

以上便是JVM加载类的几个主要过程。大致过程都明白了,那这个过程到底是如何实现的,通过什么方式来完成的呢?

类加载器,对,就是我们平时阅读源码看得见摸得着的那几个类加载器。

  • 引导类加载器。 BoostrapClassLoader, C语言实现,负责加载支撑JVM运行的位于<JAVA_HOME>/lib目录下的核心类库,比如rt.jar,charsets.jar等
  • 扩展类加载器。ExtClassLoader,主要负责加载支撑JVM运行的位于<JAVA_HOME>/lib/ext目录下的jar包
  • 应用程序类加载器。AppClassLoader, 负责加载classpath路径下类包,主要就是加载自己写的类
  • 自定义类加载器。 负责加载用户自定义路径下的类包,默认父类加载器为应用程序类加载器
 /**
 * ClassLoader
 * @author Denticle
 * @version V1.0
 * @date 2022/10/1 15:35
 */
public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        // 扩展类加载器
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }

    try {
        // 引用类加载器
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }

    Thread.currentThread().setContextClassLoader(this.loader);
    String var2 = System.getProperty("java.security.manager");
    if (var2 != null) {
        SecurityManager var3 = null;
        if (!"".equals(var2) && !"default".equals(var2)) {
            try {
                var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
            } catch (IllegalAccessException var5) {
            } catch (InstantiationException var6) {
            } catch (ClassNotFoundException var7) {
            } catch (ClassCastException var8) {
            }
        } else {
            var3 = new SecurityManager();
        }

        if (var3 == null) {
            throw new InternalError("Could not create SecurityManager: " + var2);
        }

        System.setSecurityManager(var3);
    }

}

说到类加载器,那就不得不提一下双亲委派机制。

image.png

简单概括其实就是加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。注意:此处的父类非java内继承(extends)的概念,以上类加载器并没有继承的关系,只是一个parent属性。而扩展类加载器与应用类加载器都继承URLClassLoader

import sun.misc.SharedSecrets;
import sun.misc.URLClassPath;

import java.io.IOException;
import java.net.URLClassLoader;
import java.security.AccessController;

/**
 * ExtClassLoader
 * @author Denticle
 * @version V1.0
 * @date 2022/10/1 15:35
 */
// ExtClassLoader 继承 URLClassLoader
static class ExtClassLoader extends URLClassLoader {
        private static volatile Launcher.ExtClassLoader instance;

        public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {
            if (instance == null) {
                Class var0 = Launcher.ExtClassLoader.class;
                synchronized(Launcher.ExtClassLoader.class) {
                    if (instance == null) {
                        instance = createExtClassLoader();
                    }
                }
            }

            return instance;
        }
  //次数省略若干代码
}

// AppClassLoader 继承 URLClassLoader
static class AppClassLoader extends URLClassLoader {
        final URLClassPath ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);

        public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {
            final String var1 = System.getProperty("java.class.path");
            final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);
            return (ClassLoader) AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {
                public Launcher.AppClassLoader run() {
                    URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);
                    return new Launcher.AppClassLoader(var1x, var0);
                }
            });
        }
  //此处省略若干代码
}

为什么使用双亲委派机制:

  • 沙箱安全机制: java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
  • 防止类重复加载: Java类随着它的类加载器一起具备了一种带有优先级的层次关系,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次

打破双亲委派机制

虽然双亲委派机制有诸多好处,但在实际场景中也有很多需要打破这种机制的需求,比如Tomcat,我们都知道,Tomcat是个Web容器,在正常情况下,我们只会部署一个Tomcat,而会把多个java程序放到这一个Tomcat下运行,那么我们就需要思考一个问题,如果我们的应用程序使用了同一类库的不通版本,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。所以,Tomcat打破了双亲委派机制。

类加载过程的讨论暂时就先到这儿, 我们接下来要思考的问题是我们的类加载后JVM是如何存储的,我们一个应用程序中那么多对象JVM存储是如何分配的。

初探JVM内存模型

JVM内存分为线程共享和线程私有两部分,其中,堆、方法区(JDK8为元数据区)为线程共享,程序计数器、虚拟机栈(也称线程栈)、本地方法栈为线程私有。

0C04312E-F963-4C5E-9462-11025A523644.png

堆是在虚拟机启动的时候创建,是垃圾收集器管理的内存区域。该区域主要存放我们的对象实例以及数组(不是所有的对象实例都在堆中,稍后讨论这种情况)。堆又分为新生代(Young)跟老年代(Old),新生代又分为Eden、From Survivor、To Survivor。遵循先进先出,后进后出

D0FDEEC8-77FB-49A7-9E6C-B3B29FED8CA1.png 堆的大小可以通过 -Xms(最小值)-Xmx(最大值) 来设置

方法区(元空间)

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。为直接内存(Direct memory)

JDK7之前改区域称为永久代,设置参数主要有-XX:Permsize(初始分配空间)、-XX:MaxPermsize(最大可分配空间)。

JDK8之后开始使用元空间(Metaspace)来代替,对应参数:-XX:MetaspaceSize元空间初始大小,(64位服务端的jvm下,默认值为21M) 达到此阈值就会进行Full GC、-XX:MaxMetaspaceSize 最大可分配大小 Full GC后会重置此值的大小。若不指定元空间的大小,默认情况下,元空间最大的大小是系统内存的大小

虚拟机栈(线程栈)

每个线程在创建时都会创建一个虚拟机栈,内部保存一个栈帧(Stack Frame),每个栈帧对应一次java方法的调用(每次方法调用都会创建一个栈帧),主要作用就是存储局部变量表,操作数栈,动态连接,方法返回地址等信息,遵循后入先出(LIFO).栈帧内主要存储局部变量表、操作数栈、动态链接、方法出口这些元素。

可以通过-Xss来设置虚拟机栈(注意:-Xss设置越小,一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数就越多,后期咱们再专门讨论调优的问题)

本地方法栈(Native Stack)

本地方法栈的作用与虚拟机栈发挥的作用类似,区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务. 本地方法栈与虚拟机栈一样,也会抛出StackOverflowError 和OutOfMemoryError异常。

程序计数器(PC Register)

程序计数器可以看作是当前线程所执行的字节码的行号指示器,线程正在执行的是java方法,计数器记录的是正在执行的虚拟机字节码指令的地址,如果线程正在执行的是本地(Native)方法,计数器的值为空(Undefined)。

程序计数器是JVM内存中唯一不需要考虑内存溢出的区域。

JVM内存分这么多区,那对象在创建的时候,这些内存JVM是怎么分配的呢?在讨论这个问题之前,我们先看下对象的创建是怎么个流程。

053EE60D-54F1-4573-898A-625A86CDC0DE.png

类加载检查

Xxxx xxxx = new Xxxx(),这行代码大家应该都不陌生,那执行这行代码的时候虚拟机都做了啥呢?(new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等),当虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。这其实就是上图中的第一步,。

内存分配

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为 对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。在这一步我们需要思考两个问题:

  1. 内存是怎么划分的?
  2. 怎么来解决并发的问题?比如:正在给对象A分配内存,指针还没来得及修改,B又同时使用了原来的指针来分配内存。

JVM内存划分的方法有两种:

1、指针碰撞(Bump the Pointer),这也是JVM默认使用的方法 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

2、空闲列表(Free List) 如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例并更新列表上的记录

并发问题的解决方法:

1、CAS(compare and swap) 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

2、本地线程分配缓冲(Thread Local Allocation Buffer,TLAB 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过­XX:+UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+UseTLAB),­XX:TLABSize 指定TLAB大小。

初始化:

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对 象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。

执行<init>方法

执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意:这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。


结尾填坑:我们上边提过,不是所有的对象实例都在堆中,也有可能在栈上分配。

按我们上边的分析,对象是在堆上进行分配的,当对象没有被引用的时候,需要依靠GC(垃圾回收机制我们后续讨论)进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。这里出现了一个新的名词逃逸分析。

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

看代码:

package com.jvm.demo;

/**
 * DemoEscapeAnalysis
 *
 * @author Zhao Yun Long
 * @version V1.0
 * @date 2022/10/1 15:35
 */
public class DemoEscapeAnalysis {
    public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
        //user被返回了,作用域范围不确定(逃逸了)
        return user;
    }

    public void test2() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
        /**此处的user我们可以确定当test2方法结束时,user对象就可以认为是无效对象了,
        * JVM分析确定此对象不会逃逸,直接将其分配到栈内存中,让其在方法结束时跟栈内存一起被回收掉。
        */
    }

}

参考:深入理解java虚拟机jvm高级特性与最佳实践第3版