阅读 1242

Java 干货之深入理解Java内部类

可以将一个类定义在另一个类或方法中,这样的类叫做内部类 --《Thinking in Java》

说起内部类,大家并不陌生,并且会经常在实例化容器的时候使用到它。但是内部类的具体细节语法,原理以及实现是什么样的可以不少人都还挺陌生,这里作一篇总结,希望通过这篇总结提高对内部类的认识。


内部类是什么?

由文章开头可知,内部类的定义为:定义在另一个类或方法中的类。而根据使用场景的不同,内部类还可以分为四种:成员内部类,局部内部类,匿名内部类和静态内部类。每一种的特性和注意事项都不同,下面我们一一说明。

成员内部类

顾名思义,成员内部类是定义在类内部,作为类的成员的类。如下:

public class Outer {
    
   public class Inner{
       
   }

}

复制代码

特点如下:

  1. 成员内部类可以被权限修饰符(eg. public,private等)所修饰
  2. 成员内部类可以访问外部类的所有成员,(包括private)成员
  3. 成员内部类是默认包含了一个指向外部类对象的引用
  4. 如同使用this一样,当成员名或方法名发生覆盖时,可以使用外部类的名字加.this指定访问外部类成员。如:Outer.this.name
  5. 成员内部类不可以定义static成员
  6. 成员内部类创建语法:
Outer outer=new Outer();
Outer.Inner inner=outer.new Inner();
复制代码

局部内部类

局部内部类是定义在方法或者作用域中类,它和成员内部类的区别仅在于访问权限的不同。

public class Outer{
    public void test(){
        class Inner{
            
        }
    }
}
复制代码

特点如下:

  1. 局部内部类不能有访问权限修饰符

  2. 局部内部类不能被定义为static

  3. 局部内部类不能定义static成员

  4. 局部内部类默认包含了外部类对象的引用

  5. 局部内部类也可以使用Outer.this语法制定访问外部类成员

  6. 局部内部类想要使用方法或域中的变量,该变量必须是final

    在JDK1.8 以后,没有final修饰,effectively final的即可。什么意思呢?就是没有final修饰,但是如果加上final编译器也不会报错即可。

匿名内部类

匿名内部类是与继承合并在一起的没有名字的内部类

public class Outer{
    public List<String> list=new ArrayList<String>(){
        {
            add("test");
        }
    };
}
复制代码

这是我们平时最常用的语法。 匿名内部类的特点如下:

  1. 匿名内部类使用单独的块表示初始化块{}
  2. 匿名内部类想要使用方法或域中的变量,该变量必须是final修饰的,JDK1.8之后effectively final也可以
  3. 匿名内部类默认包含了外部类对象的引用
  4. 匿名内部类表示继承所依赖的类

嵌套类

嵌套类是用static修饰的成员内部类

public class Outer {
    
   public static class Inner{
       
   }

}
复制代码

特点如下:

  1. 嵌套类是四种类中唯一一个不包含对外部类对象的引用的内部类

  2. 嵌套类可以定义static成员

  3. 嵌套类能访问外部类任何静态数据成员与方法。

    构造函数可以看作静态方法,因此可以访问。


为什么要有内部类?

从上面可以看出,内部类的特性和类方差不多,但是内部类有许多繁琐的细节语法。既然内部类有这么多的细节要注意,那为什么Java还要支持内部类呢?

1. 完善多重继承
  1. 在早期C++作为面向对象编程语言的时候,最难处理的也就是多重继承,多重继承对于代码耦合度,代码使用人员的理解来说,并不怎么友好,并且还要比较出名的死亡菱形的多重继承问题。因此Java并不支持多继承。
  2. 后来,Java设计者发现,没有多继承,一些代码友好的设计与编程问题变得十分难以解决。于是便产生了内部类。内部类具有:隐式包含外部类对象并且能够与之通信的特点,完美的解决了多重继承的问题。
2. 解决多次实现/继承问题
  1. 有时候在一个类中,需要多次通过不同的方式实现同一个接口,如果没有内部类,必须多次定义不同数量的类,但是使用内部类可以很好的解决这个问题,每个内部类都可以实现同一个接口,即实现了代码的封装,又实现了同一接口不同的实现。

  2. 内部类可以将组合的实现封装在内部中。


