Java:JVM基础——非堆部分(Java8)

PS:现在普遍都是 Java8 和 Java11 了,我不打算写 Java8 之前的了,本文出现的虚拟机都是 HotSpot 虚拟机

1、 JVM 内存结构图

2、 Java Virtual Machine(Java虚拟机)

Java虚拟机就是平常说的 JVM ,运行一个 Java 进程就会开辟一个 Java 虚拟机。

其内存结构分为四部分:

  • 类加载子系统:类加载器子系统负责从文件系统或者网络中加载Class文件。
  • 运行时数据区:JVM在执行JAVA程序时会把它管理的内存区域划分为若干个不同的数据区域,统称为运行时数据区。
  • 执行引擎:Java虚拟机最核心的组成部分,将字节码指令解释/编译为对应平台上的本地机器指令,输出执行结果。
  • 本地方法接口:Java调用非Java代码的接囗,通过Java的Native Method。

2.1 类加载子系统

2.1.1 类加载子系统结构

类加载器子系统负责从文件系统或者网络中加载Class文件,通过字节码的魔数判断Class文件。

类加载主要有三个阶段

  • 加载阶段:类加载器通过全限定类名找到类的二进制文件
  • 链接阶段:
    • 验证:验证被加载类的正确性
    • 准备:为类的静态变量分配内存,并将其初始化为默认值。不包含static-final
    • 解析:把类中的符号引用转换为直接引用
  • 初始化阶段:调用<clinit>()方法,对类的静态变量赋予自定义的值。(注意<clinit>()方法是由类静态成员的赋值语句以及static语句块合并产生的,对于存在final定义或者普通类成员变量无法产生该方法)

类在JVM中的生命周期为:加载,连接,初始化,使用,卸载。

2.1.2 双亲委派模型

找到对应的类,主要是通过 使用的类加载器全限定类名 确定唯一一个类。

这里涉及双亲委派机制。

双亲委派机制简单来说就是,当一个类加载器收到类加载请求,先去让它的父级类加载器去加载这个类,如果顶层的启动类加载器(Bootstrap ClassLoader)无法加载这个类,就会层层下推,让自己的下级加载器去加载这个类。如果都无法加载就会抛出ClassNotFoundException

说白了就是递归去加载这个类。

2.1.2.1 拓展类加载器/平台类加载器

JDk9之前,第二层叫拓展类加载器(Extension ClassLoader),在JDK9之后改为了平台类加载器(Platform ClassLoader)。为了支持模块系统,对 ExtClassLoader 和 AppClassLoader 做了一些调整。扩展机制被移除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器。

2.1.2.2 如何自定义类加载器

实现自定义类加载器,需要继承 ClassLoader 类,重写 findClass 或者 loadClass 方法。

但是不推荐重写 loadClass,这样有可能会破坏双亲委派模型。

推荐重写 findClass 方法。

