Java final关键字详解

在java中,final关键字可以有如下的用处:
final关键字可以被加到类的声明中,final类是不允许继承的;
final关键字可以被加到方法声明中,final方法是不允许重写的(override),这个效果同私有方法一样;
final关键字可以被家到属性或者变量的声明中,final属性或者变量一旦赋值之后就不允许再发生变化。对于基本类型(primitive type),比如int、double、long、byte等,一旦被生命为final,我们就可以将其当作常量来看待,但是对于引用类型或者数组(数组在java中也是对象)来说,则不是。虽然一个引用类型被赋值之后无法发生变化,但是我们仍然可以修改被引用的那个对象或者数组中的元素。因此在java中,常量的定义与其他语言相比可能会有点差异,在java中,常量的定义是:被声明为final的基本类型或者是通过编译时常量初始化的String类型;
JAVA 的用途:80%以上的高端企业级应用都使用JAVA平台(电信、银行等)。JAVA是成熟的产品,已经有10年的历史。如果你想在Java行业有所建树,想要系统的进行java的学习,那么你可以来这个群,前面是二三一,中间是三一四,后面是零二八。连起来就可以了。 这里有很多互联网大牛教你学习,还有java直播的课程。不是想学习的就不要加了。
方法的参数可以被声明为final,这些参数一旦初始化之后,在方法体中是不能改变其值的。基本上,在接口中将方法参数声明为final是没有什么意义的,因为java的编译器并没有强制要求在继承接口时,方法的参数也一定要带上final。也就是说,一个方法的参数是否为final并没有被当成是方法签名中的一部分,这个对于类的继承也是一样的。关于这一点,大家可以写个简单的程序测试一下;
本地类的方法中只能使用final类型的本地变量;
通常情况下,将方法或者变量生命为final类型有助于提高程序运行时的性能;

