关键字

Mr.Zhang2022年12月9日Java基础大约 14 分钟

关键字

静态内部类实现的单例如何做到线程安全且可延迟加载?

Java中的关键字有50多个,比如private、public、protected、class、interface、switch等等,大部分用法都比较简单,所以,我们不做讲解。本节,我们重点讲解final和static这两个关键词。这两个关键字既在开发中经常使用,也在面试中经常被考察。它们看似非常简单,但彻底搞懂却不容易,不信?我们来看下面这段代码。

public class Singleton {
  private Singleton() {}
  
  private static class SingletonHolder {
    public static final Singleton instance = new Singleton();
  }

  public static Singleton getInstance() {
    return SingletonHolder.instance;
  }
}

上述代码是使用静态内部类实现单例的经典写法,我想很多同学都会写,但深入到细节,未必每个同学都能说得清楚,不信的话,你可以试着回答下面几个问题。

1)为什么这样实现的单例是线程安全的?

2)为什么这样实现的单例支持延迟加载?

3)为什么SingletonHolder设计为静态内部类,是否可以是普通内部类?

4)为什么将SingletonHolder设计为private的,而不是public?

带着这些问题,我们来学习今天的内容。

一、final关键字

final关键字可以修饰类、方法和变量。我们依次来看下用final修饰的类、方法和变量都具有哪些特性。

1)final修饰的类叫做final类,final类不可被继承。

2)final修饰的方法叫做final方法,final方法在子类中不可被重写。 反过来,子类可以将父类中的非final方法,重写为final方法。在早期的JVM实现中,final修饰的方法叫做内联函数。虚拟机执行编译优化,将内联函数的代码直接展开插入到调用处,以减少函数调用。但这种设计早已废弃,现在的JVM会根据某些其他情况,来判断是否将某个函数视为内联函数,而不是由final关键字来决定。关于这一点,我们在JVM模块中再详细讲解。

3)final修饰变量叫做final变量或常量,final变量只能被赋值一次,之后就不能再修改。 final修饰的变量有三类:类的成员变量、函数的局部变量、函数的参数。

接下来,我们重点讲下final变量。

对于final修饰的成员变量,赋值的方法有两种,一种是在成员变量声明时,另一种是在构造函数中,毕竟构造函数只会被调用一次,所以,对象一旦被创建,final成员变量便不会再被更改。示例代码如下所示。

// 方法一
public class Demo10_1 {
  private final int fl = 6;
}

// 方法二
public class Demo10_1 {
  private final int fl;
  
  public Demo10_1(int vfl) {
    this.fl = vfl;
  }
}

对于final修饰的局部变量,赋值的方式也有两种,一种是在局部变量声明时,另一种是在使用前赋值一次,之后就不能再被赋值。使用未被赋值的final局部变量会报编译错误。示例代码如下所示。

// 方法一
public double caculateArea(double r) {
  final double pi = 3.1415;
  double area = pi * r * r;
  return area;
}

// 方法二
public double caculateArea(double r) {
  final double pi;
  pi = 3.1415; //使用前赋值
  double area = pi * r * r;
  return area;
}

final修饰的变量既可以是基本类型变量,也可以是引用类型变量。对于引用类型变量,final关键词只限制引用变量本身不可变,但引用变量所引用的对象的属性或者数组的元素是可变。 示例代码如下所示。

public class Demo10_2 {
  public static void main(String[] args) {
    final Student s = new Student(1, 1);
    f(s);
    System.out.println(s.id); //打印2
  }

  public static void f(final Student s) {
    // s = new Student(2,2); //编译报错
    s.id = 2;
  }
}

了解了final的用法之后,我们来看下,final的一个重要应用场景:不可变类。在第8节中,我们讲到String、Integer等都是不可变类。本节,我们就结合String的设计思路,来看下如何设计一个不可变类呢?

1)将类设置为final类,这样类就无法被继承,避免通过如下方式创建可变对象。

