你真的懂 Java 的 main 方法吗

在学习 Java 时,HelloWorld 一般都是我们写的第一个 Java 程序,这个程序很简单,只需要一个main 方法,再加上一个打印语句。麻雀虽小,五脏俱全,即使是一个很小的程序,但程序的执行流程与大型的程序基本没有区别。

这篇文章会讨论 Java 程序如何启动,以及其中经历的主要过程。

本文基于 openJDK11

先来看段代码:

1
2
3
4
5
6
7
8
public class Main1 {
public static Map<String, String> data = new HashMap<>();

public static void main(String[] args) {
data.put("name", "rayjun");
System.out.println(data.get("name"));
}
}

1
2
3
4
5
6
public class Main2 {

public static void main(String[] args) {
System.out.println(Main1.data.get("name"));
}
}

先执行 Main1.main(),再执行 Main2.main(),程序输出的结果是什么:

1
2
Main1.main(): rayjun
Main2.main(): null

上面程序运行的结果应该都在预料之中吧。

然后把代码改成下面这样:

1
2
3
4
5
6
7
8
public class Main1 {
public static Map<String, String> data = new HashMap<>();

public static void invoke() {
data.put("name", "rayjun");
System.out.println(data.get("name"));
}
}

1
2
3
4
5
6
7
public class Main2 {

public static void main(String[] args) {
Main1.invoke();
System.out.println(Main1.data.get("name"));
}
}

这回输出的结果是:

1
2
Main1.invoke(): rayjun
Main2.main(): rayjun

首先需要明确,main 方法是 Java 程序的入口,main 方法本身有着很严格的定义,不能随意更改。

JVM 启动后会执行 main 方法,就会新建了一个进程,每个进程都会有自己的虚拟机实例。

在第一个例子中,启动了两个 mian 方法,也就是启动了两个进程,那么说明他们运行在不同的虚拟机实例上,那么数据自然就不是共享的,所以在第二个 main 方法中无法获取到第一个 mian 方法中设置的数据。

第二个例子中,两个类在同一个 main 方法中启动,那么就是在同一个进程中,也就是在同一个虚拟机实例上,同一个虚拟机实例上,public 类型的静态变量,在整个进程中都是共享的。

如果代码按照如下的形式再次进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main1 {
public static Map<String, String> data = new HashMap<>();

public static void main(String[] args) {
invoke();
}

public static void invoke() {
data.put("name", "rayjun");
System.out.println(data.get("name"));
}
}

1
2
3
4
5
6
7
public class Main2 {

public static void main(String[] args) {
Main1.main(args);
System.out.println(Main1.data.get("name"));
}
}

执行结果如下:

1
2
Main1.main(): rayjun
Main2.main(): rayjun

从上面的结果可以知道,main 方法其实就是一个普通的方法,可以被其他类调用,所以 main 方法是程序的入口,同时也是一个普通的方法。

那么 main 方法在执行的过程中到底发生了什么?

下面是 Java 代码到执行结束的完成流程:

程序启动

Java 有个特性叫编写一次,到处运行,这是因为有虚拟机的存在,我们所编写的所有代码最终都是在虚拟机上运行。

但 JVM 是无法执行 Java 代码的,所以需要使用 javac 将Java 代码编译成字节码文件,字节码文件才可以在 JVM 上执行。

字节码文件准备好了之后,就需要启动 JVM 实例,JVM 可以接受各类参数,以便适应不同的执行环节,在初始化 JVM 实例时,需要先检查 JVM 参数,并且校验这些参数是否合法。

参数校验完成之后,就会开始初始化 JVM 的内存空间,我们所熟知的堆、栈、方法区等等。

在执行 main 方法时,发现在方法区找不到 mian 方法所对应的类,于是就会启动类加载的过程。

类在加载完成之后,就会像执行任何的普通方法一样来执行 main 方法,会将 main 方法加载到栈中执行。

JVM 刚开始会对字节码文件解释执行,但是随着 JVM 技术发展,也慢慢出现了 JIT(即时编译)技术,可以将部分字节码或者全部字节码通过编译的的方式来执行。