下面会对第5,第6点做一个详细的介绍,其他的几点都比较直观,容易理解,第5和第6点涉及到编译器如何产生字节码以及java中对堆区和栈区的使用,会稍微复杂一点。
1.本地类的方法中使用本地变量
Java代码
public class FinalField { 
public static void main(String[] args) { 
    final int x = 0; 
    final int y = 0; 
    Foo foo = new Foo() { 
        public void doBar() { 
            int z = x + y; 
            System.out.println(z); 
        } 
    }; 
    foo.doBar(); 


 
interface Foo { 
    void doBar(); 


上面的代码中,定一个了一个Foo接口,在FinalField类中,在main方法中以匿名类的方式创建了一个Foo接口的实现,然后赋值给foo变量。在这里,我们创建的这个匿名的Foo接口的实现就是一个本地类。在这个本地类中,我们使用了在main方法中定义的两个变量x和y,将它们相加之后输出到控制台。
为了在本地类的doBar方法中使用x和y,我们必须将x和y声明成final,否则编译器是会报错的。其原因还要从Java是一个基于栈的语言说起。Java程序执行时,运行时环境会为每一个线程分配一个线程栈,一个线程在执行过程中的每次方法调用都会在这个栈中分配一个栈帧,而方法中使用到的参数、变量都会在这个栈帧中进行分配。我们可以通过配置JVM的参数来指定线程栈占用空间的最大值,由于每次方法调用都需要在线程栈中分配一个栈帧,因此线程栈的大小直接关系到我们可以执行几次方法调用。一般来说线程栈的大小默认为4K,足够一个线程正常地执行所有的方法调用。但是,对于需要递归调用的方法来说,由于受到线程栈大小的限制,其计算能力也会受到影响。比如,比较经典的斐波那契数的计算就是一个递归的算法,理论上是可以计算任何输入的参数的,但是由于受到线程栈大小的影响,真正可计算的数值的大小是有限制的。
通过下面这个简单的程序及其字节码,我们来体验一下Java程序是如何利用栈来执行操作的。
Java代码 
public class ThreadStack { 
    public int run() { 
        int x = 0; 
        int y = 1; 
         
        int z = x + y; 
         
        return z; 
    } 


上面这段代码的字节码如下,这里为了简单起见只给出了run方法的字节码。
Java代码 
iconst_0 
istore_1 
iconst_1 
istore_2 
iload_1 
iload_2 
iadd 
istore_3 
iload_3 
ireturn 


字节码中的第0和1行对应源代码中的第3行,iconst指令的含义是将常数0压栈,istore指令的含义是从栈顶弹出一个值,然后赋值给变量x,字节码的第2和第3行是给变量y赋值,对应与源代码中的第4行,同样使用了iconst和istore指令。完成了对x和y变量的赋值之后,字节码的第4和第5行执行了两遍iload指令,这个指令的含义是将本地变量的值压入栈中,通过两次调用就是分别将x和y的值压入栈中。字节码第6行是一个加法指令,这个指令会从栈中弹出两个值,然后执行加法操作,然后将结果值再压入栈中。字节码的第7行是从栈顶弹出一个值然后赋值给变量z,字节码的第8行则是将变量z的值压入栈中,最后的ireturn指令则是从栈中弹出栈顶元素,然后压入调用这个方法的调用者的栈帧中。假设我们在main方法中调用了ThreadStack的run方法,那么这个返回值就会被压入main方法所在栈帧的顶部。一个方法结束之后,这个方法对应的栈帧也就消失了,留下的空间会分配给其他的方法调用所对应的栈帧。
回过头来再说本节开头的那个例子,main方法调用结束之后,它所对应的栈帧就被回收了,在main方法中声明的x和y变量也就消失了。而我们知道,在Java中,所有的对象都是在堆中被分配的,也就是说,foo所指向的那个对象是在堆中,而不是在栈中的。由于存在与堆中的对象的生命周期与存在与栈中的变量的生命周期不同(堆中对象的声明周期都是比栈中变量的声明周期要长的),因此Java是不允许堆中的对象直接使用栈中分配的变量的。碰到本节开头的例子中的情况,Java其实是将x和y复制了一份给foo所指向的那个对象使用的。这就要求x和y在后面的执行过程中不能够发生任何的变化,否则会就会造成执行上的错误。这就是为什么本地对象只能使用被声明成final的本地变量。
另外,在复制final类型的变量给本地方法使用的时候,Java针对引用类型和基本数值类型所采用的方法是不同的。我们在前面也提到过,本声明成final的基本数值类型可以被当作编译期常量来使用,因此java的编译器可以直接把这些数值放入到字节码中。而对于引用类型,编译器则是通过生成构造函数的形式来完成复制的。感兴趣的朋友可以通过改写本节开头的类,将x和y声明成String类型,然后用javap -verbose来看看生成的字节码有何不同。
2.为什么final有助与程序的性能
还是先来看一段程序,
Java代码 
public class FinalField { 
    public static void main(String[] args) { 
        ValueHolder vh = new ValueHolder(); 
        int v = vh.v; 
        System.out.println(v); 
    } 
     
    public static class ValueHolder { 
        private int v = 0; 
    } 


这个程序在FinalField类中定一个了一个子类,这样就可以在FinalField的任何方法中直接使用这个子类中的属性,代码会简单一些,同时也足够用来说明问题。
上面这个版本中,ValueHolder的v属性没有被声明成final,我们来看下编译器为我们生成的FinalField类的字节码中,是如何来访问ValueHolder中的v属性的。在源代码中是第4行。
Java代码  收藏代码
invokestatic    #19; //Method com/taobao/tianxiao/FinalField2$ValueHolder.access$0:(Lcom/taobao/tianxiao/FinalField2$ValueHolder;)I 
istore_2 

我们会看到生成的字节码中有这么两条语句,第一条语句执行一个invokestatic指令,这个指令是调用静态方法的指令,而被调用的方法是FinalField2$ValueHolder的access$0方法,调用完成之后,将栈顶的值赋值给变量v。这就奇怪了,我们并没有在ValueHolder中定一个叫做access$0的方法,这是怎么会是呢?我们先来看下ValueHolder的字节码,打开之后可以发现果然有一个叫做access$0的方法定义存在,如下所示。那么既然这个方法不是我们自己定义的,那肯定就是编译器帮我们自动生成的。
Java代码
static int access$0(com.taobao.tianxiao.FinalField2$ValueHolder); 
  Code: 
   Stack=1, Locals=1, Args_size=1 
       aload_0 
       getfield #12; //Field v:I 
       ireturn 
  LineNumberTable:  
   line 11: 0 

生成的access$0中的字节码很简单,就是去取传进来的ValueHolder对象中的v属性,然后返回。
从上面的介绍可以看到,虽然我们在源代码中只是简单的写了一句int v=vh.v,但是编译器生成的代码中,是执行了一次方法调用的。那么如果把ValueHolder中的v声明成final,会是什么情况呢?
Java代码 
iconst_0 
istore_2 

从生成的字节码来看,已经没有了之前对access$0方法的调用了,取而代之的是一条iconst_0指令,也就是直接将0压入栈顶了。通过检查ValueHolder的字节码,发现将v设置成声明成final之后,编译器也确实没有为我们生成access$0方法。从这里可以看出,将ValueHolder的v声明成final之后,会将原本需要方法调用的地方,替换成直接压常量入栈,由于减少了方法调用,程序的性能自然会提高一下。但是仔细观察FinalField的字节码会发现,在将ValueHolder的v声明成final之后,与原来相比却多了如下的两行代码,
Java代码
invokevirtual   #19; //Method java/lang/Object.getClass:()Ljava/lang/Class; 
pop 
iconst_0 
istore_2 

着两行代码被放置在iconst_0指令之前,意思是调用一下vh这个变量所指向的ValueHolder对象的getClass()方法,之后又将返回值直接丢弃掉(pop的意思就是直接将栈顶元素弹出)。这两行代码似乎是没有什么任何意义的,因为不管怎么样,v的值都会被设置成0。想来想去,只有一个解释是正确的,那就是用来验证一下vh这个变量是不是null,由于后面直接用了常量,因此对vh变量的null检查就需要额外的步骤来完成。那么有没有办法去掉这个检查,真正地让编译器直接使用常量呢,答案是将ValueHolder中的v属性声明成static final。这里就不在列出字节码了,感兴趣的话可以自己试一下。
上面的讨论针对的是基本数值类型,对通过编译器常量初始化String对象也是适用的,那么引用类型又会是什么情况呢?让我们来改一下本节最开始的时候的那段程序,如下,
Java代码
public class FinalField { 
public static void main(String[] args) { 
    final int x = 0; 
    final int y = 0; 
    Foo foo = new Foo() { 
        public void doBar() { 
            int z = x + y; 
            System.out.println(z); 
        } 
    }; 
    foo.doBar(); 


 
interface Foo { 
    void doBar(); 


通过查看字节码,正如我们所预期的,main函数中对ValueHolder的v属性的访问是通过access$0这个由编译器自动为我们生成的函数来完成的。字节码如下:
Java代码  收藏代码
invokestatic    #19; //Method com/taobao/tianxiao/FinalField2$ValueHolder.access$0:(Lcom/taobao/tianxiao/FinalField2$ValueHolder;)Ljava/lang/String; 


那么将ValueHolder的v声明成final又会是什么情况呢?答案是没有任何变化,main函数对ValueHolder中的v属性的访问仍然是通过access$0来完成的。
综上所述,我们可以得出如下几点结论:
将类中的引用类型的属性声明成final不会对程序生成的字节码造成任何的改变,仅仅可以帮助编译器确定这个属性在被赋值之后不会被修改;
将类中的基本数值类型以及用编译器常量初始化的String类型的属性声明成final,确实会让编译器对访问这些属性的操作进行优化,直接使用常量值,而不是通过自动生成访问函数来完成,从而可以减少一次方法调用。但是,由于还是为需要判断引用是否为null而调用一次getClass()方法,因此性能上的提高有限;

除了final属性或者变量之外,很多资料上也会提到final方法对程序的性能也是由帮助的。但是本文没有谈到final方法,因为编译器对final方法能够做的优化很有限,可以说基本是干不了什么事情的。这是由继承引起的问题,由于子类在覆写父类的方法时,是可以将final关键字抹去的,因此编译器是没有足够多的信息来优化final方法的。final方法的优化是在运行期由虚拟机根据程序的执行情况来完成的,优化采用的方法本质同本文说的一样,就是减少方法调用,书面化一点也就是内联。

你可能感兴趣的:(java,jvm,final,Primitive)