一、JVM 介绍

1.1 为什么要学 JVM?

在这里插入图片描述

  1. 应对面试: 如果说在面试的时候,我们连 JVM 的知识都不了解的话,面试官对我们的印象将会大打折扣。
  2. 中高级程序员必备技能: 如果说只满足于一个初级程序员,OK,根本不需要了解 JVM。它和我们平时开发没啥关系,但是如果你是 一个有追求的程序员,想在这个行业长期发展的话,也期望从一个小白升级为大牛的话,掌握 JVM 就至关重要了。
  3. 深入理解 Java: 什么意思呢?一旦掌握了 JVM,我们就知道了 Java 的运行机制,特别对于排查问题的能力将会有大幅度提升。像一些比较棘手的问题,就跟 JVM 有关系,比如:内存泄漏、CPU飙高等等。比如说我们也能够取解决这些问题,那就会不断地靠近大佬级别。

那掌握 JVM 能让你获得哪些技能呢?下面我们就来介绍一下JVM:

1.2 JVM 是什么?

JVM,全称 Java Virtual Machine,是 Java 程序的运行环境。

  • 比如说我们自己写的代码想要运行的话,都必须在 JVM 中才能运行。当然严格来说,是 Java 的二进制字节码的运行环境。
  • 我们都知道,Java 代码想要运行的话,就必须先经过编译之后,编译成 .class 文件才能运行。JVM 就是 .class 二进制字节码的运行环境。

JVM 的好处一:一次编写,到处运行。

我想你肯定是听说过这句话的,为什么我们的 Java 代码可以做到一次编写到处运行呢?大家看下面这张图:

  • 首先,最底层的是一个计算机的硬件,比如:CPU、内存;
  • 硬盘上面是操作系统,比如:Windows系统/Linux系统;
  • 然后在系统上面有 JVM 这个软件,也就是说 JVM 是运行在操作系统中的。

我们平时都说 Java 是一个跨平台的语言,它是怎么跨平台呢?就是因为 JVM 给我们屏蔽了操作系统的差异。别管是在 Windows 或者是 Linux,真正运行代码的并不是这些系统,而是我们的 JVM。所以说才能做到一次编写到处运行。

JVM 的好处二:自动内存管理,垃圾回收机制。

说到这里,一般会跟 C语言进行对比,C语言需要程序员自己去管理内存,如果程序员由于编码不当,很容易造成内存泄露的问题。而 Java 虚拟机的垃圾回收功能就大大减轻了程序员的负担,减少了程序员出错的机会。

直到了这两个 JVM 的好处之后,我们再来看一看 JVM 的组成,了解一下 JVM 是如何工作的。


二、JVM 组成

2.1 程序计数器

程序计数器属于 “运行数据区” 的一部分,这里面有一个组件叫做 PC Register,其实就是程序计数器,它到底什么意思呢?

  • 程序计数器:是线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。

首先,线程私有的有没有线程安全的问题呢?肯定是没有的。后面的话不太好理解,我举个例子,我们知道 Java 代码想要运行的话,先把 Java 的源码编译为 class 字节码文件,在字节码文件中详细说明了代码的执行过程。

在这里插入图片描述

我们举一个具体的例子,现在我们想要去查看 class 字节码的信息,我们可以通过 javap 命令来查看字节码的反汇编信息,它就详细记录了字节码的执行过程。

# 打印堆栈大小,局部变量的数量和方法的参数
java -v xx.class

我们新建一个 Java 文件,内容如下:

public class Application {
	public static void main(String[] args) {
		System.out.println("hello world");
	}
}

这里面有一个 main() 方法,打印了一个 “Hello world”,但是这些在字节码中是怎么执行的呢?

我们在文件所在目录的命令行中,先使用 javac 命令进行编译:

javac Application.java

编译结果:

在这里插入图片描述

我们再使用 javap 命令进行分析:

javap -v Application.class

输出内容如下:

Classfile /D:/test/Application.class
  Last modified 2024-4-6; size 440 bytes
  MD5 checksum d8a7300dcbdbc1a7c3b962f3f6420821
  Compiled from "Application.java"
public class com.demo.jvm.Application
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // hello world
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // com/demo/jvm/Application
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Application.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               hello world
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               com/demo/jvm/Application
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  public com.demo.jvm.Application();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "Application.java"

文件内容比较多,我们主要看一下 main() 方法的部分:

在这里插入图片描述

