Java 是如何实现跨平台的?

所谓的跨平台主要指的是在不同的硬件或操作系统上,Java 代码都可以运行,不需要针对不同平台做对应的修改。

之所以能实现一次编写到处运行,主要靠的是 JVM,也就是 Java 虚拟机。

我们编写的 Java 代码会被编译成 .class 文件这个过程想必大家都了解。

但是机器最终只认识 0101 这种二进制指令,不论在什么 x86 还是 arm,windows 还是 linux 机器都只认二进制。

把 .class 转换成对应硬件和操作系统认可的二进制指令就是 JVM 的工作。

编译执行与解释执行的区别是什么?JVM 使用哪种方式?

编译执行是将源代码一次性编译成机器码(目标代码),然后直接执行机器码;

而解释执行是将源代码逐行解释,每解释一行就立即翻译成机器码并执行。

编译执行的程序运行速度通常更快,因为它不需要在运行时进行翻译工作;而解释执行由于需要边解释边执行,运行效率相对较低。

JVM 是哪个呢?都有!

正常情况下 JVM 是解释执行,但是如果 JVM 发现这段逻辑执行特别频繁,是个热点代码,那么就会把它就会通过 JIT (JUST IN TIME) 即时编译将其直接编译成机器码,这样就是编译执行了。

什么是 Java 中的 JIT(Just-In-Time)?

Java 中的 JIT(Just-In-Time,即时编译)编译器是一种在程序运行时将字节码转换为机器码的技术。因为这种转换是在程序运行时即时进行的,因此得名“Just-In-Time”。

它在 Java 程序运行的时候,发现热点代码(频繁执行的代码段)时,就将这段代码编译成机器码,减少解释执行的开销,使得 Java 代码接近本地代码的性能。

热点代码(Hotspot Code)

JIT 编译器重点优化“热点代码”,即被多次调用或循环执行的代码。通过分析代码执行频率,JIT 能识别这些热点并进行优化编译。

这里的优化编译采用了多种技术:如方法内联(Inlining)、逃逸分析(Escape Analysis)、循环展开(Loop Unrolling)等,使得编译后的机器码更加高效。

什么是 Java 的 AOT(Ahead-Of-Time)?

Java 的 AOT(Ahead-Of-Time,预编译)是一种在程序运行之前,将 Java 字节码直接编译为本地机器码的技术。

JIT 是在 Java 运行时将一些代码编译成机器码,而 AOT 则是在代码运行之前就编译成机器吗,也就是提前编译。

提前编译的好处是减少运行时编译的开销,且减少程序启动所需的编译时间,提高启动速度。

AOT 的工作原理

AOT 编译是在构建阶段对 Java 字节码进行静态分析,并将其编译为目标平台的机器码。编译后的代码可以直接运行在目标硬件上,无需在运行时通过 JVM 进行解释或即时编译。

AOT 的优点

  • 快速启动:由于代码已经编译为本地机器码,AOT 减少了程序启动时的编译开销,适合需要快速启动的应用场景。
  • 更小的内存占用:在不需要 JIT 编译器的情况下,AOT 编译减少了 JVM 的内存占用。

AOT 的缺点

缺乏运行时优化:AOT 编译器无法像 JIT 编译器那样利用运行时的动态信息进行深度优化,可能导致在长时间运行的应用程序中性能低于 JIT。

平台依赖性:AOT 编译出的机器码是针对特定平台的,缺乏跨平台的灵活性

使用场景

AOT 主要用于要求快速启动的应用程序,如微服务、容器化应用、嵌入式系统,以及对启动性能比较敏感的场景。

JVM 的内存区域是如何划分的?

Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。

1)程序计数器

作为当前线程执行字节码的行号指示器,简单理解就是标记执行到第一行了,每个线程都有自己的程序计数器。

2)虚拟机栈

每个线程执行时在虚拟机栈中都会有自己的栈帧,存储局部变量、方法出口、操作数栈等信息,在方法调用栈帧入栈,方法返回,栈帧出栈。

3)本地方法栈

