深入理解 Java 虚拟机到底是什么

什么是 Java 虚拟机

 

作为一个 Java 程序员,我们每天都在写 Java 代码,我们写的代码都是在一个叫做 Java 虚拟机的东西上执行的。但是如果要问什么是虚拟机,恐怕很多人就会模棱两可了。在本文中,我会写下我对虚拟机的理解。因为能力所限,可能有些地方描述的不够欠当。如果你有不同的理解,欢迎交流。

我们都知道 java 程序必须在虚拟机上运行。那么虚拟机到底是什么呢?先看网上搜索到的比较靠谱的解释:

虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java 虚拟机有自己完善的硬体架构,如处理器堆栈寄存器等,还具有相应的指令系统。JVM 屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

这种解释应该算是正确的,但是只描述了虚拟机的外部行为和功能,并没有针对内部原理做出说明。一般情况下我们不需要知道虚拟机的运行原理,只要专注写 java 代码就可以了,这也正是虚拟机之所以存在的原因 -- 屏蔽底层操作系统平台的不同并且减少基于原生语言开发的复杂性,使 java 这门语言能够跨各种平台(只要虚拟机厂商在特定平台上实现了虚拟机),并且简单易用。这些都是虚拟机的外部特性,但是从这些信息来解释虚拟机,未免太笼统了,无法让我们知道内部原理。



从进程的角度解释 JVM

 

让我们尝试从操作系统的层面来理解虚拟机。我们知道,虚拟机是运行在操作系统之中的,那么什么东西才能在操作系统中运行呢?当然是进程,因为进程是操作系统中的执行单位。可以这样理解,当它在运行的时候,它就是一个操作系统中的进程实例,当它没有在运行时(作为可执行文件存放于文件系统中),可以把它叫做程序。


对命令行比较熟悉的同学,都知道其实一个命令对应一个可执行的二进制文件,当敲下这个命令并且回车后,就会创建一个进程,加载对应的可执行文件到进程的地址空间中,并且执行其中的指令。下面对比 C 语言和 Java 语言的 HelloWorld 程序来说明问题。


首先编写 C 语言版的 HelloWorld 程序。

 

 
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3.  
  4. int main(void) {
  5. printf("hello world\n");
  6. return 0;
  7. }
 

 

 

 

编译 C 语言版的 HelloWorld 程序:

 

gcc HelloWorld.c -o HelloWorld

 

 

运行 C 语言版的 HelloWorld 程序:

 

zhangjg@linux:/deve/workspace/HelloWorld/src$ ./HelloWorld 
hello world


gcc 编译器编译后的文件直接就是可被操作系统识别的二进制可执行文件,当我们在命令行中敲下 ./HelloWorld 这条命令的时候, 直接创建一个进程, 并且将可执行文件加载到进程的地址空间中, 执行文件中的指令。

 

 

作为对比, 我们看一下 Java 版 HelloWord 程序的编译和执行形式。

 

首先编写源文件 HelloWord.java :

 

 
  1. public class HelloWorld {
  2.  
  3. public static void main(String[] args) {
  4. System.out.println("HelloWorld");
  5. }
  6. }
 


编译 Java 版的 HelloWorld 程序:

 

 

 
  1. zhangjg@linux:/deve/workspace/HelloJava/src$ javac HelloWorld.java
  2. zhangjg@linux:/deve/workspace/HelloJava/src$ ls
  3. HelloWorld.class HelloWorld.java
 


运行 Java 版的 HelloWorld 程序:

 

 

zhangjg@linux:/deve/workspace/HelloJava/src$ java -classpath . HelloWorld 
HelloWorld


从上面的过程可以看到, 我们在运行 Java 版的 HelloWorld 程序的时候, 敲入的命令并不是 ./HelloWorld.class 。 因为 class 文件并不是可以直接被操作系统识别的二进制可执行文件 。 我们敲入的是 java 这个命令。 这个命令说明, 我们首先启动的是一个叫做 java 的程序, 这个 java 程序在运行起来之后就是一个 JVM 进程实例。 

 

 

上面的命令执行流程是这样的:

java 命令首先启动虚拟机进程,虚拟机进程成功启动后,读取参数 “HelloWorld”,把他作为初始类加载到内存,对这个类进行初始化和动态链接(关于类的初始化和动态链接会在后面的博客中介绍),然后从这个类的 main 方法开始执行。也就是说我们的.class 文件不是直接被系统加载后直接在 cpu 上执行的,而是被一个叫做虚拟机的进程托管的。首先必须虚拟机进程启动就绪,然后由虚拟机中的类加载器加载必要的 class 文件,包括 jdk 中的基础类(如 String 和 Object 等),然后由虚拟机进程解释 class 字节码指令,把这些字节码指令翻译成本机 cpu 能够识别的指令,才能在 cpu 上运行。

 

