目录
- 一、JVM 介绍
- 1.1 为什么要学 JVM?
- 1.2 JVM 是什么?
- 二、JVM 组成
- 2.1 程序计数器
- 2.2 Java堆
- 1)JVM 内存结构
- 2)Java 1.7 和 1.8 中堆的区别
- 2.3 Java虚拟机栈
- 1)虚拟机栈 和 栈帧
- 2)常见面试题
- 2.4 方法区/元空间
- 1)方法区/元空间的介绍
- 2)复现元空间不足的场景
- 3)常量池
- 4)运行时常量池
- 2.5 直接内存
- 1)常规IO 和 NIO 性能对比
- 2)常规IO 和 NIO 分析
一、JVM 介绍
1.1 为什么要学 JVM?
- 应对面试: 如果说在面试的时候,我们连 JVM 的知识都不了解的话,面试官对我们的印象将会大打折扣。
- 中高级程序员必备技能: 如果说只满足于一个初级程序员,OK,根本不需要了解 JVM。它和我们平时开发没啥关系,但是如果你是 一个有追求的程序员,想在这个行业长期发展的话,也期望从一个小白升级为大牛的话,掌握 JVM 就至关重要了。
- 深入理解 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,我们可以针对这每一行做一个简单的分析:
getstatic
:获取一个静态的变量,这里主要指 System.out 中的 out 是静态变量。后面的注释中也说明了,out 是一个 Print Stream。ldc
:意思是加载一个常量,这里的常量指的是 String 字符串类型的 “Hello world”。invokevirtual
:表示要调用一个方法,这里指 println() 方法。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)
是各个线程共享的内存区域(跟我们之前讲过的堆空间是一样的)。 -
主要存储类的信息、运行时常量池。
-
方法区是在虚拟机启动的时候创建,关闭虚拟机时释放元空间的内存。
-
如果方法区域中的内存无法满足分配请求,则会抛出 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 状态的改变。但其实内存这一块儿也会有一些相关的操作:
- 当切换到内核态之后,这时候就由本地的函数去读取磁盘中的文件,读取到之后,会在操作系统中划出一块儿缓冲区,我们称之为 “系统缓冲区”。磁盘内容就会先读入到系统缓冲区中,它不可能把一个 300MB 的文件一次性读取到内存中,那样的话内存太紧张了,所以说它会利用缓冲区分批次地去读取。
- 这里要注意,这个 “系统缓冲区” 我们 Java 代码是不能够运行的。所以说 Java 会在堆中分配一块儿内存,Java 的缓冲区对应我们代码中的
new byte[]
。我们 Java 代码要想访问刚才读到的文件流数据,必须要从系统的缓冲区间接地读取到 Java 的缓冲区中。 - 读入到 Java 缓冲区之后,进程就会进入到了下一个状态,我们再去调用输出流的写入操作,这样反复进行读取,我们的文件就能复制到目标的位置。
- 我们首先会从 Java 的用户态切换到内核态,这个就是一个 CPU 状态的改变。但其实内存这一块儿也会有一些相关的操作:
这里我们也发现了问题所在了:由于我们有两块缓冲区,也就是两块内存:一个是系统提供的系统缓冲区,第二个是 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