Java知识点小结2:对象的内存管理

注:本文是对《疯狂Java面试讲义》的小结。

在这里插入图片描述

局部变量和成员变量

Java的变量可分为局部变量和成员变量。

局部变量存储在栈内存中,而成员变量是对象的一部分,存储在堆内存中。

局部变量

局部变量可分为:

  • 形参:
    public void f1(int i) {
    
    
        if (i > 0) {
    
    
            ......
        }
    }

本例中的 i 是形参,它是局部变量。

  • 方法内的局部变量:
    public void f2() {
    
    
        int j = 0;
        ......
    }

本例中的 j 是方法内的局部变量。

注意:方法内的局部变量在使用前必须显式初始化。

        // 在定义变量时初始化
        int i = 0; // OK
        
        // 在定义变量时没有初始化,但在使用前确保其有值
        int j;
        j = 0; // OK
        
        // 变量没有初始化,就直接使用,编译报错
        int k;
        System.out.println(k); // 编译错误

注:成员变量无需初始化,会自动赋初值(0、false、null等)。

  • 代码块内的局部变量:
public class MyClass {
    
    
    {
    
    
        int i = 0;
        ......
    }
    ......
}

注意:代码块内的局部变量和方法内的局部变量类似,在使用前必须显式初始化。

成员变量

成员变量又分为实例变量和类变量。

  • 实例变量:属于实例,每个实例有各自的成员变量
  • 类变量:属于类,整个类只有一个成员变量。类变量用 static 修饰
public class MyClass {
    
    
    private int i; // 实例变量
    private static int j; // 类变量
    ......
}

注: static 可以修饰类里面定义的成员,包括成员变量、方法、内部类、初始化代码块等。 static 不能修饰类、局部变量、局部内部类等。

Java变量必须“先定义,后使用”。

  • 例1:实例变量的非法前向引用:
    private int i = j + 1; // 编译错误
    private int j = 0;
  • 例2:类变量的非法前向引用:
    private static int i = j + 1; // 编译错误
    private static int j = 0;
  • 例3:实例变量可以前向引用类变量
    private int i = j + 1; // OK
    private static int j = 0;

例3没有问题,这是因为类变量是随着类初始化,而实例变量是随着实例初始化,所以类变量总是在实例变量初始化之前就已经初始化好了。

注:获取Class实例的几种方法(假定有 Person 类和其 person 实例):

  • Class.forName("Person") // 注意要用类名全称
  • Person.class
  • person.getClass()

实例变量和类变量的初始化

实例变量的初始化

实例变量可以在以下几处做初始化操作:

  • 1:定义实例变量时
  • 2:初始化块(非静态)
  • 3:构造器
public class Person {
    
    
    // 1:定义实例变量时初始化
    private String name = "Tom";

    // 2:初始化块
    {
    
    
        name = "Jerry";
    }

    public String getName() {
    
    
        return name;
    }

    // 3:构造器
    public Person(String name) {
    
    
        this.name = name;
    }

    public static void main(String[] args) {
    
    
        System.out.println(new Person().getName()); // Jerry
        System.out.println(new Person("LiSi").getName()); // Lisi
    }
}

1(定义时初始化)和2(初始化块)比3(构造器)要更早执行。事实上,编译器会把1和2的代码放在构造器里,并且是放在最上面。

1和2的执行顺序,是按照其代码的自然顺序。

本例中1在前2在后,所以先执行1,再执行2。

注意:如果把1和2的顺序交换一下,会不会报错呢?

    ......
    // 初始化块
    {
    
    
        name = "Jerry";
    }

    // 定义实例变量时初始化
    private String name = "Tom";
    ......

前面提到,“初始化块”和“定义时初始化”,哪个在前就先执行哪个,那么,修改之后,在执行初始化块时,会不会报错说 name 未定义呢?

答案是没有问题。这是因为创建对象时,会先把实例变量创建出来(内存角度),然后再初始化(赋值角度)。

总结:创建对象实例时,会先把实例变量创建出来(分配内存),然后再初始化其值。其中初始化块和定义时初始化,会按其本身顺序,放到构造器的最前面来执行。

类变量的初始化

类变量的初始化类似于实例变量的初始化(只不过没有构造器):

  • 定义类变量时初始化
  • 初始化块(静态)

这二者,哪个在前就先执行哪个。

public class Book {
    
    
    static {
    
    
        price = 30.0;
    }

    static double price = 20.0;

    public static void main(String[] args) {
    
    
        System.out.println(Book.price); // 20.0
    }
}

构造器

类一定有构造器(如果没有,系统会添加一个隐式的无参构造器)。

父类构造器一定会执行(从Object开始,往下一层一层的执行)。

父类构造器一定在当前类构造器的最前面执行

