深入理解为什么Java中方法内定义的内部类可以访问方法中的局部变量

时间:2024-07-05 19:37:20

好文转载:http://blog.****.net/zhangjg_blog/article/details/19996629

开篇

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

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

定义在方法中的内部类

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

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

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

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

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

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

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

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

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

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

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

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

在外部类的方法outerMethod中定义了成员变量 String localVar, 并且用一个编译时字面量"abc"给他赋值。在 outerMethod方法中定义了内部类Inner, 并且在内部类的方法innerMethod中访问了localVar变量。 接下来我们就根据这个例子来讲解为什么可以这样做。

首先看编译后的文件, 和普通的内部类一样, 定义在方法中的内部类在编译之后, 也有自己独立的class文件:
深入理解为什么Java中方法内定义的内部类可以访问方法中的局部变量
和普通内部类的区别是, 普通内部类的class文件名为Outer$Inner.class 。 而定义在方法中的内部类的class文件名为Outer$<N>Inner.class 。 N代表数字, 如1, 2, 3 等 。 在外部类第一个方法中定义的内部类, 编号为1, 同理在外部类第二个方法中定义的内部类编号为2, 在外部类中第N个方法中定义的内部类编号为N 。 这些都是题外话, 主要想说的是, 方法中的内部类也有自己独立的class文件。 
我们通过javap反编译工具, 把 Outer$1Inner.class 反编译成可读的形式。 关于javap工具的使用, 请参考我的上一篇博客。 反编译的输出结果如下:
  1. Constant pool:
  2. #1 = Class              #2             //  Outer$1Inner
  3. #2 = Utf8               Outer$1Inner
  4. #3 = Class              #4             //  java/lang/Object
  5. #4 = Utf8               java/lang/Object
  6. #5 = Utf8               this$0
  7. #6 = Utf8               LOuter;
  8. #7 = Utf8               <init>
  9. #8 = Utf8               (LOuter;)V
  10. #9 = Utf8               Code
  11. #10 = Fieldref           #1.#11         //  Outer$1Inner.this$0:LOuter;
  12. #11 = NameAndType        #5:#6          //  this$0:LOuter;
  13. #12 = Methodref          #3.#13         //  java/lang/Object."<init>":()V
  14. #13 = NameAndType        #7:#14         //  "<init>":()V
  15. #14 = Utf8               ()V
  16. #15 = Utf8               LineNumberTable
  17. #16 = Utf8               LocalVariableTable
  18. #17 = Utf8               this
  19. #18 = Utf8               LOuter$1Inner;
  20. #19 = Utf8               innerMethod
  21. #20 = String             #21            //  abc
  22. #21 = Utf8               abc
  23. #22 = Utf8               a
  24. #23 = Utf8               Ljava/lang/String;
  25. #24 = Utf8               SourceFile
  26. #25 = Utf8               Outer.java
  27. #26 = Utf8               EnclosingMethod
  28. #27 = Class              #28            //  Outer
  29. #28 = Utf8               Outer
  30. #29 = NameAndType        #30:#14        //  outerMethod:()V
  31. #30 = Utf8               outerMethod
  32. #31 = Utf8               InnerClasses
  33. #32 = Utf8               Inner
  34. {
  35. final Outer this$0;
  36. flags: ACC_FINAL, ACC_SYNTHETIC
  37. Outer$1Inner(Outer);
  38. flags:
  39. Code:
  40. stack=2, locals=2, args_size=2
  41. 0: aload_0
  42. 1: aload_1
  43. 2: putfield      #10                 // Field this$0:LOuter;
  44. 5: aload_0
  45. 6: invokespecial #12                 // Method java/lang/Object."<init>":()V
  46. 9: return
  47. LineNumberTable:
  48. line 8: 0
  49. LocalVariableTable:
  50. Start  Length  Slot  Name   Signature
  51. 0      10     0  this   LOuter$1Inner;
  52. void innerMethod();
  53. flags:
  54. Code:
  55. stack=1, locals=2, args_size=1
  56. 0: ldc           #20                 // String abc
  57. 2: astore_1
  58. 3: return
  59. LineNumberTable:
  60. line 10: 0
  61. line 11: 3
  62. LocalVariableTable:
  63. Start  Length  Slot  Name   Signature
  64. 0       4     0  this   LOuter$1Inner;
  65. 3       1     1     a   Ljava/lang/String;
  66. }