public class MyString extends String {
  //重写toCharArray()方法,让它直接返回value数组,
  //这样就能更改value数组了
  @Override
  public char[] toCharArray() {
    return value.
  }
}

String s = new MyString("abc");
char[] chars = s.toCharArray();
chars[0] = 'x'; 
System.out.println(s); //打印xbc

2)将类中所有的属性都设置为final,在创建对象时设置,之后不再允许修改。 当然,如果能保证类中没有方法会改变这个属性的值,也可以不用将其设置为final。例如,String类中的hash属性,因为其并非在创建对象时设置,并且类中没有方法可以二次修改此属性的值,所以,hash属性也可以不设置为final。

public final class String
    implements java.io.Serializable, 
    Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    
    //...省略很多方法...
}

3)通过方法返回的属性,如果是引用类型的(数组或对象,如String类中的value数组),需要返回属性的副本而非本身。 否则,外部代码可以通过引用,修改返回对象中的属性或数组中元素。

public char[] toCharArray() {
    char result[] = new char[value.length]; //副本
    System.arraycopy(value, 0, result, 0, value.length);
    return result; //返回副本
}

二、static关键字

static关键字可以修饰变量、方法、代码块、嵌套类。我们依次来看下用static修饰的变量、方法、代码块、嵌套类都具有哪些特性。

1)static变量

前面讲到,final可以修饰的变量包括:类的成员变量、函数的局部变量、函数参数。**而static只能修饰类的成员变量。 **static修饰的变量也叫做静态变量。当类的某个成员变量被修饰为static时,此成员变量隶属于类,为类的所有对象所共享。 这也是为什么在第9节中,我们通过JOL来查看对象内存结构时,不显示静态变量的原因。静态变量跟类的代码一起,存储在方法区。 关于这一点,我们在JVM中详细介绍。

对于静态变量,我们既可以通过类来访问,也可以通过对象来访问,如下示例代码所示。当然,下面的代码并非多线程安全的,关于这点,我们在多线程模块讲解。

public class Obj {
  public static int objCount = 0;

  public Obj() {
    objCount++;
  }
}

public class Demo10_4 {
  public static void main(String[] args) {
    Obj d1 = new Obj();
    Obj d2 = new Obj();
    System.out.println(Obj.objCount); //打印2
    System.out.println(d1.objCount); //打印2
  }
}

实际上,我们经常把static和final放在一起来修饰变量,用static final修饰的变量叫做静态常量对于一些跟具体对象无关,又不会改变的常量数据,我们一般存储将其存储在静态常量中。 静态常量的命名比较特殊,所有字母都大写。示例代码如下所示。

public final class Integer extends Number 
    implements Comparable<Integer> {
    public static final int MIN_VALUE = 0x80000000;
    public static final int MAX_VALUE = 0x7fffffff;
    //...省略其他方法和属性...
}

2)static方法

用static修饰的方法叫做静态方法。跟静态变量类似,静态方法也属于类而非对象。所以,我们可以在不创建对象的情况下,调用静态方法,这样使用起来比较方便,所以,很多工具类中的方法都设计为静态方法。比如Math类、Collections类中的方法。

public final class Math {
    /**
     * Don't let anyone instantiate this class.
     */
    private Math() {}
    public static final double E = 2.7182818284590452354;
    public static final double PI = 3.14159265358979323846;

    public static int abs(int a) {
        return (a < 0) ? -a : a;
    }
}

注意,静态方法只能访问静态成员变量,以及调用类中的其他静态方法。静态方法不能访问类中的非静态成员变量,也不能调用类中的非静态方法。反过来,类中的非静态方法可以访问类中的静态变量和静态方法。 之所以有这样的规定,还是跟静态成员变量和静态方法所有权归类有关。对象可以使用类的数据,但类不能使用具体某个对象的数据。

3)static代码块