可分为以下几种情况:

  • 第一行代码为 super() ,显式调用父类的指定构造器
  • 第一行代码为 this() ,显式调用当前类的指定构造器(在指定构造器里,还是会先执行父类构造器)
  • 第一行代码不调用 super()this() ,则在最前面隐式调用父类的无参构造器(即 super()

注意: super()this() 只能在构造器中使用,并且只能用在第一行代码。二者最多只能调用一次,且不能同时使用。

考一考:前面提到,如果构造器里没有显式调用 super() ,则会在构造器最前面隐式调用父类的无参构造器 super() ,假如父类只有有参的构造器,会怎么样呢?

答案是会编译报错。看下面的例子:

class A {
    
    
    public A(String name) {
    
    
        System.out.println(name);
    }
}

class B extends A {
    
    
    
}

由于子类B没有构造器,系统会添加一个默认的无参构造器。在此构造器中,会调用父类A的无参构造器,而A没有无参构造器,所以编译报错。

父类访问子类的实例变量和方法

先看一下 this ,它指向的是对象本身。

前面提到,在创建子类对象时,一定会调用父类的构造器,如果父类构造器里面有 this,这个this指向的是谁呢?答案是子类对象。

class A {
    
    
    public A() {
    
    
        System.out.println("A::" + this.getClass().getName());
    }
}

class B extends A {
    
    
    public B() {
    
    
        System.out.println("B::" + this.getClass().getName());
    }
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        new B();
    }
}

运行结果如下:

A::package1.B
B::package1.B

可见,父类构造器里的this,指向的是子类对象,这是因为new的是子类对象,父类构造器是被子类对象的构造器所调用的。

下面举一些例子。

  • 例1:
class A {
    
    
    private String str1 = "hi";

    public void f() {
    
    
        System.out.println(this.str1);
    }
}

class B extends A {
    
    
    private String str2 = "hello";

    public void f() {
    
    
        System.out.println(this.str2);
    }
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        A a1 = new A();
        a1.f(); // hi

        A a2 = new B();
        a2.f(); // hello
    }
}

这个例子很典型,没有任何问题。

  • 例2:改为在父类构造器里调用 f() 方法:
class A {
    
    
    private String str1 = "hi";

    public void f() {
    
    
        System.out.println(this.str1);
    }

    public A() {
    
    
        this.f();
    }
}

class B extends A {
    
    
    private String str2 = "hello";

    public void f() {
    
    
        System.out.println(this.str2);
    }
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        new B(); // null
    }
}

奇怪的是,输出结果变成null了。

这是因为,在创建B对象时,首先调用父类的构造器,父类构造器里调用了 this.f() 方法。前面提到,this实际指向的是B对象,且 f() 方法被子类override了,所以调用的是子类的 f() 方法。而 str2 此时还没有初始化(先分配内存,再初始化,初始化是按从父类到子类的顺序操作的),所以是null值。

这个例子告诉我们,最好不要在构造器里调用实例方法。

  • 例3:子类和父类同名的成员变量
class A {
    
    
    private String str1 = "hi";

    public void f() {
    
    
        System.out.println(this.str1);
    }

    public A() {
    
    
        this.f();
    }
}

class B extends A {
    
    
    private String str1 = "hello";
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        new B(); // hi
    }
}

本例中,创建B对象时,先调用父类A的构造器,在构造器里调用 this.f() 方法,由于子类没有override该方法,所以调用的是父类的 f() 方法。问题在于,这里的 str1 是父类的还是子类的?

从实际运行结果可见,这里的 str1 是父类的。这是因为实例变量不存在override之说,this虽然指向子类,但编译期是父类类型,所以是父类的 str1

换句话说,在父类和子类里,实例变量即使同名,也是两个截然不同的变量,没有什么关联。父类不知道子类,更不可能知道子类的成员变量,所以在父类方法里,使用的一定是父类的成员变量。

想象一下,如果子类有成员变量 str2 ,而在父类的 f() 方法里使用 str2 ,显然会编译报错。

所以,获取“在父子类里同名的成员变量”时,是根据编译期的类型而定的。如果编译类型是子类,则会把父类的同名成员变量给“隐藏”掉。

这个例子告诉我们,父类成员变量和子类成员变量最好不要同名,徒增混淆。

总结:

  • this指向是对象本身。构造器(包括父类和子类构造器)里的this,指向的是实际创建的对象。
  • 通过this指针访问对象的实例变量时,由声明该变量的类型决定(编译期)
  • 通过this指针访问对象的实例方法时,由实际的对象来决定(运行期)

考一考

下面代码的输出结果是什么?

class A {
    
    
    String name = "Hi";

    public void display() {
    
    
        System.out.println(this.name);
    }
}

class B extends A {
    
    
    String name = "Hello";

    @Override
    public void display() {
    
    
        System.out.println(this.name);
    }
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        A a1 = new A();
        System.out.println(a1.name);
        a1.display();

        B b1 = new B();
        System.out.println(b1.name);
        b1.display();

        A a2 = new B();
        System.out.println(a2.name);
        a2.display();

        A a3 = b1;
        System.out.println(a3.name);
        a3.display();
    }
}