从这个层面上来看,在执行一个所谓的 java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机的进程,而不是我们写的一个个的 class 文件。这个叫做虚拟机的进程处理一些底层的操作,比如内存的分配和释放等等。我们编写的 class 文件只是虚拟机进程执行时需要的 “原料”。这些 “原料” 在运行时被加载到虚拟机中,被虚拟机解释执行,以控制虚拟机实现我们 java 代码中所定义的一些相对高层的操作,比如创建一个文件等,可以将 class 文件中的信息看做对虚拟机的控制信息,也就是一种虚拟指令。

 

编程语言也有自己的原理, 学习一门语言, 主要是把它的原理搞明白。 看似一个简单的 HelloWorld 程序, 也有很多深入的内容值得剖析。

 

 

JVM 体系结构简介

 

为了展示虚拟机进程和 class 文件的关系,特意画了下面一张图:



根据上图表达的内容,我们编译之后的 class 文件是作为 Java 虚拟机的原料被输入到 Java 虚拟机的内部的,那么具体由谁来做这一部分工作呢?其实在 Java 虚拟机内部,有一个叫做类加载器的子系统,这个子系统用来在运行时根据需要加载类。注意上面一句话中的 “根据需要” 四个字。在 Java 虚拟机执行过程中,只有他需要一个类的时候,才会调用类加载器来加载这个类,并不会在开始运行时加载所有的类。就像一个人,只有饿的时候才去吃饭,而不是一次把一年的饭都吃到肚子里。一般来说,虚拟机加载类的时机,在第一次使用一个新的类的时候。本专栏后面的文章会具体讨论 Java 中的类加载器。


由虚拟机加载的类,被加载到 Java 虚拟机内存中之后,虚拟机会读取并执行它里面存在的字节码指令。虚拟机中执行字节码指令的部分叫做执行引擎。就像一个人,不是把饭吃下去就完事了,还要进行消化,执行引擎就相当于人的肠胃系统。在执行的过程中还会把各个 class 文件动态的连接起来。关于执行引擎的具体行为和动态链接相关的内容也会在本专栏后续的文章中进行讨论。

 

我们知道,Java 虚拟机会进行自动内存管理。具体说来就是自动释放没有用的对象,而不需要程序员编写代码来释放分配的内存。这部分工作由垃圾收集子系统负责。

 

从上面的论述可以知道, 一个 Java 虚拟机实例在运行过程中有三个子系统来保障它的正常运行,分别是类加载器子系统, 执行引擎子系统和垃圾收集子系统。 如下图所示:



虚拟机的运行,必须加载 class 文件,并且执行 class 文件中的字节码指令。它做这么多事情,必须需要自己的空间。就像人吃下去的东西首先要放在胃中。虚拟机也需要空间来存放个中数据。首先,加载的字节码,需要一个单独的内存空间来存放;一个线程的执行,也需要内存空间来维护方法的调用关系,存放方法中的数据和中间计算结果;在执行的过程中,无法避免的要创建对象,创建的对象需要一个专门的内存空间来存放。关于虚拟机运行时数据区的内容,也会出现在本专栏后续的文章中。虚拟机的运行时内存区大概可以分成下图所示的几个部分。(这里只是大概划分, 并没有划分的很精细)

 

总结

 

写到这里,基本上关于我对 java 虚拟机的理解就写完了。这篇文章的主题虽然是深入理解 Java 虚拟机,但是你可能感觉一点也不 “深入”,也只是泛泛而谈。我也有这样的感觉。限于自己水平有限,也只能这样了,要是想深入理解 java 虚拟机,强烈建议读一下三本书:


《深入 Java 虚拟机》

《深入理解 Java 虚拟机 JVM 高级特性与最佳实践》

Java 虚拟机规范》


其实我也读过这几本书,但是它们对虚拟机的解释也是基于一个外部模型,而没有深入剖析虚拟机内部的实现原理。虚拟机是一个大而复杂的东西,实现虚拟机的人都是大牛级别的,如果不是参与过虚拟机的实现,应该很少有人能把它参透。本专栏后面的一些文章也参考了这三本书, 虽然讲解 Java 语法的书不计其数, 但是深入讲解虚拟机的书, 目前为止我就见过这三本,并且网上的资料也不是很多。

 

最后做一个总结:

虚拟机并不神秘,在操作系统的角度看来,它只是一个普通进程。

这个叫做虚拟机的进程比较特殊,它能够加载我们编写的 class 文件。如果把 JVM 比作一个人,那么 class 文件就是我们吃的食物。

加载 class 文件的是一个叫做类加载器的子系统。就好比我们的嘴巴,把食物吃到肚子里。

虚拟机中的执行引擎用来执行 class 文件中的字节码指令。就好比我们的肠胃,对吃进去的食物进行消化。

虚拟机在执行过程中,要分配内存创建对象。当这些对象过时无用了,必须要自动清理这些无用的对象。清理对象回收内存的任务由垃圾收集器负责。就好比人吃进去的食物,在消化之后,必须把废物排出体外,腾出空间可以在下次饿的时候吃饭并消化食物。

开篇