为什么内部类的语法这么繁杂

这一点是本文的重点。内部类语法之所以这么繁杂,是因为它是新数据类型加语法糖的结合。想要理解内部类,还得从本质上出发.

内部类根据应用场景的不同分为4种。其应用场景完全可以和类方法对比起来。
下面我们通过类方法对比的模式一一解答为什么内部类会有这样的特点

成员内部类——>成员方法

成员内部类的设计完全和成员方法一样。
调用成员方法:outer.getName()
新建内部类对象:outer.new Inner()
它们都是要依赖对象而被调用。 正如《Thinking in Java》所说,outer.getName()正真的形似是Outer.getName(outer),也就是将调用对象作为参数传递给方法。
新建一个内部类也是这样:Outer.new Inner(outer)

下面,我们用实际情况证明: 新建一个包含内部类的类:

public class Outer {

    private int m = 1;

    public class Inner {
    
        private void test() {
            //访问外部类private成员
            System.out.println(m);
        }
    }
}
复制代码

编译,会发现会在编译目标目录生成两个.class文件:Outer.classOuter$Inner.class

PS:不知道为什么Java总是和过不去,就连变量命名规则都要比C++多一个能由组成 :)

Outer$Inner.class放入IDEA中打开,会自动反编译,查看结果:

public class Outer$Inner {
    public Outer$Inner(Outer this$0) {
        this.this$0 = this$0;
    }

    private void test() {
        System.out.println(Outer.access$000(this.this$0));
    }
}
复制代码

可以看见,编译器已经自动生成了一个默认构造器,这个默认构造器是一个带有外部类型引用的参数构造器。

可以看到外部类成员对象的引用:Outer是由final修饰的。

因此:

  1. 成员内部类作为类级成员,因此能被访问修饰符所修饰
  2. 成员内部类中包含创建内部类时对外部类对象的引用,所以成员内部类能访问外部类的所有成员。
  3. 语法规定:因为它作为外部类的一部分成员,所以即使private的对象,内部类也能访问。。通过Outer.access$ 指令访问
  4. 如同非静态方法不能访问静态成员一样,非静态内部类也被设计的不能拥有静态变量,因此内部类不能定义static对象和方法。

但是可以定义static final变量,这并不冲突,因为所定义的final字段必须是编译时确定的,而且在编译类时会将对应的变量替换为具体的值,所以在JVM看来,并没有访问内部类。

局部内部类——> 局部代码块

局部内部类可以和局部代码块相理解。它最大的特点就是只能访问外部的final变量。 先别着急问为什么。
定义一个局部内部类:

public class Outer {

    private void test() {

        int  m= 3;
        class Inner {
            private void print() {
                System.out.println(m);
            }
        }
    }

}
复制代码

编译,发现生成两个.class文件Outer.classOuter$1Inner.classOuter$1Inner.class放入IDEA中反编译:

class Outer$1Inner {
    Outer$1Inner(Outer this$0, int var2) {
        this.this$0 = this$0;
        this.val$m = var2;
    }

    private void print() {
        System.out.println(this.val$m);
    }
}

复制代码

可以看见,编译器自动生成了带有两个参数的默认构造器。
看到这里,也许应该能明了:我们将代码转换下:

public class Outer {
    private void test() {
        int  m= 3;
        Inner inner=new Outer$1Inner(this,m);
        
        inner.print();
        }
    }

}
复制代码

也就是在Inner中,其实是将m的值,拷贝到内部类中的。print()方法只是输出了m,如果我们写出了这样的代码:

    private void test() {

        int  m= 3;

        class Inner {

            private void print() {
               m=4;
            }
        }
        
       System.out.println(m);  
    }
复制代码

在我们看来,m的值应该被修改为4,但是它真正的效果是:

private void test(){
    int m = 3;
    
    print(m);
    
    System.out.println(m);
}

private void print(int m){
    m=4;
}
复制代码