我们可以看到,虽然在源码中,main() 方法只有一行代码,打印了一下 “Hello world”,但是在 class 字节码中拆成了多行去执行。注意看 Code 中,这里面会有代码的指令地址,也可以理解为代码的执行行号,是 0、3、5、8,我们可以针对这每一行做一个简单的分析:

  1. getstatic:获取一个静态的变量,这里主要指 System.out 中的 out 是静态变量。后面的注释中也说明了,out 是一个 Print Stream。
  2. ldc:意思是加载一个常量,这里的常量指的是 String 字符串类型的 “Hello world”。
  3. invokevirtual:表示要调用一个方法,这里指 println() 方法。
  4. return:最后一行,意思就是结束了这个方法。

虽然在 Java 代码中只有一行代码,但是在 二进制字节码 中就变成了多行 ,它的执行顺序就是代码的执行行号。

假如说,有多个线程来执行这段代码,我们的程序计数器就是用来给每一个线程去记录这个行号的。如下图所示:

在这里插入图片描述

  • 线程1从位置0开始执行,当执行到位置10的时候,CPU的时间片被线程2夺走了,目前线程1没有执行权了,所以为了再次获取执行权的时候可以继续执行,它要记录一下位置,目前执行到了第10行。
  • 线程2也是从位置0开始执行,它一直执行到了第9行,然后线程2的时间片被线程1夺走了。

在这里插入图片描述

  • 线程1记录了刚才执行到了第10行,重新获取到时间片之后,继续从第10行开始执行就行了。

这样我们就感受到了 程序计数器 的作用,每个线程都有这么一个程序计数器,主要记录当前每个线程执行的代码行号。

2.2 Java堆

是一块 线程共享的区域,主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出 OutOfMemoryError 异常,也就是内存溢出。

注意:堆作为线程共享的区域,肯定会存在线程安全问题的。

1)JVM 内存结构

下面我们来看一下 Java堆 中的结构是怎样的,大家来看这个图:

这个就是运行数据区了,这里面就包含了:

  • 虚拟机栈、本地方法栈、程序记录器,这些我们介绍过了。
  • 这里面还有一个 本地内存,这里就包含了 直接内存元空间,元空间就是之前的方法区。
  • 最右边的就是 了。

我们重点来看一下堆里面的内容,可以看到它是分了两部分:

  • 年轻代:被划分为三部分:Eden区和两个大小严格相同的 Survivor区,也叫幸存者区(S0、S1)。

    根据 JVM 的策略,一个对象实例化后会先到 Eden区,假如对象在垃圾回收之后还存活,他就会被复制移动到 S0 或者 S1。假如在经过几次垃圾回收之后,对象依然存活于 Survivor区,它就会被放到老年代

  • 老年代:主要指的是生命周期比较长的对象,一般是一些老的对象。

关于对象的具体挪动规则,我们后面介绍垃圾回收的时候会再详细说明。

这里还有一个内容,我们要再介绍一下,就是元空间:

  • 元空间 的主要作用是 用来保存类的信息,静态变量、常量,还有编译后的代码

其实,在 Java8 之前,堆中有一个叫做 永久代 的东西,它跟元空间的作用是一样的。这时候面试官可能会再问这么个问题,说 Java 的 1.7 和 1.8,它们的堆的区别是什么?

2)Java 1.7 和 1.8 中堆的区别

大家来看图:

在这里插入图片描述

左边的是 Java7 的内存结构,右边是 Java 8 的内存结构,我们会发现 Java7 中的堆有一个叫部分做 “方法区/永久代”,但是在 Java8 中并没有。是这样的,到了 Java8 版本后,JVM 把 当前的 “方法去/永久代” 放到了本地内存,也就是元空间中。

为什么要放到本地内存呢?是这样的,因为元空间或者说方法区中主要存储的是一些类或者常量,那么项目随着动态类加载的情况会越来越多,那么这块内存就会变得不可控:

  • 如果内存分配小了,系统运行的过程中就会容易出现内存溢出。
  • 如果内存分配大了,又会导致浪费内存。

所以说 Java8 之后就做了优化,现在都放到了本地内存,就是为了能够让堆去节省空间,防止内存溢出。其实我们最终的目的都是为了避免 OOM,防止内存溢出。

Java 1.7 和 Java 1.8 中堆区别-总结:

  • 1.7 中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码。
  • 1.8 移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出。

2.3 Java虚拟机栈

1)虚拟机栈 和 栈帧

Java虚拟机栈:英文是 Java Virtual machine Stacks,每个线程运行时所需要的内存,称为虚拟机栈,它的特点就是先进后出

  • 每个线程运行的时候都会创建虚拟机栈,所以栈内存也是线程安全的。
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所需要的数据,或者说占用的内存。

