最近又倦怠于项目了,想着来看看面经吧,好歹对自己能说的过去。发现不同渠道来的面经,内容却很相似,就总结一下吧。
IO、多线程、反射授课文档
IO流、多线程、反射
IO流
Java 中的流(Stream)、文件(File)和 IO(输入输出)是处理数据读取和写入的基础设施,它们允许程序与外部数据(如文件、网络、系统输入等)进行交互。
java.io 包是 Java 标准库中的一个核心包,提供了用于系统输入和输出的类,它包含了处理数据流(字节流和字符流)、文件读写、序列化以及数据格式化的工具。
java.io 是处理文件操作、流操作以及低级别 IO 操作的基础包。
java.io 包中的流支持很多种格式,比如:基本类型、对象、本地化字符集等等。
一个流可以理解为一个数据的序列。输入流表示从一个源读取数据,输出流表示向一个目标写数据。

文件IO
1 | // 字节流 - 适合图片、视频等 |
网络IO
1 | URI uri = new URI("https://www.baidu.com"); |
缓冲器流
1 | // 缓冲字节流 - 性能更好 |
装饰器模式
装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。
装饰器模式通过将对象包装在装饰器类中,以便动态地修改其行为。
这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能。
使用场景
- 当需要在不增加大量子类的情况下扩展类的功能。
- 当需要动态地添加或撤销对象的功能。
1 | // 核心思想:装饰类和被装饰类实现相同的接口 |
装饰器的优点:
增强功能:在不修改原有类的基础上添加新功能
灵活组合:可以多层嵌套,按需组合功能
统一接口:所有装饰流都继承自相同的基类
透明性:使用时与普通流没有区别
多线程
Java 给多线程编程提供了内置的支持。
进程(Process)
一个进程包括由操作系统分配的内存空间,包含一个或多个线程。一个线程不能独立的存在,它必须是进程的一部分。一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。
线程(Thread)
线程是进程中的⼀个实体,是被系统独⽴调度和分派的基本单位。⼀个进程可以由多个线程组成,它们共享进程的内存空间和资源,但每个线程拥有⾃⼰的执⾏堆栈和程序计数器。
实现
Java 提供了三种创建线程的方法:
通过实现 Runnable 接口;
创建一个线程,最简单的方法是创建一个实现 Runnable 接口的类。为了实现 Runnable,一个类只需要执行一个方法调用 run()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42class RunnableDemo implements Runnable {
private Thread t;
private final String threadName;
RunnableDemo(String name) {
threadName = name;
System.out.println("Creating " + threadName);
}
public void run() {
System.out.println("Running " + threadName);
try {
for (int i = 4; i > 0; i--) {
System.out.println("Thread: " + threadName + ", " + i);
// 让线程睡眠一会
Thread.sleep(50);
}
} catch (InterruptedException e) {
System.out.println("Thread " + threadName + " interrupted.");
}
System.out.println("Thread " + threadName + " exiting.");
}
public void start() {
System.out.println("Starting " + threadName);
if (t == null) {
t = new Thread(this, threadName);
t.start();
}
}
}
public class TestThread1 {
public static void main(String[] args) {
RunnableDemo R1 = new RunnableDemo("Thread-1");
R1.start();
RunnableDemo R2 = new RunnableDemo("Thread-2");
R2.start();
}
}通过继承 Thread 类本身;
创建一个线程的第二种方法是创建一个新的类,该类继承 Thread 类,然后创建一个该类的实例。继承类必须重写 run() 方法,该方法是新线程的入口点。它也必须调用 start() 方法才能执行。该方法尽管被列为一种多线程实现方式,但是本质上也是实现了 Runnable 接口的一个实例。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43class ThreadDemo extends Thread {
private Thread t;
private final String threadName;
ThreadDemo( String name) {
threadName = name;
System.out.println("Creating " + threadName );
}
public void run() {
System.out.println("Running " + threadName );
try {
for(int i = 4; i > 0; i--) {
System.out.println("Thread: " + threadName + ", " + i);
// 让线程睡眠一会
Thread.sleep(50);
}
}catch (InterruptedException e) {
System.out.println("Thread " + threadName + " interrupted.");
}
System.out.println("Thread " + threadName + " exiting.");
}
public void start () {
System.out.println("Starting " + threadName );
if (t == null) {
t = new Thread (this, threadName);
t.start ();
}
}
}
public class TestThread2 {
public static void main(String[] args) {
ThreadDemo T1 = new ThreadDemo( "Thread-1");
T1.start();
ThreadDemo T2 = new ThreadDemo( "Thread-2");
T2.start();
}
}通过 Callable 和 Future 创建线程。
创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法将作为线程执行体,并且有返回值。
创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值。
使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值。
1 | import java.util.concurrent.Callable; |
对于上述三种实现总结:
首选实现Runnable,继承 Thread 几乎没有必要;
直接 new Thread 适合演示与非常小的使用场景;
Callable + Future/FutureTask 提供结果、异常、取消能力,是 Runnable 的升级。
最佳现代方案:ExecutorService + Callable 或 CompletableFuture;
在 JDK 21+,可考虑虚拟线程进一步简化。
线程安全
我们需要多个线程同时访问一个类时,程序仍能正确工作。
1 | // 非线程安全示例 |
考虑⼀个简单的整数加法操作,如 count = count + 1。
尽管这看起来是⼀条简单的语句,但在多数处理器上,这个操作涉及下⾯⼏个步骤:
从内存中读取 count 的当前值。
在处理器中增加该值。
将新值写回内存。如果两个线程同时执⾏这个操作,那么可能出现以下情况:
线程A读取 count 的值为1。
线程B也读取 count 的值为1。
线程A增加值到2,并写回内存。
线程B也增加值到2,并写回内存。
在这种情况下,虽然 count = count + 1 被执⾏了两次,但 count 的值只从1变到2
为了避免这种情况,可以使用Java提供的同步关键字`synchronized,在任何时候最多只能由⼀个线程进⼊,这样就可以保证increment ⽅法在count++ 操作的线程安全性。
1 | public synchronized void increment() { |
另一种写法修饰代码块
1 | public void increment() { |
在规范上:
建议使⽤共享资源作为锁对象
对于实例⽅法建议使⽤
this作为锁对象对于静态⽅法建议使⽤类对象作为锁对象
类名.class
线程池(Executor)
自JDK 1.5开始提供线程池,并且默认提供了4种线程池的实例——Executors提供了静态方法用于生成线程池实例。
线程池:顾名思义,就是一个池子,里面管理着多个线程。你只需要把任务提交给线程池,而无需关心线程的创建和销毁。线程池会复用线程来执行任务。
为什么使用线程池
降低资源消耗:通过重复利用已创建的线程,减少线程创建和销毁造成的消耗。
提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
四种实例
1 | // 1. 固定大小线程池(80%场景用这个) |
提交任务的两种方式
1 | // 方式1:执行无返回值的任务 |
虚拟线程
虚拟线程(Virtual Threads)是Java 19引入的预览特性,并在Java 21中成为正式特性。它们旨在以更低的资源开销实现高并发,特别适用于I/O密集型任务。
1 | // 方式1:直接启动 |
由于虚拟线程非常的轻量,所以无需再通过线程池优化。
异步模型
在实际业务中,其实我们更应该关注异步编程模型,而把多线程更多的看作实现细节。
反射
反射 在 Java 中,反射是⼀种强⼤的机制,它允许程序在运⾏时检查或修改其⾃身⾏为。使⽤反射,程序能够访问类的属性和⽅法,即使在编译时这些类的名称并未明确给出。这使得 Java 程序可以在运⾏时动态地创建对象、调⽤⽅法、改变字段等,即便这些类、⽅法或字段在编写原始代码时不可知。
工作流程
- 获取
Class对象:首先获取目标类的Class对象。 - 获取成员信息:通过
Class对象,可以获取类的字段、方法、构造函数等信息。 - 操作成员:通过反射 API 可以读取和修改字段的值、调用方法以及创建对象。
1 | // 举例:输出⼀个类所有的字段和⽅法 |
注解
注解(Annotations)是从 Java 5 开始引⼊的⼀种元数据形式,它们提供了⼀种为代码添加信息的⽅法,但不直接影响代码的执⾏。注解可以被⽤于类、⽅法、变量、参数和Java包等。使⽤注解的主要⽬的是提供信息给编译器,进⾏代码分析,或者通过运⾏时反射机制实现特定功能。
注解很大程度上需要配合反射才能发挥作用
定义注解
1 | public MyAnnotation { |
使⽤注解
1 |
|
1 | // 举例:定义并读取注解 |
缺点
性能开销:由于反射涉及动态解析的类型,因此⽆法执⾏某些 Java 虚拟机优化。 因此,反射操作的性能要⽐⾮反射操作的性能要差,应该在性能敏感的应⽤程序中频繁调⽤的代码段中避免。
破坏封装性:反射调⽤⽅法时可以忽略权限检查,因此可能会破坏封装性⽽导致安全问题。
内部曝光:由于反射允许代码执⾏在⾮反射代码中⾮法的操作,例如访问私有字段和⽅法,所以反射的使⽤可能会导致意想不到的副作⽤,这可能会导致代码功能失常并可能破坏可移植性
Java网络编程
最近开始给小登授课了才发现有很多内容自己都不是很清楚,于是想着先来学学吧。中学学的”学学半“,至今终于有所体会。
Java网络编程
网络编程,就是在网络通信协议下,在不同设备上运行的不同程序间进行数据传输。
常见的网络架构有:CS架构,Client-Server架构,即客户端-服务器架构。BS架构,Browser-Server架构,即浏览器-服务器架构。笼统来说就是一个要下载客户端,一个在浏览器上输网址。不过无论是哪一种,核心业务逻辑都是差不多的。
网络编程三要素:IP地址(设备在网络中的地址),端口号(应用程序在设备中的唯一标识),协议(数据在网络中传输的规则)。
IP
全称:Internet Protocol,是互联网协议地址,也称IP地址。是分配给上网设备的数字标签。常见有:IPv4、IPv6。
IPv4
全称Internet Protocol Version 4,互联网通信协议第四版。采用32位地址,每段为8位,共4段。可以用点分十进制表示法(每段分别转化成十进制,并用点隔开,如192.168.0.1)表示。
在2019年11月26日,IPv4地址已全部用完。
为了缓解这个问题,就又把IP分为了公网地址和私有地址。198.162.开头的就是私有地址,范围为192.168.0.0-192.168.255.255。
127.0.0.1,也可以是localhost:,是回送地址也称本地回环地址,永远只会寻找本机。
IPv6
为了应对IPv4用完的问题,就引入了IPv6。
IPv6,全称Internet Protocol Version 6,互联网通信协议第六版。采用128位地址,每段为16位,共8段(一共有2^128个)。可以用冒分十六进制表示法(每段分别转化成十六进制,并用冒号隔开,如2001:0db8:85a3:0000:0000:8a2e:0370:7334)表示,且可以省略每段的前导零。目前还没有大面积普及。
端口
端口,是应用程序在设备中的唯一标识。端口号是两个字节表示的整数,取值范围是0-65535。其中0-1023为系统端口,1024-65535为用户端口。一个端口只能被一个程序使用。
协议
计算机网络中,连接和通信的规则被称为网络通信协议。
有两种参考模型:
- OSI参考模型:7层模型,从下到上依次为:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。
- TCP/IP参考模型(或TCP/IP协议):4层模型,从下到上依次为:数据链路+物理层、网络层、传输层、应用层。
其中由于OSI参考模型过于理想化,未能推广,所以TCP/IP参考模型是实际上的国际标准。所以本文主要讨论TCP/IP参考模型。
TCP/IP协议的传输层又有两个应用广泛的协议:TCP和UDP。
UDP
UDP,全称用户数据报协议(User Datagram Protocol),是一种面向无连接的通信协议。主要特点为:速度快,但是大小有限制,且不能保证数据传输成功。
TCP
TCP,全称传输控制协议(Transmission Control Protocol),是一种面向连接的通信协议。主要特点为:速度相对慢,但可靠,大小无限制,且能保证数据传输成功。
Effective Java读书随想1
阔别许久,想起之前定下的一月一博客的目标,可真够宏伟的。理由倒是有很多,但是终归是我懈怠了。但即使这样,我依然不想放弃那个目标,那就从现在开始补起来吧。算上9月、10月欠的,11月要写三篇。11月对我好一点😭。
Effective Java能被誉为Java四大名著之一,看来是有原因的。我本以为它会是一本晦涩难懂的大部头,没想到如此贴近实战,给了我很多的启发,故作读书随想。
类的实例化和非实例化
本文中讨论的对象(Object),实际上就是类的实例。
本书前9节
- 考虑使用静态工厂方法替代构造方法
- 当构造方法参数过多时使用 builder 模式
- 使用私有构造方法或枚举类实现 Singleton 属性
- 使用私有构造方法执行非实例化
- 使用依赖注入取代硬连接资源(hardwiring
resources)- 避免创建不必要的对象
- 消除过期的对象引用
- 避免使用 Finalizer 和 Cleaner 机制
- 使用 try-with-resources 语句替代 try-finally 语句
实际围绕一个主题,类的实例。一个类,总是应当被设计为可实例化和不可实例化的。其中可实例化类又分为对象的创建,对象的回收。
可实例化
对于一个被设计为可实例化的类,应当考虑何种实现是最高效的。
创建对象
创建对象时,除了传统的公共构造方法外,还可以考虑使用静态工厂方法(和设计模式中的工厂方法模式没有关系)。这点实际上是其它一切优化的基础,毕竟直接使用构造函数的限制很多。
Boolean(boolean基本类型的包装类)的简单例子
1 | public static Boolean valueOf(boolean b) { |
优点:
- 静态方法命名自由,可以描述构造行为。
- 静态方法不需要每次调用时都创建一个新对象。
- 静态方法可以返回其返回类型的任何子类型的对象。
- 参数组合清晰,返回对象的类可以根据输入参数的不同而不同。
- 容易版本化,方法可以标记过时然后删除,但构造器不方便。
- 构造器之间互相调用有顺序要求,而静态方法无限制。
缺点:
- 需要调用者知道哪些静态方法是提供构造功能的,建议辅以注释。
- 静态方法不能访问实例变量。
- 可能返回null。
当构造方法参数过多时就可以使用 builder 模式,在实践中往往使用Lombok的@Builder注解实现。
当试图创建一个单例对象时,书上给出了三种方法:
先给出前两种
1 | public class Elvis { |
1 | public class Elvis { |
前两种在系统启动后直接创建实例的方式称为饿汉式,实际上还有一种称为懒汉式。
1 | public class Elvis { |
可以看到这种方式可以在构造方法中实现一些安全措施,防止通过反射创建出对象。
但在这种情况下,如果有多个线程同时获取实例,就会出现问题。再稍作改进
1 | public class Elvis { |
当这个类实现序列化时,会出现新的问题。当对象被序列化和反序列化时,Java不会调用构造函数,而是通过反射机制直接创建对象实例。
补充一下序列化和反序列化的流程
1
2 >ObjectInputStream inputStream = new ObjectInputStream(...);
>Elvis instance = (Elvis) inputStream.readObject(); // 关键调用在 readObject() 方法内部,Java 会:
创建新对象(通过反射,不调用构造函数)
检查是否有 readResolve() 方法
如果有,就用 readResolve() 的返回值替换新创建的对象
此时有两种解决方法:
- 通过实现readResolve方法,返回单例。
1 | private Object readResolve() { |
- 声明单一元素的枚举类。这也是Effective Java中提到的第三种方法。这样的写法或许有些不自然,但确实是最佳方案。
1
2
3
4public enum Elvis {
INSTANCE; // 这实际上是一个 public static final Elvis INSTANCE
public void leaveTheBuilding() { ... }
}
补充说明,事实上在spring框架中,@Autowired注释注入的实例是默认单例的。但是可以通过@Component(scope=”…”)声明别的类型。
既然在讨论创建和销毁对象,那么最性能的方案肯定是少创建、少销毁。
做个实验
1 | String a = new String("a"); |
毫无疑问是false。
但是
1 | String a = "a"; |
这个就是true,说明JVM会帮我们自动做优化。
当然这种错误一般都不会犯,更常见的是自动装箱拆箱机制导致多余实例被创建。
再次做个实验,这是一个计算所有正整数之和的函数
1 | private static long sum() { |
这个程序的结果是正确的,但是由于将变量sum声明为Long而非long,导致程序构造了大约213个不必要的Long实例。个人实验中,两者分别用了481ms和4405ms。
回收对象
作为一个有GC的语言,回收对象应该不用我们操心才对,但在某些情况下还是会有内存泄漏的问题。
要理解这点需要先理解JVM的垃圾回收机制。有空了单独开一篇博客讲讲JVM吧。
主要是在使用容器时(比如Map,List),当不再用到某个索引时,应当将对应引用声明为null,以触发垃圾自动回收。
还有就是当打开某个资源时,例如:
1 | FileInputStream fio = null; |
就容易出现忘记close的情况,哪怕记得也要再写一个try-catch,十分不便。这时就建议使用try-with-resource机制。
1 | try(FileInputStream fio = new FileInputStream(new File("file.txt"))){ |
这种写法中创建的fio对象,会在try-with-resource结束后自动调用close()方法。
第八节避免使用Finalizer和Cleaner机制。从来没用过,甚至没听说过,没啥感觉。
不可实例化
对于一个设计为非实例化的类,即使在没有显示构造方法的情况下,编译器也会自动提供一个公共的(public),无参的默认构造方法。
试图通过创建抽象类来强制执行非实例化是行不通的。该类可以被继承,而子类可以被实例化。此外,还会误导调用者认为该类是为继承而设计。
只有当类不包含显式构造方法时,才会生成一个默认构造方法,因此可以通过包含一个私有构造方法来实现非实例化
1
2
3
4
5
6
7
8 // 不可实例化类
>public class NoninstantiableClass {
>// 私有构造方法
private UtilityClass() {
throw new Exception();
>}
>...
>}其中抛出异常是非必要的,但可以防止在类中意外的调用构造方法,以保证在任何情况下都不会被实例化。但此种用法还是有点违反直觉,好像构造方法反而使它不能被实例化了。而且还会导致子类不可实例化,因为所有的构造方法都必须显式或隐式地调用父类构造方法,而此父类并没有构造方法可以调用。
浅谈JNI
在尝试用Java实现一个调节系统音量的小工具时,我感到深深的无力。只得求助于更贴近底层的C语言,用JNI来实现。以此为契机,简单总结一下JNI。
什么是JNI
JNI(Java Native Interface)是Java提供的一套编程接口,用于实现Java代码与本地代码的交互。它允许Java程序调用本地代码,也允许本地代码回调Java代码。
为什么要用JNI
- 由于Java无法直接访问硬件、操作系统、数据库等底层资源,因此需要借助c/c++来实现。
- 对于一些已有的用其它编程语言实现的库,程序员可以直接使用,无需重新实现。
- 使用底层的库,如计算、图形、渲染,往往可以获得更好的性能。
如何应用JNI
因为网上有很多相关的资料,就不再赘述了。简言之:
- 在Java类中使用native关键字声明一个方法。
- 使用 javac -h 命令生成头文件。
- 创建一个c/c++文件,引入头文件,并实现对应的方法。
- 使用本地编译器(如gcc)编译c/c++文件,生成动态链接库。
- 在Java代码中引入动态链接库,并调用对应的方法。
按照以上顺序,就能实现一个简单的JNI程序。
JNI的深层理解
我们知道无论什么语言,最终都需要编译成机器码,存入到内存中,再由CPU读取运行。所以在CPU层面上看,JNI调用的本质就是一段机器码(由Java JIT生成)调用了另一段机器码(由c编译器生成),只是一个简单的函数跳转。
此处可以看出,JNI的调用并不是像微服务架构那样,将Java和本地代码分别开多个进程,进程间通信(IPC)。而是在Java进程的堆区中额外划分出C本地堆(C Native Heap),在使用malloc()、new 或者JNI函数(如 GetStringUTFChars)时,所申请的内存就来自此处,通过函数指针进行的高效内存调用。这使得JNI相比于微服务架构,天然的有更低的资源开销和更快的性能。
对于一段由c/c++/Rust等语言编写的本地代码,在被Java调用前,就需要由对应的编译器(如gcc,clang,rustc)直接编译为针对特定操作系统和CPU架构的机器码,并打包在动态链接库中,这也就是上文中第四步做的事。
当Java代码执行到 native 方法时,会发生以下几步:
JVM已经通过JIT编译器,将调用这个 native 方法的Java代码本身编译成了机器码。
这段机器码的执行逻辑是:“准备参数,然后调用 Java_Class_Method 这个函数”。
JVM在之前加载动态库时,已经将 Java_Class_Method 这个符号解析为C代码编译后所在的内存地址。
CPU的执行流于是直接跳转到动态库中那块预先编译好的C机器码的地址,开始执行。
C代码执行完毕后,再通过返回指令跳转回JIT编译好的Java机器码中继续执行。
按照这个逻辑理解,是否只要最终能编译成机器码,就可以交互呢?答案是否定的,至少对于Java来说。
从JNI的应用角度看,在第五步引入动态链接库后,还有两大关键问题需要解决:
- 如何以一种稳定、普适、高效的方式,在二进制层面与各种本地代码库进行连接和基本通信的问题。
- 如何安全地交换数据(协调GC)、保持类型安全、处理异常等具体业务问题。
我们一个一个来看。
问题一
在解决这个问题前,先回到问题本身,我们可能会好奇于,都编译成机器码了还需要额外的规范来协调吗?难道机器码还有区别吗?
当然,机器码本身没有不同,但调用函数时如何准备参数、如何传递、如何返回,不同的约定会导致完全不同的机器码排列组合。如果不遵守同一套约定,双方就无法正确对话。
举个例子来理解,假设我们的程序由两部分组成:主程序 main.exe 和库 library.dll,并有一个简单的函数add。
1 | int result = add(10, 20); |
main.exe 由 编译器A 编译,它遵循的ABI规则是:“参数从右向左压栈,调用者清理栈”。
library.dll 中的 add 函数由 编译器B 编译,它期望的ABI规则是:“参数从左向右压栈,被调用者清理栈”。
现在,main.exe 调用 library.dll 中的 add 函数:
main.exe 按照自己的规则,生成机器码:先压入 20,再压入 10,然后调用 add。
add 函数开始执行,它按照自己的规则从栈上读取参数。它以为第一个参数在最左边,于是读到了 10(但实际上应该是 20),第二个参数读到了 20(但实际上应该是 10)。虽然结果是 30,但过程是错的。
函数返回后,add 函数按照自己的规则,清理了栈上的两个参数。
执行权回到 main.exe。main.exe 也按照自己的规则,又一次尝试清理栈上的两个参数。
此时,栈指针已经被彻底破坏,完全错位。程序几乎必然会在后续的操作中崩溃。
由此看出,尽管双方生成的机器码本身都是合法、正确的,但因为遵循了不同的“调用约定”,导致程序错误。
JNI最终采用C ABI(C语言应用程序二进制接口)作为双方的约定,这不仅仅是因为它的稳定和兼容性,更是因为JVM本身就是由C/C++编写的应用程序。可以说C ABI是JVM的“母语”,用它来与外界沟通是效率最高、最可靠的方式。
问题二
由于JVM机制的特殊性,与本地代码存在着两大根本的冲突:
- 为了优化性能(减少内存碎片),GC(垃圾回收)会定期移动内存中的对象。这就导致C/C++无法获得一个可靠的对象地址,一旦GC移动对象,C指针立即变成悬空指针,导致程序崩溃。
- Java的强类型和沙箱机制,不会允许你访问一个对象的私有字段。而C/C++可以访问任意内存,没有内置的安全检查,这就导致数据泄露和安全漏洞。
这就需要JNI的特定规范来约束本地代码,让他们以一种JVM能够理解和安全管理的方式运行。具体来讲,一方面JNI禁止直接操作指针,而是引入了 “JNI引用”(jobject, jstring等) 的概念,使JVM能在背后协调本地代码对对象的访问:要么临时“钉住”对象不让GC移动(Pin),要么先复制一份数据(Copy);另一方面,JNI强制所有访问都必须通过 JNIEnv* 提供的函数(如 GetObjectField, GetIntArrayElements),在执行操作前进行必要的安全检查(如检查数组索引是否越界)。
此处的Pin和Copy策略的选择完全取决于JVM,虽然不同的策略会有所差异(如对应的内存地址不同),但作为Java开发者,无法也不应该假设JVM会采用哪种策略,而是必须按照规范编写代码(即总是成对调用Get/Release),以保证在任何情况下都是正确的。
最终,C ABI提供了基础的、跨平台的函数调用机制,而JNI特定规范则在此基础上定义了专属于JVM的、安全的内存对象交互语义。两者结合,共同构成了Java与本地代码交互的完整解决方案。
JNI的内存管理
到上文为止,Java与本地代码的交互机制已经比较完整了。但是还有一些细节没有涉及,如明明在同一片内存中,为什么垃圾回收机制不会影响到C/C++呢?
当然你可能会觉得为什么不让垃圾回收机制统一管理本地代码呢,这样不是更方便吗?这不是技术上无法实现,而是有其巧思在此,主要是为了兼顾性能、灵活性和语言特性而必须付出的代价。此处不深究。
这个问题可以从内存的角度来看,虽然Java代码和本地代码都运行在整个Java进程的内存上,但其内部还有划分。在Java进程的堆区中会额外划分出C本地堆(C Native Heap),在使用malloc()、new 或者JNI函数(如 GetStringUTFChars)申请的内存就来自此处,而JVM的垃圾回收器(GC)完全不关心这块区域。申请了就必须释放,否则就会泄漏。而两者的的栈帧则是共用同一个进程栈,这也是为什么C函数中的局部参数会自动回收。
JNA和JNR
JNA (Java Native Access),是一个开源库,它基于JNI,但提供了一个纯Java的接口来访问本地库,不需要写任何C/C++代码。
示例:调用 C 标准库函数
1 | import com.sun.jna.Library; |
高级特性:调用自定义库
假设我们有一个自定义的 C 库 mylib,其中包含以下函数:
1 | // mylib.h |
对应的 JNA 调用代码:
1 | import com.sun.jna.Library; |
JNR (Java Native Runtime),由JRuby团队开发,旨在解决JNA的性能问题。它同样允许纯Java代码调用本地库。JNR的设计比JNA更底层、更智能。它的核心是 libffi(一个广泛使用的外部函数接口库)。
示例:调用 C 标准库函数
1 | import jnr.ffi.LibraryLoader; |
高级特性:调用自定义库
使用 JNR 调用前面提到的自定义库 mylib:
1 | import jnr.ffi.LibraryLoader; |
总的来说,两者都是 JNI 的优秀替代方案,可以大大简化 Java 与本地代码的交互过程。
选择哪一个取决于具体需求:
- 如果需要快速实现功能并且对性能要求不是极高,选择 JNA
- 如果需要更好的性能并且愿意学习更复杂的 API,选择 JNR
从、堆栈说开去——浅谈内存管理
起因于昨日的面试,问Java中int和Interger的区别,没答上来。故总结一下数据结构中堆、栈的相关知识。
堆、栈的词源
堆(heap)让人联想到土堆、草堆,有“杂乱无章、自由拜访”的意味,而在英语中本意为“杂乱拜访的物体”。栈(stack)在古代指存放货物的仓库(如“货栈”),货物通常按顺序堆叠存放,有“层层堆叠、顺序存取”的特点。内存管理中堆、栈的也是同样。
内存
要想理清堆、栈在内存管理中的区别,就首先应弄清此处讨论的内存是什么。内存在广义上包括随机存储器(RAM),只读存储器(ROM),以及高速缓存(CACHE)。
- 只读存储器(ROM),一般用于存放计算机的基本程序和数据(如bios),往往直接焊死在主板上,且工作过程中只能读出,不能更改。
- 高速缓存(CACHE)位于CPU与主存(RAM)之间,是一个读写速度比内存更快的存储器,用于解决CPU速率和主存访问速率差距过大的问题。
而在狭义上的内存往往指随机存储器(RAM),也就是用户可以自由插拔的内存条中的存储空间。它是CPU可以直接、快速访问数据的地方,一般的程序指令想要被执行就必须先被加载到RAM中。
但由于RAM的容量有限,且无隔离性(所有程序共享同一物理地址空间,程序 A 可随意读写程序 B 的内存),所以实际上程序并不会直接访问RAM的地址,而是访问虚拟地址,通过内存管理单元 (MMU) 动态翻译成RAM中的实际地址,并利用硬盘(或 SSD)上的一部分空间(称为页面文件或交换空间)来扩展可用的“内存”资源。为了区分虚拟地址空间和实际地址空间,就把RAM的实际地址空间称为物理内存,程序访问的虚拟地址空间称为虚拟内存。
所以虚拟内存并不是一个物理硬件,只是在程序的眼里,它在操作一个连续、独立且大小可能超过物理内存总容量的私有地址空间。对于程序员来说,也就不需要手动管理物理地址,而是只专注虚拟内存的管理。
所以在这里的内存管理实际指的是虚拟内存的管理。
内存管理
以32位CPU为例,最大寻址$2^{32}$,那么虚拟地址空间的地址范围就应该是0x00000000~0xFFFFFFFF,也就是提供4GB的虚拟内存。
原生程序(如 C/C++)
在程序的视角看,内存被划分为几个逻辑区域,从低地址(0x00000000)开始,至高地址结束(0xFFFFFFFF)结束,依次为代码段(Text Segment)、数据段(Data Segment)、BSS 段(Block Started by Symbol)、堆(Heap)、栈(Stack)。
- 代码段:只读,存放编译后的机器指令。
- 数据段:读写,已初始化的全局变量和静态变量。
- BSS段:读写,未初始化的全局变量和静态变量。且会隐式初始化为0。
- 堆:读写,从低地址向高地址,存放动态分配的内存(malloc/new 申请),需手动分配/释放(free/delete)。
- 栈:读写,从高地址向低地址,存放函数调用栈帧(局部变量、参数、返回地址),上下文数据(寄存器备份、异常处理链)。
在一段c/c++代码运行前,会先编译成机器码存入代码段,并将全局变量、静态变量等存入数据段或BSS段中。CPU会读取代码段中的机器码,基于指令和内存地址,动态的读取或修改其它段的数据。
一方面函数的调用强调严格的顺序和高效的逻辑关系,另一方面又在某些时候需要动态的内存空间和大内存需求(如处理运行时才能确定大小的数据),就干脆分成两部分,也就是栈和堆。所以栈和堆在本质上都是存储空间,只是设计目标不同,导致实现方式不同,最终承担的职责也不同。
具体来说,例如:
1 | int a = 1; |
变量a和其值1都被分配在栈上,当函数或代码块结束时,a会自动从栈中销毁。
容易导致内存泄露的往往是下面一种情况:
1 | char* p = new char[100]; |
当函数或代码块结束时,p会自动从栈中销毁,但堆中的100个char不会被销毁。如果不手动释放,就会导致内存泄漏。
JVM
众所周知,Java 程序通过 JVM(Java 虚拟机) 运行,其内存结构其内存结构与原生程序(如 C/C++)也有显著区别。但归根结底是要通过操作系统的虚拟内存系统映射到物理内存才能使用,只是需要经过 JVM 的抽象层(如 GC、字节码解释器)封装。
示例:
当在java中创建对象时
1 | Object obj = new Object(); // 在 JVM 堆中分配内存 |
JVM 向操作系统申请虚拟内存页 → 操作系统将其映射到物理内存 → CPU 通过 MMU(内存管理单元)访问实际物理地址。
JVM中的内存结构主要分为线程私有区域(Thread-Local),和线程共享区域(Shared),栈和堆就分别存放在两个区域中(当然线程私有区域中又分为方法区、本地方法栈、程序计数器、堆栈、栈帧、对象引用、对象数据等等,栈和堆又可以划分成多个区域,但由于我自己还没搞懂,就先按下不表)。
- 栈(JVM Stack):存储方法参数和局部变量(基本类型 + 对象引用),存放计算过程的中间结果(如 i++ 的临时值),方法执行完毕后返回的位置。
- 堆(JVM Heap):存放对象实例,对象实例的属性值,对象实例的引用。
所以在大体上来说,JVM中的栈和堆,与原生程序中的栈和堆是很相似的。
那么回到int和Interger上来。Java中有两种数据类型:基本数据类型,有boolean、byte、int、char、long、short、double、float;引用数据类型 ,分为数组、类、接口。Java为每个基本数据类型都提供了对应的包装类,并在Java 5引入了自动装箱和自动拆箱,使其可以方便的相互转化。
Java中int往往存于栈中,而Integer往往存于堆中。但如果认为这是通过数据类型来判断的,就倒果为因了。从线程私有区域、线程共享区域的名字上就可以看出,实际上是通过上下文来区分的。
例如
1 | void method(){ |
a作为一个方法的局部变量存在,存放在栈中。
1 | class demo { |
在这里a作为一个类的成员变量存在,属于全局变量,存放在堆中。
只不过,由于类中常用Integer,方法中常用int,所以有这种错觉。归根结底是根据线程私有还是线程共享,或者说全局变量还是局部变量来区分。
至于这种代码习惯的产生有很多原因,例如Integer默认为null,而且可以用
List<Integer>,而int只占用4字节,计算更高效。
由于Integer实际是一个类,所以在创建Integer实例的时候,实际获得的是地址。所以两个通过new生成的Integer变量永远是不相等的。
1 | Integer a = new Integer(1); |
但当int和Integer类型比较时,由于自动拆箱的机制存在,会自动把Integer转换成int,然后进行比较。
1 | Integer a = new Integer(1); |
当然此处都是使用==进行比较,但Java中还有一个相似的方法:equals。==对于基本数据类型,比较的是值;对于引用数据类型,比较的是的内存地址,即是否指向同一个变量。equals是Object类的一个方法,默认实现与==相同。但许多类(如string,Integer)都重写了equals方法,用于比较对象的内容。如果需要比较自定义类的内容,就必须重写equals方法。
由于JVM的垃圾回收机制,可以自动释放堆上不再使用的对象,所以程序员往往不需要主动释放对象。
我的第一个博客!
对于是否要开设个人博客,起初是很纠结的。一方面,有个专属的空间确实很酷;
但另一方面,我又不是个能写的人,很怀疑自己能不能一直坚持下去。但与其踌躇不定,不如行动起来,这篇博客就如此诞生了。若说vue不会写,直接套用模板还不会么?
相信这会是一个好头(没坚持下去,这篇博客当然也看不到了,哈哈)。
2025-07-23 21:03:36
部署到服务器上,也太麻烦了???