m被作为参数拷贝进了方法中。因此修改它的值其实没有任何效果,所以为了不让程序员随意修改m而却没达到任何效果而迷惑,m必须被final修饰。

绕了这么大一圈,为什么编译器要生成这样的效果呢?
其实,了解闭包的概念的人应该都知道原因。而Java中各种诡异的语法一般都是由生命周期带来的影响。上面的程序中,m是一个局部变量,它被定义在栈上,而new Outer$1Inner(this,m);所生成的对象,是定义在堆上的。如果不将m作为成员变量拷贝进对象中,那么离开m的作用域,Inner对象所指向的便是一个无效的地址。因此,编译器会自动将局部类所使用的所有参数自动生成成员。

为什么其他语言没有这种现象呢?
这又回到了一个经典的问题上:Java是值传递还是引用传递。由于Java always pass-by-value,对于真正的引用,Java是无法传递过去的。而上面的问题核心就在与m如果被改变了,那么其它的m的副本是无法感知到的。而其他语言都通过其他的途径解决了这个问题。
对于C++就是一个指针问题

理解了真正的原因,便也能知道什么时候需要final,什么时候不需要final了。

public class Outer {
    private void test() {
        class Inner {
        int m=3;
            private void print() {
                System.out.println(m);//作为参数传递,本身都已经 pass-by-value。不用final
                int c=m+1; //直接使用m,需要加final
                
            }
        }
    }

}
复制代码

而在Java 8 中,已经放宽政策,允许是effectively final的变量,实际上,就是编译器在编译的过程中,帮你加上final而已。而你应该保证允许编译器加上final后,程序不报错。

  1. 局部内部类还有个特点就是不能有权限修饰符。就好像局部变量不能有访问修饰符一样

  2. 由上面可以看到,外部对象同样是被传入局部类中,因此局部类可以访问外部对象

嵌套类——>静态方法

嵌套类没什么好说的,就好像静态方法一样,他可以被直接访问,他也能定义静态变量。同时不能访问非静态成员。
值得注意的是《Think in Java》中说过,可以将构造函数看作为静态方法,因此嵌套类可以访问外部类的构造方法。

匿名类——>局部方法+继承的语法糖

匿名类可以看作是对前3种类的再次扩展。具体来说匿名类根据应用场景可以看作:

  • 成员内部类+继承
  • 局部内部类+继承
  • 嵌套内部类+继承

匿名类语法为:

new 继承类名(){
  
  //Override 重载的方法    
    
}
复制代码

返回的结果会向上转型为继承类。

声明一个匿名类:

public class Outer {

    private  List<String> list=new ArrayList<String>(){
        {
            add("test");
        }
    };

}
复制代码

这便是一个经典的匿名类用法。 同样编译上面代码会看到生成了两个.class文件Outer.class,Outer$1.classOuter$1.class放入IDEA中反编译:

class Outer$1 extends ArrayList<String> {
    Outer$1(Outer this$0) {
        this.this$0 = this$0;
        this.add("1");
    }
}

复制代码

可以看到匿名类的完整语法便是继承+内部类。
由于匿名类可以申明为成员变量,局部变量,静态成员变量,因此它的组合便是几种内部类加继承的语法糖,这里不一一证明。
在这里值得注意的是匿名类由于没有类名,因此不能通过语法糖像正常的类一样声明构造函数,但是编译器可以识别{},并在编译的时候将代码放入构造函数中。

{}可以有多个,会在生成的构造函数中按顺序执行。


怎么正确的使用内部类

在第二小节中,我们已经讨论过内部类的应用场景,但是如何优雅,并在正确的应用场景使用它呢?本小节将会详细讨论。

1.注意内存泄露

《Effective Java》第二十四小节明确提出过。优先使用静态内部类。这是为什么呢? 由上面的分析我们可以知道,除了嵌套类,其他的内部类都隐式包含了外部类对象。这便是Java内存泄露的源头。看代码:

定义Outer:

public class Outer{

    public  List<String> getList(String item) {

        return new ArrayList<String>() {
            {
                add(item);
            }
        };
    }
}
复制代码

使用Outer:

public class Test{