如上图所示,栈帧里面就包含了方法的参数、局部变量、返回地址。如果当前方法调用了其他方法,就会对应有其他的栈帧。但是:

  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

假如栈帧1调用了栈帧2,栈帧2又调用了栈帧3,就会逐个进行压栈操作,最终如下所示:

当方法执行完毕之后,先是栈帧3弹栈,就会释放栈帧3的内存,其次是栈帧2,最后才是栈帧1,最终操作结果如下所示:

现在我们熟悉了虚拟机栈以后,我们来回答几个面试题:

2)常见面试题

1.垃圾回收是否涉及栈内存?

垃圾回收主要指的是堆内存,当栈帧弹栈以后,内存就会释放,这里并不需要垃圾回收器去回收。

2.栈帧内存分配的越大越好吗?

未必,默认的栈帧内存通常为 1024KB,即 1MB。栈帧过大会导致线程数变少。

例如:机器总内存为 512MB,目前能活动的线程数则为 512 个,如果栈内存改为 2048KB,那么能活动的栈帧就会减半。

3.方法内的局部变量是否线程安全?

  • 如果方法内局部变量没有逃离方法的作用范围,那么它是线程安全的。
  • 如果是局部变量引用了对象,并逃离方法的作用范围,则需要考虑线程安全。

(如果局部变量逃逸了,线程就不安全。)

举个例子:如下图所示,分别观察 m1()、m2()、m3() 中的 sb 变量是否存在线程安全问题?

  • m1()方法: m1() 方法中的 sb 就是一个局部变量,然后在这里面添加了两个数据,1和2,最终打印了一下当前的数据就结束了。这种情况下,局部变量 sb 就是线程安全的。因为在 m1() 中,对于局部变量 sb 来说,每个线程来了以后,都会创建这么一个栈帧,那每个栈帧都会有这样一个局部变量 sb。即使我们操作了成千上万次,它也会去创建成千上万次,也就是说对于每个线程来说,局部变量 sb 都是独有的,所以说并没有线程安全问题
  • m2()方法: m2() 方法中有一个行参 StringBuilder,然后往里面添加了1和2两个数据,最终也是打印了一下。它是线程安全的吗?并不是,虽然形参 sb 也是一个局部变量,但是在这个参数传递的过程当中,有可能会被其他线程调用,比如 main() 方法中就开启了一个新的线程来去调用 m2() 方法,同时 main() 方法中也有一个局部变量 sb,也就是说 main() 方法也在操作当前的局部变量,那么 main() 方法所对应的线程和 m2() 方法所对应的线程,多个线程在同时操作局部变量 sb 进行添加数据,两个线程共用了同一个局部变量,所以说不是线程安全的
  • m3()方法: m3() 跟 m2() 的情况是一样的,它也不是线程安全的,它虽然没有记录形参,但是它会把局部变量进行返回,那么这个局部变量也有可能被其他线程公用,比如我们可以在 main() 方法中去调用 m3() 方法,得到局部变量 sb,然后在 main() 方法中开启多个线程同时去操作这个变量,那它也就成了多个线程共用的变量,那也就线程不安全了

4.栈内存溢出有哪些情况?

  • 栈帧过多导致栈内存溢出。

    典型问题:递归调用,如下所示:

  • 栈帧过大导致栈内存溢出。

    一个栈帧默认有 1MB 的内存,一个虚拟机栈一般不会出现超过 1MB 的内存,所以这个情况出现少一些。

5.堆和栈的区别是什么?

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储 Java 对象和数组的。堆会GC垃圾回收,而栈不会。

  • 栈内存是线程私有的,而堆内存是线程共享的,要考虑线程安全的问题。

  • 两者异常错误不通,但如果栈内存或者堆内存不足都会抛出异常:

    栈空间不足:java.lang.StackOverFlowError。

    堆空间不足:java.lang.OutOfMemoryError。

2.4 方法区/元空间

1)方法区/元空间的介绍

首先我们来看一下方法区所在的位置,在图中可以看到,方法区属于运行数据区的一部分。

下面是关于方法区的介绍:

  • 方法区(Method Area) 是各个线程共享的内存区域(跟我们之前讲过的堆空间是一样的)。

  • 元空间默认空间大小是21M,如果空间不足会触发 Full GC,然后扩容。

  • 主要存储类的信息、运行时常量池。

  • 方法区是在虚拟机启动的时候创建,关闭虚拟机时释放元空间的内存。

  • 如果方法区域中的内存无法满足分配请求,则会抛出 OutOfMemoryError: Metaspace。

    (有时也会明确提示元空间太小了)