innerMethod方法中一共就以下有三个指令:

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

我们可以看到, “abc”这个字符串确实出现在Outer.class常量池的第15项。 这就奇怪了, 明明是定义在外部类的字面量, 为什么会出现在 内部类的常量池中呢? 其实这正是编译器在编译方法中定义的内部类时, 所做的额外工作。 

下面我们将这个被内部类访问的局部变量改成整形的。 看看在字节码层面上会有什么变化。 修改后的源码如下:
  1. public class Outer {
  2. void outerMethod(){
  3. final  int localVar = 1;
  4. /*定义在方法中的内部类*/
  5. class Inner{
  6. void innerMethod(){
  7. int a = localVar;
  8. }
  9. }
  10. }
  11. }

内部类反编译后的class文件如下: (由于在这里常量池不是重点, 所以省略了常量池信息)

  1. {
  2. final Outer this$0;
  3. flags: ACC_FINAL, ACC_SYNTHETIC
  4. Outer$1Inner(Outer);
  5. flags:
  6. Code:
  7. stack=2, locals=2, args_size=2
  8. 0: aload_0
  9. 1: aload_1
  10. 2: putfield      #10                 // Field this$0:LOuter;
  11. 5: aload_0
  12. 6: invokespecial #12                 // Method java/lang/Object."<init>":()V
  13. 9: return
  14. LineNumberTable:
  15. line 8: 0
  16. LocalVariableTable:
  17. Start  Length  Slot  Name   Signature
  18. 0      10     0  this   LOuter$1Inner;
  19. void innerMethod();
  20. flags:
  21. Code:
  22. stack=1, locals=2, args_size=1
  23. 0: iconst_1
  24. 1: istore_1
  25. 2: return
  26. LineNumberTable:
  27. line 10: 0
  28. line 11: 2
  29. LocalVariableTable:
  30. Start  Length  Slot  Name   Signature
  31. 0       3     0  this   LOuter$1Inner;
  32. 2       1     1     a   I
  33. }

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

  1. iconst_1

这句字节码的意义是:将int类型的常量 1 压入操作数栈。 这就是在内部类中访问外部类方法中的局部变量int localVar = 1的原理。 由此可见, 当内部类中访问的局部变量是int型的字面量时, 编译器直接将对该变量的访问嵌入到内部类的字节码中, 也就是说, 在运行时, 方法中的内部类和外部类, 和外部类方法中的局部变量就没有任何关系了。 这也是编译器所做的额外工作。

上面两种情况有一个共同点, 那就是, 被内部类访问的外部了方法中的局部变量, 都是在编译时可以确定的字面常量。 像下面这样的形式都是编译时可确定的字面常量:
  1. final  String localVar = "abc";
  1. final  int localVar = 1;

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

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

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

