Java中的final关键字与String类

Java中的String类型为什么是final的?

腾讯二面的时候被问到这个问题,回答的不是很好。以前只是知道String类是不可变的,不可被继承的,但是没有深入研究过,immutable和final并不是一回事,为什么一定要声明为final的。

首先我觉得String类是immutable不可变的,而final则是它不可变的具体实现。所以这其实是两个问题

  1. 为什么String类是不可变的?
  2. 为什么final能保证它的不可变性质?

下面分别看一下两个问题

一、final关键字

Java中的final关键字主要用在三个地方:

  1. 数据
  2. 方法

1、final数据

之所以叫final数据而不是final变量,因为Java中分为基本数据类型和对象,final在修饰这两个类的时候也是有一些差别的。

1.1、编译期常量

对于编译期常量编译器可以将该常量的值带入任何可能用到的计算式中,也就是 说可以在编译时执行计算式,这减轻了一些运行时的负担。

在Java中,这类常量必须是基本数据类型,并且以关键字final表示,在对这个常量进行定义的时候就必须进行赋值。

一个即是static又是final的域只占一段不能改变的存储空间,且变量名全部用大写字母明明,字母之间用下划线隔开。

1.2、final对象

对于基本类型,final使数值恒定不变。而对于对象引用,final使引用恒定不变。一旦引用被初始化指向一个对象,就再无法把它改为指向另一个对象,然而对象自身却是可以被修改的。下面会有例子说明。

1.3、空白final

Java中的“空白final”是指:被声明为final但又未给定初值的域。但是无论上面情况,编译器都确保空白final在使用前必须被初始化。

空白的final关键字提供了很大的灵活性,一个类的final域可以根据对象而有所不同却又保持其恒定不变的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class BlankFinal {
static class A{
int a;
public A(int a){
this.a=a;
}
}
private final int i=0;//直接初始化
private final int j;//未初始化的空白final
private final A a;//未初始化的空白final

public BlankFinal(){//空白final必须在构造器中赋值,不然编译器会报错
j=1;
a=new A(1);
}

public BlankFinal(int x){//空白final必须在构造器中赋值,不然编译器会报错
j=x;
a=new A(x);
}

public static void main(String[] args) {
}

}

必须在域的定义处或者构造器中对final赋值。

1.4、final参数

Java还允许在参数列表中以声明的方式将参数指明为final,意味着无法在方法中更改参数引用所指向的对象

1
2
3
static void f(final int i){//编译报错
i++;
}

2、final方法

使用final方法的原因有两个

第一就是把方法锁定,以防止任何继承类修改它的含义。确保在继承中是方法行为保持不变,并不会被覆盖。

第二就是在早期的Java实现中,如果一个方法指明为final,就是同意编译器将针对该方法的所有调用都转为内嵌调用。

2.1、private与final

类中所有的private方法都隐式地指定为final。给private方法添加final关键字,不会有任何意义。

如果试图覆盖一个private方法,编译器不会给出错误,但是还是会有问题的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class BlankFinal {
public static void main(String[] args) {
OverridingPrivate2 op2=new OverridingPrivate2();
op2.f();//OverridingPrivate2.f()
OverridingPrivate op=(OverridingPrivate)op2;//向上转型
op.f();//The method f() from the type OverridingPrivate is not visible
}
}

class WithFinals{
private void f(){
System.out.println("WithFinals.f()");
}
}

class OverridingPrivate extends WithFinals{
private void f(){
System.out.println("OverridingPrivate.f()");
}
}

class OverridingPrivate2 extends OverridingPrivate{
public void f(){
System.out.println("OverridingPrivate2.f()");
}
}

如果一个基类中的方法是private的,那么对子类来说是隐藏不可见的。

即使在子类中定义了一个名称相同的方法,也只是生成了一个名称相同的方法,但是并没有重写该方法。

3.final类

将一个类定义为final,则说明该类是不可继承的。最常见的肯定就是下面要介绍的String类了。

二、String类与不可变性

《Effictive Java》中提到,不可变类只是其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期内固定不变。

Java类库中包含许多不可变的类,其中有:

  • String
  • 基本数据类型的包装类
  • BigInteger
  • BigDecimal

为了使类成为不可变,要遵循下面五条规则:

  1. 不要提供任何会修改对象状态的方法
  2. 保证类不会被扩展。一般做法是使这个类成为final的
  3. 使所有的域都是final的
  4. 使所有的域都成为私有的
  5. 确保对于任何可变组件的互斥访问

不可变类对象的几点好处:

  • 简单:不可变对象只有一种状态,即被创建时的状态
  • 不可变对象本质上是线程安全的,不需要同步
  • 不可变对象可以自由共享
  • 不可变对象之间可以共享内部信息。比如BigInteger,数值相同符号不同,可以共享数值