当字节码被加载,然后就会进入程序的运行状态,我们所熟知的垃圾回收机制就开始运行,程序运行的过程中,不断有对象被创建,也会不断有对象被回收。

如果没有发生内存泄漏的情况,程序会在垃圾回收的支持下持续运行。

程序运行

程序开始执行的时候,会创建一些线程。

JVM 在启动的时候会创建一个 VM Thread,这是所有线程的祖先,其他的线程都是由这个线程派生出去的。

在 Java 中,线程分为两种:普通线程守护线程

普通线程就是通常业务逻辑执行的代码,代码执行完成之后,普通线程也就退出了。而守护线程一般运行在后台,比如说响应命令行的操作,守护线程会在普通线程退出之后退出。

在 Java 中,如果要把一个线程编程守护线程,只需要调用 Thread.setDaemon(),判断一个线程是否是守护线程,只需要调用 Thread.isDeamon()

执行 Java 程序的时候,除了需要为运行的代码创建一个线程之外,还需要创建一个处理后台任务的守护线程。

执行以下代码:

1
2
3
4
5
6
7
8
9
10
public class Main3 {

public static void main(String[] args) {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("Thread id: "+threadInfo.getThreadId() + ", thread name: " + threadInfo.getThreadName() + ", isDaemon: "+threadInfo.isDaemon());
}
}
}

执行结果如下:

1
2
3
4
5
6
7
Thread id: 1, thread name: **main**, isDaemon: false
Thread id: 2, thread name: **Reference Handler**, isDaemon: true
Thread id: 3, thread name: **Finalizer**, isDaemon: true
Thread id: 4, thread name: **Signal Dispatcher**, isDaemon: true
Thread id: 9, thread name: **Common-Cleaner**, isDaemon: true
Thread id: 10, thread name: **Monitor Ctrl-Break**, isDaemon: true
Thread id: 12, thread name: **Attach Listener**, isDaemon: true

通过上面的代码可以发现,只有 main 线程不是守护线程,其他的线程都是守护线程

  • Reference Handler:该线程负责将待回收的对象管理起来,等待回收
  • Finalizer:该线程负责从 Reference Handler 获取待回收对象,检查该对象是否实现了 finalize 方法,如果实现了该方法而且没有被调用过,就会调用该方法,使对象继续存活
  • Attach Listener:该线程负责接收外部命令,并且把该命令执行的结果返回给发送者,比如:jps,jmp 等等
  • Single Dispatcher:Attach Listener 在接收到命令之后,会交给该线程进行分发到不同的模块去处理并获得处理结果
  • Common-Cleaner:该线程是 JDK9 之后新增的守护线程,用来更高效的处理垃圾回收
  • Monitor Ctrl-Break:该线程用于应用监测以及用于排查问题

只要 JVM 中还有非守护线程存在,JVM 实例就不会退出。

程序如何退出

当程序执行完成之后,JVM 也要退出,程序执行完的标志就是非守护线程都退出了。当所有的非守护线程退出之后,守护线程也会退出。

程序在执行完成之后,会调用 System.exit() 方法,然后虚拟机退出,程序彻底结束。

exit 方法接收一个整型的参数,如果传入的值为 0,那么就表示程序是正常退出的,如果是任何非零的值,那么就表示是异常退出。

其实在代码中,非常不推荐调用这个方法,因为这个方法会造成一些意向不到的情况。

看下面这段代码:

1
2
3
4
5
6
7
8
try {
System.out.println("App invoking");
throw new Exception();
} catch (Exception e) {
System.exit(2);
} finally {
System.out.println("App existing");
}

上面的代码会输出:

1
App invoking

finally 中的代码并不会执行,这样会出现异常的情况,会破坏程序原本的正常逻辑,所以不要去调用这个方法。

Java 运行程序的整体流程就是上面那些过程,当然省略了不少的细节,这些细节我们再通过后续的文章慢慢补充。

文 / Rayjun

© 2020 Rayjun    PowerBy Hexo