Java基础之冰川表面
本篇文章是罗列一些关于java基础的核心点,这些点是基础中的基础,也是重点中的重点。为什么本篇文章叫做冰川表面呢?我想表达的意思是,这些基础只是一个引子,背后牵扯出来的东西可能会很多,面试往往都是从基础的知识点慢慢深入挖掘的,所以千万不能忽视对于它们的复习。
一、关键字
final
1. 数据
声明数据为常量,可以是编译时常量,也可以是在运行时被初始化后不能被改变的常量。
- 对于基本类型,
final
使数值不变; - 对于引用类型,
final
使引用不变,也就不能引用其它对象,但是被引用的对象本身是可以修改的。
1 | final int x = 1; |
2. 方法
声明方法不能被子类覆盖。
private 方法隐式地被指定为 final,如果在子类中定义的方法和基类中的一个 private 方法签名相同,此时子类的方法不是覆盖基类方法,而是在子类中定义了一个新的方法。
3. 类
声明类不允许被继承。
static
1. 静态变量
静态变量在内存中只存在一份,只在类初始化时赋值一次。
- 静态变量:类所有的实例都共享静态变量,可以直接通过类名来访问它;
- 实例变量:每创建一个实例就会产生一个实例变量,它与该实例同生共死。
1 | public class A { |
2. 静态方法
静态方法在类加载的时候就存在了,它不依赖于任何实例,所以静态方法必须有实现,也就是说它不能是抽象方法(abstract
)。
3. 静态语句块
静态语句块在类初始化时运行一次。
4. 静态内部类
内部类的一种,静态内部类不依赖外部类,且不能访问外部类的非静态的变量和方法。
5. 静态导包
1 | import static com.xxx.ClassName.* |
在使用静态变量和方法时不用再指明 ClassName
,从而简化代码,但可读性大大降低。
6. 变量赋值顺序
静态变量的赋值和静态语句块的运行优先于实例变量的赋值和普通语句块的运行,静态变量的赋值和静态语句块的运行哪个先执行取决于它们在代码中的顺序。
存在继承的情况下,初始化顺序为:
- 父类(静态变量、静态语句块)
- 子类(静态变量、静态语句块)
- 父类(实例变量、普通语句块)
- 父类(构造函数)
- 子类(实例变量、普通语句块)
- 子类(构造函数)
二、Object 通用方法
概览
1 | public final native Class<?> getClass() |
equals()
1. equals() 与 == 的区别
- 对于基本类型,
==
判断两个值是否相等,基本类型没有equals()
方法。 - 对于引用类型,
==
判断两个实例是否引用同一个对象,而equals()
判断引用的对象是否等价。
1 | Integer x = new Integer(1); |
默认情况下也就是从超类Object
继承而来的equals
方法与==
是完全等价的,比较的都是对象的内存地址,但我们可以重写equals
方法,使其按照我们的需求的方式进行比较,如String
类重写了equals
方法,使其比较的是字符的序列,而不再是内存地址。
2. 等价关系
(一)自反性
1 | x.equals(x); // true |
(二)对称性
1 | x.equals(y) == y.equals(x); // true |
(三)传递性
1 | if (x.equals(y) && y.equals(z)) |
(四)一致性
多次调用 equals()
方法结果不变
1 | x.equals(y) == x.equals(y); // true |
(五)与 null
的比较
对任何不是 null
的对象 x
调用 x.equals(null)
结果都为 false
1 | x.euqals(null); // false; |
3. 实现
- 检查是否为同一个对象的引用,如果是直接返回
true
; - 检查是否是同一个类型,如果不是,直接返回
false
; - 将
Object
实例进行转型; - 判断每个关键域是否相等。
1 | public class EqualExample { |
hashCode()
hasCode()
返回散列值,而 equals()
是用来判断两个实例是否等价。等价的两个实例散列值一定要相同,但是散列值相同的两个实例不一定等价。
在覆盖 equals()
方法时应当总是覆盖 hashCode()
方法,保证等价的两个实例散列值也相等。
下面的代码中,新建了两个等价的实例,并将它们添加到 HashSet
中。我们希望将这两个实例当成一样的,只在集合中添加一个实例,但是因为 EqualExample
没有实现 hasCode()
方法,因此这两个实例的散列值是不同的,最终导致集合添加了两个等价的实例。
1 | EqualExample e1 = new EqualExample(1, 1, 1); |
理想的散列函数应当具有均匀性,即不相等的实例应当均匀分布到所有可能的散列值上。这就要求了散列函数要把所有域的值都考虑进来,可以将每个域都当成 R 进制的某一位,然后组成一个 R 进制的整数。R 一般取 31,因为它是一个奇素数,如果是偶数的话,当出现乘法溢出,信息就会丢失,因为与 2 相乘相当于向左移一位。
之所以选择31,是因为它是个奇素数,如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不是很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,就是用移位和减法来代替乘法,可以得到更好的性能:31*i==(i<<5)-i。现在的VM可以自动完成这种优化。
1 |
|
clone()
1. cloneable
clone()
是 Object
的受保护方法,这意味着,如果一个类不显式去覆盖 clone()
就没有这个方法。
1 | public class CloneExample { |
1 | CloneExample e1 = new CloneExample(); |
接下来覆盖 Object
的 clone()
得到以下实现:
1 | public class CloneExample { |
1 | CloneExample e1 = new CloneExample(); |
1 | java.lang.CloneNotSupportedException: CloneTest |
以上抛出了 CloneNotSupportedException
,这是因为 CloneTest
没有实现 Cloneable
接口。
1 | public class CloneExample implements Cloneable { |
应该注意的是,clone() 方法并不是 Cloneable 接口的方法,而是 Object 的一个 protected 方法。Cloneable 接口只是规定,如果一个类没有实现 Cloneable 接口又调用了 clone() 方法,就会抛出 CloneNotSupportedException。
2. 深拷贝与浅拷贝
- 浅拷贝:拷贝实例和原始实例的引用类型引用同一个对象;
- 深拷贝:拷贝实例和原始实例的引用类型引用不同对象。
1 | public class ShallowCloneExample implements Cloneable { |
1 | ShallowCloneExample e1 = new ShallowCloneExample(); |
1 | public class DeepCloneExample implements Cloneable { |
1 | DeepCloneExample e1 = new DeepCloneExample(); |
使用 clone()
方法来拷贝一个对象即复杂又有风险,它会抛出异常,并且还需要类型转换。Effective Java
书上讲到,最好不要去使用 clone()
,可以使用拷贝构造函数或者拷贝工厂来拷贝一个对象。
1 | public class CloneConstructorExample { |
1 | CloneConstructorExample e1 = new CloneConstructorExample(); |
四、继承
访问权限
Java 中有三个访问权限修饰符:private
、default
、protected
以及public
,如果不加访问修饰符,表示包级可见。
可以对类或类中的成员(字段以及方法)加上访问修饰符。
- 成员可见表示其它类可以用这个类的实例访问到该成员;
- 类可见表示其它类可以用这个类创建对象。
protected
用于修饰成员,表示在继承体系中成员对于子类可见,但是这个访问修饰符对于类没有意义。
如果子类的方法覆盖了父类的方法,那么子类中该方法的访问级别不允许低于父类的访问级别。这是为了确保可以使用父类实例的地方都可以使用子类实例,也就是确保满足里氏替换原则。
字段决不能是公有的,因为这么做的话就失去了对这个字段修改行为的控制,客户端可以对其随意修改。可以使用公有的 getter
和 setter
方法来替换公有字段。
抽象类与接口
1. 抽象类
抽象类和抽象方法都使用 abstract
进行声明。抽象类一般会包含抽象方法,抽象方法一定位于抽象类中。
抽象类和普通类最大的区别是,抽象类不能被实例化,需要继承抽象类才能实例化其子类。
1 | public abstract class AbstractClassExample { |
1 | public class AbstractExtendClassExample extends AbstractClassExample{ |
1 | // AbstractClassExample ac1 = new AbstractClassExample(); // 'AbstractClassExample' is abstract; cannot be instantiated |
2. 接口
接口是抽象类的延伸,在 Java 8 之前,它可以看成是一个完全抽象的类,也就是说它不能有任何的方法实现。
从 Java 8 开始,接口也可以拥有默认的方法实现,这是因为不支持默认方法的接口的维护成本太高了。在 Java 8 之前,如果一个接口想要添加新的方法,那么要修改所有实现了该接口的类。
接口的成员(字段 + 方法)默认都是 public
的,并且不允许定义为 private
或者protected
。
接口的字段默认都是 static
和 final
的。
1 | public interface InterfaceExample { |
1 | public class InterfaceImplementExample implements InterfaceExample { |
1 | // InterfaceExample ie1 = new InterfaceExample(); // 'InterfaceExample' is abstract; cannot be instantiated |
3. 比较
- 从设计层面上看,抽象类提供了一种 IS-A 关系,那么就必须满足里式替换原则,即子类对象必须能够替换掉所有父类对象。而接口更像是一种 LIKE-A 关系,它只是提供一种方法实现契约,并不要求接口和实现接口的类具有 IS-A 关系。
- 从使用上来看,一个类可以实现多个接口,但是不能继承多个抽象类。
- 接口的字段只能是
static
和final
类型的,而抽象类的字段没有这种限制。 - 接口的方法只能是
public
的,而抽象类的方法可以由多种访问权限。
4. 使用选择
使用抽象类:
- 需要在几个相关的类中共享代码。
- 需要能控制继承来的方法和域的访问权限,而不是都为
public
。 - 需要继承非静态(
non-static
)和非常量(non-final
)字段。
使用接口:
- 需要让不相关的类都实现一个方法,例如不相关的类都可以实现
Compareable
接口中的compareTo()
方法; - 需要使用多重继承。
在很多情况下,接口优先于抽象类,因为接口没有抽象类严格的类层次结构要求,可以灵活地为一个类添加行为。并且从 Java 8 开始,接口也可以有默认的方法实现,使得修改接口的成本也变的很低。
super
- 访问父类的构造函数:可以使用
super()
函数访问父类的构造函数,从而完成一些初始化的工作。 - 访问父类的成员:如果子类覆盖了父类的中某个方法的实现,可以通过使用
super
关键字来引用父类的方法实现。
1 | public class SuperExample { |
1 | public class SuperExtendExample extends SuperExample { |
1 | SuperExample e = new SuperExtendExample(1, 2, 3); |
1 | SuperExample.func() |
覆盖与重载
-
覆盖(
Override
)存在于继承体系中,指子类实现了一个与父类在方法声明上完全相同的一个方法; -
重载(
Overload
)存在于同一个类中,指一个方法与已经存在的方法名称上相同,但是参数类型、个数、顺序至少有一个不同。应该注意的是,返回值不同,其它都相同不算是重载。
五、String
String, StringBuffer and StringBuilder
1. 是否可变
String
不可变StringBuffer
和StringBuilder
可变
2. 是否线程安全
String
不可变,因此是线程安全的StringBuilder
不是线程安全的StringBuffer
是线程安全的,内部使用synchronized
来同步
String 不可变的原因
1. 可以缓存 hash 值
因为 String
的 hash
值经常被使用,例如 String
用做 HashMap
的 key
。不可变的特性可以使得 hash
值也不可变,因此只需要进行一次计算。
2. String Pool 的需要
如果一个 String
对象已经被创建过了,那么就会从 String Pool
中取得引用。只有 String
是不可变的,才可能使用 String Pool
。
3. 安全性
String
经常作为参数,String
不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String
是可变的,那么在网络连接过程中,String
被改变,改变 String
对象的那一方以为现在连接的是其它主机,而实际情况却不一定是。
4. 线程安全
String
不可变性天生具备线程安全,可以在多个线程中安全地使用。
String.intern()
使用 String.intern()
可以保证相同内容的字符串实例引用相同的内存对象。
下面示例中,s1
和 s2
采用 new String()
的方式新建了两个不同对象,而 s3
是通过 s1.intern()
方法取得一个对象引用,这个方法首先把 s1
引用的对象放到 String Poll
(字符串常量池)中,然后返回这个对象引用。因此 s3
和 s1
引用的是同一个字符串常量池的对象。
1 | String s1 = new String("aaa"); |
如果是采用 “bbb” 这种使用双引号的形式创建字符串实例,会自动地将新建的对象放入 String Pool 中。
1 | String s4 = "bbb"; |
在 Java 7 之前,字符串常量池被放在运行时常量池中,它属于永久代。而在 Java 7,字符串常量池被放在堆中。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误。
六、基本类型与运算
包装类型
八个基本类型:
- boolean/1
- byte/8
- char/16
- short/16
- int/32
- float/32
- long/64
- double/64
基本类型都有对应的包装类型,基本类型与其对应的包装类型之间的赋值使用自动装箱与拆箱完成。
1 | Integer x = 2; // 装箱 |
new Integer(123)
与 Integer.valueOf(123)
的区别在于,new Integer(123)
每次都会新建一个对象,而 Integer.valueOf(123)
可能会使用缓存对象,因此多次使用 Integer.valueOf(123)
会取得同一个对象的引用。
1 | Integer x = new Integer(123); |
编译器会在自动装箱过程调用 valueOf()
方法,因此多个 Integer
实例使用自动装箱来创建并且值相同,那么就会引用相同的对象。
1 | Integer m = 123; |
valueOf()
方法的实现比较简单,就是先判断值是否在缓存池中,如果在的话就直接使用缓存池的内容。
1 | public static Integer valueOf(int i) { |
在 Java 8 中,Integer
缓存池的大小默认为 -128~127。
1 | static final int low = -128; |
Java 还将一些其它基本类型的值放在缓冲池中,包含以下这些:
- boolean values true and false
- all byte values
- short values between -128 and 127
- int values between -128 and 127
- char in the range \u0000 to \u007F
因此在使用这些基本类型对应的包装类型时,就可以直接使用缓冲池中的对象。
Differences between new Integer(123), Integer.valueOf(123) and just 123
switch
从 Java 7 开始,可以在 switch
条件判断语句中使用 String
对象。
1 | String s = "a"; |
switch
不支持 long
,是因为 swicth
的设计初衷是为那些只需要对少数的几个值进行等值判断,如果值过于复杂,那么还是用 if 比较合适。