   public static List<String> getOutersList(){
   
    Outer outer=new Outer();
    //do something
    List<String> list=outer.getList("test");
   
    return list;    
   }
   public static void main(String[] args){
       List<String> list=getOutersList();
       
      
      //do something with list
   }
   
}

复制代码

相信这样的代码一定有同学写出来,这涉及到一个习惯的问题:

不涉及到类成员方法和成员变量的方法,最好定义为static

我们先研究上面的代码,最大的问题便是带来的内存泄露:
在使用过程中,我们定义Outer对象完成一系列的动作

  • 使用outer得到了一个ArraList对象
  • ArrayList作为结果返回出去。

正常来说,在getOutersList方法中,我们new出来了两个对象:outerlist,而在离开此方法时,我们只将list对象的引用传递出去,outer的引用随着方法栈的退出而被销毁。按道理来说,outer对象此时应该没有作用了,也应该在下一次内存回收中被销毁。

然而,事实并不是这样。按上面所说的,新建的list对象是默认包含对outer对象的引用的,因此只要list不被销毁,outer对象将会一直存在,然而我们并不需要outer对象,这便是内存泄露。

怎么避免这种情况呢?

很简单:不涉及到类成员方法和成员变量的方法,最好定义为static

public class Outer{

    public static List<String> getList(String item) {

        return new ArrayList<String>() {
            {
                add(item);
            }
        };
    }
}
复制代码

这样定义出来的类便是嵌套类+继承,并不包含对外部类的引用。

2.应用于只实现一个接口的实现类

  • 优雅工厂方法模式

我们可以看到,在工厂方法模式中,每个实现都会需要实现一个Fractory来实现产生对象的接口,而这样接口其实和原本的类关联性很大的,因此我们可以将Fractory定义在具体的类中,作为内部类存在

  • 简单的实现接口
       new Thread(new Runnable() {
           @Override
           public void run() {
               System.out.println("test");
           }
       }

       ).start();
    }
复制代码

尽量不要直接使用Thread,这里只做演示使用 Java 8 的话建议使用lambda代替此类应用

  • 同时实现多个接口
public class imple{

    public static Eat getDogEat(){
        return new EatDog();
    }

    public static Eat getCatEat(){
        return new EatCat();
    }

    private static class EatDog implements Eat {
        @Override
        public void eat() {
            System.out.println("dog eat");
        }
    }
    private static class EatCat implements Eat{
        @Override
        public void eat() {
            System.out.println("cat eat");
        }
    }
}
复制代码

3.优雅的单例类

public class Imple {

    public static Imple getInstance(){
        return ImpleHolder.INSTANCE;
    }


    private static class ImpleHolder{
        private static final Imple INSTANCE=new Imple();
    }
}
复制代码

4.反序列化JSON接受的JavaBean 有时候需要反序列化嵌套JSON

{
    "student":{
        "name":"",
        "age":""
    }
}
复制代码

类似这种。我们可以直接定义嵌套类进行反序列化

public JsonStr{
    
    private Student student;
    
    public static Student{
        private String name;
        private String age;
        
        //getter & setter
    }

    //getter & setter
}

复制代码

但是注意,这里应该使用嵌套类,因为我们不需要和外部类进行数据交换。

核心思想:

  • 嵌套类能够访问外部类的构造函数
  • 将第一次访问内部类放在方法中,这样只有调用这个方法的时候才会第一次访问内部类,实现了懒加载

内部类还有很多用法,这里不一一列举。


总结

内部类的理解可以按照方法来理解,但是内部类很多特性都必须剥开语法糖和明白为什么需要这么做才能完全理解,明白内部类的所有特性才能更好使用内部类,在内部类的使用过程中,一定记住:能使用嵌套类就使用嵌套类,如果内部类需要和外部类联系,才使用内部类。最后不涉及到类成员方法和成员变量的方法,最好定义为static可以防止内部类内存泄露。

尊重劳动成果,转载请标注出处。


如果觉得写得不错,欢迎关注微信公众号:逸游Java ,每天不定时发布一些有关Java干货的文章,感谢关注

参考文章:
Java 中引入内部类的意义?
成员内部类里面为什么不能有静态成员和方法?