下面以String类的代表来具体进行分析

String类声明为final主要是是为了保证其不可变性。

我个人认为:String在Java中的对象,但是它在使用上其实更接近基本数据类型,所以将它设计成不可变的对象类型,最大程度上接近了基本数据类型,仔细想想面向对象的特性继承封装多态,其实在String类中完全没有体现,所以我觉得它虽然实现上是一个类,但设计者还是想把它当做基本数据类型来使用。

在Java中Long, Double, Integer等类也是final的,也说明了这个问题。

1、什么是不可变

String不可变很简单。给一个字符串“a”赋值“b”或者执行+操作变成“aa”,不会在原来的内存地址上修改数据,而是生成一个新的String对象然后指向这个新对象。

2、如何实现不可变

先来看一下String类的定义

1
2
3
4
5
6
7
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

String类声明为final,说明是不可被继承的,而String类的实现,底层是一个char数组,也被声明为final,说明该数组是不可变的。

上面提到了final修饰一个对象的时候保证指向的的引用不变,但是对象自身是可以改变的。

1
2
final int a=1;
a=2;

这样编译会报错,因为final的变量不可以被再次赋值。

1
2
3
final int[] a=new int[]{1,2,3,4};
a[0]=0;
System.out.println(Arrays.toString(a));

输出为[0, 2, 3, 4]。这说明虽然a指向的数组对象无法改变,但是数组对象内部的结构却可以改变。

再回到String类中。通过上面这个例子可以知道,如果单纯的把String类中的char数组设置为final,那么这个String的值依然是可以改变的,所以设计人员从以下两个方面进行了保护:

  1. 数组声明为private,保证最高的访问权限,在自己类内部的方法中也保证不改变这个char数组的值
  2. 把String类声明为final, 防止继承的子类对它的equals等关键方法进行重写

3、不可变的好处

String类型设计为不可变主要有以下两个方面:

  • 安全方面
  • 性能方面

性能

这个方面很好理解,最熟悉的字符串常量池的存在就是为了性能优化,而如果字符串是可变的,那么常量池存在的意义也就不大了。

字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串池,每当代码创建字符串常量时,JVM会首先检查字符串常量池。如果字符串已经存在池中,就返回池中的实例引用。如果字符串不在池中,就会实例化一个字符串并放到池中。Java能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突进行共享。

转自http://www.importnew.com/10756.html

安全

3.1、线程安全性

如果一个对象可以被多个线程访问到,但是并没有被声明为final,那么你需要提供额外的线程安全机制。而String类直接声明为final,即不可改变。就不必再额外考虑线程安全的问题。

3.2、String作为参数

String类的变量经常作为参数进行传递,而Java中对象类型的传递的则是引用,下面来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void fun(String s){
s+=" in fun";
System.out.println(s);
}

static void fun(StringBuilder s){
s.append(" in fun");
System.out.println(s);
}

public static void main(String[] args) {
String s1="s1";
StringBuilder s2=new StringBuilder("s2");
fun(s1);
fun(s2);
System.out.println(s1);
System.out.println(s2);
}

输出结果

1
2
3
4
s1 in fun
s2 in fun
s1
s2 in fun

可以看出,与可变的StringBuilder相比,String类作为参数传到另一个方法中之后,无论该方法在传入字符串的基础上如何操作,因为String的不可变性,都会在方法中生成一个新的对象,而对传入的对象没有任何影响。

3.2、String作为key

在Java中很多地方都是以String类型的对象作为参数的,比如Spring中的bean的name,Socket或者数据库连接的名称等等,还有就是HashMap或者HashSet的key,这些以String类型对象为参数的场景中,如果String是可变的,那么带来很多问题。下面用一个StringBuilder作为例子

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {		
Map<StringBuilder, Integer> map=new HashMap<StringBuilder, Integer>();
StringBuilder s1=new StringBuilder("aab");
StringBuilder s2=new StringBuilder("aa");
map.put(s1, 1);
map.put(s2, 2);
s2.append("b");
System.out.println(map.keySet());//[aab, aab]
}

从输出结果可以看出,会出现两个key值相等的情况。当然,这个例子并不完全恰当,因为StringBuilder其实没有重写equals方法,所以在map中查找的时候比较的是指向的对象而不是想String一样比较字符串是否相同,这个例子主要体现的就是以String作为key的时候,如果是可变的,那么会出现key值相同的情况。

三、参考地址

《Java编程思想》第四版

《Effictive Java》第二版

https://www.zhihu.com/question/31345592