大家看下面的图:

方法区逻辑上是属于堆的一部分,但是不同的厂商存储的位置不太一样,我们目前都是用的 Oracle 提供的 HotSpot 编译器。在 JDK8 之前,方法区是存储在堆中一个叫永久代的存储区域中,但是在 JDK8 之后把永久代给移除了,换了一种实现,这种实现就叫元空间(Metaspace)

  • 元空间就不在堆内存中了,它用的是本地内存,也就是操作系统的内存。为什么要挪动到这里呢,就是为了避免OOM。

下面我们来看元空间存储了哪些内容:

  • Class: 这个就是类的信息,包含了:类的结构、方法、字段等。
  • Classloader: 这个是加载类的。
  • 运行常量池: 后面我们会专门介绍。
2)复现元空间不足的场景

首先,我们先准备这样一个类,实现生成 1w 个类信息,看看它会不会出问题:

package com.demo.test;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
 * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
 * -XX:MaxMetaspaceSize=8m
 */
public class MetaspaceDemo extends ClassLoader {

    public static void main(String[] args) {
        MetaspaceDemo demo = new MetaspaceDemo();
        for (int i = 0; i < 10000; i++) {
            // ClassWriter 作用是生成类的二进制字节码
            ClassWriter cw = new ClassWriter(0);
            // 入参:版本号,public,类名,包名,父类,接口
            cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
            // 返回 byte[]
            byte[] bytes = cw.toByteArray();
            // 执行了类的加载
            demo.defineClass("Class" + i, bytes, 0, bytes.length); // Class 对象
        }
    }
}

如果是正常执行,不会有任何报错:

在这里插入图片描述

我们需要在启动命令中限制一下元空间的大小,在IDEA中编辑启动配置,选择 “Add VM options”。

-XX:MaxMetaspaceSize=8m 拷贝到输入框中,点击 “Apply”。

再次启动,我们就可以看到如下报错:

  • Exception in thread “main” java.lang.OutOfMemoryError: Metaspace

在这里插入图片描述

3)常量池

常量池 可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

我们还是可以通过 javap 命令来查看字节码的结构,包含了三部分内容:类的基本信息、常量池、方法定义。

我们还是找到之前用过的一个 Application 类进行演示:

package com.demo.jvm;

public class Application {
	public static void main(String[] args) {
		System.out.println("hello world");
	}
}

先使用 javac 命令进行编译:

javac Application.java

然后使用 javap 命令来查看常量池:

javap -v Application.class

完整的执行结果如下所示:

Classfile /D:/test/Application.class
  Last modified 2024-4-6; size 440 bytes
  MD5 checksum d8a7300dcbdbc1a7c3b962f3f6420821
  Compiled from "Application.java"
public class com.demo.jvm.Application
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // hello world
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // com/demo/jvm/Application
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Application.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               hello world
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               com/demo/jvm/Application
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  public com.demo.jvm.Application();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "Application.java"

首先,第一部分是类的基本信息介绍。

在这里插入图片描述

下面这部分就是常量池了:

在这里插入图片描述

再往下就有一些方法的定义,首先第一个是默认的无参构造函数,其次是 main() 方法。

在这里插入图片描述

对应关系如下所示:

在这里插入图片描述

4)运行时常量池
  • 常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会被放入 运行时常量池,并把里面的 符号地址变为真实地址。我们前面提到的 #1、#2、#3 就是符号地址。

在这里插入图片描述

2.5 直接内存

直接内存:并不属于 JVM 中的内存结构,不由 JVM 进行管理,属于虚拟机所在操作系统的内存。常见于 NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高

  • 补充:我们平时的 IO 叫做 BIO,NIO 要比 BIO 的吞吐量高很多。
1)常规IO 和 NIO 性能对比

举个例子:比如说我们要用 Java 代码完成一次文件的拷贝,将文件从 E:/bak1/ 复制到 E:/bak2/ 中,如下所示:

在这里插入图片描述

这样我们有两种实现方式:常规IO,或者 NIO。下面我们我们就用这两种方式来实现一下,对比一下它们的不同。

实现代码如下:

package com.demo.test;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * 演示 ByteBuffer 作用
 */
public class DirectMemoryDemo {
    public static final String FROM = "E:\\bak1\\01-java成神之路.mp4";
    public static final String TO = "E:\\bak2\\abc.mp4";
    public static final int _1Mb = 1024 * 1024;