在我的上一篇博客 深入理解 Java 中为什么内部类可以访问外部类的成员  中, 通过使用 javap 工具反编译内部类的字节码, 我们知道了为什么内部类中可以访问外部类的成员, 其实是编译器在编译内部类的 class 文件时,偷偷做了一些工作, 使内部类持有外部类的引用, 并且通过在构造方法上添加参数注入这个引用, 在调用构造方法时默认传入了外部类的引用。 我们之所以感到疑惑, 就是因为编译器使用的障眼法。当我们把字节码反编译出来之后, 编译器的这些小伎俩就会清清楚楚的展示在我们面前。 感兴趣的朋友可以移步到上一篇博客, 博客链接: http://blog.csdn.net/zhangjg_blog/article/details/20000769

 

在本文中, 我们要对定义在方法中的内部类进行分析。 和上一篇博客一样, 我们还是使用 javap 工具对内部类的字节码进行解剖。 并且和上一篇文章进行对比分析, 探究定义在外部类方法中的内部类和定义在外部类中的内部类有哪些相同之处和不同之处。 这篇博客的讲解以上一篇为基础, 对这些知识点不是很熟悉的同学, 强烈建议先读上一篇博客。 博客的链接已经在上面给出。

 

 

定义在方法中的内部类

在平时写代码的过程中, 我们经常会写类似下面的代码段:

 

 
  1. public class Test {
  2.  
  3. public static void main(String[] args) {
  4. final int count = 0;
  5.  
  6. new Thread(){
  7. public void run() {
  8. int var = count;
  9. };
  10. }.start();
  11. }
  12. }
 


这段代码在 main 方法中定义了一个匿名内部类, 并且创建了匿名内部类的一个对象, 使用这个对象调用了匿名内部类中的方法。 所有这些操作都在 new Thread (){}.start () 这一句代码中完成, 这不禁让人感叹 java 的表达能力还是很强的。 上面的代码和以下代码等价:

 

 

 
  1. public class Test {
  2.  
  3. public static void main(String[] args) {
  4. final int count = 0;
  5.  
  6. //在方法中定义一个内部类
  7. class MyThread extends Thread{
  8. public void run() {
  9. int var = count;
  10. }
  11. }
  12.  
  13. new MyThread().start();
  14. }
  15. }
 


这里我们不关心方法中匿名内部类和非匿名内部类的区别, 我们只需要知道, 这两种方式都是定义在方法中的内部类, 他们的工作原理是相同的。 在本文中主要根据非匿名内部类讲解。 

 

 

让我们仔细观察上面的代码都有哪些 “奇怪” 的行为:

 

1 在外部类的 main 方法中有一个局部变量 count, 并且在内部类的 run 方法中访问了这个 count 变量。 也就是说, 方法中定义的内部类, 可以访问方法中的局部变量(方法的参数也是局部变量);

2 count 变量使用 final 关键字修饰, 如果去掉 final, 则编译失败。 也就是说被方法中的内部类访问的局部变量必须是 final 的。

 

由于我们经常这样做, 这样写代码, 久而久之养成了习惯, 就成了司空见惯的做法了。 但是如果要问为什么 Java 支持这样的做法, 恐怕很少有人能说的出来。 在下面, 我们就会分析为什么 Java 支持这种做法, 让我们不仅知其然, 还要知其所以然。

 

 

为什么定义在方法中的内部类可以访问方法中的局部变量?

 

1 当被访问的局部变量是编译时可确定的字面常量时

 
我们首先看这样一段代码, 本文的以下部分会以这样的代码进行讲解。 
 
 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final String localVar = "abc";
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. void innerMethod(){
  9. String a = localVar;
  10. }
  11. }
  12. }
  13. }
 

在外部类的方法 outerMethod 中定义了成员变量 String localVar, 并且用一个编译时字面量 "abc" 给他赋值。在 outerMethod 方法中定义了内部类 Inner, 并且在内部类的方法 innerMethod 中访问了 localVar 变量。 接下来我们就根据这个例子来讲解为什么可以这样做。
 
首先看编译后的文件, 和普通的内部类一样, 定义在方法中的内部类在编译之后, 也有自己独立的 class 文件:
 
 
和普通内部类的区别是, 普通内部类的 class 文件名为 Outer$Inner.class 。 而定义在方法中的内部类的 class 文件名为 Outer$<N>Inner.class 。 N 代表数字, 如 1, 2, 3 等 。 在外部类第一个方法中定义的内部类, 编号为 1, 同理在外部类第二个方法中定义的内部类编号为 2, 在外部类中第 N 个方法中定义的内部类编号为 N 。 这些都是题外话, 主要想说的是, 方法中的内部类也有自己独立的 class 文件。 
 
我们通过 javap 反编译工具, 把 Outer$1Inner.class 反编译成可读的形式。 关于 javap 工具的使用, 请参考我的上一篇博客。 反编译的输出结果如下:
 