与虚拟机栈类似,它是用于本地方法的调用,即 native 方法。

4)堆

堆主要存放的就是平时 new 的对象实例和数组,按垃圾回收划分,堆可以分为新生代、老年代、永久代(Java 8 后被元空间取代,不在堆内了)。

5)方法区

方法区主要存储类结构、常量、静态变量、即时编译(JIT)后的代码等信息,Java 8 后存在元空间(元空间可以认为是方法区的一个实现),存储在堆外内存中。

程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要管理。

而堆和方法区是线程共享的,所以垃圾回收器会关注这两个地方。

Java 中堆和栈的区别是什么?

栈(Stack):主要用于存储局部变量和方法的调用信息(如返回地址、参数等)。在方法执行期间,局部变量(包括引用变量,但不包括它们引用的对象)被创建在栈上,并在方法结束时被销毁。

堆(Heap):用于存储对象实例和数组。每当使用 new 关键字创建对象时,JVM 都会在堆上为该对象分配内存空间。

从其他方面进一步区分:

  • 生命周期:我们知道JVM里面的垃圾回收主要是对堆空间的处理,而栈空间是不会被回收的,所以栈空间的生命周期都非常的短,比如一次方法的调用,调用的时候存入,执行完成就被弹出释放。而堆空间是需要通过GC进行回收的。所以堆空间的数据生命周期会相对较长!
  • 空间大小:栈的空间大小都是固定的,根据操作系统决定,如果是64位的则大小为8个字节。但是堆的空间大小并不确定,根据对象的大小进行一个划分

特别注意,如果定义的变量是一个基本数据类型,比如int a = 10;这个时候并不会分配堆内存,10 会直接存在栈空间。

如果是引用数据类型,比如A a = new A();这种 a 分配到栈空间是一个地址,指向堆中的实例化的A。

如果 A 中定义了一个属性B b = new B();这个b并不会存在栈空间,而是直接放在堆空间,存储的是事例化的B的地址!

什么是 Java 中的直接内存?

我们启动 JVM 的时候都会设置堆的大小,而直接内存占用的是堆外的内存,它不属于堆

理论上我们在 Java 中想要操作堆外的内存,需要将其拷贝到堆内,因此 Java 弄了个 Direct Memory,它允许 Java 访问原生内存(非堆内内存),这样就减少了堆外到堆内的这次拷贝,提升 I/O 效率,在文件读写和网络传输场景直接内存有很大的优势。

不过堆外内存不归 JVM 设置的堆大小限制(在 JVM 中可以利用 -XX:MaxDirectMemorySize 参数设置直接内存的最大值),且不受垃圾回收器管理,因此在使用上需要注意直接内存的释放,防止内存泄漏

在 Java 中可以利用 Unsafe 类和 NIO 类库使用直接内存。

例如利用 NIO 的 ByteBuffer.allocateDirect(1024) 即可分配得到一个直接内存。

简单示例如下:

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class DirectMemoryExample {
public static void main(String[] args) {
// 分配直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

// 写入数据
directBuffer.put("Hello, 面试鸭 Direct Memory!".getBytes());

// 切换为读模式
directBuffer.flip();

// 读取数据
byte[] bytes = new byte[directBuffer.remaining()];
directBuffer.get(bytes);

// 打印结果
String retrievedData = new String(bytes, StandardCharsets.UTF_8);
System.out.println(retrievedData);

// 手动释放直接内存
((sun.nio.ch.DirectBuffer)directBuffer).cleaner().clean();
}
}

注意最后一行释放内存的 cleaner 。因为垃圾回收器无法直接管理堆外内存,所以 JVM 在创建 ByteBuffer 的时候,在堆内存储了这个对象的指针,然后注册了一个关联的 cleaner(清理器)。而这个 cleaner 是个虚引用。

如果 JVM 检测到没有对象关联 ByteBuffer,说明这个堆外内存已经成为了垃圾,此时 ByteBuffer 会被回收,然后 cleaner 会被加入到引用队列中,之后会就会被触发其 clean 接口,然后清理堆外内存。