    public static void main(String[] args) {
        io(); // 659.7956 ms
        directBuffer(); // 370.8198 ms
    }

    /**
     * 常规IO
     */
    private static void io() {
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1Mb];
            while (true) {
                int len = from.read(buf);
                if (len == -1) {
                    break;
                }
                to.write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("io 用时:" + (end - start) / 1000_000.0 + " ms");
    }

    /**
     * NIO
     */
    private static void directBuffer() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Mb);
            while (true) {
                int len = from.read(byteBuffer);
                if (len == -1) {
                    break;
                }
                byteBuffer.flip(); // 切换到读模式
                to.write(byteBuffer);
                byteBuffer.clear(); // 切换到写模式
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0 + " ms");
    }
}

文件大小为:325MB

执行结果如下所示:

在这里插入图片描述

可以看到差距还是很明显的,NIO 几乎比常规IO 快了一倍,NIO 的读写效率更高一些。为什么 NIO 的读写效率更高呢?下面我们就一起来分析一下。其实主要还是跟 “直接内存” 有很大关系。

2)常规IO 和 NIO 分析

我们先来看一下常规IO 是怎么操作的,我们先来看个图:

在这里插入图片描述

这个就是常规IO 数据的操作流程。

  • 首先我们要知道,Java 本身并不支持磁盘读写的能力,它要调用磁盘读写的话必须调用我们操作系统提供的函数,这里就是调用本地的(native)方法。我们之前说过 native 修饰的方法都是操作系统提供的方法,Java 就是使用 native 修饰的方法来去操作磁盘文件。
  • 这里就涉及到了 CPU 的运行状态:用户态、内核态。
    • 我们首先会从 Java 的用户态切换到内核态,这个就是一个 CPU 状态的改变。但其实内存这一块儿也会有一些相关的操作:
      1. 当切换到内核态之后,这时候就由本地的函数去读取磁盘中的文件,读取到之后,会在操作系统中划出一块儿缓冲区,我们称之为 “系统缓冲区”。磁盘内容就会先读入到系统缓冲区中,它不可能把一个 300MB 的文件一次性读取到内存中,那样的话内存太紧张了,所以说它会利用缓冲区分批次地去读取。
      2. 这里要注意,这个 “系统缓冲区” 我们 Java 代码是不能够运行的。所以说 Java 会在堆中分配一块儿内存,Java 的缓冲区对应我们代码中的 new byte[]我们 Java 代码要想访问刚才读到的文件流数据,必须要从系统的缓冲区间接地读取到 Java 的缓冲区中。
      3. 读入到 Java 缓冲区之后,进程就会进入到了下一个状态,我们再去调用输出流的写入操作,这样反复进行读取,我们的文件就能复制到目标的位置。

这里我们也发现了问题所在了:由于我们有两块缓冲区,也就是两块内存:一个是系统提供的系统缓冲区,第二个是 Java 中有一个 Java 的缓冲区。读取数据的时候就必然会涉及到数据要去存两份的问题,第一次先读取到系统中去,第二次才能读取到 Java 的缓冲区。因为我们 Java 代码本身是访问不到系统缓冲区的,我们必须要把它读取到 Java 缓冲区之后,才能对它进行操作。这里就造成了一次不必要的数据复制,因此效率就不是很高。

以上就是 常规IO 的操作,下面我们介绍一下 NIO 是怎么做的:

在这里插入图片描述

这里面就用到了直接内存,也就是说在操作系统中划出了一块儿缓冲区,这块缓冲区和 常规IO 不一样的地方在于操作系统划分的内存,Java代码是可以访问的!换句话说,这块儿内存,系统可以访问它,Java代码也能够访问它。它是两端代码都可以共享的内存区域,这就是 直接内存

加入了直接内存之后,大家可以很明显地看出来,磁盘文件在读取的时候,Java代码操作起来就非常方便了。其实就是比我们刚才的代码少了一次缓冲区的复制操作,所以这个速度就得到了成倍的提升。这就是直接内存给我们带来的好处,它确实比较适合这种文件的IO操作。

总结一下直接内存:

  • 直接内存并不属于 JVM 的内存结构,不由 JVM 进行管理,是虚拟机所在的操作系统内存。
  • 直接内存常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理。

整理完毕,完结撒花~🌻





参考地址:

1.新版Java面试专题视频教程,java八股文面试全套真题+深度详解(含大厂高频面试真题),https://www.bilibili.com/video/BV1yT411H7YK

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