Constant pool:
   #1 = Class              #2             //  Outer$1Inner
   #2 = Utf8               Outer$1Inner
   #3 = Class              #4             //  java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               this$0
   #6 = Utf8               LOuter;
   #7 = Utf8               <init>
   #8 = Utf8               (LOuter;)V
   #9 = Utf8               Code
  #10 = Fieldref           #1.#11         //  Outer$1Inner.this$0:LOuter;
  #11 = NameAndType        #5:#6          //  this$0:LOuter;
  #12 = Methodref          #3.#13         //  java/lang/Object."<init>":()V
  #13 = NameAndType        #7:#14         //  "<init>":()V
  #14 = Utf8               ()V
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               LOuter$1Inner;
  #19 = Utf8               innerMethod
  #20 = String             #21            //  abc
  #21 = Utf8               abc
  #22 = Utf8               a
  #23 = Utf8               Ljava/lang/String;
  #24 = Utf8               SourceFile
  #25 = Utf8               Outer.java
  #26 = Utf8               EnclosingMethod
  #27 = Class              #28            //  Outer
  #28 = Utf8               Outer
  #29 = NameAndType        #30:#14        //  outerMethod:()V
  #30 = Utf8               outerMethod
  #31 = Utf8               InnerClasses
  #32 = Utf8               Inner
{
  final Outer this$0;
    flags: ACC_FINAL, ACC_SYNTHETIC

  Outer$1Inner(Outer);
    flags:
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #10                 // Field this$0:LOuter;
         5: aload_0
         6: invokespecial #12                 // Method java/lang/Object."<init>":()V
         9: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0      10     0  this   LOuter$1Inner;

  void innerMethod();
    flags:
    Code:
      stack=1, locals=2, args_size=1
         0: ldc           #20                 // String abc
         2: astore_1
         3: return
      LineNumberTable:
        line 10: 0
        line 11: 3
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       4     0  this   LOuter$1Inner;
               3       1     1     a   Ljava/lang/String;

}

innerMethod 方法中一共就以下有三个指令:
         0: ldc           #20                 // String abc
         2: astore_1
         3: return
 
Idc 指令的意思是将索引指向的常量池中的项压入操作数栈。 这里的索引为 20 , 引用的常量池中的项为字符串 “abc” 。 这句话就揭示了内部类访问方法局部变量的原理。 让我们从常量池第 20 项看起。  
 
 
常量池中第 20 项确实是字符串 “abc” 。 但是这个字符串 “abc” 明明是定义在外部类 Outer 中的, 因为出现在外部类的 outerMethod 方法中。 为了查看这个 “abc” 是否在外部类中, 我们继续反编译外部类 Outer.class 。 为了篇幅考虑, 在这里指给出 Outer.class 反编译输出的常量池的一部分。
  ......
  ......

  #13 = Utf8               LOuter;
  #14 = Utf8               outerMethod
  #15 = String             #16            //  abc
  #16 = Utf8               abc

  ......
  ......

我们可以看到, “abc” 这个字符串确实出现在 Outer.class 常量池的第 15 项。 这就奇怪了, 明明是定义在外部类的字面量, 为什么会出现在 内部类的常量池中呢? 其实这正是编译器在编译方法中定义的内部类时, 所做的额外工作。 
 
下面我们将这个被内部类访问的局部变量改成整形的。 看看在字节码层面上会有什么变化。 修改后的源码如下:
 
 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final int localVar = 1;
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. void innerMethod(){
  9. int a = localVar;
  10. }
  11. }
  12. }
  13. }
 

内部类反编译后的 class 文件如下: (由于在这里常量池不是重点, 所以省略了常量池信息)
 
 
  1. {
  2. final Outer this$0;
  3. flags: ACC_FINAL, ACC_SYNTHETIC
  4.  
  5. Outer$1Inner(Outer);
  6. flags:
  7. Code:
  8. stack=2, locals=2, args_size=2
  9. 0: aload_0
  10. 1: aload_1
  11. 2: putfield #10 // Field this$0:LOuter;
  12. 5: aload_0
  13. 6: invokespecial #12 // Method java/lang/Object."<init>":()V
  14. 9: return
  15. LineNumberTable:
  16. line 8: 0
  17. LocalVariableTable:
  18. Start Length Slot Name Signature
  19. 0 10 0 this LOuter$1Inner;
  20.  
  21. void innerMethod();
  22. flags:
  23. Code:
  24. stack=1, locals=2, args_size=1
  25. 0: iconst_1
  26. 1: istore_1
  27. 2: return
  28. LineNumberTable:
  29. line 10: 0
  30. line 11: 2
  31. LocalVariableTable:
  32. Start Length Slot Name Signature
  33. 0 3 0 this LOuter$1Inner;
  34. 2 1 1 a I
  35. }
 

从上面的输出可以看到, innerMethod 方法中的第一句字节码为: 
iconst_1

这句字节码的意义是:将 int 类型的常量 1 压入操作数栈。 这就是在内部类中访问外部类方法中的局部变量 int localVar = 1 的原理。 由此可见, 当内部类中访问的局部变量是 int 型的字面量时, 编译器直接将对该变量的访问嵌入到内部类的字节码中, 也就是说, 在运行时, 方法中的内部类和外部类, 和外部类方法中的局部变量就没有任何关系了。 这也是编译器所做的额外工作。
 
上面两种情况有一个共同点, 那就是, 被内部类访问的外部了方法中的局部变量, 都是在编译时可以确定的字面常量。 像下面这样的形式都是编译时可确定的字面常量:
final  String localVar = "abc";