对于某些静态成员变量,如果其初始化操作无法通过一个简单的赋值语句来完成,这时,我们可以将静态成员变量的初始化逻辑,放入static修饰的代码块中,如下所示。静态代码块是在类加载时执行,如果类中有多个静态代码块,那么静态代码块的执行顺序跟书写顺序相同。

public class ParserFactory {
  private static Map<String, Parser> parsers = new HashMap<>();
  
  static {
    parsers.put("json", new JSONParser());
    parsers.put("xml", new XMLParser());
    parsers.put("yaml", new YAMLParser());
  }
  
  //...省略其他方法和属性...
}

4)static嵌套类

final能修饰类,static也可以,不过,static只能修饰嵌套类。 嵌套类是指定义在一个类中的类,所以,也叫做内部类。承载内部类的类叫做外部类。常用的内部类有3种:普通内部类、静态内部类、匿名内部类。

内部类在编译成字节码之后,会独立于外部类,生成一个新的class文件,命名方式为:外部类名内部类名.class。对于匿名内部类,因为内部类没有名字,所以命名方式为:外部类名[序号].class。其中,[序号]为1、2、3...表示此匿名内部类是外部类的第几个匿名内部类。

普通内部类

示例代码如下所示。ArrayList类中定义了一个内部类Itr,负责遍历ArrayList容器中的元素。Itr类独自属于ArrayList类,其他类不会用到它,所以,我们把Itr类定义为ArrayList的内部类。这样,代码的可读性和可维护性更好,更加满足封装原则(不该对外暴露的不暴露)。

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, 
        Cloneable, java.io.Serializable {
    //...省略其他属性和方法...
    
    public Iterator<E> iterator() {
        return new Itr();
    }

    private class Itr implements Iterator<E> {
       int cursor;      
       int lastRet = -1; 
       int expectedModCount = modCount;
       Itr() {}
       //...省略其他属性和方法...
    }
}

注意,private内部类对除外部类之外的代码不可见,如果想在除了外部类之外的代码中使用内部类,要么将内部类设置为public,要么让内部类实现一个外部的接口,外部代码使用接口来访问内部类的代码。示例代码如下所示。

public interface I {}
public class A {
  private class B {}
  private class C implements I {} //实现外部接口的内部类
  public class D {} //public修饰的内部类
  
  public B getB() {
    return new B();
  }
  
  public I getC() {
    return new C();
  }
  
  public D getD() {
    return new D();
  }
}

public class Demo {
  public static void main(String[] args) {
    A a = new A();
    A.B b = a.getB(); //编译报错
    I c = a.getC();
    A.D d1 = a.getD();
    A.D d2 = a.new D();
  }
}

静态内部类

静态内部类跟普通内部类主要区别有三个。

1)第一个区别是,在访问权限上,内部类跟外部类中的方法具有相同的访问权限。也就是说,静态内部类跟静态方法一样,只能访问外部类的静态变量和静态方法,而普通内部类可以访问外部类的所有变量和所有方法。

2)第二个区别是,静态内部类可以包含静态变量和静态方法,而普通内部类不行,不过这点在JDK16中有所改变,在JDK16中,普通内部类也可以包含静态变量和静态方法了。

3)第三个区别是,如果要创建普通内部类的对象,需要先创建外部类的对象,而静态内部类的对象可以独立于外部类单独创建。 示例代码如下所示。

public class A {
  public class D {}
  public static class E {}
}

public class Demo {
  public static void main(String[] args) {
    A a = new A();
    A.D d = a.new D();
    A.E e = new A.E();
  }
}

根据如下代码,有如下问题:

public class Singleton {
  private Singleton() {}

  private static class SingletonHolder {
    public static final Singleton instance = new Singleton();
  }

  public static Singleton getInstance() {
    return SingletonHolder.instance;
  }
}

1)为什么这样实现的单例是线程安全的?为什么这样实现的单例支持延迟加载?