什么是 Java 中的常量池?

常量池其实就是方法区的一部分,全称应该是运行时常量池(runtime constant pool),主要用于存储字面量和符号引用等编译期产生的一些常量数据。

比如一些字符串、整数、浮点数都是字面量,源代码中一个写了一个固定的值的都叫字面量。

比如你代码写了一个 String s = 'aa'; 那么 aa 就是字面量,存储在常量池当中。

符号引用指的是字段的名称、接口全限定名等等,这些都算符号引用。

常量池的好处是减少内存的消耗,比如同样的字符串,常量池仅需存储一份。

且常理池在类加载后就已经准备好了,这样程序运行时可以快速的访问这些数据,提升运行时的效率。

不过在 Java 1.7 的时候,HotSpot 将字符串从运行时常量池(方法区内)中剥离出来,搞了个字符串常量池存储在堆内,因为字符串对象也经常需要被回收,因此放置到堆中好管理回收。

你了解 Java 的类加载器吗?

我们平常写的代码是保存在一个 .java文件里面,经过编译会生成.class文件,这个文件存储的就是字节码,如果要用上我们的代码,那就必须把它加载到 JVM 中。

而类加载的步骤主要分为:加载、链接、初始化

加载

加载阶段,需要用到类加载器来将 class 文件里面的内容加载到 JVM 中生成类对象。

JDK8 的时候一共有三种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,用 C++ 实现的(JDK9 后用 java 实现),主要负责加载<JAVA_HOME>\lib目录中或被 -Xbootclasspath 指定的路径中的并且文件名是被虚拟机识别的文件,它是所有类加载器的父亲。
  2. 扩展类加载器(Extension ClassLoader),它是 Java 实现的,独立于虚拟机,主要负责加载<JAVA_HOME>\lib\ext目录中或被 java.ext.dirs 系统变量所指定的路径的类库。
  3. 应用程序类加载器(Application ClassLoader),它是 Java 实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这个加载器就是我们程序中的默认加载器。

img

在 JDK9 之后,类加载器进行了一些修改,主要是因为 JDK9 引入了模块化,即 Jigsaw,原来的 rt.jar、tool.jar 等都被拆成了数十个 jmod 文件,已满足可扩展需求,无需保留 <JAVA_HOME>\lib\ext ,所以扩展类加载器也被重命名为平台类加载器(PlatformClassLoader),主要加载被 module-info.java 中定义的类。

且双亲委派的路径也做了一定的变化:

在平台和应用类加载器受到加载请求时,会先判断该类是否属于一个系统模块,如果属于则委派给对应的模块类加载器加载,反之才委派给父类加载器。

双亲委派

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。

下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。

类加载器层次关系图

  • ClassLoader 类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

注意 ⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法。

其实这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好。

另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

双亲委派模型的好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。

如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoaderBootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

Java 中的强引用、软引用、弱引用和虚引用分别是什么?

Java 根据其生命周期的长短将引用类型又分为强引用、软引用、弱引用、幻象引用。

  • 强引用:就是我们平时 new 一个对象的引用。当 JVM 的内存空间不足时,宁愿抛出 OutOfMemoryError 使得程序异常终止,也不愿意回收具有强引用的存活着的对象。
  • 软引用:生命周期比强引用短,当 JVM 认为内存空间不足时,会试图回收软引用指向的对象,也就是说在 JVM 抛出 OutOfMemoryError 之前,会去清理软引用对象,适合用在内存敏感的场景。
  • 弱引用:比软引用还短,在 GC 的时候,不管内存空间足不足都会回收这个对象,ThreadLocal中的 key 就用到了弱引用,适合用在内存敏感的场景。
  • 虚引用:也称幻象引用,之所以这样叫是因为虚引用的 get 永远都是 null ,称为 get 了个空虚,所以叫虚。

虚引用的唯一作用就是配合引用队列来监控引用的对象是否被加入到引用队列中,也就是可以准确的让我们知晓对象何时被回收。