final  int localVar = 1;

他们之所以被称为字面常量, 是因为他们被 final 修饰, 运行时不可改变, 当编译器在编译源文件时, 可以确定他们的值, 也可以确定他们在运行时不会被修改, 所以可以实现类似 C 语言宏替换的功能。也就是说虽然在编写源代码时, 在另一个类中访问的是当前类定义的这个变量, 但是在编译成字节码时, 却把这个变量的值放入了访问这个变量的另一个类的常量池中, 或直接将这个变量的值嵌入另一个类的字节码指令中。 运行时这两个类各不相干, 各自访问各自的常量池, 各自执行各自的字节码指令。在编译方法中定义的内部类时, 编译器的行为就是这样的。 
 
 

2 当被访问的局部变量的值在编译时不可确定时

 
那么当方法中定义的内部类访问的局部变量不是编译时可确定的字面常量, 又会怎么样呢?想要让这个局部变量变成编译时不可确定的, 只需要将源码修改如下:
 
 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final String localVar = getString();
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. void innerMethod(){
  9. String a = localVar;
  10. }
  11. }
  12.  
  13. new Inner();
  14. }
  15.  
  16. String getString(){
  17. return "aa";
  18. }
  19. }
 

由于使用 getString 方法的返回值为 localVar 赋值, 所以在编译时期, 编译器不可确定 localVar 的值, 必须在运行时执行了 getString 方法之后才能确定它的值。 既然编译时不不可确定, 那么像上面那样的处理就行不通了。 那么在这种情况下, 内部类是通过什么机制访问方法中的局部变量的呢? 让我们继续反编译内部类的字节码:
 
Constant pool:
   #1 = Class              #2             //  Outer$1Inner
   #2 = Utf8               Outer$1Inner
   #3 = Class              #4             //  java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               this$0
   #6 = Utf8               LOuter;
   #7 = Utf8               val$localVar
   #8 = Utf8               Ljava/lang/String;
   #9 = Utf8               <init>
  #10 = Utf8               (LOuter;Ljava/lang/String;)V
  #11 = Utf8               Code
  #12 = Fieldref           #1.#13         //  Outer$1Inner.this$0:LOuter;
  #13 = NameAndType        #5:#6          //  this$0:LOuter;
  #14 = Fieldref           #1.#15         //  Outer$1Inner.val$localVar:Ljava/la
ng/String;
  #15 = NameAndType        #7:#8          //  val$localVar:Ljava/lang/String;
  #16 = Methodref          #3.#17         //  java/lang/Object."<init>":()V
  #17 = NameAndType        #9:#18         //  "<init>":()V
  #18 = Utf8               ()V
  #19 = Utf8               LineNumberTable
  #20 = Utf8               LocalVariableTable
  #21 = Utf8               this
  #22 = Utf8               LOuter$1Inner;
  #23 = Utf8               innerMethod
  #24 = Utf8               a
  #25 = Utf8               SourceFile
  #26 = Utf8               Outer.java
  #27 = Utf8               EnclosingMethod
  #28 = Class              #29            //  Outer
  #29 = Utf8               Outer
  #30 = NameAndType        #31:#18        //  outerMethod:()V
  #31 = Utf8               outerMethod
  #32 = Utf8               InnerClasses
  #33 = Utf8               Inner
{
  final Outer this$0;
    flags: ACC_FINAL, ACC_SYNTHETIC

  Outer$1Inner(Outer, java.lang.String);
    flags:
    Code:
      stack=2, locals=3, args_size=3
         0: aload_0
         1: aload_1
         2: putfield      #12                 // Field this$0:LOuter;
         5: aload_0
         6: aload_2
         7: putfield      #14                 // Field val$localVar:Ljava/lang/String;
        10: aload_0
        11: invokespecial #16                 // Method java/lang/Object."<init>":()V
        14: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0      15     0  this   LOuter$1Inner;

  void innerMethod();
    flags:
    Code:
      stack=1, locals=2, args_size=1
         0: aload_0
         1: getfield      #14                 // Field val$localVar:Ljava/lang/String;
         4: astore_1
         5: return
      LineNumberTable:
        line 10: 0
        line 11: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       6     0  this   LOuter$1Inner;
               5       1     1     a   Ljava/lang/String;
}

首先来看它的构造方法。 方法的签名为:
 Outer$1Inner(Outer, java.lang.String);

我们只到, 如果不定义构造方法, 那么编译器会为这个类自动生成一个无参数的构造方法。 这个说法在这里就行不通了, 因为我们看到, 这个内部类的构造方法又两个参数。 至于第一个参数, 是指向外部类对象的引用, 在前面一篇博客中已经详细的介绍过了, 不明白的可以先看上一篇博客, 这里就不再重复叙述。 这也说明了方法中的内部类和类中定义的内部类有相同的地方, 既然他们都是内部类, 就都持有指向外部类对象的引用。  我们来分析第二个参数, 他是 String 类型的, 和在内部类中访问的局部变量 localVar 的类型相同。 再看构造方法中编号为 6 和 7 的字节码指令:
         6: aload_2
         7: putfield      #14                 // Field val$localVar:Ljava/lang/String;