首先,静态变量的初始化是在类加载时,而类是在用到它时才会被加载,什么才算用到它呢?比如,创建对象、创建子类的对象、调用静态方法、调用静态变量、使用反射时,这几种情况下,JVM会先将类加载到方法区。其次,外部类加载并不会导致内部类的加载。再者,类的加载过程是线程安全。

当我们调用Singleton.getInstance()来获取单例对象时,JVM会先将Singleton类加载。紧接着,getInstance()函数访问了SingletonHolder类的静态变量,于是,触发JVM加载SingletonHolder类。而加载SingletonHolder类会触发静态变量的初始化操作,也就是执行SingletonHolder类中的唯一一行代码。

因此,instance的创建是在SingletonHolder类加载过程中完成的,所以是线程安全的。并且,只有在第一次调用getInstance()函数时,才会创建instance,所以,满足延迟加载。再此之后,即便再调用getInstance()函数,因为SingletonHolder类都已经加载到JVM中,instance静态变量也已经初始化完成,不会再重复执行初始化操作,所以,getInstance()函数返回的是同一个Singleton实例。

2)为什么SingletonHolder设计为静态内部类,是否可以是普通内部类?为什么将SingletonHolder设计为private的,而不是public?

普通内部类不能定义静态变量和静态方法,所以,如果SingletonHolder设计为普通内部类,那么instance将不能是static的,这样instance无法在类加载时创建,那么其创建过程又会存在线程安全问题。所以,SingletonHolder设计为静态内部类。

除此之外,因为SingletonHolder类不会被除Singleton之外的代码使用,所以,我们将其设置为private,而不是public。

匿名类

在多线程开发中,我们会经常用到匿名内部类。如下所示。因为实现Runnable接口的类只会被使用一次,所以,没必要单独定义一个新类。回调模式中的回调对象,往往也会被设计成匿名内部类。

Thread t = new Thread(new Runnable() {
  @Override
  public void run() {
    System.out.println("hello wangzheng");
  }
});
t.start();

匿名内部类跟定义它的外部函数,具有相同的访问权限,如果外部函数是静态函数,那么匿名内部类只能访问外部类的静态成员变量和静态函数;如果外部函数是普通函数,那么匿名内部类可以访问外部类的任何成员变量和成员函数,包括private修饰的成员变量和成员函数。除此之外,匿名内部类还可以访问外部函数的final局部变量。

public class Demo10_5 {
  private static int a = 1;
  private int b = 2;
  private static void f() {}
  private void g() {}
  public static void main(String[] args) {
    final int c = 3;
    int d = 4;
    Thread t = new Thread(new Runnable() {
      @Override
      public void run() {
        a += 1;
        b += 3; //编译报错,非静态成员变量
        int y = c + 1;
        int x = d + 2; //编译报错,非final局部变量
        f();
        g(); //编译报错,非静态成员函数
      }
    });
    t.start();
    d = 3;//不加这一行会触发编译优化,JVM将变量d当做final变量
  }
}

为什么非final局部变量不能被匿名内部类访问呢?

这也是一个比较常考的面试题。

这是因为,外部函数通过类似参数传递的方式,将局部变量传递给匿名内部类来使用。前面讲过,Java的参数传递是值传递。匿名内部类对参数(相当于局部变量的副本)进行修改,不会改变局部变量本身的值。在程序员看来,明明在匿名类中修改了局部变量的值,却没有生效,不符合直觉认知。所以,为了保持匿名内部类跟外部函数的数据一致性,Java在设计上,只允许匿名内部类访问final修饰的局部变量。

public interface ICallable {
   void add();
}

public class Demo {
  public void test() {
    int a = 1;
    ICallable callback = new ICallable() {
      @Override
      public void add() {
        a++;
        System.out.println(a); //打印2
      }
    };
    System.out.println(a); //被修改了,但却仍然打印1
  }
}

三、总结

四、课后思考题

加载外部类并不会导致内部类的加载。请写个代码,做个实验来证明这一点。