答案为:

Hi
Hi

Hello
Hello

Hi
Hello

Hi
Hello
  • a1:编译类型为A,实际类型也为A,所以name和display()都是A的
  • b1:编译类型为B,实际类型也为B,所以name和display()都是B的
  • a2:编译类型为A,实际类型为B,所以name是A的,display()是B的
  • a3:编译类型为A,实际类型为B,所以name是A的,display()是B的

本例中,为了混淆,故意令A和B的成员变量名都叫 name 。虽然a3和b1实际指向同一对象,但 a3.nameb1.name 是不同的变量(编译类型是子类时,子类的name把父类的name隐藏掉了)。如果二者叫不同的名字,代码就会清晰一些。

方法则相对简单一些:不管编译期类型是什么,只看实际对象是什么,就调用它的方法。

super

如果在子类中显式指定 super.xxxsuper.xxx() ,则会访问其父类的成员变量或方法。

super仍然是指向实际对象的,其编译类型是父类。

使用super,就可以访问被子类隐藏掉的同名父类成员变量,或者指定父类的方法。

父子类的类变量

  • 可以通过类名来访问类变量
  • 也可以通过super来访问父类的类变量

final修饰符

  • final修饰变量:变量赋初值后,不能重新赋值
  • final修饰方法:不能被子类override
  • final修饰类:不能被继承

final修饰变量

  • final修饰实例变量
    • 定义变量时指定初始值
    • 在初始化块(非静态)中指定初始值
    • 在构造器中指定初始值
  • final修饰类变量
    • 定义变量时指定初始值
    • 在初始化块(静态)中指定初始值
  • final修饰局部变量
    • 定义变量时指定初始值

注意:以实例变量为例,虽然可以在多个地方指定初始值,但是对一个变量,只能在一处指定初始值,不能在多处赋值。

用final修饰的变量,如果定义变量时指定初始值,而且这个初始值在编译期能确定下来,那么这个变量将不再是一个变量,而是会被当作“宏变量”,也就是所有出现该变量的地方,直接用其值来替换。比如:

    final int i = 123;
    final int j = 1 + 3;
    final String str = "abc" + "def";

literal字符串

Java会将literal字符串缓存在字符串池中。

        String str1 = "hello";
        String str2 = "hello";
        System.out.println(str1 == str2); // true

本例中,str1和str2指向同一个对象。

        String str1 = "hello";
        String str2 = "he" + "llo";
        System.out.println(str1 == str2); // true

本例中,str1和str2指向同一个对象,因为str2的值在编译期可确定。

final修饰方法

final修饰的方法不能被override。

class A {
    
    
    final void f() {
    
    }
}

class B extends A {
    
    
    void f() {
    
    } // 编译错误
}

但是下面这个例子是OK的:

class A {
    
    
    private final void f() {
    
    }
}

class B extends A {
    
    
    void f() {
    
    } // OK
}

这是因为父类的private方法对子类是不可见的,所以子类可以定义同名方法(不是override,不能添加 @Override 注解)。

不过,这里的final没什么意义,因为private方法本来就无法override。

内部类中的局部变量

如果在内部类中使用局部变量,则该变量默认有final修饰。

比如:

interface A {
    
    
    void f();
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        int i = 123;
        A a = new A() {
    
    
            @Override
            public void f() {
    
    
                System.out.println(i);
            }
        };
        a.f(); // 123
    }
}

本例中,在匿名内部类中使用了外部的局部变量i。

如果试图修改变量的值,则编译错误。比如:

interface A {
    
    
    void f();
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        int i = 123;
        new A() {
    
    
            @Override
            public void f() {
    
    
                i++; // 编译错误
            }
        };
    }
}

编译错误如下:

Variable 'i' is accessed from within inner class, needs to be final or effectively final

同理,如果在外部类中试图修改i的值,也会编译报错:

interface A {
    
    
    void f();
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
        int i = 123;
        new A() {
    
    
            @Override
            public void f() {
    
    
                System.out.println(i); // 编译错误
            }
        };
        i++;
    }
}

Lambda也有同样的问题:

        int i = 123;

        Logger.getLogger("MyLogger").info(() -> "i = " + i); // 编译错误

        i++;

看来想在log里打印一下某个变量的值,还不太容易呢。

一个比较土的解决办法是,为了记log,再定义一个临时变量:

        int i = 123;
        int j = i;

        Logger.getLogger("MyLogger").info(() -> "i = " + j);
        
        i++;

至于为什么有此限制,是因为局部变量的作用域本来是在方法里的,但是内部类或Lambda可能产生隐式的闭包(Closure),闭包使得局部变量脱离它所在的方法而继续存在,也就是扩大了局部变量的作用域,将会引起混乱。所以Java要求内部类和Lambda所访问的局部变量必须是final的(如果不写,Java会自动加上)。

猜你喜欢

转载自blog.csdn.net/duke_ding2/article/details/142365872
今日推荐