这句话的意思是, 使用构造方法的第二个参数, 为当前这个内部类对象的成员变量赋值, 这个被赋值的成员变量的名字是 val$localVar 。 由此可见, 编译器自动为内部类增加了一个成员变量, 其实这个成员变量就是被访问的外部类方法中的局部变量。 这个局部变量在创建内部类对象时, 通过构造方法注入。 在调用构造方法时, 编译器会默认为这个参数传入外部类方法中的局部变量的值。 
 
再看内部类中的方法 innerMethod 中是如何访问这个所谓的 “局部变量的”。 看 innerMethod 中的前两条字节码:
         0: aload_0
         1: getfield      #14                 // Field val$localVar:Ljava/lang/String;

这两条指令的意思是, 访问成员变量 val$localVar 的值。 而源代码中是访问外部类方法中局部变量的值。 所以, 在这里 将编译时对外部类方法中的局部变量的访问, 转化成运行时对当前内部类对象中成员变量的访问。 
 

在源代码层面上, 它的工作方式有点像这样: (注意, 下面的代码不符合 Java 的语法, 只是模拟编译器的行为)

 

 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final String localVar = getString();
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. /*下面两个成员变量都是编译器自动加上的*/
  9. final Outer this$0; //指向外部类对象的引用
  10. final String val$localVar; //被访问的外部类方法中的局部变量的值
  11.  
  12. /*构造方法, 两个参数都是编译器添加的*/
  13. public Inner(Outer outer, String outerMethodLocal){
  14. this.this$0 = outer;
  15. this.val$localVar = outerMethodLocal;
  16. super();
  17. }
  18.  
  19. void innerMethod(){
  20.  
  21. /*将对外部类方法中的变量的访问, 转换成对当前对象的成员变量的访问*/
  22. //String a = localVar;
  23. String a = val$localVar;
  24. }
  25. }
  26.  
  27. /*在外部类方法中创建内部类对象时, 传入相应的参数,
  28. 这两个参数分别是当前外部类的引用, 和当前方法中的局部变量*/
  29. //new Inner();
  30. new Inner(this, localVar);
  31.  
  32. }
  33.  
  34. String getString(){
  35. return "aa";
  36. }
  37. }
 


讲到这里, 内部类的行为就比较清晰了。 总结一下就是: 当方法中定义的内部类访问的方法局部变量的值, 不是在编译时能确定的字面常量时, 编译器会为内部类增加一个成员变量, 在运行时, 将对外部类方法中局部变量的访问。 转换成对这个内部类成员变量的方法。 这就要求内部类中的这个新增的成员变量和外部类方法中的局部变量具有相同的值。 编译器通过为内部类的构造方法增加参数, 并在调用构造器初始化内部类对象时传入这个参数, 来初始化内部类中的这个成员变量的值。 所以, 虽然在源文件中看起来是访问的外部类方法的局部变量, 其实运行时访问的是内部类对象自己的成员变量。 

 

 

 

为什么被方法内的内部类访问的局部变量必须是 final 的

上面我们讲解了, 方法中的内部类访问方法局部变量是怎么实现的。 那么为什么这个局部变量必须是 final 的呢? 我认为有以下两个原因:
 
1 当局部变量的值为编译时可确定的字面常量时( 如字符串 “abc” 或整数 1 ), 通过 final 修饰, 可以实现类似 C 语言的编译时宏替换功能。 这样的话, 外部类和内部类各自访问自己的常量池, 各自执行各自的字节码指令, 看起来就像共同访问外部类方法中的局部变量。 这样就可以达到语义上的一致性。 由于存在内部类和外部类中的常量值是一样的, 并且是不可改变的,这样就可以达到数值访问的一致性。
 
2 当局部变量的值不是可在编译时确定的字面常量时(比如通过方法调用为它赋值), 这种情况下, 编译器给内部类增加相同类型的成员变量, 并通过构造函数将外部类方法中的局部变量的值赋给这个新增的内部类成员变量。
 
 如果这个局部变量是基本数据类型时, 直接拷贝数值给内部类成员变量。代码示例和运行时内存布局是这样的:
 
 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final int localVar = getInt();
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. void innerMethod(){
  9. int a = localVar;
  10. }
  11. }
  12.  
  13. new Inner();
  14. }
  15.  
  16. int getInt(){ return 1; }
  17. }
 

这样的话, 内部类和外部类各自访问自己的基本数据类型的变量, 他们的变量值一样, 并且不可修改, 这样就保证了语义上和数值访问上的一致性。
 

如果这个局部变量是引用数据类型时, 拷贝外部类方法中的引用值给内部类对象的成员变量, 这样的话, 他们就指向了同一个对象。 代码示例和运行时的内存布局如下:

 

 
  1. public class Outer {
  2.  
  3. void outerMethod(){
  4. final Person localVar = getPerson();
  5.  
  6. /*定义在方法中的内部类*/
  7. class Inner{
  8. void innerMethod(){
  9. Person a = localVar;
  10. }
  11. }
  12.  
  13. new Inner();
  14. }
  15.  
  16. Person getPerson(){ return new Person("zhangjg", 30); }
  17. }