由于使用getString方法的返回值为localVar赋值, 所以在编译时期, 编译器不可确定localVar的值, 必须在运行时执行了getString方法之后才能确定它的值。 既然编译时不不可确定, 那么像上面那样的处理就行不通了。 那么在这种情况下, 内部类是通过什么机制访问方法中的局部变量的呢? 让我们继续反编译内部类的字节码:

  1. Constant pool:
  2. #1 = Class              #2             //  Outer$1Inner
  3. #2 = Utf8               Outer$1Inner
  4. #3 = Class              #4             //  java/lang/Object
  5. #4 = Utf8               java/lang/Object
  6. #5 = Utf8               this$0
  7. #6 = Utf8               LOuter;
  8. #7 = Utf8               val$localVar
  9. #8 = Utf8               Ljava/lang/String;
  10. #9 = Utf8               <init>
  11. #10 = Utf8               (LOuter;Ljava/lang/String;)V
  12. #11 = Utf8               Code
  13. #12 = Fieldref           #1.#13         //  Outer$1Inner.this$0:LOuter;
  14. #13 = NameAndType        #5:#6          //  this$0:LOuter;
  15. #14 = Fieldref           #1.#15         //  Outer$1Inner.val$localVar:Ljava/la
  16. ng/String;
  17. #15 = NameAndType        #7:#8          //  val$localVar:Ljava/lang/String;
  18. #16 = Methodref          #3.#17         //  java/lang/Object."<init>":()V
  19. #17 = NameAndType        #9:#18         //  "<init>":()V
  20. #18 = Utf8               ()V
  21. #19 = Utf8               LineNumberTable
  22. #20 = Utf8               LocalVariableTable
  23. #21 = Utf8               this
  24. #22 = Utf8               LOuter$1Inner;
  25. #23 = Utf8               innerMethod
  26. #24 = Utf8               a
  27. #25 = Utf8               SourceFile
  28. #26 = Utf8               Outer.java
  29. #27 = Utf8               EnclosingMethod
  30. #28 = Class              #29            //  Outer
  31. #29 = Utf8               Outer
  32. #30 = NameAndType        #31:#18        //  outerMethod:()V
  33. #31 = Utf8               outerMethod
  34. #32 = Utf8               InnerClasses
  35. #33 = Utf8               Inner
  36. {
  37. final Outer this$0;
  38. flags: ACC_FINAL, ACC_SYNTHETIC
  39. Outer$1Inner(Outer, java.lang.String);
  40. flags:
  41. Code:
  42. stack=2, locals=3, args_size=3
  43. 0: aload_0
  44. 1: aload_1
  45. 2: putfield      #12                 // Field this$0:LOuter;
  46. 5: aload_0
  47. 6: aload_2
  48. 7: putfield      #14                 // Field val$localVar:Ljava/lang/String;
  49. 10: aload_0
  50. 11: invokespecial #16                 // Method java/lang/Object."<init>":()V
  51. 14: return
  52. LineNumberTable:
  53. line 8: 0
  54. LocalVariableTable:
  55. Start  Length  Slot  Name   Signature
  56. 0      15     0  this   LOuter$1Inner;
  57. void innerMethod();
  58. flags:
  59. Code:
  60. stack=1, locals=2, args_size=1
  61. 0: aload_0
  62. 1: getfield      #14                 // Field val$localVar:Ljava/lang/String;
  63. 4: astore_1
  64. 5: return
  65. LineNumberTable:
  66. line 10: 0
  67. line 11: 5
  68. LocalVariableTable:
  69. Start  Length  Slot  Name   Signature
  70. 0       6     0  this   LOuter$1Inner;
  71. 5       1     1     a   Ljava/lang/String;
  72. }

首先来看它的构造方法。 方法的签名为:

  1. Outer$1Inner(Outer, java.lang.String);

我们只到, 如果不定义构造方法, 那么编译器会为这个类自动生成一个无参数的构造方法。 这个说法在这里就行不通了, 因为我们看到, 这个内部类的构造方法又两个参数。 至于第一个参数, 是指向外部类对象的引用, 在前面一篇博客中已经详细的介绍过了, 不明白的可以先看上一篇博客, 这里就不再重复叙述。这也说明了方法中的内部类和类中定义的内部类有相同的地方, 既然他们都是内部类, 就都持有指向外部类对象的引用。  我们来分析第二个参数, 他是String类型的, 和在内部类中访问的局部变量localVar的类型相同。 再看构造方法中编号为6和7的字节码指令:

  1. 6: aload_2
  2. 7: putfield      #14                 // Field val$localVar:Ljava/lang/String;
这句话的意思是, 使用构造方法的第二个参数, 为当前这个内部类对象的成员变量赋值, 这个被赋值的成员变量的名字是 val$localVar 。 由此可见, 编译器自动为内部类增加了一个成员变量, 其实这个成员变量就是被访问的外部类方法中的局部变量。 这个局部变量在创建内部类对象时, 通过构造方法注入。 在调用构造方法时, 编译器会默认为这个参数传入外部类方法中的局部变量的值。 
再看内部类中的方法innerMethod中是如何访问这个所谓的“局部变量的”。 看innerMethod中的前两条字节码:
  1. 0: aload_0
  2. 1: getfield      #14                 // Field val$localVar:Ljava/lang/String;

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

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

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

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

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

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

深入理解为什么Java中方法内定义的内部类可以访问方法中的局部变量

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

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

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

深入理解为什么Java中方法内定义的内部类可以访问方法中的局部变量

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

相关文章