Java类里,包含属性,方法,构造函数,初始化块,局域变量,内部类等成员,每种成员可以被各种修饰符修饰。其实被static修饰符修饰的成员,被称为静态成员(类成员),而没有被static修饰的成员,被称为实例成员。
1、静态成员(类成员)
静态成员属于整个类,而不属于单个对象。类成员被static关键字修饰的静态属性,静态块,静态方法,静态内部类等。
对static关键字而言,有一条非常重要的规则:类成员(静态属性,静态初始化块,静态方法,静态内部类)不能访问实例成员(实例属性,实例初始化块,实例方法,实例内部类)。因为类成员是属于类的,类成员的作用域比实例成员的作用域大,完全可能出现类成员已经初始化完成,但实例成员还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。
1)静态属性
静态属性属于整个类。当系统第一次准备使用该类时,系统会为该静态属性在JVM的方法区中分配内存空间,静态属性开始生效,直到该类被卸载,该类的静态属性所占用的内存才被系统的垃圾回收机制回收(Perm区的major GC)。静态属性生存范围几乎等同于该类的生存范围。当类初始化完成后,静态属性也被初始化完成。
静态属性既可以通过类来访问,也可以通过类的对象来访问。但通过类的对象来访问静态属性时,实际上并不是访问该对象所拥有的属性,因为当系统创建该类的对象时,系统不会再为该静态属性分配内存,也不会再次对静态属性进行初始化,也就是说,对象根本不拥有对应类的静态属性。通过对象访问静态属性只是一种假象,通过对象访问的依然是该类的静态属性。可以这样理解:当通过对象访问静态属性时,系统会在底层转换为通过该类来访问静态成员。
PS:C#不允许通过对象访问静态属性,对象只能访问实例属性;静态属性必须通过类来访问。
由于对象实际上并不持有静态属性,静态属性由该类持有,同一个类的所有对象访问静态属性时,实际上访问的是该类持有的静态属性。因此,可看到同一个类的所有实例共享同一块内存区(静态属性存放在JVM方法区的类信息里)。
2)静态方法
静态方法也是属于类的,通常直接使用类作为调用者来调用类方法,但也可以通过类的对象来调用类方法。与静态属性类似,即使使用对象来调用方法,效果也与采用类调用类方法完全一样。
当使用实例来访问类成员时,实际上,依然是委托给该类来访问类成员,因此即使某个实例为null,它也可以访问它所属类的类成员。
例如:
package com.demo3; public class NullAccessStatic { static void test() { System.out.println("static修饰的静态方法"); } public static void main(String[] args) { // 定义一个NullAccessStatic变量,其值为null NullAccessStatic nas = null; // null对象调用所属类的静态方法 nas.test(); } }
输出结果:
static修饰的静态方法
PS:如果一个null对象访问实例成员(包括成员和方法),会引发NullPointerException异常。
3)静态初始化块
静态初始化块也是类成员的一种,静态初始化块用于执行类初始化动作,在类的初始化阶段,系统会调用该类的静态初始化块来对类进行初始化。一旦类的初始化结束,静态初始化块将永远不会得到执行的机会。
2、实例成员
创建对象的根本途径是构造器,通过new关键字来调用某个类的构造器即可创建这个类的实例。大多时候,定义一个类就是为了重复创建该类的实例,同一个类的多个实例具有相同的特征,而类则定义了多个实例的共同特征。从某个角度来看,类定义的是多个实例的特征,因此类不是一种具体存在,实例才是具体存在。
1)对象、引用、指针
有这样一行代码:
Person p=new Person();
这行代码创建了一个Person实例,也叫Person对象,这个Person实例被赋给p变量。这行代码中实际产生了两个东西:一个p变量,一个Person实例。Person实例被存放在JVM中的堆内存区中,用来存储Person的实际数据信息;而p变量实际上是一个引用,它被存放在JVM的线程栈内存中,指向实际的Person实例。
实际上,java里的引用就是C里的指针,只是Java语言把这个指针封装起来,避免开发者进行繁琐的指针操作。当一个实例被创建成功后,这个实例将保存在堆内存中,java程序中不允许直接访问堆内存中的对象,只能通过该对象的引用操作该对象,也就是说,不管数组还是对象,都只能通过引用访问它们。Java线程栈内存中,不会存放对象,只会存放基本数据类型和引用。
如果堆内存中的对象没有任何变量指向该对象,那么程序将无法再访问该对象,这个对象也就变成了垃圾,Java里的垃圾回收机制会回收该对象,释放该对象占用的内存空间。
2)静态绑定 VS 动态绑定
绑定是指一个方法的调用与方法主体关联起来。
示例(伪代码,非Java代码):
class 父亲 { sayWorld() { //方法体1 System.out.println("RootWorld"); } }
class 儿子 extends 父亲 { sayWorld() { //方法体2 System.out.println("ChildrenWorld"); } }
class Test { 测试方法() { 父亲 obj = new 儿子(); obj.sayWorld(); } }
代码可以分为编译时和运行时,两个状态。所以,在Test类中,实例obj有两种类型判断方式:
那么,对于方法调用obj.sayWorld();,它对应的方法体是“方法体1”,还是“方法体2”?(请先暂时忘掉java里父子类间的方法重写,假设自己是最初设计Java实现的人)
有两种选择方案:
(1)静态类型。根据obj的声明类型(即,父类),选择方法体。
obj.sayWorld(); -----------> 父亲.sayWorld(){......}
由于在编译源代码时,就能知道声明类型,根据声明类型的信息,即可确定方法调用对应的方法体,运行时直接调用对应的方法体。
编译时,根据静态类型,来判断方法调用与方法体关联的绑定方式,就是静态绑定。
(2)动态类型。在运行时,根据obj的实际类型(即,儿子),选择方法体。
obj.sayWorld(); -----------> 儿子.sayWorld(){......}
在运行时,获取对象的实际类型,然后根据实际类型去查找对应的方法体,能使代码更灵活。可以实现方法重写。
运行时,根据动态类型,来判断方法调用和方法体关联的绑定方式,就是动态绑定。
理解了静态绑定和动态绑定的概念后,可以得出两者的对比如下:
静态绑定发生在编译时期,动态绑定发生在运行时。
静态绑定使用类信息来完成,而动态绑定则需要使用对象信息来完成。
JVM提供了4条方法调用相关的字节码指令:invokestatic、invokespecial、invokevirtual、invokeinterface 。源代码被编译成.class文件时,根据方法属性不同,所有的方法调用会被相应的字节码指令替换掉,例如:
5: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
invokespecial,这个指令是用来调用类的构造方法、父类的方法(通过super)和私有方法的。
invokeinterface,这个指令是通过接口来调用方法的。
invokevirtual,这个指令是用来调用类中的一般方法的,例如public方法。
invokestatic,这个指令用来调用类中的静态方法。
Java里,对于构造方法、父类的方法(通过super)、私有方法和静态方法是采用静态绑定,即invokespecial和invokestatic指令采用静态绑定。对应其他方法,采用动态绑定,即invokeinterface和invokevirtual指令采用动态绑定。
PS:invokeinterface和invokevirtual指令采用动态绑定逻辑,是Java实现继承时方法重写的根本原因。
3)this关键字
Java提供了一个this关键字,this关键字只能出现在实例块和实例方法中,static修饰的类成员不能使用this关键字,因此java语法规定:静态成员不能直接访问非静态成员。this关键字总是指向调用该方法的对象。this关键字最大的作用就是让类中一个方法,可以访问该类实例里的另一个方法或属性。
Java运行对象的一个成员直接调用另一个成员,可以省略this前缀。省略this前缀只是一种假象,虽然省略了this,但是this依然是存在的。
对于实例方法,可以根据python的处理方式,理解为:实例方法的形参列表里,默认隐藏了一个this形参。
其实,JVM内部是通过栈帧的默认规定实现this的。
示例:
package com.demo3; public class Demo { static String staticName; String instanceName; public static void sayHello() { System.out.println("Static sayHello: " + staticName); } public void sayWorld() { System.out.println("this sayWorld: " + this.instanceName); } }
通过javap反编译:
javap -c Demo.class
反编译结果 :
通过对比可以发现,获取“staticName”静态属性,使用的是getstatic指令,getstatic指令是指加载位于JVM方法区的静态属性到栈帧操作区的栈顶;而获取“this.instanceName”指令使用的是“aload_0”和getfield指令,“aload_0”指令用于加载栈帧局域变量区的第一个变量到栈帧操作区的栈顶,"getfield"指令用于弹出操作区栈顶引用,并获取该引用指向的堆中实例的属性,最后将属性压入操作区栈顶。
根据JVM栈帧的局域变量区的特性:实例方法对应栈帧的局域变量区的第一个变量是该实例自己的引用,即this;静态方法对应栈帧的局域变量区的第一个变量不是this,是其他局域变量。如图:
所以,在使用this的地方,在编译时,都被aload_0指令代替。
4)super关键字
示例:
package com.demo3; public class Demo { String instanceName; public void sayHello() { System.out.println("Demo.sayHello"); } }
package com.demo3; public class Demo2 extends Demo { String instanceName; public void sayWorld() { System.out.println(this.instanceName); this.sayHello(); } public void saySun() { System.out.println(super.instanceName); super.sayHello(); } }
用javap反编译Demo2.class文件:
javap -c Demo2.class
获得方法反编译指令:
如上图,根据反编译后的字节码指令可知:
在编译时,this关键字被aload_0指令替代;super也被aload_0指令替代。不过super后面的属性被指定为"父类.属性";super后面的方法被指定为"父类.方法",而且用invokespecial指令(静态绑定)调用该父类方法。
getfield指令和invokespecial一样,也是采用静态绑定逻辑,根据“XXX.属性”,直接获取XXX作用域的属性。
综上所述:
在使用super的地方,在编译后,都被aload_0指令代替,getfield获取"父类.属性",invokespecial调用"父类.方法";
在使用this的地方,在编译后,都被aload_0指令代替,getfield指令获取"本类.属性",invokevirtual调用"本类.方法"。
4)方法参数传递
我们都知道:C 语言中函数参数的传递有:值传递,地址传递,引用传递这三种形式。但是在Java里,方法的参数传递方式只有一种:值传递。所谓值传递,就是将实际参数值的副本(复制品)传入方法内,而参数本身不会受到任何影响。
要说明这个问题,先要明确两点:
引用在Java中是一种数据类型,跟基本类型int等等同一地位。
程序运行永远都是在JVM栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。
在运行JVM栈中,基本类型和引用的处理是一样的,都是传值。如果是传引用的方法调用,可以理解为“传引用值”的传值调用,即“引用值”被做了一个复制品,然后赋值给参数,引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用值,被程序解释(或者查找)到JVM堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是JVM堆中的数据。所以这个修改是可以保持的了。
5)toString()方法
toString方法是一个非常特殊的方法,它来自与Object类,是一个“自我描述”方法,该方法通常用于实现这样一个功能:当程序员直接打印该对象时,系统将会输出该对象的“自我描述”信息,用以告诉外界该对象具有的状态信息。
Object类提供的toString方法总是返回该对象实现类的“类名+@+hashCode”值,这个返回值并不能真正实现“自我描述”的功能,因此如果用户需要自定义类能实现“自我描述”功能,就必须重写Object类的toString()方法。
6)equals和==
对于基本类型来说,==是比较两个值是否相等;对于引用类型来说,==是用来判断两个引用变量是否指向同一个对象。
equals方法是Object类的方法,equals函数主要用来定义“对象相等”的比较逻辑。默认是比较两个对象在堆中,是否是同一个指针(同一个对象), 即:return (this == obj);。基本类型的包装类,例如Integer、Float等等、String类,都将equals方法进行了重写。它们不再比较对象的堆地址是否相同,而是比较对象的内容是否相同。
equals方法与Object的方法hashCode()方法是相关联的。这主要设计到hash表(散列表)的相关知识。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字(key)的记录在表中的地址P,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数,P则为关键字key的hash值。
equals中涉及的比较逻辑就是key,而函数f()就是hashCode()方法,P则为hashCode()方法的返回值。
所以根据散列表的特性可以得到:
当两个对象调用equals函数,返回true时,则意味着关键字key相同,那么hashCode函数的返回值也应该相同。
当两个对象调用equals函数,返回false时,则意味着关键字key不相同,那么hashCode函数的返回值可能相同。
因此,在重写equals函数时,也应该改变相应的hashCode方法,以适应特性。
3、局域变量
形参的作用域是整个方法,由方法调用时,指定值。主要被分配在线程栈的栈帧中。在方法结束时,自动销毁。形参最好在方法的执行过程中,不要被重新赋值。因为形参代表了实际的入口参数,最好不要轻易改变入口参数,可以用final关键字修饰。
方法局域变量和代码块局域变量的作用域是变量定义时,最近括号{}代码块之间。例如:
package com.demo3; public class Test { public static void main(String[] args) { int a = 1; { int b = 1; } int[] array = new int[] { 1, 2, 3, 4, 5 }; for (int i = 0; i < array.length; i++) { System.out.println(array[i]); } } }
a的作用域是整个方法,b的作用域是{int b=1;}代码块,i的作用域是for的循环体。
Java允许局域变量和成员变量同名,如果方法里的局域变量和成员变量同名,局域变量会覆盖成员变量,如果需要在方法里引用被覆盖的成员变量,则可以使用this(对于实例属性)或类名(对于静态属性)作为调用者来限定访问成员变量。不过,大部分时候,应该尽量避免局域变量和成员变量同名。
4、初始化
在Java里,类和对象的关系,大体可以分为两个过程:
加载和连接类。通过JVM的类加载系统,加载.class文件,在JVM中形成类信息(保存在方法区中,并在堆中生成一个Class对象,来描述类信息)。
对象生命周期。根据类信息,在堆中生成对象,并调用构造方法,初始化对象,然后使用对象,最后被当作垃圾回收。
在这两个过程中,主要涉及“连接”、“静态块”和“实例块,构造函数”三个部分。
1)、连接
连接主要包括“验证”“准备”“解析”三个步骤。在这三个步骤之前有个“装载”步骤。
(1)加载
虚拟机需要完成以下三件事情:
a) 通过一个类的权限定名来获取此类定义的二进制字节流。
b) 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
c) 在java堆中生成一个代表这个类的java.lang.Class对象,作为访问方法区的入口
例如:
(2)验证
连接阶段的第一步,这一阶段的目的是为了确保 Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
(3)准备
准备阶段是正式为静态变量分配内存空间,并设置默认的初始值的阶段(只是默认的初始值,不是初始化),这些内存空间都将在方法区中进行分配。例如,将为int类型的静态变量a分配4个字节的内存空间,并且赋予默认值0。
(4)解析
解析过程就是在类型的常量池中寻找类、接口、字段和方法的符号引用,把这些符号引用替换成直接引用的过程。
PS:符号引用VS直接引用
符号引用是指类、接口、字段和方法等的字符串表示形式,例如字段“com.demo.Person.name”。直接引用是指类、接口、字段和方法等信息在内存中的入口访问地址。他们之间的关系就像是域名与IP的关系,“www.oschina.net”对应一个IP“XXX.XXX.XXX.XXX”。
2)、静态块初始化(<clinit>)
在初始化阶段,Java虚拟机执行类的初始化语句,为类的静态变量赋予初始值。
在使用编译器(例如,javac)编译源代码为.class文件时,编译器会将“静态变量声明”和“静态块”按源代码里的先后顺序收集并组装成一个函数<clinit>,并添加到.class文件中。该方法只能被 Jvm 在“初始化”这一步中调用,专门承担初始化工作。
初始化一个类之前必须保证其直接超类已被初始化。该初始化过程是由 Jvm 保证线程安全的。
示例:
package com.demo2; public class Root { static { System.out.println("RootStatic"); } }
package com.demo2; public class Children extends Root { static { System.out.println("ChildrenStatic1"); } static { System.out.println("ChildrenStatic2"); } }
package com.demo2; public class Test { public static void main(String[] args) throws ClassNotFoundException { Class.forName("com.demo2.Children"); } }
输出结果:
RootStatic ChildrenStatic1 ChildrenStatic2
引起JVM启动类加载系统加载类并初始化类的几种情况:
(1).创建类的实例。
new Test();
(2).访问某个类或接口的静态变量,或者对该静态变量赋值。
int b = Test.a; Test.a = b;
(3).调用类的静态方法
Test.doSomething();
(4).反射
Class.forName(“com.mengdd.Test”);
(5).初始化一个类的子类
class Parent{ } class Child extends Parent{ public static int a = 3; } Child.a = 4;
(6).Java虚拟机启动时被标明为启动类的类(包含main方法)
java com.mengdd.Test
3)、实例块和构造函数(<init>)
在类被装载、连接和初始化,这个类就随时都可能使用了。对象实例化和初始化是就是对象生命的起始阶段的活动。
在编译时,编译器会保证类至少有一个构造函数,如果没有定义构造函数,则编译器默认添加一个空的构造函数。
然后编译器会将代码里的“实例变量声明”和“实例块”按源代码里的书写顺序,收集为一个被称为“initcode”的代码片段,最后遍历类的构造函数,在每个构造函数里添加上initcode代码。每个对应形成一个新的构造函数,这些新的构造函数被称为<init>函数。
相关的添加规则是:
(1)如果原始构造函数的第一句是调用父类构造函数,例如super();,则新的构造函数函数体结构为:
父类<init>函数+initcode+原始构造函数函数体
(2)如果原始构造函数的第一句是调用本类的其他构造函数,例如this(name);,则新的构造函数函数体为:
本类<init>函数+原始构造函数函数体
(3)如果原始构造函数的第一句既不是父类构造函数,也不本类构造函数。那么,编译器会在原始构造函数函数体的最前面添加一个默认的父类构造函数调用super();。最后当作第(1)种情况处理。
实例:
package com.demo1; public class Person { int a; { a = 3; } public Person() { } public Person(String name) { super(); } public Person(String name, int a) { this(name); } }
通过命令行反编译:
javap -c Person.class
输出结果为:
Compiled from "Person.java" public class com.demo1.Person { int a; public com.demo1.Person(); Code: 0: aload_0 1: invokespecial #10 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_3 6: putfield #12 // Field a:I 9: return public com.demo1.Person(java.lang.String); Code: 0: aload_0 1: invokespecial #10 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_3 6: putfield #12 // Field a:I 9: return public com.demo1.Person(java.lang.String, int); Code: 0: aload_0 1: aload_1 2: invokespecial #22 // Method "<init>":(Ljava/lang/String;)V 5: return }
如果这个类是 Object,那么它的 <init>() 方法则不包括对父类 <init>() 方法的调用。
示例:
package com.demo2; public class Root { { System.out.println("RootStatic"); } public Root() { System.out.println("Root构造函数"); } }
package com.demo2; public class Children extends Root { { System.out.println("ChildrenStatic1"); } public Children() { System.out.println("Children构造函数"); } { System.out.println("ChildrenStatic2"); } }
package com.demo2; public class Test { public static void main(String[] args) throws ClassNotFoundException { Children c = new Children(); } }
输出结果:
RootStatic Root构造函数 ChildrenStatic1 ChildrenStatic2 Children构造函数
4)静态初始化块、实例初始化块、构造函数
示例:
package com.demo3; public class Demo { // --------------------------- 静态块------------------------- static { System.out.println("static块"); } // --------------------------- 实例块------------------------- { System.out.println("实例块1"); } // --------------------------- 构造函数------------------------- public Demo() { System.out.println("构造函数"); } // --------------------------- 实例块------------------------- { System.out.println("实例块2"); } }
package com.demo3; public class Test { public static void main(String[] args) throws ClassNotFoundException { System.out.println("------------------1-----------------"); Demo demo1 = new Demo(); System.out.println("------------------2-----------------"); Demo demo2 = new Demo(); } }
输出结果:
------------------1----------------- static块 实例块1 实例块2 构造函数 ------------------2----------------- 实例块1 实例块2 构造函数
总之:
<clinit>是类初始化,在类信息加载时初始化,其内容是按源代码里的先后顺序收集“静态变量声明”和“静态块”。
<init>是对象初始化,是在实例化一个对象时调用,其内容是按源代码里的先后顺序收集“实例变量声明”和“实例块”,然后根据不同情况,添加到构造函数体中。每个构造函数形成一个<init>.
请各位读者老爷多多提意见