深入理解为什么 Java 中方法内定义的内部类可以访问方法中的局部变量_int local var-CSDN 博客


 

由于这两个引用变量指向同一个对象, 所以通过引用访问的对象的数据是一样的, 由于他们都不能再指向其他对象(被 final 修饰), 所以可以保证内部类和外部类数据访问的一致性。

 

内部类简介

 

虽然 Java 是一门相对比较简单的编程语言,但是对于初学者, 还是有很多东西感觉云里雾里, 理解的不是很清晰。内部类就是一个经常让初学者感到迷惑的特性。 即使现在我自认为 Java 学的不错了, 但是依然不是很清楚。其中一个疑惑就是为什么内部类对象可以访问外部类对象中的成员(包括成员变量和成员方法)? 早就想对内部类这个特性一探究竟了,今天终于抽出时间把它研究了一下。

 

内部类就是定义在一个类内部的类。定义在类内部的类有两种情况:一种是被 static 关键字修饰的, 叫做静态内部类, 另一种是不被 static 关键字修饰的, 就是普通内部类。 在下文中所提到的内部类都是指这种不被 static 关键字修饰的普通内部类。 静态内部类虽然也定义在外部类的里面, 但是它只是在形式上(写法上)和外部类有关系, 其实在逻辑上和外部类并没有直接的关系。而一般的内部类,不仅在形式上和外部类有关系(写在外部类的里面), 在逻辑上也和外部类有联系。 这种逻辑上的关系可以总结为以下两点:

 

1 内部类对象的创建依赖于外部类对象;

2 内部类对象持有指向外部类对象的引用。

 

上边的第二条可以解释为什么在内部类中可以访问外部类的成员。就是因为内部类对象持有外部类对象的引用。但是我们不禁要问, 为什么会持有这个引用? 接着向下看, 答案在后面。

 

 

通过反编译字节码获得答案

 
在源代码层面, 我们无法看到原因,因为 Java 为了语法的简洁, 省略了很多该写的东西, 也就是说很多东西本来应该在源代码中写出, 但是为了方便起见, 不必在源码中写出,编译器在编译时会加上一些代码。 现在我们就看看 Java 的编译器为我们加上了什么?
 
首先建一个工程 TestInnerClass 用于测试。 在该工程中为了简单起见, 没有创建包, 所以源代码直接在默认包中。在该工程中, 只有下面一个简单的文件。
 
 
  1. public class Outer {
  2. int outerField = 0;
  3.  
  4. class Inner{
  5. void InnerMethod(){
  6. int i = outerField;
  7. }
  8. }
  9. }
 

该文件很简单, 就不用过多介绍了。 在外部类 Outer 中定义了内部类 Inner, 并且在 Inner 的方法中访问了 Outer 的成员变量 outerField。
 
虽然这两个类写在同一个文件中, 但是编译完成后, 还是生成各自的 class 文件:
 
 
 
这里我们的目的是探究内部类的行为, 所以只反编译内部类的 class 文件 Outer$Inner.class 。 在命令行中, 切换到工程的 bin 目录, 输入以下命令反编译这个类文件:
 
javap -classpath . -v Outer$Inner

-classpath .   说明在当前目录下寻找要反编译的 class 文件
-v   加上这个参数输出的信息比较全面。包括常量池和方法内的局部变量表, 行号, 访问标志等等。
 
注意, 如果有包名的话, 要写 class 文件的全限定名, 如: 
 
javap -classpath . -v com.baidu.Outer$Inner
 
反编译的输出结果很多, 为了篇幅考虑, 在这里我们省略了常量池。 下面给出除了常量池之外的输出信息。
 
 
{
  final Outer this$0;
    flags: ACC_FINAL, ACC_SYNTHETIC


  Outer$Inner(Outer);
    flags:
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #10                 // Field this$0:LOuter;
         5: aload_0
         6: invokespecial #12                 // Method java/lang/Object."<init>":()V
         9: return
      LineNumberTable:
        line 5: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0      10     0  this   LOuter$Inner;

  void InnerMethod();
    flags:
    Code:
      stack=1, locals=2, args_size=1
         0: aload_0
         1: getfield      #10                 // Field this$0:LOuter;
         4: getfield      #20                 // Field Outer.outerField:I
         7: istore_1
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       9     0  this   LOuter$Inner;
               8       1     1     i   I
}
首先我们会看到, 第一行的信息如下:
final Outer this$0;
这句话的意思是, 在内部类 Outer$Inner 中, 存在一个名字为 this$0 , 类型为 Outer 的成员变量, 并且这个变量是 final 的。 其实这个就是所谓的 “在内部类对象中存在的指向外部类对象的引用”。但是我们在定义这个内部类的时候, 并没有声明它, 所以这个成员变量是编译器加上的。 
 