这里是 ClassLoader 类中关于类加载方法的代码。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        // getClassLoadingLock方法中,使用ConcurrentHashMap对当前加载的全限定类名生成一个Object对象,并对其加锁,避免该类会被同时加载
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 检查这个类是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 如果它的父类不为空,也就是非 BootstrapClassLoader,那么调用父级的loadClass方法去加载这个类。
                        c = parent.loadClass(name, false); 
                    } else {
                        // BootstrapClassLoader 是使用的 C/C++ 语言,这里会调用本地方法去尝试加载这个类
                        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
                    // to find the class.
                    long t1 = System.nanoTime();
                    // 如果这个类还没有被加载,那么这里调用findClass方法,尝试使用自定义的类加载器
                    // 重写该方法,实现自定义类加载器
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

2.1.2.3 如何打破双亲委派机制

这是面试时问到的问题,如何打破双亲委派模型。

  1. 可以通过重写 loadClass 方法,打破模型。Tomcat就是通过重写loadClass和findClass方法实现对不同目录下的加载,从而打破双亲委派模型。
  2. 可以通过使用线程上下文类加载器,这个类加载器可以通过java.lang.Thread类setContext-ClassLoader()方法进行设置。

2.2 执行引擎

将字节码指令解释/编译为对应系统平台上的本地机器指令。

执行引擎包含三个部分:

  • 解释器:逐行解释字节码,生成对应系统的机器指令执行。
  • JIT编译器:即时编译器,将源代码直接编译成和本地机器平台相关的机器语言。并且寻找热点高频执行的代码将其放入元空间中,即元空间中存放的JIT缓存代码;
  • 垃圾回收器:使用对应算法扫描垃圾对象,并将其回收释放内存空间。

2.3 本地方法接口

简单来讲,就是使用 native 定义的方法,其实现使用非 Java 语言。

这个 Native Method 就是一个本地方法接口,通过这个接口调用本地方法库,作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。

3、运行时数据区

PS:运行时数据区单独拿出来,这里边设计知识点比较多

运行时数据区其实就是常说的堆栈,主要分成两个区域,线程共享线程私有

线程私有:

  • 虚拟机栈
  • 程序计数器
  • 本地方法栈
  • 线程分配缓冲区

线程共享:

  • 方法区(Java8之后已经不在运行时数据区了,而是改为元空间移动到直接内存(本地内存)中了)

3.1 虚拟机栈

Java内存可以粗糙的分为 ,其中栈就是指的虚拟机栈,在具体一点指的是虚拟机栈中的局部变量表。

虚拟机栈是线程私有的内存区域之一,每个线程都有自己对应的虚拟机栈。

一个线程的虚拟机栈是由多个栈帧组成,每个栈帧就是一个方法的调用,主要包括四部分:

  • 局部变量表:定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
  • 操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中临时的存储空间。执行复制、交换、求和等操作。
  • 动态链接:将符号引用和字面量在运行时转化成直接引用。比如 String a = "X"; ,就是将变量 a 的值 X 由 X 这个字面量替换成 "X" 字符串的实际内存地址。
  • 返回地址(方法出口信息):一个方法的结束,有两种方式:①正常执行完成;②出现未处理的异常,非正常退出。无论哪种方式退出,在方法退出后都返回到该方法被引用的位置。

3.1.1 局部变量表

局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

局部变量表所需容量大小是在编译期确定下来的,并保存在方法的Code属性的maxumum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

3.1.1.1 Slot(变量槽)

局部变量表中最基本的存储单元是 Slot(变量槽)。

局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量

在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占两个slot。

  • byte、short、char在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true
  • long和double则占据两个Slot,使用的是slot的起始索引。

JVM 会为局部变量表中的每个 Slot 创建一个索引,通过索引就能访问到局部变量表中的变量值。

一个实例方法被调用时,它的方法参数和局部变量将会按照顺序被复制到局部变量表中每个 Slot 上。

如果当前帧是由构造方法或者实例方法创建的,那么该对象的引用 this 将会存放在 index 为 0 的 slot 处,其余参数按照参数表顺序进行排列。

局部变量表

从字节码层面来讲,因为this变量不存在与静态方法的局部变量表中,所以静态方法无法调用this。

Slot重用:
如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

public void testMethod(){
  int a = 0;
  {
    int b = 0; // 变量 b 的作用域只在这个大括号里,之后的 c 可能会重用 b 的 Slot
    b = a + 1;
  }
  int c = a + 1;
}

3.1.1.2 逃逸分析

有面试被问到,Java的实例对象都是在堆中吗?

这里就会引入 JVM 的逃逸分析,实例对象会在栈中,这种说法并不严谨,在栈中没有生成实际的对象。

3.1.1.2.1 什么是逃逸分析?

首先逃逸分析是一种算法,这套算法在 Java 即时编译器(JIT),编译 Java 源代码时使用。通过逃逸分析算法,可以分析出某一个方法中的某个对象,是否会被其它方法或者线程访问到。

如果分析结果显示,某对象并不会被其它线程访问,则有可能在编译期间,对其做一些深层次的优化。

3.1.1.2.2 逃逸状态

1、全局逃逸(GlobalEscape)

即一个对象的作用范围,逃出了当前方法或者当前线程,有以下几种场景:

  • 对象是一个静态变量;
  • 对象作为当前方法的返回值;
  • 如果复写了类的 finalize 方法,则此类的实例对象都是全局逃逸状态(因此为了提高性能,除非万不得已,不要轻易复写 finalize 方法);

2、参数逃逸(ArgEscape)

即一个对象,被作为方法参数传递,或者被参数引用。

3、无逃逸(NoEscape)

即方法中的对象,没有发生逃逸,这种对象会被 Java 即时编译器进一步的优化。

3.1.1.2.3 逃逸分析优化

经过「逃逸分析」之后,如果一个对象的逃逸状态是 GlobalEscape 或者 ArgEscape,则此对象必须被分配在「堆」内存中,但是对于 NoEscape 状态的对象,则不一定,具体会有以下几种优化情况。

  • 1、锁消除:比如在调用 StringBuffer 对象的同步方法时,就能够自动地把 synchronized 锁消除掉了。从而提高 StringBuffer 的性能
  • 2、对象分配消除:对象分配消除是指将本该在「堆」中分配的对象,转化为由「栈」中分配
  • 3、标量替换:对象就是聚合量,它可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做「标量替换」。这样如果一个对象没有发生逃逸,那压根就不需要在「堆」中创建它,只会在栈或者寄存器上创建一些能够映射这个对象标量即可,节省了内存空间,也提升了应用程序性能。

3.2 程序计数器

用来存储指向下一条指令的地址,也即将将要执行的指令代码。由执行引擎读取下一条指令。

程序计数器的作用是什么:

  1. 多线程宏观上是并行(多个事件在同一时刻同时发生)的,但实际上是并发交替执行的
  2. 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
  3. JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

3.3 本地方法栈

用于管理本地方法的调用。

由于程序计数器无法存储native方法的指令地址,所以需要本地方法栈来登记实现本地方法。

3.4 线程分配缓冲区

TLAB是JVM在堆内存的Eden区划分出来的一块专用于原始线程进行对象分配的区域。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,如需要分配内存,在自己的TLAB上分配。当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,采用CAS + 失败重试

这样避免了多线程在堆上分配同一块内存时出现的线程安全问题。这个问题还可以通过对内存分配的同步处理去解决,但是耗费性能和执行效率。

CAS+失败重试:CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。(后续写线程安全的时候会介绍乐观锁等)

3.5 方法区

直接内存并不属于JVM的区域,它是在服务器内存开辟的堆外内存。Java4新加入的NIO类,引入基于通道(channel)和缓存区(buffer)的I/O方式,可以使用Native Method直接分配堆外内存,通过一个存储在堆中的 DirectByteBuffer 对象作为这块区域的地址引用进行操作,避免数据在Java堆和Native堆的来回复制

在 JDK8 之后叫做元空间,存在于直接内存中,主要包含类元信息和运行时常量池。

  • 类元信息: 类常量池、类版本、方法、字段、接口
  • 运行时常量池:类加载后解析的字面量、符号引用、JIT编译后缓存的代码
posted @ 2022-04-10 23:56  钢板意志  阅读(491)  评论(0编辑  收藏  举报