虽然编译器在创建内部类时为它加上了一个指向外部类的引用, 但是这个引用是怎样赋值的呢?毕竟必须先给他赋值, 它才能指向外部类对象。 下面我们把注意力转移到构造函数上。 下面这段输出是关于构造函数的信息。
 
  Outer$Inner(Outer);
    flags:
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #10                 // Field this$0:LOuter;
         5: aload_0
         6: invokespecial #12                 // Method java/lang/Object."<init>":()V
         9: return
      LineNumberTable:
        line 5: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0      10     0  this   LOuter$Inner;

我们知道, 如果在一个类中, 不声明构造方法的话, 编译器会默认添加一个无参数的构造方法。 但是这句话在这里就行不通了, 因为我们明明看到, 这个构造函数有一个构造方法, 并且类型为 Outer。 所以说,编译器会为内部类的构造方法添加一个参数, 参数的类型就是外部类的类型。
 
下面我们看看在构造参数中如何使用这个默认添加的参数。 我们来分析一下构造方法的字节码。 下面是每行字节码的意义:
 
aload_0 :  
 将局部变量表中的第一个引用变量加载到操作数栈。 这里有几点需要说明。 局部变量表中的变量在方法执行前就已经初始化完成;局部变量表中的变量包括方法的参数;成员方法的局部变量表中的第一个变量永远是 this;操作数栈就是执行当前代码的栈。所以这句话的意思是: 将 this 引用从局部变量表加载到操作数栈。
 
aload_1:
将局部变量表中的第二个引用变量加载到操作数栈。 这里加载的变量就是构造方法中的 Outer 类型的参数。
 
putfield      #10                 // Field this$0:LOuter;
使用操作数栈顶端的引用变量为指定的成员变量赋值。 这里的意思是将外面传入的 Outer 类型的参数赋给成员变量 this$0 。 
这一句 putfield 字节码就揭示了, 指向外部类对象的这个引用变量是如何赋值的。
 
下面几句字节码和本文讨论的话题无关, 只做简单的介绍。 下面几句字节码的含义是: 使用 this 引用调用父类(Object)的构造方法然后返回。
 
用我们比较熟悉的形式翻译过来, 这个内部类和它的构造函数有点像这样: (注意, 这里不符合 Java 的语法, 只是为了说明问题)
 
 
  1. class Outer$Inner{
  2. final Outer this$0;
  3.  
  4. public Outer$Inner(Outer outer){
  5. this.this$0 = outer;
  6. super();
  7. }
  8. }
 


说到这里, 可以推想到, 在调用内部类的构造器初始化内部类对象的时候, 编译器默认也传入外部类的引用。 调用形式有点像这样: (注意, 这里不符合 java 的语法, 只是为了说明问题)
 
 
这也印证了上面所说的内部类和外部类逻辑关系的第一条: 内部类对象的创建依赖于外部类对象。
 
关于在内部类中如何使用指向外部类的引用访问外部类成员, 就不用多做解释了, 其实和普通的通过引用访问成员的方式是相同的。 在内部类的 InnerMethod 方法中, 访问了外部类的成员变量 outerField, 下面的字节码揭示了访问是如何进行的:
 
  void InnerMethod();
    flags:
    Code:
      stack=1, locals=2, args_size=1
         0: aload_0
         1: getfield      #10                 // Field this$0:LOuter;
         4: getfield      #20                 // Field Outer.outerField:I
         7: istore_1
         8: return

getfield      #10                 // Field this$0:LOuter;
将成员变量 this$0 加载到操作数栈上来
 
getfield      #20                 // Field Outer.outerField:I
使用上面加载的 this$0 引用, 将外部类的成员变量 outerField 加载到操作数栈
 
istore_1
将操作数栈顶端的 int 类型的值保存到局部变量表中的第二个变量上(注意, 第一个局部变量被 this 占用, 第二个局部变量是 i)。操作数栈顶端的 int 型变量就是上一步加载的 outerField 变量。 所以, 这句字节码的含义就是: 使用 outerField 为 i 赋值。
 
上面三步就是内部类中是如何通过指向外部类对象的引用, 来访问外部类成员的。
 
 
 

总结

 
文章写到这里, 相信读者对整个原理就会有一个清晰的认识了。 下面做一下总结:
 
本文通过反编译内部类的字节码, 说明了内部类是如何访问外部类对象的成员的,除此之外, 我们也对编译器的行为有了一些了解, 编译器在编译时会自动加上一些逻辑, 这正是我们感觉困惑的原因。 
 
关于内部类如何访问外部类的成员, 分析之后其实也很简单, 主要是通过以下几步做到的:
 
1 编译器自动为内部类添加一个成员变量, 这个成员变量的类型和外部类的类型相同, 这个成员变量就是指向外部类对象的引用;
 
2 编译器自动为内部类的构造方法添加一个参数, 参数的类型是外部类的类型, 在构造方法内部使用这个参数为 1 中添加的成员变量赋值;
 
3 在调用内部类的构造函数初始化内部类对象时, 会默认传入外部类的引用。
 
 

 

posted @ 2024-06-26 14:30  CharyGao  阅读(10)  评论(0编辑  收藏  举报